201 lines
8.5 KiB
Python
201 lines
8.5 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
|
||
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)
|