dieya_risk_prediction_backend/app/api/v1/integration_risk.py

201 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Signed external assessments and report lookup; no browser JWT or conversation required."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from sqlalchemy.exc import SQLAlchemyError
from app.api.dependencies import DatabaseSession
from app.core.config import get_settings
from app.core.exceptions import AppException
from app.models.entities import IntegrationAssessment, OperationAudit
from app.repositories.integration_risk import IntegrationRiskRepository
from app.schemas.integration_risk import IntegrationRiskRequest
from app.services.external_risk_report import ExternalRiskReportService
from app.services.integration_auth import authenticate_integration, authorize_operator
from app.services.markdown_reports import render_markdown_report
logger = logging.getLogger(__name__)
class IntegrationRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def wrapped(request: Request):
try:
return await handler(request)
except AppException as exc:
headers = {"Retry-After": "60"} if exc.status_code == 429 else {}
return JSONResponse(
status_code=exc.status_code,
headers=headers,
content={
"code": exc.code,
"message": exc.message,
"data": exc.details,
},
)
except SQLAlchemyError:
logger.error("External report database operation failed")
return JSONResponse(
status_code=503,
content={
"code": 503,
"message": "风险数据库暂不可用,请使用原请求编号重试",
"data": None,
},
)
except RequestValidationError as exc:
invalid_json = any(error["type"] == "json_invalid" for error in exc.errors())
status = 400 if invalid_json else 422
locations = [".".join(map(str, error["loc"])) for error in exc.errors()]
return JSONResponse(
status_code=status,
content={
"code": status,
"message": "请求体必须是合法 JSON"
if invalid_json
else "请求参数不符合约束:" + "、".join(locations),
"data": None,
},
)
except Exception as exc: # noqa: BLE001 - external callers receive no internal traceback
logger.error("External interface failed: %s", exc.__class__.__name__)
return JSONResponse(
status_code=500,
content={
"code": 500,
"message": "平台内部处理异常,请使用原请求编号重试",
"data": None,
},
)
return wrapped
router = APIRouter(prefix="/integrations/risk-assessments", tags=["integrations"], route_class=IntegrationRoute)
async def integration_client(request: Request, session: DatabaseSession) -> str:
if (
request.method == "POST"
and request.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json"
):
raise AppException("Content-Type 必须为 application/json", code=400, status_code=400)
if len(await request.body()) > 65536:
raise AppException("请求体超过 64KB", code=422, status_code=422)
return await authenticate_integration(request, session)
ClientId = Annotated[str, Depends(integration_client)]
def report_response(record: IntegrationAssessment) -> JSONResponse:
if record.status == "processing":
return JSONResponse(
status_code=202,
headers={"Retry-After": "5"},
content={
"code": 202,
"message": "报告正在生成,请稍后使用原参数重试本接口",
"data": {"requestId": record.request_id, "status": "processing"},
},
)
if record.status == "failed":
raise AppException("报告生成失败,请使用原请求编号和内容重新提交", code=503, status_code=503)
result = record.result_data
return JSONResponse(
content={
"code": 200,
"message": "success",
"data": {
"requestId": record.request_id,
"status": "completed",
"assessmentId": record.id,
"score": result["score"],
"level": result["level"],
"modelVersion": result["modelVersion"],
"asOfDate": record.as_of_date.isoformat(),
"reportMarkdown": record.report_markdown,
},
},
headers={
"X-Request-Id": record.request_id,
"X-Assessment-Id": str(record.id),
"X-Model-Version": result["modelVersion"],
"X-Risk-Score": f"{result['score']:.2f}",
"X-Risk-Level": result["level"],
"Cache-Control": "no-store",
},
)
@router.post(
"/report",
response_class=JSONResponse,
responses={
200: {"description": "JSON 结果,reportMarkdown 字段包含完整报告正文"},
202: {"description": "同一请求正在生成"},
},
)
async def create_external_report(
request: Request,
payload: IntegrationRiskRequest,
session: DatabaseSession,
client_id: ClientId,
) -> JSONResponse:
if payload.source_system != client_id:
raise AppException("sourceSystem 必须与 X-Client-Id 一致", code=403, status_code=403)
if request.headers.get("Idempotency-Key") != payload.request_id:
raise AppException("Idempotency-Key 必须与 requestId 一致", code=422, status_code=422)
today = datetime.now(timezone(timedelta(hours=8))).date()
if payload.assessment.as_of_date and payload.assessment.as_of_date > today:
raise AppException("asOfDate 不允许使用未来日期", code=422, status_code=422)
user = await authorize_operator(session, payload.operator, client_id)
user_id, operator_name, emp_no = user.id, user.name, user.emp_no
repository = IntegrationRiskRepository(session)
record, owned = await repository.claim(payload, user_id, today)
if not owned:
return report_response(record)
record_id, token, as_of = record.id, record.owner_token, record.as_of_date
try:
async with asyncio.timeout(get_settings().integration_report_timeout_seconds):
result = await ExternalRiskReportService(session).assess(payload, as_of)
markdown = render_markdown_report(payload, result, as_of, operator_name)
session.add(
OperationAudit(
actor_user_id=user_id,
actor_emp_no=emp_no,
action="integration.risk_assess",
resource_type="integration_assessment",
resource_id=str(record_id),
audit_metadata={
"clientId": client_id,
"requestId": payload.request_id,
"modelVersion": result.model_version,
},
)
)
if not await repository.finish(
record_id, token, result=result.model_dump(mode="json", by_alias=True), markdown=markdown
):
raise AppException("评估处理权已变更,请查询原请求", code=503, status_code=503)
except asyncio.CancelledError:
await session.rollback()
await repository.finish(record_id, token, error="请求处理被中断")
raise
except Exception as exc: # noqa: BLE001 - preserve retryable failure without exposing database/provider errors
logger.warning("External assessment failed request=%s error=%s", payload.request_id, exc.__class__.__name__)
await session.rollback()
await repository.finish(record_id, token, error="报告生成失败")
raise AppException("报告生成失败,请使用原请求编号和内容重试", code=503, status_code=503) from exc
record = await repository.get(client_id, payload.request_id)
return report_response(record)