Files
group_fqcd_jr/docs/演示用/代码修改方案-2026-09-14.md
T
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

82 KiB
Raw Blame History

代码修改方案

依据:docs/演示用/代码库全面审查报告-2026-09-14.md 生成日期:2026-09-14 范围:报告列出的全部 49 项(P0×4 / P1×10 / P2×17 / P3×18) 声明:本文档只做修改规划,未改动任何代码。标注 file:line 的位置在撰写时已重新读源码核对过。


〇、阅读说明

0.1 严重级别与处置批次的关系

用户要求标 阻塞 / 重要 / 建议,同时要求区分"本次必修"与"可延后"。这两件事不是同一维度——"级别"描述后果,"批次"描述排程。因此本方案给每一项同时标注两个标签:

级别 定义 对应报告级别
阻塞 资金错账、数据损坏、重复处理、合规门禁失效、无凭证可被大规模滥用。不得上线 P0 全部 + P1-5(上调,理由见 A-5)
重要 高概率功能错误、事件循环阻塞、资源泄漏、依赖漂移、语义不一致 其余 P1 + 2 项 P2(上调,理由见 B-9 / B-10)
建议 边界瑕疵、维护陷阱、重复代码、日志口径 P2 主体 + P3
建议(低) 代码卫生、加固 P3 部分
批次 含义
必修 本次迭代必须完成,完成前不允许接入真实用户流量
应修 本次迭代应完成;若排程紧张,可在 P0 全部闭环、reviewer 同意后顺延到下一迭代
延后 明确不修,理由见 §8

0.2 ⚠️ 撰写本方案时新核实到的 6 条事实(会改变改法)

这 6 条是我在为每一项写具体改法时重新读源码才发现的,它们与只按报告字面执行的做法有实质差别,请先读完:

  1. 前端其实已经在发 Idempotency-Key,是后端从来没接收。 app/static/portal/common/api-client.js:67 已把 T002 标为 idempotent: true,:170 的逻辑是 if (endpoint.idempotent) headers['Idempotency-Key'] = options.idempotencyKey || crypto.randomUUID().replaceAll('-','')。 也就是说每笔下单请求都带着这个头,只是 trading.py:61-69 没声明该参数,于是被静默丢弃。 → A-1 不是破坏性变更(对官方前端而言)。但它同时暴露第二层问题:dashboard.js:91 用 apiClient.post('T002', body),没传 idempotencyKey,所以每次调用都生成新的随机键——网络超时后用户重试会换键,即使后端接了幂等也去重不了。必须同步改前端生成"一次下单会话一个稳定键",否则 A-1 只做一半。

  2. 报告中"T002 是唯一缺口"的结论,在"会写库"的范围内成立——我把它核实了一遍。 docs/05:1283 自己也提到 AD008/AD009 没有幂等头。我去查了 app/api/controllers/asset_allocation.py:该文件 POST 端点确实无 key 参数,但 app/service/asset_allocation_service.py 全文无 session.add / commit() / insert()(纯分析、不写库),所以它的豁免是合理的。而 recommendations.py:30/68/88 都已带 key。结论:报告 P0-1 的定性正确,不需要扩大范围。

  3. data_scope 取"全局最高范围"是一次真实事故修复的产物,不能简单改回逐权限口径。 identity_repository.py:45-54 的注释白纸黑字记录了:原先写死 self,导致 9002 (risk_operator) 与 9003 (admin) 拿着 all 级权限却什么都查不到。 → 所以 P1-8 的正确改法不是改 data_scope 的计算,而是保留它、只改"用错地方"的调用方(详见 B-8)。若按字面变更 identity_repository.py:55,会原样复现那个事故。

  4. ApiTransactionService.execute_in 与 submit_order 的提交时机存在一个残余窗口。 execute_in(api_transaction_service.py:45-89)在 action 之前写幂等记录、在 action 之后 :88 再 commit() 落 response_json。而 submit_order 自己就在 :445 commit()。 这留出一个缝隙:业务已提交、但 response_json 尚未落库时进程崩溃 → 重试会看不到回放结果 → 重复下单。 → A-1 必须包含"把 submit_order 的内部 commit 交还给 execute_in"这一步,否则幂等仍有漏洞。

  5. enforce_login_rate_limit 正好是访客令牌限流所需的全部模板。 app/api/dependencies/rate_limit.py:81-117 的实现是"按客户端 IP、不依赖认证上下文"——它本来就是为了"登录时还没有身份"这个场景写的(:84-86 注释明确说明),而签发访客令牌同样是"签发前没有身份"。基本可以照抄,不需要新设计。

  6. 顺带发现一处文档与代码不一致(建议同批修)。 docs/05:79 写 Idempotency-Key 长度 8-64,而 api_transaction_service.py:26 与 :64 实际强制 16-128。另有 测试报告/2026-09-11.md:94 记录了一次"期望 400 VALIDATION_ERROR、实际 422"的分歧。本方案不含此项定论,但建议与 A-1 同批确认并统一。

0.3 开工前必须先有结论的 3 个问题(会卡住编排)

# 待决问题 卡住哪一项 为什么必须先决定
D-1 是否允许为 _next_id 破例加 AUTO_INCREMENT A-2 选方案甲还是乙 AGENTS.md 明确禁止修改既有列类型/可空性/含义。若不允许,必须走"新增发号表"的替代方案,工作量与文档影响完全不同
D-2 T002 缺 Idempotency-Key 时是拒绝(422)还是派生兜底 A-1 拒绝 = 与其它写接口一致,但会破坏未升级的外部调用方;派生兜底 = 不破坏,但"短时间内重复同参数提交"会被误判成重试。推荐拒绝(理由见 A-1),但需要你拍板
D-3 P1-8 是否本轮动 B-8 它依赖 RBAC 种子数据的实际 scope 组合(报告 §8-1 列为未核实)。情形不同,结论在"重要"与"建议"之间跳变,工作量从 1 天降到 0.1 天

一、A 档|阻塞(本次必修)

A-1 场内下单接口接入幂等 —— 避免重复扣款与重复建仓

项目 内容
级别 / 批次 阻塞 / 必修
报告出处 P0-1(确定性缺陷)
工时 1.5 人天(含前端,不含前置核实)
可并行 否(与 A-2 / A-3 / A-5 同改 trade_service.py)

问题描述 POST /api/v1/users/me/orders(T002,app/api/controllers/trading.py:61-69)既不声明 Idempotency-Key 头,也不写 api_request_receipt。app/api/middleware.py 全文 33 行只有一个 attach_trace_id,没有全局幂等层。它是全项目唯一会写库却没有幂等保护的写接口(核实见 §0.2-2)。

叠加 §0.2-1 的新发现:官方前端已经在发这个头(api-client.js:67/170),后端从未接收。

影响范围

  • 直接写入:fin_sim_order、fin_transaction、fin_cash_ledger、fin_holding 四张表重复行;fin_sim_account.available_cash / cash_balance 重复扣减。
  • 下游读取:T003 委托列表、T006 成交明细、T007 资金流水、T001 账户看板全部出现幻影数据。
  • 反向误判:重复买入会让持仓占比虚高,进而让后续正常买入被 HoldingRatioExceededError(trade_service.py:324-328)误拒——错误会自我放大。
  • 调用方:官方前端;tools/portal.py:164、tools/portal_api_check.py:99、tools/acceptance_check.py:89 等脚本已自动补键,不受影响。

修改方案

  1. app/api/controllers/trading.py:61-69 增加头参数,写法与其它 controller 保持一致(参照 risk.py:101):

    key: str | None = Header(default=None, alias="Idempotency-Key"),
    
  2. 用 execute_in 而不是 execute(这是硬约束): ApiTransactionService.execute(api_transaction_service.py:22)自己开 SessionFactory() + session.begin(),而 submit_order 内部 :445 会 commit() —— 套进去就成了"内层提交外层事务"。必须用 execute_in(:45),它在调用方传进来的 session 上读写幂等记录。

    return envelope(await ApiTransactionService().execute_in(
        session, context, scope="trade:order:create",
        key=key, body=payload.model_dump(mode="json"),
        action=lambda s: TradeService(s).submit_order_in_session(payload, context),
    ), context)
    
  3. 把 submit_order 的内部提交交还给 execute_in(关闭 §0.2-4 的崩溃窗口)。 把 trade_service.py:288-455 拆分:

    • _submit_order_in_session(self, payload, context) —— 保留 288-443 的全部逻辑(含 :365/:400 的 flush),移除 :445 的 commit()。
    • submit_order(...) 保留为薄封装:result = await self._submit_order_in_session(...) + await self._session.commit()(向后兼容既有单测与其它调用方)。
    • execute_in 的 action 调前者;由 execute_in:88 的 session.commit() 一次性提交业务写入 + response_json,这才真正做到"幂等记录与业务写入同事务"(docs/05 §5.2 要求的语义)。
  4. 缺键策略(取决于 D-2,推荐 422 拒绝): execute_in:64-65 已内置"16-128 位 ASCII"校验,缺键会抛 ValidationAgentError → 走统一信封 422。推荐直接沿用,不再额外兜底。 理由:api_transaction_service.py:64 这条规则被全项目所有写接口共用,为 T002 开一个"缺键放行"的特例,等于制造"部分请求走幂等、部分不走"的心智负担,且违背 docs/05 §5.1。 若你担心未升级的外部调用方,折中是加一个带到期日的配置开关 TRADE_ORDER_REQUIRE_IDEMPOTENCY_KEY(默认 true,一个版本后移除,并在 TODO 登记),而不是永久保留。

  5. 前端同步改造(否则 A-1 只做一半): app/static/portal/customer/dashboard/dashboard.js:91 当前是 apiClient.post('T002', body)。改为在打开下单对话框时生成一次稳定键,并在一次成功提交后失效重建:

    let idemKey = null;                       // 作用域提升到对话框生命周期
    // 打开对话框时:idemKey = crypto.randomUUID().replaceAll('-', '');
    const response = await apiClient.post('T002', body, { idempotencyKey: idemKey });
    // 成功后:idemKey = null;
    

    api-client.js:170 已经读 options.idempotencyKey,只需确认 apiClient.post 的第三个参数能透传到 options(撰写时未验证,列为前置核实 N5)。 注意:键必须在"整次交互"稳定,不能每次点击都换,也不能在失败重试时换——这正是当前 crypto.randomUUID() 默认行为的缺陷。

  6. 文档同步:

    • docs/演示用/后端接口文档-2026-09-14.md T002 章节的"幂等"行由"否"改为"是(Idempotency-Key,16-128 位 ASCII)"。
    • 顺手统一 docs/05:79 的长度口径(文档 8-64 / 代码 16-128),并回填 测试报告/2026-09-11.md:94 的分歧结论。

风险与兼容性

  • 最大隐藏前置:execute_in 的幂等能力依赖 api_request_receipt 上的唯一键(INSERT ... ON DUPLICATE KEY UPDATE 若无唯一索引会退化成每次插新行,幂等完全失效)。必须先验证该唯一索引存在,见 N1。
  • 破坏性面:任何不带键的调用方会从 201 变 422。已知带键的工具脚本不受影响;未知调用方需先扫一遍(N5)。
  • 哈希稳定性:request_hash 由 digest()(api_transaction_service.py:16)计算,json.dumps(sort_keys=True, default=str) 保证字段顺序无关,default=str 覆盖 OrderCreateRequest 里的 Decimal。同一笔业务的重复请求哈希一致。
  • 错误码混淆:缺键 422 与"数量不是 lot_size 整数倍"422 同码。前端要用 error.message 区分,不能只看状态码。
  • 与 A-3 的交互:execute_in:74-77 自身对幂等记录 with_for_update(),会把同键请求串行化;叠加 A-3 的账户行锁后,同一用户的并发下单会被完全串行化。这是资金安全下的正确取舍,但要在压测里确认超时可接受。

验证与回归

  • 直接复用现成模板:tests/integration/test_risk_idempotency_mysql.py 已覆盖三条关键断言(同键不重复执行 / 同键不同正文 409 / 缺失或过短 422)。复制一份交易版,把 action 换成下单。
  • 手工:curl 连发两次同键 → 两次响应体完全相同;fin_sim_order 只有 1 行;available_cash 只扣一次。
  • 并发:10 路并发同键 → 恰好 1 次成交,9 次回放。
  • 崩溃窗口:mock response_json 写入前中断 → 重启后同键重试不产生第二笔订单(这条专门验 §0.2-4 的修复)。
  • 回归:T001 看板 / T003 列表 / T005 撤单 / T006 成交;尤其要确认下单九步校验链的状态码没有被 execute_in 改变(不可交易 422 → 适当性 422 → 行情过期 503 → 账户 404 → 数量 → lot_size → 资金 422 → 占比 422 → 可用份额 422)。
  • 工具:python tools/e2e_smoke_test.py、python tools/acceptance_check.py 全绿。

A-2 _next_id() 用 SELECT MAX(id)+1 发主键 —— 并发下必然主键冲突

项目 内容
级别 / 批次 阻塞 / 必修
报告出处 P0-2(确定性缺陷)
工时 甲 1 人天 / 乙 1.5 人天
可并行 否(须在 A-3 之后或同批)

问题描述 app/service/trade_service.py:111-123 用 SELECT MAX(id)+1 手动发号。alembic/baseline_generated.sql:270-271 已确认 fin_sim_order.id 为 `id` BIGINT UNSIGNED PRIMARY KEY 无 AUTO_INCREMENT(fin_transaction 在 :310 同形)。 调用点 4 处,全部是金融主表::344 FundSimOrder、:371 FundTransaction、:430 FundCashLedger、:471 FundHolding。submit_order 一次要发 3 个 id。

影响范围

  • 症状:第二个客户的并发请求直接 500(IntegrityError → 事务回滚)。
  • 严重时引发连锁:A-3(行锁)上线后并发窗口反而变宽,冲突概率上升。
  • 受影响入口:下单(submit_order);任何新建持仓的路径(_upsert_holding:471)。

修改方案

方案甲(推荐,取决于 D-1)—— 加 AUTO_INCREMENT

  1. 新增一个 Alembic 迁移,对 4 张表做 ALTER TABLE <table> MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT;
  2. 显式设置自增起点:ALTER TABLE <table> AUTO_INCREMENT = <当前 MAX(id)+1>;(MySQL 通常自动取 max+1,但显式指定可避免施工期数据变动导致的意外)。
  3. 删除 _next_id 方法,4 处调用改为不传 id(交由 DB 发号)。
  4. 必须同步:把该方法 docstring 里"并发与单测场景下够用"这句话一并删掉——它现在正在误导后来者,是该缺陷存活至今的原因之一。

方案乙(若 D-1 不允许改基线)—— 引入独立发号器

  1. 新增一张发号表 fin_id_allocator(table_name VARCHAR(64) PRIMARY KEY, next_id BIGINT UNSIGNED NOT NULL)。
  2. _next_id 改为原子取号:INSERT ... ON DUPLICATE KEY UPDATE next_id = LAST_INSERT_ID(next_id + 1) + SELECT LAST_INSERT_ID()(MySQL 单语句原子,不依赖事务隔离级别)。
  3. 说明:会断号(事务回滚不归还),业务侧不存在"必须连续"的约束(订单号 order_no 另有一套生成规则),可接受。

⚠️ 重要决策差异:方案乙会新增一张表,从而让全库表数从 89 变成 90。上一轮文档审计刚刚把 AGENTS.md、docs/02/05/08/09/28 等处的口径统一为"89 业务表 = 场内 51 + 场外/推广 17 + 投顾 21",方案乙会让这些文档全部重新失效。这个隐藏成本必须计入排期。

执行顺序约束 建议 A-3(行锁)→ A-2(发号器):加锁后并发冲突更容易被压测打出来,正好用来验证发号器修复是否真实生效;反过来先做 A-2 则缺少可复现的验证手段。

风险与兼容性

  • 甲:ALTER TABLE 属 DDL,会重建表;fin_transaction 若有外键引用需确认 FK 行为;大表需低峰执行或走 gh-ost / pt-osc。
  • 甲:AGENTS.md 明令禁止修改既有列。本项属修复与设计稿的偏差(docs/00 的设计原意就是自增),需一次显式豁免并在 AGENTS.md 留痕。
  • 乙:如上,文档口径冲击。
  • 共有:若有单测依赖固定 id 值会失败;fin_sim_order 列表的分页依赖 id 单调性(自增仍满足)。
  • 回滚性:方案甲一旦上线,回滚 = 反向 DDL;建议先备份、并在副本库演练。

验证与回归

  • 迁移先在数据库副本上跑,前后各执行 python tools/audit_schema.py 与 python tools/audit_constraints.py 比对。
  • 并发:20 路并发下单(不同客户),断言无 IntegrityError、无重复 id、order_no 唯一。
  • 回归:重跑造数脚本 python tools/seed_custom_holdings.py、python tools/portal.py 等。
  • Alembic head 仍为单一(python -m alembic heads)。

A-3 submit_order 读账户/持仓未加锁 —— 可突破持仓上限、可超卖成负

项目 内容
级别 / 批次 阻塞 / 必修
报告出处 P0-3(确定性缺陷)
工时 0.5-1 人天(含 gap lock 观察)
可并行 否(与 A-1/A-2/A-5 同文件;且应排在 A-2 之前)

问题描述 trade_service.py:297-298 读取账户与持仓时无行锁。Grep with_for_update 在整个 trade_service.py 零命中。而 app/service/offsite_fund_service.py 的 confirm_document 是用了行锁的——证明团队掌握这个模式,此处是遗漏。

影响范围

  • 突破持仓上限:客户已接近 single_investor_max_holding_ratio,并发提交两笔买入 → 两笔都读到旧的 holding.total_quantity、都判"未超限"、都通过。
  • 超卖:持仓 1000 份,并发两笔各卖 1000 份 → 两笔都读到 available_quantity=1000、都通过 → available_quantity 变负。
  • 受影响表:fin_holding、fin_sim_account;间接影响后续所有持仓/风控判断(risk_repository.py 有 8 处以上 join FundCustomerProfile)。

修改方案

  1. 不要无条件给 _load_account / _load_holding 加 with_for_update()。 这两个私有方法除了写路径 :297-298,还被只读端点调用(:686 与 :722,账户看板相关)。无差别加锁会把行锁带到读路径,造成性能退化并引入死锁面。 改法 —— 加关键字参数,默认关闭:

    async def _load_account(self, customer_id: int | str, *, for_update: bool = False) -> FundSimAccount:
        stmt = select(FundSimAccount).where(FundSimAccount.customer_id == customer_id_int)
        if for_update:
            stmt = stmt.with_for_update()
        ...
    

    _load_holding 同理。只有 :297-298 传 for_update=True。

  2. 固定加锁顺序:先 account(按 customer_id),再 holding(按 customer_id + product_id)。 在 _load_holding(..., for_update=True) 的 docstring 里明确写"必须在 _load_account(for_update=True) 之后调用"。目前全仓只有此处同时持有两把锁,但 _upsert_holding(:457,内部 :471 还嵌着 _next_id(FundHolding))与 _reduce_holding(:425)都在锁内执行,一旦将来出现反向路径必然互锁。

  3. 好消息:check-then-act 本就在同一事务内,不需要结构性改动。 :312-328(买入的资金/占比校验)与 :333-337(卖出的可用份额校验)都发生在 :298 之后、:404-443 扣减之前,且唯一的 commit 在 :445。加锁后它们天然落在同一把行锁的保护下。

  4. 待确认的前置(N8):撤单端点 T005 若也会改动 holding(frozen/available),必须同样加锁,否则形成"下单持锁、撤单不持锁"的半边锁,A-3 等于没做。这一条我在本方案撰写时未能确定,已列入批次 0 的核实清单。

  5. 建议在 TradeService 类顶部加一段注释,写明"本服务的写路径必须遵循 账户 → 持仓 的加锁顺序",防止后续新增方法重蹈覆辙。

风险与兼容性

  • gap lock 副作用(最值得警惕):在 MySQL REPEATABLE READ 下,SELECT ... FOR UPDATE 对不存在的行会加 gap lock。_load_holding 返回 None(客户首次买入某产品)时,并发的"不同客户首次买入同一产品"会互相阻塞。这是真实副作用,压测时必须观察。 若不可接受,替代方案:对"无持仓"分支改用 SELECT id ... FOR UPDATE 走唯一索引减少锁定范围,或改为 INSERT ... ON DUPLICATE KEY UPDATE 幂等建仓。
  • 死锁:见上第 2 点。建议在测试环境临时开启 innodb_print_all_deadlocks 抓一次确认无 1213。
  • 同用户串行化:fin_sim_account 行被锁住期间,同一用户的所有下单请求排队——这是资金安全的正确取舍,但叠加 A-1 后串行化程度更高,需要确认超时预算。

验证与回归

  • 持仓上限用例:并发两笔买入,合计会突破占比 → 断言第 2 笔返回 422 HOLDING_RATIO_EXCEEDED,且总持仓不超过上限。
  • 超卖用例:持仓 1000,并发两笔各卖 1000 → 断言恰好 1 笔成功、1 笔 422 INSUFFICIENT_HOLDING,且 available_quantity 不为负。
  • 死锁探针:并发混合买/卖 200 次,断言无 Deadlock detected (1213)。
  • 回归 T001 账户看板不得被加锁:关键是断言 :686 / :722 走的是 for_update=False 分支;可用 performance_schema 或 SHOW ENGINE INNODB STATUS 抽查读路径无锁等待。
  • 必须与 B-9(连接池)同批部署 —— 加锁延长了事务持有时长,不扩容连接池会直接把 5+10 打满(见 B-9)。

A-4 访客令牌端点零认证、零限流

项目 内容
级别 / 批次 阻塞 / 必修
报告出处 P0-4(确定性缺陷)
工时 0.5 人天
可并行 是(与其它项零冲突)

问题描述 app/api/controllers/visitor_tokens.py 全文 14 行,APIRouter 定义(:5)没有任何 dependencies;签发函数无参数、无限流。 对照:app/api/controllers/auth.py:26-28 给登录端点专门挂了 enforce_login_rate_limit,注释写明"这是全平台最需要限流的端点(密码爆破的入口)"。

影响范围

  • 攻击者可毫秒级铸造海量有效 JWT,每个都能通过 build_request_context。
  • 关键放大点:/api/v1/agent-runs 的限流是按 user_id 计的,而访客 token 的 sub 每次都是新随机值 → 限流被天然绕过。
  • 后果:免费消耗 LLM 额度 / 灌爆 agent_run 与 conversation 表 / 挤占 worker 队列。

修改方案

  1. app/api/dependencies/rate_limit.py 新增 enforce_visitor_token_rate_limit()。 §0.2-5 已说明:enforce_login_rate_limit(:81-117)就是所需模板。它为"签发前还没有身份"的场景设计,按 IP 计数、不依赖认证上下文——照抄结构即可:

    VISITOR_WINDOW_SECONDS = 300
    VISITOR_MAX_ATTEMPTS = 20
    VISITOR_COUNTER_PREFIX = "visitor_token"
    

    阈值建议做成配置项而不是硬编码(加到 app/core/config.py 的 Settings),因为演示场景的真实用量只能在现场标定。

  2. visitor_tokens.py:5 的 router 加依赖(挂在 router 而不是单个函数,与 auth.py:28 的做法一致,保证以后新增端点不会漏):

    router = APIRouter(
        prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"],
        dependencies=[Depends(enforce_visitor_token_rate_limit)],
    )
    
  3. 建议顺带做(成本低):给访客身份加可追溯维度。 VisitorTokenIssuer.issue() 目前生成纯随机 sub。建议在 token payload 中加入客户端 IP 的哈希,使后续限流与审计能关联到同一来源。 ⚠️ 不要把原始 IP 写进 JWT payload:JWT 对客户端可读,且用户换网会导致校验失败。存哈希即可。

  4. 若部署在反向代理后(重要):rate_limit.py:98 用 request.client.host,不读 X-Forwarded-For。这会退化为"按代理 IP 计数"。 本方案建议:先在网关层做 IP 维度限流,代码侧保持现状作为第二道。不要贸然在代码里读 X-Forwarded-For——那必须建立"只信任来自网关的连接"的机制,否则伪造一个头就能绕过(比现在更糟)。

风险与兼容性

  • 误伤共享出口 IP:公司 NAT、演示现场 WiFi 下多个真实用户会被合并计数 → 演示时有人领不到令牌。阈值必须在真实流量下标定,因此建议配置化。
  • 限流本身 fail-open:rate_limit.py:59-61 与 :104-106 在 Redis 不可用时放行。Redis 挂了这道防线就消失。是否改为 fail-closed 属产品决策(对应 P2-16),本项不强行改;但建议至少让告警日志带上 IP 前缀,便于事后追溯。
  • /internal/** 健康检查端点不受影响。

验证与回归

  • 脚本连打 30 次 → 第 21 次起 429 + Retry-After 响应头 + retryable=true(:87 已有该机制)。
  • 停掉 Redis → 仍能签发(fail-open 为预期行为),日志出现"限流后端不可用"。
  • 回归:客服浮窗的访客首访→首次问答完整链路仍可通过;python tools/e2e_smoke_test.py 若含访客链路需重跑。
  • NAT 场景:同一 IP 连续 5 个真实用户,确认在第 20 次以内都能拿到令牌。

A-5 卖出手续费可能超过成交额 —— 账户被倒扣(P1 → 上调阻塞)

项目 内容
级别 / 批次 阻塞 / 必修(由报告 P1-5 上调)
报告出处 P1-5
工时 0.5 人天
可并行 否(同文件,紧跟 A-3)

为什么上调为阻塞 报告把它放在 P1,但它的后果是账户资金被倒扣,属于资金错误而非显示瑕疵——与 P0-1(重复扣款)、P0-3(扣成负数)是同一类损害。而且在演示环境里,"卖 1 份低价产品"这种操作极易被触发。从"资金正确性"这个维度看,它应当与 P0 同批。

问题描述

  • trade_service.py:280-284 _compute_fee:if fee < rule.minimum_fee: fee = rule.minimum_fee —— 应用最低手续费无上界保护。
  • :329-332 net_amount = gross_amount - fee_amount —— 无正负检查。
  • :416-422 卖出分支直接 account.cash_balance += net_amount —— 负数也会被加上去。

影响范围

  • fin_sim_account.cash_balance / available_cash 被扣减。
  • fin_cash_ledger 出现负数的"卖出回款"(:424 ledger_amount = net_amount),与 entry_type="卖出回款"(:423)语义直接矛盾。
  • balance_after(:436)会记录一个"倒扣后"的余额,污染资金流水的可审计性。

修改方案

  1. _compute_fee 加卖出语义的上界:

    def _compute_fee(self, gross: Decimal, rule: _FeeRule, *, order_side: str) -> Decimal:
        fee = gross * rule.fee_rate + rule.fixed_fee
        if fee < rule.minimum_fee:
            fee = rule.minimum_fee
        if order_side == "sell" and fee > gross:   # 新增:卖出不得倒扣
            fee = gross
        return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
    

    调用点 :310 同步补 order_side=payload.order_side。买入不受影响(买入本就是 gross + fee,不存在上界问题)。

  2. 兜底断言(即使第 1 步被绕过也要拦住):在 :330-332 计算 net_amount 之后加

    if net_amount < 0:
        raise ValidationAgentError("本次卖出不足以支付手续费,无法成交")
    
  3. 事前拒绝 + 事后兜底,两者都要。第 2 步保证了"绝不会倒扣",但没有可读提示。建议在卖出校验链里再补一条明确的拒绝理由: if gross_amount <= fee_rule.minimum_fee: raise ValidationAgentError(f"卖出金额 {gross_amount} 元低于最低手续费 {rule.minimum_fee} 元,无法成交")。

  4. 需要跟业务确认(不阻塞本项):fin_fee_rule.minimum_fee 若确实是 5 元,那么"卖 1 份"到底应该是拒绝交易,还是这笔业务的正常行为就是如此?两种都合理,但"不得倒扣"是确定的技术底线,无论业务如何决定都要做。

风险与兼容性

  • 原本"能提交但倒扣"的请求会变成 422。需确认前端 dashboard.js:91-97 的失败分支也会像成功分支那样把 message 显示出来(成功分支用的是 alert.textContent = ...;失败分支路径在本次撰写时未验证,列入 N5)。
  • 历史脏数据不会被自动修复。若库中已有负余额或负回款,需要一次性对账与冲正脚本,不在本方案工作量内,请单独排。

验证与回归

  • 用例:minimum_fee = 5,卖出成交额 1.00 → 断言 422 且事务完全回滚(fin_sim_order / fin_transaction / fin_cash_ledger 零新增)。
  • 用例:minimum_fee = 0,极小成交额 → fee 正常、net_amount > 0。
  • 不变性断言(建议加入常规测试):fin_cash_ledger.balance_after 必须始终等于同笔操作后的 fin_sim_account.cash_balance。这条断言能同时守护 A-1(重复扣款)与 A-5。
  • 回归:正常买入/卖出的金额计算不受影响(买入尤其不能引入上界逻辑)。

A-6 check_compliance 死代码 + governance docstring 与代码相反

项目 内容
级别 / 批次 阻塞 / 必修(因其属合规门禁类失效)
报告出处 P1-1(确定性缺陷)
工时 0.5 人天
可并行 是(唯一约束:删除前需 N3 结论)

问题描述(三件事叠在一起,缺一件等于没修)

  1. app/service/agent/base.py:162-165 定义 check_compliance,但它调用 governance.review(result, context, self.config, self.memories) 时没有传 agent_type。
  2. 真实链路 base.py:114-116 的 execute() 直接调 governance.review(..., agent_type=self.definition.agent_type),绕过了 check_compliance → 全仓零生产调用点(死代码)。
  3. 一旦有人按方法名的语义调用它:governance.py:219 customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES → 空串得 False → :281 跳过追加免责声明 → F5 合规红线静默失效。
  4. 其危险之处在于:governance.py:211-212 的 docstring 写着"空串按'调用方未声明'处理,保守照旧追加,避免漏加"——与实际行为完全相反。维护者读完会以为"忘记传 agent_type 是安全的"。

影响范围

  • 合规红线 F5(面向客户输出必须 100% 附固定免责话术)。当前尚未造成事故(因为是死代码),但若未来接线就会静默失效。
  • 更大的危害是那条反向 docstring 正在主动误导。

修改方案(必须一起做的三件事)

  1. 修正 docstring governance.py:211-212,使其描述与 :219 / :281 的实际行为一致(空串 = 不追加),并明确写出"调用方必须传 agent_type"。

  2. 让"漏传"当场失败而不是静默降级(治本,推荐): 在 review() 入口加 if not agent_type: raise ValueError("review() 必须显式传入 agent_type")。 与其把它降级成"不加免责声明",不如让它炸掉。已知调用点 base.py:114 已正确传参,安全。

  3. 处理死代码本体:

    • 推荐甲:删除 check_compliance,并从 base.py:88-92 的"禁止覆写名单"中移除;同步更新两处测试——tests/unit/service/test_agent_governance.py:20(断言它不可覆写)与 tests/unit/service/test_customer_service_agent.py:170(清单)。
    • 乙(不推荐):改为传 agent_type=self.definition.agent_type。但这会形成"两处都能走合规"的分支结构,是新的混乱源。
    • ⚠️ 前置(N3):docs/09:124 把 check_compliance 列为"业务代码不得覆盖"的公开 API。删除前必须确认仓库外无调用者,并在同一 PR 内先在 docs/09 显式作废该承诺。
  4. 补契约测试防回归:断言 BaseAgent 不存在 check_compliance 属性;断言 governance.review 在缺 agent_type 时抛错而非静默跳过。

风险与兼容性

  • 删除公共方法属 API 破坏,且与 docs/09 的既有承诺冲突 → 必须文档先行、同 PR 完成。
  • 若 review() 改为必填参数,任何遗漏的调用点会在启动时炸(这正是想要的),但要确保测试替身与自定义 Agent 都被覆盖到位。
  • docs/06:307 把"接线 check_compliance"列为 P1 欠债——本项完成后,该条目应改为"已删除,理由见审查报告"。

验证与回归

  • Grep check_compliance → 应仅剩测试中的"不存在性断言"。
  • 端到端:客服回答尾部必须含固定免责话术;临时把某 Agent 的 agent_type 改成非客户面向值,断言话术不再追加(证明门禁真的在工作,而不是恒真)。
  • 风控 Agent(INTERNAL_AGENT_TYPES = {"risk"},governance.py:56)行为不变。
  • tests/unit/service/test_customer_service_agent.py 与 test_agent_governance.py 全绿。

二、B 档|重要(本次应修)

以下每项按「问题描述 — 影响范围 — 修改方案 — 验证与回归」组织,篇幅较 A 档精简。


B-1 异步路径中调用同步 Milvus 客户端 —— 阻塞事件循环

P1-2 | 重要 | 应修 | 1-1.5 人天 | 可与大部份项并行

问题描述:4 处在 async def 里直接调用同步 MilvusClient:memory_recall_service.py:175、knowledge_search_service.py:207-213/224/229、knowledge_schema.py:205、projection_cleanup_service.py:115/123/127。

影响范围:一次 Milvus 慢查询会冻结整个 FastAPI 进程(含所有协程与 worker 心跳,心跳缺失可能被误判 lease 丢失)。客服 search_knowledge 工具超时是 10s,期间进程完全无响应。

修改方案——分两类处置,不能一刀切(这是我复核出的关键差别):

类别 涉及位置 改法
知识检索类 knowledge_search_service.py、knowledge_schema.py 直接换成已存在的异步实现 app/infrastructure/milvus_adapter.py MilvusKnowledgeClient(内含 AsyncMilvusClient,await client.search(...),白名单校验,失败转 RecoverableAgentError)。⚠️ 不是改一行:它有 ALLOWED_COLLECTIONS 白名单(:72)与 MIN/MAX_TOP_K(:35-36),接口形态与同步 MilvusClient.search 不同
记忆召回 / 投影清理类 memory_recall_service.py:175、projection_cleanup_service.py 这两处用的是本地 MilvusClient 直连 settings.milvus_uri 的记忆集合(不是知识集合),且需要 list_collections(不确定 AsyncMilvusClient 有无异步等价方法)。稳妥改法:await asyncio.to_thread(client.search, ...) + 外层 asyncio.timeout(n),与项目既有规范一致(参照 fund_market_adapter.py:273/382、offsite_fund_service.py:1542)

前置(N6):app/service/knowledge_retrieval_service.py 用的正是正确的 await self.client.search(...),但全仓无生产实例化点(仅单测引用)。它很可能就是为此准备的替代实现却从未接线——先确认,若是则直接接线而非重写。

风险与兼容性:

  • 超时后不能返回空结果——那会把"向量库挂了"伪装成"知识库没有内容"。必须抛 RecoverableAgentError 走降级(该模块 docstring 明确要求)。
  • asyncio.to_thread 默认线程池有上限,Milvus 持续慢时会占满。建议配超时 + 观察是否需要独立线程池。
  • 语义召回结果必须保持既有行为(合并顺序、confidence = min(1.0, |score| * 0.7) 不得漂移)。

验证与回归:

  • 打桩注入 3 秒延迟到 search 路径,用另一并发协程测量 P99 延迟不受影响。
  • Milvus 不可用时,客服问答仍返回(降级到 MySQL LIKE),且不出现"知识库无内容"的假话术。
  • python tools/e2e_smoke_test.py;记忆召回结果与改动前逐条比对(Top-K 顺序与 score)。

B-2 Milvus 客户端从不关闭 + 无 shutdown 钩子

P1-3 | 重要 | 应修 | 0.5-1 人天 | 应与 B-11 同做

问题描述:bootstrap.py:117-129 用 @lru_cache(maxsize=1) 持有一个进程级永生的 MilvusClient;projection_cleanup_service.py:115-127 每次调用新建局部变量且从不 close()。已确认 app/main.py:56 的 FastAPI(title=..., version=...) 没有传 lifespan(Grep lifespan 在 main.py 无命中)。

影响范围:批量记忆清理触发 _cleanup_vector 100 次 → 100 个未关闭的 MilvusClient 与底层 gRPC 通道泄漏。MilvusKnowledgeClient.close() / aclose()(milvus_adapter.py:124-128)有定义但全仓无调用点。

修改方案:

  1. app/main.py:54-56 引入 lifespan(注意 app = create_app() 在模块级,lifespan 需在 create_app 之前定义或写成内部闭包):
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        yield
        try:
            await shutdown_all()
        except Exception:
            logger.warning("shutdown 失败", exc_info=True)
    application = FastAPI(..., lifespan=lifespan)
    
  2. 新增 app/infrastructure/shutdown.py 统一收集需释放的单例:get_vector_memory_adapter(需 cache_clear())、default_counter_backend、build_graph_driver、MilvusKnowledgeClient 实例。
  3. projection_cleanup_service._cleanup_vector 改 try/finally: client.close(),优先方案是改为复用 bootstrap 的单例,不再每次新建——同时解决了 P1-2 与 P1-3。
  4. default_counter_backend 必须一并纳入(见 B-11),否则只修一半。

风险与兼容性:yield 之后的 shutdown 代码要用 try/except 兜住,自身异常不能掩盖业务异常;测试里 create_app() 会被复用,需确保 shutdown 内部容错、不会让单测尝试连接真实 Milvus。

验证与回归:单测断言 await client.close() 后 _client is None;批量跑 100 次记忆清理后观察进程连接数不增长;加一条 app.main 的 lifespan 单测。


B-3 依赖声明双源漂移

P1-4 | 重要 | 应修 | 0.25 人天(+0.25 写校验) | 完全独立,可并行

问题描述:requirements.txt:1 自称 source of truth 是 pyproject.toml,但::23 的 openai>=1.0,<2 在 pyproject.toml:10-39 完全不存在;aiosqlite 在 pyproject.toml:45 与 :51 重复声明两次;:54-59 把 dev 依赖混进运行时文件。

影响范围:用 requirements.txt 装的环境会多一个无人使用的 openai(Grep "import openai" / from openai 全仓零命中);两文件将持续漂移,最终导致"本地能跑、线上不能跑"。

修改方案:

  1. 由 pyproject.toml 重新生成:pip-compile 或 uv export --no-hashes -o requirements.txt。
  2. 短期手动版:删 openai、去重 aiosqlite、把 dev 依赖拆到 requirements-dev.txt。
  3. 加一条校验脚本(放 tools/)断言两文件依赖集合一致,挂到 CI 或 pre-commit。
  4. 顺手处理 P2-17:.workdir/zsy_v2/z_pyproject.toml:24 那份把已被明确否决的 milvus-lite 列进 runtime 的陈旧副本(主 pyproject.toml:52-56 注释说明刻意排除)——删除或加 DEPRECATED 标记。

风险与兼容性(N7):删 openai 前再确认一次是否存在字符串形式的动态导入(importlib.import_module("openai") 会被 Grep "import openai" 漏掉)。若有则需保留。建议在干净环境完整安装验证后再合。

验证与回归:新建虚拟环境 pip install -r requirements.txt → 服务可起;python tools/e2e_smoke_test.py 通过;新校验脚本对新旧两文件都跑一遍。


B-4 _distribution 排序用 str(key) 却用原 key 索引

P1-6 | 重要 | 应修 | 0.25 人天 | 完全独立,可并行

问题描述:app/service/risk_daily_report_service.py:363 把键转成 str 排序,:367 却用原 key 索引 counts。

影响范围:若 risk_level 被存为整数 1(不在 preferred_order 的 ("高","中","低") 中),keys 追加 "1",随后 counts["1"] → KeyError → 日报生成整体 500。

修改方案:

# 原:keys.extend(sorted(str(key) for key in counts if key not in preferred_order))
keys.extend(sorted((k for k in counts if k not in preferred_order), key=str))

保留原键,只在排序环节取 str。同时把 :330 的 name 展示统一包一层 str(key),避免出现 1风险 这类奇怪字面量(虽然不报错)。

额外确认:检查 preferred_order 的元素类型与 counts 的键类型是否一致。若不一致,key in counts 恒为 False、永远走 extend 分支——本修复能让它不崩,但排序会退化。

风险与兼容性:极低,纯局部修复。

验证与回归:构造 items=[{"risk_level": 1}] 的单元断言不抛 KeyError、name == "1风险";手工生成一次日报;确认正常数据(高/中/低)输出与改前逐字段相同。


B-5 NL2SQL 结果无截断标记

P1-7 | 重要 | 应修 | 0.5 人天(含文档) | 完全独立,可并行

问题描述:app/service/financial_nl2sql_service.py:329 直接拼 LIMIT {plan.limit};:261-265 把 total 设为 len(rows),没有 truncated 标志。

影响范围:limit 默认 50。当查询恰好返回 50 行时,调用方无法区分"真只有 50 行"与"被 LIMIT 截断"。风控/投顾据此统计(如"共 50 笔大额交易")会系统性低估。

修改方案——照抄项目已有样板 risk_repository._capped 的 limit + 1 手法:

  1. :329 → return f"{sql} LIMIT {plan.limit + 1}", params。安全前提不变:plan.limit 由 Pydantic ge=1, le=200(nl2sql_contracts.py:14/:37)兜住,插值仍是整数。
  2. :261-265 改为:
    rows = await self._execute(sql, params)
    truncated = len(rows) > plan.limit
    rows = rows[: plan.limit]
    result["data"] = {"total": len(rows), "rows": rows, "truncated": truncated, "limit": plan.limit}
    
  3. _result(...) 第 7 个参数的 row count 要在切片之后再传(:263 位置)。
  4. dry_run 分支(:256-260)会向用户展示 LIMIT 51,是个小困惑点——建议在 dry_run 里单独拼回 plan.limit 展示。

风险与兼容性:truncated 是纯增量字段,旧客户端忽略即可;但若下游有严格 schema 校验需先升级。需同步 docs/演示用/后端接口文档 中 NL2SQL 相关章节。

验证与回归:构造恰好 200 行 → truncated=true、len(rows)==200;199 行 → truncated=false;显式传 limit=5 而总行数 10 → truncated=true, len==5;GROUP BY 与无 GROUP BY 两种形态都要覆盖。


B-6 接口用裸 int(cursor),绕过 parse_cursor 的契约校验

P1-9 | 重要 | 应修 | 0.25 人天 + 前端排查 0.25 | 可并行

问题描述:app/api/controllers/trading.py:81、:135、:163 三处是 cursor_id = int(cursor) if cursor else None。

影响范围:?cursor=abc → int() 抛 ValueError,未被包装成 400 INVALID_CURSOR,很可能冒泡成 500。?cursor=0 / 负数不报错但查询 id < 0 返回空列表,客户端误以为"翻到底"(静默数据丢失)。

修改方案:统一改用 app/core/cursor.py:32 已有的 parse_cursor(cursor, field="cursor")。它已实现:拒绝非 ASCII / 非十进制(+1、1.0、0x10、1e3、Unicode 数字)、拒绝 < 1 与 > 2**63-1、空串按 None、且错误消息不回显客户端原值(避免日志投毒)。

前置(N5):确认这三个端点的 cursor 语义确实是 core/cursor.py docstring 所说的"记录 ID 边界";若某处是时间戳游标则不能套用。

风险与兼容性:?cursor=0 从"返回空列表"变成 400 —— 若有客户端把 0 当"首页",会被打断。必须扫一遍前端(app/static/portal/**)确认没有传 0 或负数。

验证与回归:?cursor=abc → 400 INVALID_CURSOR;?cursor=0 → 400;?cursor=99999999999999999999 → 400;正常翻页无 off-by-one(limit+1 探测已由既有的 core/cursor.py 保证)。


B-7 场外规则:total_fund_shares 只拦 == 0 不拦负数

P1-10 | 重要 | 应修 | 0.25 人天 | 完全独立,可并行

问题描述:app/service/offsite_fund_rules.py:83-84 只判断 total_fund_shares == 0,因此负数能通过。随后 :95 ratio > TWENTY_PERCENT(ratio 为负 → 恒"正常")与 :107-111 current_shares > limit(limit 为负 → 恒"异常")给出相反结论,且都不报"数据异常"。

影响范围:同一份脏数据在"申购后持有比例"和"申购单笔份额上限"两条规则上结论相反——审核人员会看到自相矛盾的校验结果,且无从判断是数据问题。

修改方案::84 的 total_fund_shares == 0 改为 <= 0。同时核对该类所有用它做分母或比较的位置(:91/:95/:107/:111)是否都在这一处 early-return 的保护范围内——从代码看是同一段,改一处即可,但赎回分支要一并确认。

风险与兼容性:会让原本"给结论"的脏数据变成"无法判断"。需确认下游(场外 OCR 单据校验流程)能接受该枚举——规则引擎本身已有 正常/异常/无法判断 三态,且用于缺失字段场景,应当可接受。

验证与回归:单测 total_fund_shares = -1000 → 四条相关规则全为"无法判断",且两条规则不再给出相反结论(这是判定修复成功的关键断言);= 0 的行为与改前一致。


B-8 data_scope 全局最高范围 vs 逐权限口径并存

P1-8 | 重要 | 应修(取决于核实) | 核实 0.5 + 改造 1 | 必须最先核实

问题描述:app/repository/identity_repository.py:33-55 把 data_scope 设为用户所有权限中的最高范围。项目内两种口径并存:public_platform_service.py:276 用 context.permission_scopes.get(permission)(逐权限,正确样板);而 risk_evidence_archive_service._scope_condition、financial_nl2sql_service.py:419 用全局 data_scope。

⚠️ 关键——不要按字面去改 identity_repository.py:55(§0.2-3): 该行上面的 :45-54 注释记录了这是一次已修复的真实事故的产物:原先写死 "self",结果导致 9002 (risk_operator) 与 9003 (admin) 拿着 all 级权限却什么都查不到。若把 data_scope 改回逐权限口径,会原样复现那个事故。它被 risk_query_service.py:198、risk_analysis_service.py:126、risk_evidence_archive_service.py:218、risk_action_service.py:212 依赖。

正确改法:保留 data_scope 的计算逻辑不动,只在需要按单权限判定的调用方改用 context.permission_scopes[<本次校验的权限码>]。

前置(决定本项工作量):先做报告 §8-1 的核实——读 tools/seed_test_rbac.py 与 sys_role_permission 实际内容,逐角色确认是否真的存在"A 权限 scope=all + B 权限 scope=self"的组合。

  • 若无此组合:本项从"重要"降为"建议"——只需在 identity_repository.py:55 上方补一句注释("全局 data_scope 不得用于单权限判定,请改用 permission_scopes[code]"),工作量从 1 天降到 0.1 天。
  • 若存在:按上述方向改造两个调用方。

风险与兼容性:改 filter 逻辑可能让某些角色突然查不到数据(收紧)或看到更多(放松),两者都是事故。必须逐角色出前后对比报告。

验证与回归:对每个角色跑同一组查询,改动前后结果集 diff 必须为空(除有意收紧的用例外);重点覆盖 own_customers 这一档(最容易出现"子集判断"错误)。


B-9 连接池未显式配置(P2-6 上调为重要)

P2-6 | 重要 | 应修 | 0.25 人天 | 必须与 A-3 同批部署

问题描述:app/infrastructure/db.py:34 是 create_async_engine(_url, pool_pre_ping=True),没有 pool_size / max_overflow / pool_timeout / pool_recycle → 使用默认 5 + 10。

为什么上调: 一次 Agent 运行可有数十次模型 + 工具调用;A-3(行锁)与 A-1(幂等 with_for_update)会显著延长事务持有时长。锁等待时间变长 → 单请求占用连接的时间变长 → 5+10 必然打满,报 TimeoutError: QueuePool limit of size 5 overflow 10 reached。 这一项不能与 A-3 分开排期,否则 A-3 上线当天就会出现大面积连接池超时,且很容易被误判为"锁有问题"。

修改方案:显式配置 pool_size=20, max_overflow=10, pool_timeout=30, pool_recycle=1800(数值需按真实并发标定;pool_recycle 必须小于 MySQL 的 wait_timeout)。不要用 NullPool(会丢掉 pool_pre_ping 的重连价值)。

风险与兼容性:连接数 × worker 副本数会撞 MySQL max_connections,需事先核算。

验证与回归:并发 50 路下单,断言无连接池超时;SHOW PROCESSLIST 观察峰值连接数落在预算内。


B-10 重试无退避、无 jitter(P2-8 上调为重要)

P2-8 | 重要 | 应修 | 0.5 人天 | 可并行

问题描述:app/service/model_gateway.py:271-291 与 app/worker/risk_scan_scheduler.py:117-152 的重试是纯 for _ in range(n),无 sleep、无 jitter。对照 app/worker/runtime.py:901-904 是有指数退避的。

为什么上调:与 B-9 同理——A-2 / A-3 上线后并发写路径变多,上游短暂不可用时,无退避重试会在毫秒级耗尽全部尝试;多副本还会形成惊群,把"短暂 503"放大成"全面雪崩"。

修改方案:抽一个公用的 async_retry(attempts, base_delay, jitter, retry_on) 放到 app/core/retry.py,两处接入。加 jitter 防多副本同步重试。

风险与兼容性:

  • 加了退避后,上游持续失败时的总耗时显著变长(原本毫秒级耗尽,现在可能几十秒)→ 可能撞上层超时。必须同步检查调用方的 timeout 预算。
  • ⚠️ 重试不得重放非幂等写。模型生成类调用是否幂等需确认——若不幂等,只能对连接类错误重试,不能对"已发出但未收到响应"重试。这一点要在实现时明确。

验证与回归:注入持续失败的上游,断言总耗时在新预算内、最终抛出明确错误;单副本与多副本两种情况下都不再出现"同时重试"的尖峰。


B-11 每次调用新建 Redis 客户端,懒加载缓存形同虚设

P2-5 | 重要 | 应修 | 0.5 人天 | 应与 B-2 同做

问题描述:app/worker/runtime.py:503-516 的 _redis_client 每次调用都新建客户端;app/infrastructure/rate_limiter.py:92-96 的懒加载缓存挂在每次新建的对象上 → 缓存失效,每请求一次握手。

影响范围:Redis 连接数与握手开销随请求量线性增长;批次跑满时可能耗尽 Redis maxclients。

修改方案:_redis_client 改为模块级单例,并纳入 B-2 的 shutdown_all() 释放路径;RedisCounterBackend 改由 default_counter_backend() 单例持有,保留 rate_limit.py:34 的 get_counter_backend() 作为测试注入点(不要破坏替身机制)。

风险与兼容性:单例在 Redis 重启后会持有坏连接——需要 ping 探测或依赖驱动自带重连;务必保留测试替身注入钩子。

验证与回归:1000 次请求后观察 Redis INFO clients 的连接数不增长;重启 Redis 后能自动恢复,不会出现"永久不可用"。


B-12 _permission_error 样板 ×17,绕过统一鉴权与审计

P2-4 | 重要 | 应修 | 0.5-1 人天 | 可并行

问题描述:app/service/offsite_fund_service.py 有 17 处自造的 _permission_error(:144/188/217/262/527/676/767/858/1029/1093/1158/1405/1502/1848/1881/2114/2168),不走统一的 AuthorizationService.require(后者带统一审计)→ 审计口径不统一。

改法(刻意保守):该类是 2820 行的 God Class(P2-3),一次性重构风险极高。本轮不做整体重构——只修改 _permission_error 的内部实现,让它调用 AuthorizationService.require 并写审计,对外保持原有的 403/404 语义与消息文本不变。这样零调用点改动即可统一审计口径。

风险与兼容性:若 AuthorizationService.require 的异常类型/消息与 _permission_error 不同,17 处的所有 403/404 断言都会变。稳妥做法是在 _permission_error 内部 try/except 后转回原异常类型。

验证与回归:17 条路径逐条触发一次未授权,断言响应体与改前逐字节相同,同时审计表新增了记录。


三、C 档|建议修复 / 可延后

以下项目不影响资金正确性与合规门禁,可在 A/B 档完成后按迭代排期处理。

# 位置 问题 建议改法 工时 可延后理由
C-1 app/service/agent/base.py:145-149 classify_intent 依赖缺失时静默返回 None,与"该 Agent 不需要分类"无法区分 装配缺失时抛 RecoverableAgentError;"不需要分类"改为显式 sentinel 0.25 当前装配正确,属防御性加固
C-2 app/core/errors.py:74-165 错误码有意复用(SESSION_NOT_FOUND×4 等),前端难区分"用户非法操作"与"Worker 租约丢失" 不要拆码(为对齐 docs/05 §3.6 的刻意设计,有 AST 测试守着);改法是把区分信息放进 field_errors 或新增 detail 字段 0.5 属语义表达改进,非缺陷;且改动面覆盖全部客户端
C-3 app/service/offsite_fund_service.py:123 God Class:2820 行 / 60+ 方法 按职责拆邮件 / 识别 / NL2SQL / 规则 / 通知 / 统计六个服务,每次只搬一个域,每次都保持外层 API 不变 3-5 纯技术债;B-12 已在不重构的前提下解决审计口径问题
C-4 app/service/model_gateway.py:103-123,174-200,225-246 每次模型调用新建 httpx 客户端并关闭(丢 keep-alive) 复用 AsyncClient 并在 lifespan(B-2)里释放 0.5 性能而非正确性
C-5 app/service/model_gateway.py:238-239 连续两行 return endpoints,第二行不可达 删除 0.05 死代码,无运行时影响
C-6 app/service/risk_daily_report_service.py、app/repository/agent_run_repository.py SELECT 后再 INSERT/UPDATE 无行锁(TOCTOU) 参照 runtime.py:_failure(已正确加了 with_for_update())补锁 0.5 竞争窗口窄、当前量级难触发;建议随 A-3 的锁改造一起做
C-7 app/service/offsite_fund_rules.py:170 "最低申购金额"硬编码 <= 1 元,而产品表有 min_amount 列未被使用 先跟业务确认口径(报告 §8-2 疑点):是读 fin_product.min_amount 还是维持 1 元 0.25 业务口径未定,改错方向比不改更糟
C-8 app/service/risk_judgement_service.py:161-178 距阈值仅差一丝(499999.99 / 0.7999)也判"疑似误报 + 高置信",直接引导专员关闭预警 引入相对误差容差带(abs(v-threshold)/threshold < eps 时判"边界待核"、置信度下调) 0.5 需业务给出 eps 口径
C-9 offsite_fund_service.py:2374 / promotion_performance.py:133;profile_graph_projection_service.py:267 / offsite_fund_service.py:2643 _json_safe / _text 在两处重复定义 抽到 app/core/ 的公共模块 0.25 重复但行为一致
C-10 app/service/agent/implementations/customer_service.py:148-165 HIGH_SCORE=0.75 / MID_SCORE=0.55 / MIN_GAP=0.07 / TOP_K=5 调参阈值硬编码(注释自述"gap 从 0.090 掉到 0.076,几乎跌破 0.07") 移到配置中心或 Settings 0.5 每次调参要发版,属效率问题
C-11 app/core/customer_service_rules.py:11 docstring 说走 query_knowledge,实际登录客户走 search_knowledge 改 docstring(注意:该类不一致已造成过真实事故——docs/40 记录发布配置只发 search_knowledge → 访客工具白名单抛 ForbiddenAgentError) 0.1 纯文档,但强烈建议尽快(有前科)
C-12 app/api/controllers/visitor_tokens.py + app/api/dependencies/auth.py:26 限流在所有端点 fail-open 对登录/访客签发这类敏感端点改 fail-closed(A-4 完成后再评估) 0.5 产品取舍;改动会让 Redis 故障直接演变为全站不可用
C-13 app/service/knowledge_publication_service.py:153-155 except Exception: pass 真静默(全仓唯一一处) 至少 logger.warning(..., exc_info=True) 0.05 极低危但成本也极低,建议随手做
C-14 app/service/profile_assembly_service.py:64-69 _fact_id() 用微秒时间戳做主键(float→int 精度损失;循环内批量调用同一微秒会碰撞),docstring 断言"不可能发生" 改计数器 + 时间戳组合,或直接引 DB 序列 0.25 画像路径,非资金;建议在 A-2 的发号器方案确定后顺带统一
C-15 app/service/trade_service.py:200-205 raise ... from None(全仓唯一一处)丢弃根因,且恰在交易拒绝路径上 改 raise ... from exc 或保留原异常上下文 0.05 排障成本;应与 A-3 同批顺手做
C-16 app/service/trade_service.py:341-342 order_no 用秒级时间戳 + 8 位 hex,无 DB 唯一约束兜底 加唯一索引 + 冲突重试(或沿用 A-2 的发号器) 0.25 uuid4 段已足够随机,实际碰撞概率极低
C-17 app/service/offsite_fund_service.py:2527-2532 _next_mail_id() 用"当日 count()+1"发号,超 999 后 :03d 格式错位 改不带宽度限制的编号 + 唯一约束 0.25 需日发 >999 封才暴露
C-18 app/service/trade_service.py:142-143 MAX_QUOTE_AGE 用 > 严格比较(恰好 15:00 放行);source_updated_at 若为未来时间(时钟偏差)差值为负 → 永远放行 改为 >=,并对负差值显式拒绝 0.1 边界瑕疵;时钟偏差罕见但确实存在
C-19 app/service/risk_natural_language.py:89-96 "今天"的上界是当前时刻而非当日 24:00;time.max 在 MySQL fsp=0 列上可能因舍入归入次日 上界改用当日 23:59:59 显式构造 0.25 边界瑕疵
C-20 app/worker/runtime.py:794-796 租约续期失败的告警缺 exc_info=True,真实原因不可见 补上 0.05 可观测性
C-21 app/api/middleware.py:29 X-Trace-ID 完全由客户端提供并原样回显,无格式校验 → 日志投毒 / 追踪串扰 加长度 + 字符集白名单校验,非法则忽略而非回显 0.1 建议做;与 A-4 的滥用场景同属一类
C-22 app/core/risk_cursor.py:16-17 游标指纹用无密钥 SHA-256 换 HMAC(带服务端密钥) 0.25 docstring 已诚实自述"防无意复用、不防篡改";服务端仍有 user_id 过滤,越权受限
C-23 app/service/agent/implementations/risk_agent.py:342-373;customer_service.py:189/551 提示注入面:用户文本(含正则提取的 alert_no)直接 f-string 进 system prompt,无分隔符/转义包裹 用户输入加标签包裹 + 显式指令隔离。注意:当前缓解靠"工具权限由 ToolExecutor 独立校验、不信任模型",未见可直接越权链路 1 属提升面而非已知漏洞;需要整体 prompt 回归
C-24 app/service/agent/implementations/customer_service.py:464-465 _product_risk_level 把"工具异常""向量库降级""知识库确实无此条"统一压成 None,用户看到同一句话术 区分三态,至少让"库故障"与"无此条"话术不同(同文件 :300/:408 是正确降级,照抄) 0.25 体验问题
C-25 app/worker/graph_projection_worker.py processed_event_ids 是无界内存 set,长期运行持续增长 改 LRU / 按轮次清空 0.25 需长期运行才暴露
C-26 app/worker/runtime.py:77,102-107 装配关键路径用 _UNSET: Any + 多个 Any 绕过 mypy strict 收窄类型 0.5 类型安全;改动面涉及整个装配函数
C-27 app/service/trade_service.py:568-572,649-653 scalar_one() 在产品主数据被删时抛 NoResultFound → 500(历史订单应仍可查) 改 scalar_one_or_none() + 友好降级 0.25 需先删除产品主数据才触发
C-28 app/core/fund_contracts.py:13-43 nav 等金额字段无 gt=0 / allow_inf_nan=False 约束 补约束 0.25 输入加固
C-29 全项目(trade_service.py:142,176,251,292 等) datetime.now(UTC).replace(tzinfo=None) 手工重复,而 app/core/timeutil.py 已有 to_utc_naive 统一替换 0.5 时区 bug 有重演空间;改动面广但机械
C-30 alembic/ 迁移图为单一 head + merge 齐全(干净),但无 CI 断言"恰好 1 个 head" 加一条 CI 断言 alembic heads 输出恰好一行 0.1 当前正确,属防回归

C 档合计约 8-12 人天,建议按"C-11 / C-13 / C-15 / C-18 / C-20 / C-21 / C-30"这一类 0.05-0.1 人天的零碎项先做(能跟着 A/B 档的 PR 顺手带上),C-3 的 God Class 拆分单独立项。


四、依赖关系与执行批次

4.1 同文件冲突矩阵(决定哪些不能并行)

文件 被哪些项改动 结论
app/service/trade_service.py A-1(拆 commit)、A-2(删 _next_id)、A-3(改 _load_* 签名)、A-5(改 _compute_fee)、C-15、C-16、C-18、C-27 四项必修全部命中同一文件 → 必须严格串行
app/api/controllers/trading.py A-1、B-6 同文件,建议同一人一次性改完
app/service/agent/base.py + governance.py A-6 独立
app/main.py B-2 独立
requirements.txt + pyproject.toml B-3 独立
app/infrastructure/rate_limiter.py B-11(+B-2 的释放路径) 与 B-2 有交集,建议一起做
app/infrastructure/db.py B-9 独立,但必须与 A-3 同批部署

4.2 批次编排

┌─ 批次 0:前置核实(0.5-1 人天,全部只读,可完全并行,必须最先)
│   N1  api_request_receipt 唯一索引是否存在 ─────────► 卡住 A-1
│   N2  RBAC 种子 scope 组合(seed 脚本 + 实际表) ───► 卡住 B-8 定性
│   N3  check_compliance 有无仓库外调用方 ───────────► 卡住 A-6 删除方案
│   N4  AGENTS.md 是否允许 AUTO_INCREMENT 豁免(D-1)─► 卡住 A-2 甲/乙
│   N5  扫前端:cursor=0/负数、T002 无键调用方、
│       dashboard.js 失败分支是否透传 message、
│       apiClient.post 第三参数是否透传 options ─────► 卡住 A-1 / B-6
│   N6  KnowledgeRetrievalService 是否为 B-1 的既定替代实现 ─► 卡住 B-1 改法
│   N7  openai 有无字符串形式动态导入 ─────────────► 卡住 B-3 删除
│   N8  T005 撤单是否也改 holding ────────────────► 卡住 A-3 是否需补锁
└────────────────────────────────────────────────

┌─ 批次 1a:完全独立项(可多人并行,互不冲突)──────────► 约 2 人天 / 1 日历天
│   A-4 访客令牌限流  ▏ B-3 依赖漂移  ▏ B-4 KeyError  ▏ B-7 场外负数
│   B-5 NL2SQL 截断   ▏ B-6 cursor 校验
└────────────────────────────────────────────────

┌─ 批次 1b:资金安全主线(严格串行,约 3.5 人天)
│   ① A-3 行锁   ★ B-9 连接池扩容必须与 ① 同批上线
│      └─► ② A-2 发号器   (加锁后并发窗口变宽,正好用来验证发号器)
│            └─► ③ A-1 幂等  (依赖 submit_order 已被前两项稳定)
│   ★ A-5 手续费紧跟 ①(同文件,rebase 成本最低)
└────────────────────────────────────────────────

┌─ 批次 2:架构 / 性能(可 2 人并行,依赖批次 1 完成)──► 约 4 人天 / 2 日历天
│   B-1 异步 Milvus  ▏ B-2 lifespan  ▏ B-11 Redis 单例  ▏ B-10 重试退避
│   A-6 check_compliance(等 N3 + 同步 docs/09)  ▏ B-12 offsite 权限审计
└────────────────────────────────────────────────

┌─ 批次 3:待定性插入(0.5-1.5 人天,取决于 N2 结论)
│   B-8 data_scope(若无高危组合 → 降级为 0.1 天的注释补充)
└────────────────────────────────────────────────

┌─ 批次 4:C 档(约 8-12 人天,按迭代拆分,不阻塞上线)
└────────────────────────────────────────────────

4.3 工作量汇总

批次 人天 可并行度 日历天(2 人投入)
批次 0(核实) 0.5-1 高 0.5
批次 1a(独立项) ~2 高(3 人) 1
批次 1b(资金主线) ~3.5(含 A-5、B-9) 低(串行) 2-3
批次 2(架构/性能) ~4 中(2 人) 2
批次 3(待定性) 0.5-1.5 视结论 0.5-1
A + B 档合计 约 10.5-13 人天 — 约 6-8 日历天
批次 4(C 档) 8-12 中 按迭代

人天含编码 + 单元测试 + 文档同步,不含集成联调、发版与评审时间。 若 D-1 走方案乙(新增发号表),A-2 增加 0.5 人天,且需额外 0.5 人天做文档口径同步。

4.4 上线顺序约束

  1. DDL 先行:A-2 若走方案甲属 ALTER TABLE,必须先于应用发布执行;建议先在副本库演练 + 备份。
  2. 滚动发布:B-2(lifespan)与 B-11(Redis 单例)改变进程生命周期管理,建议滚动重启而非批量重启,观察连接数。
  3. B-9 不得延后于 A-3:连接池扩容与行锁必须在同一发布窗口(理由见 B-9)。
  4. 文档必须同 PR:A-1(docs/05 幂等口径 + 后端接口文档)、A-6(docs/09 作废承诺)、A-2 方案乙(AGENTS.md 等表数口径)。

五、跨项风险总表

风险 涉及项 后果 缓解
同一文件并发改动冲突 A-1/A-2/A-3/A-5 → trade_service.py rebase 地狱、逻辑互相覆盖 严格串行(批次 1b),或由同一人一次改完四项
api_request_receipt 缺唯一索引 A-1 幂等完全失效且不报错 N1 核实;若无索引必须先补索引再上线
行锁把读路径拖慢/拖死 A-3 T001 看板锁等待 for_update 默认 False;回归断言读路径无锁
gap lock 阻塞首次买入 A-3 不同客户首买同一产品互相阻塞 压测必测;必要时改为 INSERT ... ON DUPLICATE KEY UPDATE 建仓
连接池打满 A-3 + B-9 大面积 QueuePool TimeoutError,易被误判为"锁有问题" B-9 与 A-3 同窗口上线
回滚数据不一致 A-2 方案甲 反向 DDL 风险 先备份、副本演练;明确回滚脚本
表数口径失效 A-2 方案乙 上一轮刚统一的 89 表口径在多份文档中重新失效 若选乙,必须预留 0.5 人天做文档同步
缺键调用方被打断 A-1 201 → 422 N5 全量扫描;官方前端已自带键(§0.2-1)
历史脏数据未修复 A-1 / A-5 已存在的重复订单、负余额不会自动修正 单独排一次性对账冲正脚本(不在本方案工作量内)
docs/09 承诺冲突 A-6 删除公共 API 违背文档承诺 N3 核实 + 同 PR 在 docs/09 显式作废
data_scope 改错方向 B-8 复现 9002/9003 查不到数据的事故 严格只改调用方、不动计算逻辑(§0.2-3)
超时预算被打破 B-10 加退避后总耗时变长,撞上层超时 同步核算调用方 timeout
Redis 故障放大 B-11 / C-12 改单例后重启卡住;改 fail-closed 后 Redis 挂 = 全站不可用 单例配合 ping 探测;fail-closed 决策需产品拍板
提示注入改造引发行为漂移 C-23 改 system prompt 会改变模型输出,需全量话术回归 单独立项,配 A/B 对比集

六、回归验证矩阵

修改项 必跑测试 必跑工具 / 脚本 人工验证点 必须观察的指标
A-1 幂等 tests/integration/test_risk_idempotency_mysql.py(作为模板,新增交易版) tools/e2e_smoke_test.py、tools/acceptance_check.py 同键两次响应体一致;九步校验链状态码不变 fin_sim_order 行数、available_cash 扣减次数
A-2 发号器 新增 20 路并发下单用例 tools/audit_schema.py、tools/audit_constraints.py、alembic heads 副本库先演练 migration IntegrityError 计数 = 0;无重复 id
A-3 行锁 新增"突破上限""超卖""死锁探针"三个并发用例 SHOW ENGINE INNODB STATUS T001 看板读取无锁等待 1213 死锁数 = 0;读路径 P99 不变
A-4 访客限流 新增 tests/unit/api/test_visitor_token_rate_limit.py tools/e2e_smoke_test.py 30 连打第 21 次起 429;Redis 停掉后 fail-open 429 计数、Retry-After 头存在
A-5 手续费 新增"min_fee > gross"用例 + 不变性断言 同上 事务完全回滚、无任何 DB 写入 ledger.balance_after == account.cash_balance 恒成立
A-6 合规 test_customer_service_agent.py、test_agent_governance.py Grep check_compliance 话术随 agent_type 正确开关 客服输出 100% 带免责话术
B-1 异步 Milvus 记忆召回结果对比测试 tools/e2e_smoke_test.py 注入 3s 延迟后并发协程不受影响;Milvus 挂了话术是"降级"不是"无内容" 事件循环阻塞时长、召回 Top-K 一致性
B-2 资源释放 lifespan 单测 — 100 次清理后连接数不增长 进程句柄 / gRPC 通道数
B-3 依赖 干净环境安装验证 新增的依赖一致性校验脚本 服务可起 ——
B-4 KeyError 非字符串键单元测试 手工生成一次风控日报 —— ——
B-5 截断 200/199/limit=5 三个用例 docs 同步 dry_run 展示的 LIMIT 值合理 ——
B-6 cursor abc / 0 / 超范围 / 正常翻页 前端全量点击测试 无客户端传 0 500 错误数 = 0
B-7 场外规则 total_fund_shares = -1000 用例 场外 OCR 单据校验流程 两条规则结论一致 ——
B-8 data_scope 逐角色查询对比(改前/改后 diff) tools/seed_test_rbac.py 9002 / 9003 仍能查到全量 结果集 diff 为空
B-9 连接池 50 路并发下单 SHOW PROCESSLIST 无 QueuePool TimeoutError 峰值连接数 ≤ 预算
B-10 重试 注入持续失败上游 —— 总耗时在新预算内 无同时重试尖峰
B-11 Redis 单例 —— Redis INFO clients Redis 重启后自动恢复 连接数不随请求线性增长
B-12 权限审计 17 条路径逐条触发 —— 响应体与改前逐字节相同 审计表新增记录数

通用回归(每次发布都必须跑)

  • python tools/e2e_smoke_test.py、python tools/acceptance_check.py、python tools/portal_api_check.py
  • 前端四套角色页面(现在是 6 个角色目录)的核心链路人工点击
  • docs/40-前端验收清单.md 中的幂等项 8-4 与 RBAC 相关项

七、执行清单(按优先级排序)

说明:P = 可并行,S = 必须串行。工时单位:人天。

第一批:解锁前置(必须最先,全部只读)

序 事项 类型 工时 阻塞谁
1 N1 核实 api_request_receipt 的唯一索引存在 前置 0.1 A-1
2 N4 决策 D-1:是否允许 AUTO_INCREMENT 豁免 前置 0.1 A-2
3 N8 核实 T005 撤单是否也改 holding 前置 0.1 A-3
4 N5 前端扫描:cursor=0/负数、T002 无键调用方、apiClient.post 第三参数 前置 0.25 A-1 / B-6
5 N3 核实 check_compliance 有无仓库外调用方 前置 0.1 A-6
6 N2 核实 RBAC 种子 scope 组合 前置 0.5 B-8
7 N6 确认 KnowledgeRetrievalService 是否为既定替代实现 前置 0.1 B-1
8 N7 确认 openai 无字符串动态导入 前置 0.05 B-3
9 D-2 决策:T002 缺幂等键是拒绝还是派生兜底 前置 0.1 A-1

第二批:阻塞项(上线前必须完成)

序 事项 级别 并行 工时 备注
10 A-3 submit_order 加行锁(for_update 参数化 + 固定加锁顺序) 阻塞 S(第 1 步) 1 ★ 与第 13 项同窗口上线
11 B-9 连接池显式配置(必须与 A-3 同批) 重要 P 0.25 由 P2 上调
12 A-5 卖出手续费上界 + 负值兜底 阻塞 S(紧跟 A-3) 0.5 由 P1 上调
13 A-2 _next_id 发号器(甲:AUTO_INCREMENT / 乙:发号表) 阻塞 S(A-3 之后) 1-1.5 取决于 D-1
14 A-1 T002 接入幂等(含拆分 submit_order 事务 + 前端稳定键) 阻塞 S(最后) 1.5 依赖 N1 / N5 / D-2
15 A-4 访客令牌 IP 限流 阻塞 P 0.5 照抄 enforce_login_rate_limit
16 A-6 check_compliance 处理 + governance docstring 纠正 阻塞 P 0.5 依赖 N3;需同步 docs/09

第三批:重要项(本迭代应完成)

序 事项 级别 并行 工时 备注
17 B-1 异步 Milvus(知识类换 async 客户端 / 记忆类 to_thread + 超时) 重要 P 1-1.5 依赖 N6
18 B-2 引入 lifespan + 统一 shutdown 重要 P 0.5-1 与第 24 项一起做
19 B-11 Redis 客户端单例化 重要 P 0.5 与第 18 项一起做
20 B-10 重试加退避 + jitter 重要 P 0.5 注意超时预算
21 B-12 _permission_error 内部改走统一鉴权(保持响应不变) 重要 P 0.5-1 不做 God Class 拆分
22 B-3 依赖双源统一 + 一致性校验脚本 重要 P 0.5 依赖 N7
23 B-4 _distribution 排序键修复 重要 P 0.25 纯局部
24 B-5 NL2SQL truncated 标志 重要 P 0.5 照抄 risk_repository._capped
25 B-6 cursor 改用 parse_cursor 重要 P 0.5 依赖 N5
26 B-7 total_fund_shares <= 0 统一判"无法判断" 重要 P 0.25 纯局部
27 B-8 data_scope 逐权限判定(先核实 N2 再决定是否为改造) 重要 视 N2 0.1-1.5 ⚠️ 不得改 identity_repository.py:55 的计算

第四批:建议项(下次迭代)

序 事项 级别 工时
28 零碎随手做:C-11(customer_service_rules docstring)、C-13(真静默 except)、C-15(from None)、C-18(行情时效边界)、C-20(exc_info)、C-21(trace_id 校验)、C-30(alembic head CI 断言) 建议 0.6
29 C-1 classify_intent 静默降级 建议 0.25
30 C-6 / C-27 / C-28 边界与降级加固 建议 1
31 C-14 _fact_id 与 C-16 order_no 唯一约束(建议在第 13 项定案后统一处理) 建议 0.5
32 C-29 统一 to_utc_naive 建议 0.5
33 C-22 游标 HMAC 化 / C-25 worker 无界 set / C-26 装配类型收窄 建议 1
34 C-10 调参阈值配置化 / C-24 三态降级话术 建议 0.75
35 C-3 God Class 拆分(邮件 → 识别 → NL2SQL → 规则 → 通知 → 统计,每次一域) 建议 3-5
36 C-23 提示注入隔离(需整体 prompt 回归) 建议 1
37 C-2 错误码语义增强(不拆码,加 detail 字段) / C-12 fail-closed 决策 建议 1

明确的"暂不动"清单

事项 理由
errors.py 拆错误码 是对齐 docs/05 §3.6 的刻意设计且有 AST 测试守护,拆了会破坏契约测试与历史文档
identity_repository.py:55 的 data_scope 计算 改了会复现"9002/9003 拿 all 权限却查不到数据"的已修事故
AD008/AD009 补幂等头 已核实 asset_allocation_service 全文件不写库,豁免合理
projection_cleanup_service.py:129 的 filter 字符串拼接 memory_uuid 由服务端生成(非用户可控),当前不构成注入面
C-7 场外最低申购金额口径 属报告 §8-2 疑点,业务口径未定前改错方向比不改更糟

八、必修 / 延后的判定说明

8.1 列入"必修"的依据(6 项)

项 判定依据 有无临时绕过手段
A-1 T002 幂等 资金重复扣款,且是唯一缺口;重复下单还会反向触发"持仓超限"误拒 无。客户端不重试只能靠用户自觉,不可控
A-2 _next_id 并发下必然主键冲突(MAX(id)+1 + 无 AUTO_INCREMENT) 无。限流只能降低概率不能消除
A-3 行锁 突破持仓上限 / 超卖成负,直接影响资金与合规指标 无。这是唯一的正确性手段
A-4 访客限流 无需凭证即可无限铸造身份,且现有按 user_id 的限流天然失效 只能在网关临时挡 IP(这不失为一个上线前的临时缓解,但代码侧仍必修)
A-5 手续费上界 账户被倒扣(后果等同 A-1 / A-3),且演示场景极易触发 可临时把 minimum_fee 设为 0,但那是改业务数据、不是修复
A-6 合规门禁 虽为死代码(当前不触发),但属合规红线类缺陷 + docstring 正在主动误导维护者 临时手段是"不要调用它",但缺陷本体仍在

8.2 列入"延后"的依据

类型一:当前不可触发 / 需特定条件

  • C-17 _next_mail_id(需日发 >999 封)、C-25 无界 set(需长期运行)、C-27 产品被删(需主数据删除)
  • 判定:这些都写在 except/生成逻辑里,等条件成熟再做,成本不变。

类型二:性能而非正确性

  • C-4 httpx 复用、C-9 重复函数、C-29 to_utc_naive 统一
  • 判定:不修不影响功能正确性,先集中火力解决资金/合规面。

类型三:需业务决策,改错方向更糟

  • C-7 场外 1 元口径、C-8 误判容差 eps、C-12 fail-closed、C-2 错误码语义
  • 判定:技术方案依赖业务口径。在没有结论前动手,等于把猜测写进代码。这些应该带着选项去问业务,而不是自行假定。

类型四:技术债,收益/风险比不足以放进本迭代

  • C-3 God Class 拆分(3-5 人天,纯内部结构,B-12 已用低成本方式解决了最紧要的审计口径问题)
  • C-23 提示注入(1 人天 + 全量 prompt 回归;当前缓解机制——工具权限由 ToolExecutor 独立校验、不信任模型——是有效的)
  • C-26 装配类型收窄(收益是静态检查,风险是改整个装配函数)

8.3 报告 §8「未核实清单」在本方案中的落点

审查报告列出了 12 条静态阅读无法定论的项。本方案对它们的处理:

报告未核实项 本方案处理 落到哪个前置
1. RBAC 种子 scope 组合 决定 B-8 是否改造 N2
2. 场外最低申购金额口径 列为"暂不动",待业务确认 —(C-7 延后)
3. 限流 fail-open / 游标无密钥 产品决策,列入门 C-12 / C-22 延后
4. check_compliance 是否被仓库外调用 决定能否删除 N3
5. .env 历史是否泄露密钥 Bash 不可用无法验证,需在有 Git 的环境自查 独立安全项
6. 部署拓扑对限流的影响 建议网关层 IP 限流作为主手段 A-4 第 4 点
7. openai 是否被动态导入 决定能否删除 N7
8. frozen_quantity 是否有写入点 决定 A-3 是否需覆盖撤单路径 N8
9. worker_lease_seconds 实际值 与 B-1 的事件循环阻塞有交互 建议压测时一并观察
10. KnowledgeRetrievalService 是否预留实现 决定 B-1 是接线还是重写 N6
11. 40+ 迁移的 upgrade() 体 建议跑 preflight A-2 的验证环节
12. docs/06 P1/P2 欠债逐条核对 仅 A-6 涉及的一条已处理 其余不在本次范围

8.4 环境与结论边界

撰写本方案时 Bash 工具不可用(exit 127),PowerShell 不回显 stdout,所有结论均通过 Read / Glob / Grep 静态阅读得出。因此:

  • 可确认的:调用链、是否加锁、是否传参、字段类型、DDL 形态——这些静态可判。
  • 无法确认的:所有需要"跑一次"的结论——如 _next_id 的实际碰撞概率、gap lock 的实际阻塞程度、连接池打满的真实阈值、迁移在真实库的表现。
  • 实践建议:A-2 / A-3 / B-9 这几项都属于"理论上成立但未实测",但都在资金主路径上,建议按"已成立"处理而非等实测;同时把压测作为这几项的验收门槛,而不是可选项。

本方案只做规划,未改动任何代码。