dieya_risk_prediction_backend/app/api/v1/integration_risk.py

219 lines
9.6 KiB
Python

"""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, Response
from fastapi.routing import APIRoute
from pydantic import TypeAdapter, ValidationError
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 IntegrationOperator, IntegrationRiskRequest, RequestId
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) -> Response:
if record.status == "processing":
return JSONResponse(
status_code=202,
headers={"Retry-After": "5"},
content={
"code": 202,
"message": "报告正在生成,请稍后查询",
"data": {"requestId": record.request_id},
},
)
if record.status == "failed":
raise AppException("报告生成失败,请使用原请求编号和内容重新提交", code=503, status_code=503)
result = record.result_data
return Response(
content=record.report_markdown,
media_type="text/markdown; charset=utf-8",
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=Response,
responses={
200: {"description": "Markdown 报告", "content": {"text/markdown": {"schema": {"type": "string"}}}},
202: {"description": "同一请求正在生成"},
},
)
async def create_external_report(
request: Request,
payload: IntegrationRiskRequest,
session: DatabaseSession,
client_id: ClientId,
) -> Response:
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)
@router.get(
"/report",
response_class=Response,
openapi_extra={
"parameters": [
{"name": "requestId", "in": "query", "required": True, "schema": {"type": "string", "maxLength": 128}}
],
"responses": {
"200": {"description": "原始 Markdown 报告", "content": {"text/markdown": {"schema": {"type": "string"}}}},
"202": {"description": "报告正在生成"},
},
},
)
async def get_external_report(request: Request, session: DatabaseSession, client_id: ClientId) -> Response:
if list(request.query_params.keys()) != ["requestId"] or len(request.query_params.getlist("requestId")) != 1:
raise AppException("查询参数必须且只能包含一个 requestId", code=422, status_code=422)
if await request.body():
raise AppException("报告查询不能携带请求体", code=400, status_code=400)
try:
request_id = TypeAdapter(RequestId).validate_python(request.query_params["requestId"])
except ValidationError as exc:
raise AppException("requestId 格式不符合约束", code=422, status_code=422) from exc
record = await IntegrationRiskRepository(session).get(client_id, request_id)
if record is None:
raise AppException("当前调用系统未找到该请求", code=404, status_code=404)
await authorize_operator(session, IntegrationOperator.model_validate(record.request_data["operator"]), client_id)
return report_response(record)