Files
group_xinghuo_jinrong/app/main.py
T
GaoYiYuan_0626 037ce7edca docs(架构改进): 补齐 PRD/开发计划/TODO/交接文档,落地无密钥告警与 Redis 分布式锁
一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工)
- 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、
  无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试)
- 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G,
  经两轮独立 AI 评审,无阻断级错误
- 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险
  11 项的批次策略
- 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项
- 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工
- 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明

二、代码改动(T-107/108/109、T-201.1、T-201.2)
- app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复
- app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路
- app/api/audit_middleware.py:审计失败日志补 status/path/request_id
- app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与
  release_lock(Lua 原子释放,只删自己的锁)
- app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备;
  Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的
  AttributeError)安全退回进程内锁,绝不抛异常

三、文档勘误(A1/A2/A3)
- MEMORY.md:文件数 42→45、Tools 4→5
- 02-mysql-agent专用.sql:会话表 5→6
- 架构设计-风控模块.md:同步更正

四、测试
- 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、
  Fake 缺方法降级、只删自己锁、三处调用点 key 前缀
- tests/test_audit_middleware.py:补充告警字段断言
- 全量 pytest 510 passed(原基线 503)
2026-09-09 18:10:03 +08:00

125 lines
4.9 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.
"""FastAPI 入口(B7 集成 · T-01/T-02 演进):路由挂载、trace 中间件、审计中间件、lifespan、统一错误体。
lifespan(B7 挂账⑤⑥):
- 启动期校验:非 development 环境须 JWT 就绪(RS256 公钥已配置;
T-01 前的 debug 工厂在场同样拒绝——AUTH_FACTORY_IS_DEBUG 双保险);
- Redis 网关单例注册(惰性连接,publish/DEL/EXISTS 失败降级不阻塞业务);
- shutdown 统一 dispose 数据库引擎(utils/db 工厂,B6 复审 P3 泄漏收口)。
trace:X-Trace-Id 透传/生成 + 响应头回写(trace.py 约束:call_next 前 set,
同步路由线程池由 anyio 传播,一致性 B8 断言兜底)。
audit(T-02):平台访问审计(http_access)+ input_guard_log 双写见
audit_middleware;X-Request-Id 独立生成(B7 复审 P3-4)。
"""
from __future__ import annotations
import logging
import re
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import deps
from app.api.audit_middleware import audit_middleware
from app.api.chat import router as chat_router
from app.api.risk import router as risk_router
from app.api.simulate import router as simulate_router
from app.config.settings import settings
from app.service.auth_service import jwt_ready
from app.service.risk import redis_gateway
from app.utils.db import dispose_engines
from app.utils.response import error_body, register_error_handlers
from app.utils.trace import (
bind_request_id,
new_trace,
reset_request_id,
reset_trace,
set_trace,
)
logger = logging.getLogger(__name__)
# 透传外部 X-Trace-Id 的格式白名单(防响应头注入;不合规一律新生成)
_TRACE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.app_env != "development":
if deps.AUTH_FACTORY_IS_DEBUG:
raise RuntimeError(
"debug auth factory is wired but app_env is not 'development'; "
"deploy T-01 JWT auth first or set app_env=development"
)
reason = jwt_ready()
if reason:
raise RuntimeError(f"JWT auth not ready for non-development env: {reason}")
reason = jwt_ready() if settings.app_env != "development" else None
if reason:
raise RuntimeError(f"JWT auth not ready for non-development env: {reason}")
if settings.app_env == "development" and settings.jwt_dev_secret == "change-me-in-dev-only":
logger.warning(
"JWT_DEV_SECRET is the public default; issued dev tokens are forgeable "
"(demo/CI only, never expose to untrusted networks)"
)
if not settings.deepseek_api_key:
from app.service.agent_service import _DEGRADED_PREFIX
logger.warning(
"DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 %s),LLM 能力不可用",
_DEGRADED_PREFIX,
)
redis_gateway.set_gateway(redis_gateway.RedisGateway())
try:
yield
finally:
redis_gateway.set_gateway(None)
dispose_engines()
app = FastAPI(title="JinRong Agent Platform", version="0.2.0", lifespan=lifespan)
register_error_handlers(app)
app.include_router(risk_router)
app.include_router(simulate_router)
app.include_router(chat_router) # T-06 对话最小闭环
@app.middleware("http")
async def audit_middleware_entry(request: Request, call_next):
"""T-02 访问审计:先注册(执行序在 trace 之内,trace_id/request_id 已绑定)。"""
return await audit_middleware(request, call_next)
@app.middleware("http")
async def trace_middleware(request: Request, call_next):
"""trace_id/request_id 贯通 + 未捕获异常兜底(B7 复审 P2-2)。
异常发生在本中间件之内时,Starlette 的 ServerErrorMiddleware(栈外层)
生成的 500 响应不经过用户中间件——trace 头丢失的根因;此处 catch 后
直接产出统一错误体,保证 500 也带 X-Trace-Id/X-Request-Id。
"""
incoming = request.headers.get("X-Trace-Id", "")
trace_id = incoming if _TRACE_ID_PATTERN.fullmatch(incoming) else new_trace()
token = set_trace(trace_id)
request_id, rid_token = bind_request_id(request.headers.get("X-Request-Id", ""))
try:
try:
response = await call_next(request)
except Exception:
logger.exception("unhandled error on %s %s", request.method, request.url.path)
response = JSONResponse(
status_code=500, content=error_body("INTERNAL_ERROR", "internal server error")
)
finally:
reset_trace(token)
reset_request_id(rid_token)
response.headers["X-Trace-Id"] = trace_id
response.headers["X-Request-Id"] = request_id
return response
@app.get("/health")
def health():
return {"status": "ok", "env": settings.app_env}