219 lines
9.6 KiB
Python
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)
|