## 新入库(`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 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
82 KiB
代码修改方案
依据:
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 条是我在为每一项写具体改法时重新读源码才发现的,它们与只按报告字面执行的做法有实质差别,请先读完:
-
前端其实已经在发
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 只做一半。 -
报告中"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 的定性正确,不需要扩大范围。 -
data_scope取"全局最高范围"是一次真实事故修复的产物,不能简单改回逐权限口径。identity_repository.py:45-54的注释白纸黑字记录了:原先写死self,导致 9002 (risk_operator) 与 9003 (admin) 拿着 all 级权限却什么都查不到。 → 所以 P1-8 的正确改法不是改data_scope的计算,而是保留它、只改"用错地方"的调用方(详见 B-8)。若按字面变更identity_repository.py:55,会原样复现那个事故。 -
ApiTransactionService.execute_in与submit_order的提交时机存在一个残余窗口。execute_in(api_transaction_service.py:45-89)在 action 之前写幂等记录、在 action 之后:88再commit()落response_json。而submit_order自己就在:445commit()。 这留出一个缝隙:业务已提交、但response_json尚未落库时进程崩溃 → 重试会看不到回放结果 → 重复下单。 → A-1 必须包含"把submit_order的内部 commit 交还给execute_in"这一步,否则幂等仍有漏洞。 -
enforce_login_rate_limit正好是访客令牌限流所需的全部模板。app/api/dependencies/rate_limit.py:81-117的实现是"按客户端 IP、不依赖认证上下文"——它本来就是为了"登录时还没有身份"这个场景写的(:84-86注释明确说明),而签发访客令牌同样是"签发前没有身份"。基本可以照抄,不需要新设计。 -
顺带发现一处文档与代码不一致(建议同批修)。
docs/05:79写Idempotency-Key长度 8-64,而api_transaction_service.py:26与:64实际强制 16-128。另有测试报告/2026-09-11.md:94记录了一次"期望 400VALIDATION_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等脚本已自动补键,不受影响。
修改方案
-
app/api/controllers/trading.py:61-69增加头参数,写法与其它 controller 保持一致(参照risk.py:101):key: str | None = Header(default=None, alias="Idempotency-Key"), -
用
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) -
把
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要求的语义)。
-
缺键策略(取决于 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 登记),而不是永久保留。 -
前端同步改造(否则 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()默认行为的缺陷。 -
文档同步:
docs/演示用/后端接口文档-2026-09-14.mdT002 章节的"幂等"行由"否"改为"是(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
- 新增一个 Alembic 迁移,对 4 张表做
ALTER TABLE <table> MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT; - 显式设置自增起点:
ALTER TABLE <table> AUTO_INCREMENT = <当前 MAX(id)+1>;(MySQL 通常自动取 max+1,但显式指定可避免施工期数据变动导致的意外)。 - 删除
_next_id方法,4 处调用改为不传id(交由 DB 发号)。 - 必须同步:把该方法 docstring 里"并发与单测场景下够用"这句话一并删掉——它现在正在误导后来者,是该缺陷存活至今的原因之一。
方案乙(若 D-1 不允许改基线)—— 引入独立发号器
- 新增一张发号表
fin_id_allocator(table_name VARCHAR(64) PRIMARY KEY, next_id BIGINT UNSIGNED NOT NULL)。 _next_id改为原子取号:INSERT ... ON DUPLICATE KEY UPDATE next_id = LAST_INSERT_ID(next_id + 1)+SELECT LAST_INSERT_ID()(MySQL 单语句原子,不依赖事务隔离级别)。- 说明:会断号(事务回滚不归还),业务侧不存在"必须连续"的约束(订单号
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 处以上 joinFundCustomerProfile)。
修改方案
-
不要无条件给
_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。 -
固定加锁顺序:先
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)都在锁内执行,一旦将来出现反向路径必然互锁。 -
好消息:check-then-act 本就在同一事务内,不需要结构性改动。
:312-328(买入的资金/占比校验)与:333-337(卖出的可用份额校验)都发生在:298之后、:404-443扣减之前,且唯一的 commit 在:445。加锁后它们天然落在同一把行锁的保护下。 -
待确认的前置(N8):撤单端点 T005 若也会改动
holding(frozen/available),必须同样加锁,否则形成"下单持锁、撤单不持锁"的半边锁,A-3 等于没做。这一条我在本方案撰写时未能确定,已列入批次 0 的核实清单。 -
建议在
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 队列。
修改方案
-
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),因为演示场景的真实用量只能在现场标定。 -
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)], ) -
建议顺带做(成本低):给访客身份加可追溯维度。
VisitorTokenIssuer.issue()目前生成纯随机sub。建议在 token payload 中加入客户端 IP 的哈希,使后续限流与审计能关联到同一来源。 ⚠️ 不要把原始 IP 写进 JWT payload:JWT 对客户端可读,且用户换网会导致校验失败。存哈希即可。 -
若部署在反向代理后(重要):
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-332net_amount = gross_amount - fee_amount—— 无正负检查。:416-422卖出分支直接account.cash_balance += net_amount—— 负数也会被加上去。
影响范围
fin_sim_account.cash_balance/available_cash被扣减。fin_cash_ledger出现负数的"卖出回款"(:424ledger_amount = net_amount),与entry_type="卖出回款"(:423)语义直接矛盾。balance_after(:436)会记录一个"倒扣后"的余额,污染资金流水的可审计性。
修改方案
-
_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,不存在上界问题)。 -
兜底断言(即使第 1 步被绕过也要拦住):在
:330-332计算net_amount之后加if net_amount < 0: raise ValidationAgentError("本次卖出不足以支付手续费,无法成交") -
事前拒绝 + 事后兜底,两者都要。第 2 步保证了"绝不会倒扣",但没有可读提示。建议在卖出校验链里再补一条明确的拒绝理由:
if gross_amount <= fee_rule.minimum_fee: raise ValidationAgentError(f"卖出金额 {gross_amount} 元低于最低手续费 {rule.minimum_fee} 元,无法成交")。 -
需要跟业务确认(不阻塞本项):
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 结论) |
问题描述(三件事叠在一起,缺一件等于没修)
app/service/agent/base.py:162-165定义check_compliance,但它调用governance.review(result, context, self.config, self.memories)时没有传agent_type。- 真实链路
base.py:114-116的execute()直接调governance.review(..., agent_type=self.definition.agent_type),绕过了check_compliance→ 全仓零生产调用点(死代码)。 - 一旦有人按方法名的语义调用它:
governance.py:219customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES→ 空串得False→:281跳过追加免责声明 → F5 合规红线静默失效。 - 其危险之处在于:
governance.py:211-212的 docstring 写着"空串按'调用方未声明'处理,保守照旧追加,避免漏加"——与实际行为完全相反。维护者读完会以为"忘记传 agent_type 是安全的"。
影响范围
- 合规红线 F5(面向客户输出必须 100% 附固定免责话术)。当前尚未造成事故(因为是死代码),但若未来接线就会静默失效。
- 更大的危害是那条反向 docstring 正在主动误导。
修改方案(必须一起做的三件事)
-
修正 docstring
governance.py:211-212,使其描述与:219/:281的实际行为一致(空串 = 不追加),并明确写出"调用方必须传agent_type"。 -
让"漏传"当场失败而不是静默降级(治本,推荐): 在
review()入口加if not agent_type: raise ValueError("review() 必须显式传入 agent_type")。 与其把它降级成"不加免责声明",不如让它炸掉。已知调用点base.py:114已正确传参,安全。 -
处理死代码本体:
- 推荐甲:删除
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显式作废该承诺。
- 推荐甲:删除
-
补契约测试防回归:断言
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)有定义但全仓无调用点。
修改方案:
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)- 新增
app/infrastructure/shutdown.py统一收集需释放的单例:get_vector_memory_adapter(需cache_clear())、default_counter_backend、build_graph_driver、MilvusKnowledgeClient实例。 projection_cleanup_service._cleanup_vector改try/finally: client.close(),优先方案是改为复用bootstrap的单例,不再每次新建——同时解决了 P1-2 与 P1-3。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 全仓零命中);两文件将持续漂移,最终导致"本地能跑、线上不能跑"。
修改方案:
- 由
pyproject.toml重新生成:pip-compile或uv export --no-hashes -o requirements.txt。 - 短期手动版:删
openai、去重aiosqlite、把 dev 依赖拆到requirements-dev.txt。 - 加一条校验脚本(放
tools/)断言两文件依赖集合一致,挂到 CI 或 pre-commit。 - 顺手处理 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 手法:
:329→return f"{sql} LIMIT {plan.limit + 1}", params。安全前提不变:plan.limit由 Pydanticge=1, le=200(nl2sql_contracts.py:14/:37)兜住,插值仍是整数。: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}_result(...)第 7 个参数的 row count 要在切片之后再传(:263位置)。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 上线顺序约束
- DDL 先行:A-2 若走方案甲属
ALTER TABLE,必须先于应用发布执行;建议先在副本库演练 + 备份。 - 滚动发布:B-2(lifespan)与 B-11(Redis 单例)改变进程生命周期管理,建议滚动重启而非批量重启,观察连接数。
- B-9 不得延后于 A-3:连接池扩容与行锁必须在同一发布窗口(理由见 B-9)。
- 文档必须同 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 这几项都属于"理论上成立但未实测",但都在资金主路径上,建议按"已成立"处理而非等实测;同时把压测作为这几项的验收门槛,而不是可选项。
本方案只做规划,未改动任何代码。