Files
group_xinghuo_jinrong/docs/项目框架设计/开发计划-基金转换交易.md
T
GaoYiYuan_0626andWorkBuddy 4fd2ec05c8 基金转换 T-4+T-5(并行组 A):convert_repository 代理侧 + locks.try_lock 非阻塞抢锁
T-4(convert_repository · agent 库 risk_convert_detail):
- 新增 7 方法:insert_placeholder(占位 pending) / complete_convert(回写 completed+折算详情)
  / mark_failed / get_by_group_id / get_by_client_request_id(None 直接返回 None)
  / list_expired_candidates(hours) / mark_expired(S2 标记不硬删)
- 引擎走 agent 库 rw(D20);status 5 值枚举;不与 risk_repository 混职责
- Decimal 折算字段经 _to_bind 转 float 绑定(sqlite 不支持直接绑 Decimal,同 T-3)

T-5(locks.try_lock):
- 单次非阻塞抢锁:抢到返回 _Token(with 进入 True),抢不到立即 _NoLock(进入 False,不等不降级)
- Redis 不可用退回进程内 Lock.acquire(blocking=False);_NoLock.__enter__ 返回 False 避免 with None 报错
- 不改 run_locked(D4:其等 2s 后降级 fn(False),语义相反;3 处调用点零改动)

测试:test_convert_repository.py 6 用例 + test_locks_redis.py +4 用例(既有 6 例零改动)
pytest 全量 634 passed / 3 skipped(基线 624 + 10,零回归)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-09-10 15:53:31 +08:00

105 KiB
Raw Blame History

开发计划 · 基金转换交易(convert)

文档状态:开发计划,尚未动代码。待独立审核 + 用户确认后按批次实施。 代码基线:分支 risk-control-agent,HEAD ea9a132(含 6 个基金转换设计文件);批 0(T-0 / T-0b)完成后 pytest 516 绿(开工前基线 510)。 上游依据:docs/PRD/PRD-基金转换交易.md(v0.9.1)· docs/项目框架设计/架构设计-基金转换交易.md(v1.0) · docs/项目框架设计/评审待办-风控主架构与基金转换.md(13 条 0 悬空)。 任务编号:沿用架构 §15 的 T-0 ~ T-13(不重新编号),§15.1 依赖拓扑为准。 本计划的增量职责:架构解决「怎么做」,本计划解决「谁先谁后、每一步改哪个文件的哪几行、 怎么验证、改错了怎么退」。所有事实性陈述均已实地核对代码,核对结果见 §1.4。


审核记录

第一轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码)

总体结论:需修改后实施(小改)。 审核方逐条核对了 §1.4 的 15 条事实与 §12 的回归面, 并用 grep 独立复查。判定结果:15 条事实全部属实的 14 条(F-11 计数有误,见下), §12 漏报 2 条必破用例(其中 1 条是真 MySQL 集成用例),R-c 代价被低估。

# 审核意见 档位 本轮处理
1 §12 漏报 tests/test_integration_risk.py:426-437 的 test_convert_400_and_no_new_trade_audit(真 MySQL 集成用例,断言 convert → 400 + 不落审计) 重要 接受:§12 补 R15;T-9 的 DoD 增该用例的处置
2 §12 漏报 tests/test_trade_gateway.py:145-154 的 test_redeem_accepted_without_alert(redeem 正向用例,env 无持仓无批次) 重要 接受:§12 补 R16;R-c 补 redeem 侧降级规则(初版只覆盖 subscribe 侧)
3 R-c 让 subscribe 全面降级 → 批次维护在单测层零覆盖,验收第 9 条(不超扣)形同虚设 重要(建设性) 接受并实质修订:R-c 拆为 (1) 降级规则 + (2) 覆盖率补偿——新测试必须自建种子、覆盖真实路径,不得让降级路径充当覆盖;T-10 增第 5 道风险控制
4 F-11「get_engine 共 11 处」计数错误,实际 13 处(test_db.py 是 5 处不是 6 处) 可选 接受:F-11 更正为 13 处,T-0b DoD 同步更正

驳回:0 条。

审核方确认无误的部分(供后续会话判断哪些已复核过): F-1 / F-3 / F-4 / F-5 / F-6 / F-8 / F-9 / F-10 / F-13 / F-14 / F-15 逐条比对代码后属实; W-1 / W-2 两处 PRD-架构冲突确实存在、以架构为准合理;R-a / R-b / R-d / R-e / R-f / R-g 六条裁定成立; §13 的 19 条验收条条有承载任务、无落空;一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)未被卷入; list_trades_range 不改可接受(convert 两条流水跨不同产品,RISK-003 按产品计数不受影响); T-10 排在 T-7 之后合理(convert 走 bootstrap_lots,不依赖普通申赎的批次)。

审核方无法核实、本轮已补实的部分:审核方称 F-7(sqlite 不支持 ON DUPLICATE KEY UPDATE)为方言常识未实跑。 本计划已实跑验证(sqlite 3.50.4):ON DUPLICATE KEY UPDATE → near "DUPLICATE": syntax error; ON CONFLICT(b) DO UPDATE → 正常执行;PRAGMA index_list 的 unique 字段 = 'u'(UNIQUE 断言可行)。 另实测 pydantic 2.13.4:Decimal | None = Field(None, gt=0) 对 0/-1 → 422、对 None → 放行(R5 的保住方案成立)。

仍待实测的部分(依赖真 MySQL 或需跑测试,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · T-6 回滚断言 · T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。

第二轮(2026-09-10 · 独立子代理,全新上下文,只报告不改码)

总体结论:基本可进入下一步。 审核方逐条打开代码文件核实上一轮 4 条意见的落地情况 (结论:4 条全部已落地,其中 1 条改了但遗留一处内部矛盾),并新发现 1 处真实的并发漏洞。 判定:0 阻断级 · 2 重要 · 2 可选,本轮全部接受,驳回 0。

# 审核意见 档位 本轮处理
1 R-a 的并发论证不成立:理由是「阶段一持执行权锁」,但该锁 key 是 convert:idem:{client_request_id}(请求粒度);同一 (customer_id, product_id)、不同 client_request_id 的两笔并发转换会各持不同锁、双双进入阶段一 → 首次建行时并发 INSERT 撞 UNIQUE → IntegrityError 重要 接受:R-a 裁定增第 ③ 步「IntegrityError 回退 UPDATE」 + 写清并发必要性 + T-6 增「并发首次建行」用例;并实测排除了两个看似可行的替代写法(见下)
2 文档自相矛盾:T-10 正文仍写「风险控制(四道)」,而审核记录声称已改为「五道」 重要 接受:T-10 已更正为「五道」(上一轮该处编辑未生效,本轮重修并逐条验证)
3 用例数不自洽:§11 写「+97 → 607」,但保守区间写「580~600」 可选 接受:统一为「+97 → 607(含压测)· CI 内 ≈597 · 落地区间 585~610」,4 处口径(§0 / §10 / §11 / §13)同步
4 2 处 DoD 偏模糊:T-1「5×14 档(或按产品数)」、T-13「记录在 docs/ 下」 可选 接受:T-1 改为 3 条可 SQL 断言的判据;T-13 落点明确到「PRD §9 第 18 条」「交接文档」具体章节

本轮实测补跑(审核方标注「无法核实」的方言问题)——这是 R-a 最终写法的决定依据:

候选写法 sqlite 3.50.4 MySQL 结论
INSERT ... SELECT ... WHERE NOT EXISTS(无 FROM) ✅ 实测通过 ❌ SELECT 无 FROM 时不允许 WHERE 不通用
同上 + FROM DUAL ❌ no such table: DUAL ✅ 不通用
条件 UPDATE → INSERT → IntegrityError 回退 UPDATE ✅(实测:捕获异常后同事务内可继续 UPDATE、可正常提交) ✅(InnoDB 语句级回滚,语义一致) 通用 → 采纳

审核方确认无误的部分:任务排序(T-10 位于 T-7 之后)合理 · §13 的 19 条验收无落空 · 一期不做项(自动分拆 / 撤单 / 巨额赎回比例 / 二期补偿自动化)未被卷入 · 回归面独立 grep 无新遗漏 (含 test_core_ro_sum.py:52 用 trade_type='convert' 作种子数据的用例——因 R-d 的首条件 IN('subscribe','redeem') 仍将其排除,恒等通过,§12 R10 处置正确)· F-1~F-15 抽核行号与代码一致。

仍待实测(依赖真 MySQL / pytest,留到实施期):T-0b 的 3 条权限断言 · T-1 灌库 · T-6 的回滚断言与并发建行用例 · T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算回填。

第三轮(2026-09-10 · 独立子代理 · 聚焦验证,只报告不改码)

总体结论:可进入下一步(todo 开发)。 本轮只做两件事:复核第二轮 4 条是否真正落地、快速复扫有无新问题。

# 第三轮发现 档位 本轮处理
1 §14 风险 #6 仍写「四道风险控制」,与 T-10 的「五道」矛盾 —— 第二轮修复遗漏了第三处 重要 接受:已改为「五道」,并注明「第五道 = 覆盖率补偿,见 §8」
2 推测:SQLAlchemy 2.0 在语句失败后会把事务置为 pending,第 ③ 步继续执行将抛 PendingRollbackError,裁定因此失效 重要(推测) 驳回(附实测证据):本机实测 SQLAlchemy 2.0.51 + sqlite 3.50.4 → 捕获 IntegrityError 后继续 UPDATE 成功(rowcount=1)、with engine.begin() 正常退出且提交成功,未抛 PendingRollbackError。原因是 SQLAlchemy Core 的 Connection 不标记事务失效(该行为属 ORM Session 的 flush 机制),而本项目全链路用 text() + Core Connection。已把版本号 + 驳回理由 + 实施期防护写入 §1.5 R-a
3 tests/test_suitability.py:26 导入了 get_engine,未在 §12 列出(担心其 teardown 用默认角色发 DELETE) 可选 接受(经核实无风险):实测该 import 未被调用(全文件仅第 26 行出现一次),不构成调用点、也无角色隐患 → 已补入 F-11 备注
4 MySQL 的 rowcount 按「changed」而非「matched」计,同值 UPDATE 会得 0 → 多走一次冗余兜底 可选 不处理(无害):仅多一次尝试,语义正确,不引入错误

第二轮 4 条的复核结果:3 条已改到位(#1 R-a 三步 + 并发必要性 + T-6 用例;#3 用例数 4 处口径一致;#4 两处 DoD 已可机械验证), 1 条修复不彻底(#2 的 §14 未同步 —— 本轮已补,见上表第 1 行)。

第三轮确认无误:R-a 的方言互斥对照表诚实(第三轮独立复现 sqlite 侧结果与文档一致)· §14 新增的 R-a1/R-a2 与 §1.5 R-a 自洽 · §12 的 R1~R16 覆盖完整(无新遗漏)· T-1 / T-13 / T-6 的 DoD 均可机械验证。

三轮审核合计:12 条意见 → 接受 11 · 驳回 1(第三轮 #2,附实测证据)· 0 条悬空 · 0 条遗留阻断。

第四轮(2026-09-10 · 独立子代理 · 终验,只报告不改码)

终局结论:可以进入第 5 步 todo 开发。

逐条核实第三轮 4 条的闭环情况:4 条全部闭环。

  • §14 #6 已为「五道」,与 T-10 一致(正文无「四道」残留,「四道」仅存在于审核记录的历史描述中)
  • §1.5 R-a 已写明实测版本号(SQLAlchemy 2.0.51 + sqlite 3.50.4)与驳回理由,审核方判定技术正确
  • tests/test_suitability.py 全文件仅第 26 行 import、无调用,F-11 备注与之相符
  • MySQL rowcount 冗余兜底项标注「不处理(无害)」,代码逻辑未被误改

专项检查「声明已改但实际未改」的残留 → 无。 三轮计数自洽(4+4+4=12 条,接受 11 / 驳回 1 / 0 悬空)。

审核收敛结论:经四轮独立审核,本计划已达到可实施状态。 四轮的价值排序(供后人参考):第一轮最有价值(补出真 MySQL 集成用例漏项 + 指出降级致零覆盖的实质性缺陷)· 第二轮最有技术含量(发现执行权锁是请求粒度这一并发漏洞)· 第三/四轮为收敛验证。 后续若再改本计划,必须重跑至少一轮聚焦验证(模式:给出「上轮意见 + 声称处置」→ 让对方打开文件核实,而非只看自述)。


0. 结论速览

批次 任务 内容 依赖 风险
第 0 批 · 门禁 T-0 ✅ sqlite/MySQL 结构对齐(core_holding 列名 + PK、补 core_product_nav)+ 建库自校验 + 门禁用例 —— 2026-09-10 完成 无 低(但阻断)
T-0b ✅ DB 账号分离(D20):00-grant.sql + settings 3 组账号 + get_engine(db, role) + 3 个权限断言 —— 2026-09-10 完成 无(可并行 T-0) 低(但阻断)
第 1 批 · 数据与纯函数 T-1 MySQL DDL + 3 个种子 + reset.ps1 + sqlite DDL 同步 T-0 + T-0b 双绿 低
T-2 service/convert/ 纯函数包(types/calc/fee/nav/lot_bootstrap/errors) 无 低
T-2b calc_convert_demo.py 实算 + 回填 PRD §5.3 与验收断言 T-2 低
第 2 批 · 仓储与锁 T-3 core_ro 五个新方法 + share_lot_repository T-1 低
T-4 convert_repository(占位/回写/查询/清理) T-1 低
T-5 locks.try_lock + 单测 无 低
第 3 批 · 事务与编排 T-6 convert_core_repository.apply_convert(阶段一单事务) T-1/T-3 高(方言 + 并发)
T-7 convert_service 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) T-2~T-6 高(关键路径)
第 4 批 · 引擎与网关 T-8 _amount_view + engine.process_convert_event + alert_service.events 无(可与 T-2 并行) 中
T-9 api/simulate.py 模型与错误码 + trade_gateway convert 分派 T-7 中
T-11 core_tools 汇总去重 + 持仓 qty <= 0 过滤 + sum_trades_on_date 去重 T-8 中
第 5 批 · 高风险专项 T-10 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ rebuild_lots.py T-3(排在 T-7 后) 最高(打穿 510)
第 6 批 · 补偿 T-12 rebuild_alerts --convert-group + cleanup_pending_convert.py T-4/T-7 低
第 7 批 · 收口 T-13 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 全部 中

关键路径:T-0 → T-1 → T-2 → T-6 → T-7 → T-13 并行组 A:T-3 / T-4 / T-5(T-1 完成后同时开工) 并行组 B:T-8 全程可与 T-2 之后任意任务并行 硬门禁:T-0 与 T-0b 双双绿才允许启动 T-1 及之后(T-0 用例 = test_db.py::test_core_holding_columns) 测试基线:516(批 0 后;原 510)→ 预计 591 ~ 616(CI 内约 603;含不进 CI 的压测用例约 613,见 §11)


1. 背景与约束

1.1 上游依据与决策链

  1. 第 1 步「讨论需求」→ 第 2 步 PRD(v0.9,经 5 轮外部审查 + 架构校准轮,接受 27 / 修正性接受 5 / 驳回 0)
  2. 第 3 步架构设计(v1.0,独立评审 M-7 通过:接受 10 / 修正性接受 3 / 驳回 0,T-0 为唯一硬前置)
  3. 用户拍板 P1~P8(处置表 §九):批次兜底补建 / 归零保留行 / Core 明细仅补偿 / 补差费 B 默认 / 最低持有双动作 / 22 号文费率 / 份额 2 位 / T+1 建模
  4. 主架构评审 C1(core_ro 无 DB 级只读强制)跨线并入 → D20 / T-0b
  5. 本计划:把上述结论拆成 14 个可独立验证、可独立回滚的开发任务

1.2 硬约束

约束 对本计划的影响
五条红线 Core 只读(本期升级为 DB 级强制)· 审计只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易(NAV_NOT_READY/LOT_CONFLICT 属技术故障码,不适用本铁律)· 四 Agent 不互调 LLM
既有用例必须全绿(批 0 前 510 → 当前 516) 每个任务完成后即跑全量,不留到最后
实测才能改文档 PRD §9 第 18 条:性能阈值为预估值,实测补录;不得反向修改实测数据迁就指标
不新增路由 tests/test_main.py:58-74 硬编码路由清单 → convert 复用 /api/simulate/trade,该断言零影响(需显式声明,见 §12)
不新增第三方依赖 技术选型硬阀门(requirements.txt 不动)
sqlite 无账号概念 T-0b 的账号分离只在真 MySQL 生效,sqlite 路径不引入任何连接方式变化
不动范围 RISK-001~006 口径不变(仅金额视图去重)· entities.py ORM 不新增 · 不改 run_locked · 不改 get_latest_nav 语义 · 不改 rules._eligible 的 trade_type 白名单

1.3 文档口径差异裁定(实现前必须先定,否则会两边都做)

核对 PRD v0.9 与架构 v1.0 时,发现 2 处同一件事的表述不一致。裁定原则:架构 v1.0 晚于并细于 PRD 对应段落,以架构为准;PRD 对应行视为旧版本残留。

# 事项 PRD 表述 架构表述 裁定(实现依据)
W-1 阶段一「两条流水同事务插入」的落点 §5.2 表格:「gateway_repository.py → 新增 insert_convert_pair() 同事务插两条」 §2 / D3:新增 app/gateway/convert_core_repository.py::apply_convert()(单事务:2 流水 + 批次 + 明细 + 两端持仓) 以架构为准:gateway_repository 只扩列(qty/convert_group_id),convert 的写全部走 convert_core_repository
W-2 引擎如何吃两条流水 §7.0 表述:「引擎只调用一次 process_trade_event(),传入两条流水」 D5:新增 process_convert_event(out_trade, in_trade),与 process_trade_event 共用内部 _run();不改现有函数签名 以架构为准:process_trade_event 签名零改动(避免动 13 处调用方)

为何必须显式裁定:两处差异都属于「同一功能的两个落点」,若不裁定, 实现者可能既写 insert_convert_pair() 又写 apply_convert()(重复且事务边界冲突), 或给 process_trade_event 加第二参数(打穿既有 13 处调用与断言)。这是成本最高的返工类型。

1.4 开工前代码事实核对表(本计划新增 · 全部实地核对)

下表每条都是「架构/PRD 未点明,但实现时必然撞上」的事实。核对方式:读源码 + grep 全仓引用, 行号为撰写时实测值。这些是审核方最应复核的部分——若有一条与代码不符,说明我对现状的判断有误。

# 事实 证据(文件:行) 对实现的影响
F-1 sqlite core_holding 为 market_value/quantity 且无 PK、无 UNIQUE tests/_ddl.py:50-54 T-0 根因;须改为 MySQL 的 id 自增 PK + uk_cust_product + 5 业务列
F-2 tests/test_db.py 已存在(3 个用例:缓存单例 / dispose / 空缓存) tests/test_db.py:30-54 架构 §15 写「新增 tests/test_db.py::test_core_holding_columns」表述不准 → 应为在既有文件追加用例,禁止覆盖
F-3 sqlite core_trade 缺 qty(MySQL 侧 qty DECIMAL(18,4) NULL 已存在) tests/_ddl.py:55-60 vs scripts/core/01-ddl.sql core_trade T-1 在 MySQL 只加 convert_group_id;sqlite 要补两列(qty + convert_group_id)
F-4 sqlite 缺 core_product_nav 表(MySQL 有,get_latest_nav 查它) tests/_ddl.py:15-138(无该表)· app/repository/core_ro.py:450-460 架构 §9 的 sqlite 同步清单(4 张新表)漏了它;不补则 get_nav_as_of 的 sqlite 单测无法跑 → 建议并入 T-0
F-5 sqlite core_holding 改列名会打穿 2 处 测试 INSERT(显式写 market_value, quantity) tests/test_chat_tools.py:81 · tests/test_concentration_c4.py:74 T-0 必须同步改这 2 处为 qty;否则 OperationalError: no such column: quantity
F-6 app/ 全仓无任何代码读 core_holding.quantity;list_holdings 用 SELECT h.*、concentration_profile 只用 market_value grep -rn "market_value|quantity" app/ → 仅 core_ro.py:290/320/324/340(均 market_value) 列名统一对生产代码零影响,回归面只在 2 处测试 INSERT
F-7 UPSERT 方言互斥:MySQL 用 INSERT ... ON DUPLICATE KEY UPDATE,sqlite 用 INSERT ... ON CONFLICT(...) DO UPDATE,两者不通用 架构 D9 备注「⚠️ sqlite ≥3.24 支持该语法」——该表述不准确(3.24 支持的是 ON CONFLICT,非 MySQL 语法) 阶段一事务在 sqlite 单测会直接语法错:apply_convert 的 core_holding 写入必须改用方言无关写法(见 §1.5 R-a)
F-8 convert 两条流水的 trade_type 必须是 redeem / subscribe,靠 convert_group_id 关联 推论链:rules._eligible(rules.py:78-83)只放行 subscribe/redeem → 若写 convert 则两条都进不了事件线,与 PRD §6.1「两条流水都进事件线」矛盾;且架构 §7 _amount_view 明写「组内保留 trade_type='redeem' 那条」 实现时必须写死为 redeem/subscribe;MySQL ENUM 里的 convert 值本期不使用(保留不用,注释说明)
F-9 core_ro.sum_trades_on_date(SQL 侧求和)硬写 trade_type IN ('subscribe','redeem'),不经过 _amount_view app/repository/core_ro.py:420-432 自检第 2 问「谁读」的遗漏项:convert 落地后此方法会把两条流水都算进去 → 双计。架构 §6 只提了 core_tools,未提它(见 §1.5 R-d)
F-10 sum_trades_on_date 生产代码无调用方,仅 tests/test_core_ro_sum.py 在用 grep -rn "sum_trades_on_date" → 仅 core_ro.py:420 定义 + 该测试 5 处断言 改动零生产回归,但它是公开仓储方法,应顺带修正口径
F-11 get_engine 调用点在 T-0b 前为 13 处(app 4 + conftest 4 + test_db.py 5) T-0b 实施后实测行号:app/gateway/gateway_repository.py:30 · app/repository/core_ro.py:60 · risk_repository.py:37 · session_repository.py:25 · tests/conftest.py:185/186/236/237 · tests/test_db.py 21 处(角色/缓存/回退用例自身,均带显式 role 或默认 rw) T-0b 的改动清单必须逐点定角色,不能只改 db.py(
※ 初版误记为 11 处,经第一轮审核指出后实测更正为 13 处。
※ 第三轮补充:tests/test_suitability.py:26 仅 import 未调用 get_engine,不计入调用点、也无角色隐患)
F-12 既有 4 处断言会因「放开 convert」而失效 tests/test_trade_gateway.py:92-99(test_convert_rejected)· :215-219(test_api_convert_returns_400)· :98(core_trade 计数为 0)· :261-263(amount=0 → 422,必须保住) 逐条处置见 §12
F-13 tests/test_trade_gateway.py 的 env fixture 未灌 core_holding 与净值 tests/test_trade_gateway.py:40-72(只灌 customer/customer_risk/product) T-10 后 subscribe 分支要建批次、redeem 分支要兜底补建(读 core_holding)→ 在既有用例上必然走到无数据路径,见 §1.5 R-c
F-14 scripts/core/01-ddl.sql 中 core_product.fee_rate 无 COMMENT、core_trade.trade_type ENUM 已含 convert 01-ddl.sql(core_product / core_trade 段) T-1 补 COMMENT(Q8 定稿);convert ENUM 值保留不用(F-8)
F-15 agent 库 DDL 落点 = docs/项目框架设计/表设计/02-mysql-agent专用.sql(audit_log/risk_alert 在 01-mysql-共用底座.sql) grep -rln "CREATE TABLE.*audit_log" → 01-mysql-共用底座.sql;tests/conftest.py:181 的 bootstrap 提示链 架构 §9 选点(risk_convert_detail → 02-mysql-agent专用.sql)正确,与 risk_aml_list 等业务专用表同类

1.5 实现级裁定(本计划拍板,若不同意请在审核中驳回)

架构回答了架构级问题,但以下 7 条是实现级空白——不定就无法写代码,或写出来必然出错。 每条给出「问题 / 裁定 / 理由 / 若被驳回的替代方案」。

R-a · core_holding 写入用「UPDATE + rowcount + INSERT」两步,不用方言 UPSERT

  • 问题:架构 D9 写 INSERT ... ON DUPLICATE KEY UPDATE,但 sqlite 不支持该语法(F-7)。若实现时按 D9 原文写,阶段一事务在任何 sqlite 单测里立即语法错。
  • 裁定(第二轮审核后修订):apply_convert 内对两端 core_holding 统一走「条件 UPDATE → 首次 INSERT → 异常回退 UPDATE」:
    # ① 条件 UPDATE(常规路径:该持仓行已存在)
    r = conn.execute(update_sql, params)   # UPDATE core_holding SET qty=:q, cost_amount=:c,
                                           #   market_value=:mv, pnl_pct=:pnl, as_of=:d
                                           #   WHERE customer_id=:cid AND product_id=:pid
    if r.rowcount == 0:
        # ② 首次建行 → INSERT
        try:
            conn.execute(insert_sql, params)
        except IntegrityError:
            # ③ 兜底:并发事务已插入该行 → 退化为 UPDATE(同一事务内可继续执行,已实测)
            conn.execute(update_sql, params)
    
  • 理由:① 方言无关(不依赖任一方言的 UPSERT 语法);② 仍在 with engine.begin() 单事务内,原子性不变;③ 不引入 engine.dialect.name 分支(分支是长期维护负担,且 MySQL 分支在 sqlite 单测里永不被执行)。
  • ⚠️ 为何不能用「条件 INSERT」(本轮实测排除的两个方案,留痕防返工):
    写法 sqlite MySQL 结论
    INSERT ... SELECT ... WHERE NOT EXISTS(无 FROM) ✅ 可用(实测通过) ❌ MySQL 的 SELECT 不带 FROM 时不允许 WHERE 不通用
    同上 + FROM DUAL ❌ 实测报 no such table: DUAL ✅ 可用 不通用
    → 二者又组成一对互斥方言,仍做不到「同一套 SQL 两库通吃」。故最终选异常回退写法。
  • ⚠️ 并发兜底的必要性(第二轮审核指出 · 初版理由③有误): 初版称「阶段一持执行权锁可保不撞 UNIQUE」——该论证不成立。执行权锁的 key 是 convert:idem:{client_request_id}(请求粒度,见 §6.2 步骤⑤ / T-5),不是持仓粒度。 因此同一客户、同一产品、不同 client_request_id 的两笔并发转换(典型场景:A→C 与 B→C 同时转入 C;或首次转入某产品)会各持不同锁、双双进入阶段一,都 UPDATE 得 rowcount == 0 后并发 INSERT → 撞 UNIQUE(customer_id, product_id) 抛 IntegrityError (事务回滚、返回 5xx;非数据损坏,但属未兜底的异常路径)。②③ 两步正是为此存在。
  • 必须覆盖的用例(写入 T-6 的 DoD):模拟两笔并发首次转入同一 (cid, pid) → 断言最终只有一行、金额为两笔之和、两笔都成功(不得出现 5xx)。
  • 实测依据(SQLAlchemy 2.0.51 + sqlite 3.50.4,本项目系统 Python 3.13.14 环境实跑): 同一事务内捕获 IntegrityError 后,连接仍可用、可继续执行 UPDATE(rowcount == 1)、 with engine.begin() 块正常退出且事务提交成功——未抛 PendingRollbackError。

    第三轮的推测已被本次实测驳回:第三轮曾推测「SQLAlchemy 2.0 在语句失败后会把事务置为 pending, 后续执行抛 PendingRollbackError,从而使本裁定失效」。实测否定了该推测——SQLAlchemy Core 的 Connection 在语句级失败后不标记事务失效(该行为属 ORM Session 的 flush 机制); 本项目全链路用 text() + Core Connection,不涉及 Session。 实施期防护:若后续升级 SQLAlchemy 主版本,T-6 的「并发首次建行」用例会立刻暴露行为变化,不会静默失效。 MySQL InnoDB 亦为语句级回滚,失败语句不影响事务后续执行,语义一致。

  • 替代方案(若被驳回):保留 D9 原写法 + if engine.dialect.name == "sqlite" 分支。不推荐:两套 SQL 必然漂移,且 MySQL 分支在 sqlite 单测里永不被执行。 另一备选 = 阶段一增加 (customer_id, product_id) 维度的执行锁 —— 一期不取:会把锁管理复杂度引入事务内部, 并新增「锁超时」这一失败模式;若 T-6 单测证明异常回退不足,再评估。

R-b · convert 两条流水写 trade_type='redeem'/'subscribe',本期不使用 ENUM 里的 convert

  • 问题:MySQL core_trade.trade_type ENUM 已含 convert(F-14),实现者极易直觉地写 trade_type='convert'。
  • 裁定:转出端写 redeem、转入端写 subscribe,两行共享 convert_group_id;trade_type='convert' 本期任何代码都不写。
  • 理由:rules._eligible(rules.py:78-83)白名单只有 subscribe/redeem,写 convert 会让流水被引擎过滤掉,直接违反 PRD §6.1 / 验收第 6 条(F-8)。
  • 落地要求:在 convert_core_repository 与 errors.py 各留一条中文注释说明「ENUM 值 convert 保留不用,原因见裁定 R-b」,防后人改回去。

R-c · 批次维护的「数据不全降级」与「覆盖率补偿」(第一轮审核后修订)

  • 问题:PRD §4.4 要求批次由交易统一出入口维护(subscribe/转换转入 → 建批次;redeem/转换转出 → FIFO 扣减),但:

    • 普通申购请求体只有 amount 没有份额,份额需 amount ÷ 当日净值;sqlite 测试库无 core_product_nav 数据;
    • tests/test_trade_gateway.py 的 env fixture(:40-72)既无 core_holding 也无批次(F-13),而 redeem 侧要读持仓做 D8 兜底补建。
  • 裁定(两条并行,缺一不可):

    (1) 降级规则 —— 只在「源数据缺失」时生效,用于保住既有 510

    场景 处置
    subscribe / 转换转入,取不到当日净值 logger.warning(记 trade_id + 原因)→ 跳过建批次,不抛异常、不阻断交易
    redeem,既无批次也无持仓行(core_holding 无该 (cid,pid)) logger.warning → 跳过批次扣减,不抛异常、不阻断交易
    redeem,无批次但有持仓 走 D8 兜底补建(lot_bootstrap.bootstrap_lots)再扣 —— 这是 D8 的主场景,必须执行

    (2) 覆盖率补偿 —— 回答「降级会不会导致批次维护零覆盖」

    • test_share_lot.py 必须自建完整种子(core_holding + core_share_lot + core_product_nav), 完整覆盖两条真实路径:redeem FIFO 扣减(含 D8 兜底补建)× subscribe 建批次 × rebuild_lots.py 入口。
    • 不得用降级路径充当测试覆盖(降级是「数据不全时的兜底」,不是被测对象)。
    • 断言 core_share_lot.remain_qty 之和 = 初始值 − 实际扣减量(不超扣)。
  • 理由:① 批次扣减(redeem 侧)是 convert 正确性的前提(PRD §4.4 的核心缺陷——不扣就超扣); ② 既有 510 的 fixture 无持仓/净值,若强行走「缺数据即报错」会直接打穿 510(违反 §1.2 硬约束); ③ 但若只做降级不做补偿,批次维护会在单测层零覆盖,验收第 9 条(不超扣)形同虚设 —— 这是第一轮审核指出的实质问题,故补入 (2)。

  • 一句话原则:降级只用于「既有测试数据不全」的场景;新测试必须造全数据、测真实路径。

  • 若被驳回:替代方案 = 给 test_trade_gateway.py 的 env 等多个 fixture 统一补灌持仓与净值种子 (T-10 工作量增加、改动面扩到 3+ 个测试文件,但覆盖更自然)。可接受但要评估。

  • 注意:此裁定只约束 T-10 的普通申赎;换入端的新批次(convert 转入)必须建(它有完整折算结果,不存在取不到净值的问题)。

R-d · core_ro.sum_trades_on_date 同步加 convert 去重(架构 §6 未覆盖的读取方)

  • 问题:F-9/F-10——该方法 SQL 侧按 trade_type IN ('subscribe','redeem') 求和,convert 落地后会双计,且它不经过 _amount_view。架构 §6 只列了 core_tools.query_recent_trades。
  • 裁定:
    AND trade_type IN ('subscribe', 'redeem')
    AND (convert_group_id IS NULL OR trade_type = 'redeem')   -- 组内只计转出端,同 _amount_view 口径
    
  • 理由:① 口径与 _amount_view(D7)完全一致,一处语义两处落地但可互相印证;② 既有 test_core_ro_sum.py 的 5 条断言数据无 convert_group_id → 恒等通过,零回归;③ 顺带补 1 条 convert 去重用例。
  • 落地要求:test_core_ro_sum.py 追加 1 条用例(同日一组 convert 两条流水 + 1 笔普通赎回,断言只计转出端与本笔普通赎回)。
  • 若被驳回:至少要在 docstring 加「不适用 convert 场景,金额聚合请用 rules._amount_view」,避免后人误用 —— 不接受「什么都不做」。

R-e · conftest.py 的真库 engine 改用 role="admin",业务调用仍走拆分账号

  • 问题:T-0b 后 xh_core_rw 无 DELETE 权限、xh_agent_rw 对 audit_log 只有 INSERT。而 tests/conftest.py 的 teardown 要 DELETE FROM core_trade(:219)、DELETE FROM risk_alert/audit_log(:221-223),setup 还要跑 prepare_risk_demo.sql(含 INSERT)。若 conftest 沿用默认角色,真库集成测试的 setup/teardown 会因权限被拒而失败。
  • 裁定:tests/conftest.py:183/184/232/233 四处 get_engine(...) 显式传 role="admin"(= root);app/ 内的业务调用一律不用 admin。
  • 理由:① 测试的建数据/清数据是运维动作,不是应用行为,用管理员账号符合最小权限的边界定义(应用运行时不持有 root,测试脚手架可以);② 若给 xh_core_rw 加 DELETE 只为清理,会直接破坏「无 DELETE」这一最小权限目标(评审 C1 的初衷),否决。
  • 附带动作:架构 §11.1 表里 root 的用途需扩充一行——「真 MySQL 集成测试的 setup/teardown(tests/conftest.py)」;本计划 §3.2 落实,并在实施时同步回写架构文档那一格。

R-f · T-0 范围扩至 core_product_nav(架构 §15 只写了 core_holding)

  • 问题:F-4——sqlite 缺 core_product_nav 表,而 get_latest_nav(core_ro.py:450)与本次新增的 get_nav_as_of 都查它。
  • 裁定:T-0 的 sqlite 对齐一并补建 core_product_nav(含 uk_product_date 唯一约束),并把 T-0 的门禁断言从「单表列名」扩为「① core_holding 列名 + PK;② 关键表存在性」。
  • 理由:① 两者同属「sqlite/MySQL 结构失配」,是同一个 R1 风险的两种表现,一次改完、一个门禁守住,比分两次改省一轮回归;② 若留到 T-1 补,则 T-1 会同时承担「新表 DDL」与「补历史缺失表」两类工作,出问题时归因困难。
  • 若被驳回:core_product_nav 挪到 T-1,T-0 只做 core_holding。可接受,但 T-0 的 DoD 需相应缩小。

R-g · 自校验落在 _ddl.create_sqlite_engine() 内,test_db.py 作为可独立跑的门禁用例

  • 问题:架构 §9 要求「conftest.py 加启动期列名断言」。但 conftest.py 的 sqlite_engine fixture 只是其中一个建库入口——测试文件各自 create_sqlite_engine() 的场景更多(如 test_trade_gateway.py:42)。
  • 裁定:双落点、同源常量——
    1. 主:tests/_ddl.py::create_sqlite_engine() 建表后调用新增的 _assert_ddl_aligned()(读权威列/表清单常量,缺项即 AssertionError)。所有建库路径自动被覆盖,无需改 conftest。
    2. 辅:tests/test_db.py::test_core_holding_columns(T-0 门禁用例),供 CI 单独指定运行(pytest tests/test_db.py::test_core_holding_columns)。
  • 理由:① 建库入口唯一(create_sqlite_engine 是 _ddl.py 唯一建表函数),断言放这里 = 全量覆盖,不留「某个测试文件自己建库绕过断言」的口子;② CI 需要可单独指定的门禁用例 → 保留 test_db.py 那条;③ 两份断言读同一份常量 → 不会漂移。
  • 若被驳回:只在 conftest.py 加 fixture 断言(架构原文),但需接受「部分测试文件不走 conftest fixture、门禁被绕过」的风险。

R-h · plan_lots 的「零剩余不触发强制处置」(T-2 执行期新增裁定)

  • 问题:PRD §12 I-4 的触发条件是「余额 < min_hold_qty」。客户申请份额 == 全部可转份额时余额 = 0,字面上 0 < min_hold_qty 成立 → 会被判为「强制全转 / 强制赎回剩余」,forced_full_transfer = True 落响应与审计;但此时 actual_qty == requested_qty,客户指令其实没有被改动。
  • 裁定:触发条件增加「余额 > 0」前置:
    trigger = leftover > 0 and threshold > 0 and leftover < threshold
    
  • 理由:① forced_full_transfer 的语义是「客户指令被改变」(PRD 二轮第 9 条),零剩余时该语义不成立——前端会误报「您的申请已被系统改为全额转出」;② 零剩余时 min_hold_action 的两个分支(强制全转 / 强制赎回剩余)都无事可做,标记只产生噪音;③ 不改变 PRD 审查例的结论(持 6000、申请 5000、阈值 1000 → 余额恰好 1000 → 仍不触发)。
  • 落地:calc.plan_lots() 内有对应中文注释;tests/test_convert_calc.py::TestMinHoldAction::test_zero_leftover_not_forced 锁定该语义。
  • 若被驳回:按 PRD 字面实现(余额 = 0 也触发),但需同步改前端文案,说明「申请全额转出时也会带 forced_full_transfer=true」——评审大概率判为误导。

2. 执行分组与排期策略

第 0 批(门禁,不可并行跳过)
  T-0  sqlite/MySQL 结构对齐 + 建库自校验 + test_db 门禁用例
  T-0b DB 账号分离(00-grant / settings / get_engine(db,role) / 3 权限断言)
        │  ⛔ 双双绿才放行
        ▼
第 1 批  T-1(DDL + 种子 + sqlite 同步)───┬─► T-2(纯函数包)─► T-2b(实算回填)
                                          │
第 2 批  T-3 / T-4 / T-5(并行组 A)───────┤
                                          ▼
第 3 批  T-6(阶段一事务)─► T-7(编排 · 关键路径)─┬─► T-9(API + 网关分派)
                                                   └─► T-12(补偿脚本)
第 4 批  T-8(引擎改造,全程并行)─► T-11(工具/求和去重)
第 5 批  T-10(批次维护 · 回归风险最高,单独结项)
第 6 批  T-13(全量回归 + 集成 + 50 并发压测 + 性能补录)

排期原则

原则 说明
门禁优先 T-0/T-0b 不是「准备工作」,是独立交付物:四件事做完(sqlite 对齐 + 自校验 + 账号分离 + 权限断言),T-1 才能开始
纯函数先于集成 T-2 不依赖任何表结构,可与 T-1 并行开工,且它的单测最容易穷举(金额逻辑最易错)
高风险单列 T-10 改 trade_gateway 主流程、直接打穿 510 → 单独结项:先跑基线快照 → 改造 → 再跑全量
每任务一个回滚点 每个任务完成后立即 commit(不含下游任务改动),出现回归可精确回退到上一任务
不做「顺手重构」 除 §1.5 明列的补充(R-a~R-g),不碰任何非本任务范围的代码

3. 第 0 批 · 门禁

3.1 T-0 · sqlite/MySQL 结构对齐 + 建库自校验

目标:让 sqlite 测试库的结构成为 MySQL 权威结构的真子集,且失配在建库那一刻就炸,而不是留到集成测试期。

涉及文件

文件 动作
tests/_ddl.py 【改】重写 core_holding DDL · 新增 core_product_nav · 新增权威清单常量 + _assert_ddl_aligned()
tests/test_chat_tools.py 【改】:81 INSERT 列名 quantity → qty
tests/test_concentration_c4.py 【改】:74 同上
tests/test_db.py 【改】追加 test_core_holding_columns(不覆盖既有 3 个用例,见 F-2)

改法(逐项)

  1. core_holding DDL 重写(以 scripts/core/01-ddl.sql 为准;sqlite 无 DECIMAL(18,4) 精度语义,保留类型名即可):

    "core_holding": """
        CREATE TABLE core_holding (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
            qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL,
            market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL,
            UNIQUE (customer_id, product_id))
    """,
    

    保留 market_value 列名不动(F-6:core_ro 的 list_holdings/concentration_profile 依赖它)。 不建外键(sqlite 测试库既有表均无 FK,保持一致,避免插入顺序约束)。

  2. 新增 core_product_nav(F-4 / R-f):

    "core_product_nav": """
        CREATE TABLE core_product_nav (
            id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL,
            nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL,
            UNIQUE (product_id, nav_date))
    """,
    
  3. 新增权威清单常量 + 自校验(R-g):

    # 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(T-0 门禁比对基准)
    EXPECTED_CORE_HOLDING_COLUMNS = {
        "id", "customer_id", "product_id", "qty",
        "cost_amount", "market_value", "pnl_pct", "as_of",
    }
    REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"}
    
    def _assert_ddl_aligned(engine) -> None:
        """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁,防止失配留到集成测试期)。"""
    

    create_sqlite_engine() 在 for ddl in SQLITE_TABLES.values() 之后调用它。

  4. 新增门禁用例 tests/test_db.py::test_core_holding_columns:

    • 断言 PRAGMA table_info(core_holding) 的列名集合 ⊇ EXPECTED_CORE_HOLDING_COLUMNS
    • 断言主键 = {id}(PRAGMA table_info 的 pk 字段)
    • 断言 UNIQUE(customer_id, product_id) 存在(PRAGMA index_list(core_holding) + index_info)

      已实测可行(sqlite 3.50.4):UNIQUE(a,b) 会生成 sqlite_autoindex_* 且 PRAGMA index_list 的 unique 字段为 'u',可稳定断言。

    • 断言 core_product_nav 表存在且含 (product_id, nav_date) 唯一约束
  5. 同步改 2 处测试 INSERT(F-5):把 market_value, quantity 改成 market_value, qty,数值保持原样(100 / 1000 / 500)。

    ⚠️ 实施时发现的漏项(原计划未覆盖):这 2 处 INSERT 原本只写 4 列, 而新 DDL 的 cost_amount / pnl_pct / as_of 均为 NOT NULL —— 只改列名会直接撞 NOT NULL constraint failed 而全红。 故须一并补全这三列,取中性值(cost_amount = market_value、pnl_pct = 0、 as_of = '2026-09-04' 固定日期,不依赖当天日期以保证测试确定性)。

依赖:无 完成标准(DoD)

  • pytest tests/test_db.py::test_core_holding_columns -q 绿
  • pytest -q 全量全绿(批 0 前基线 510;含改后的 2 处 INSERT,另补 3 个 NOT NULL 列)
  • 反向验证门禁真的生效 —— 已固化为自动化用例(无需手工改删再恢复): test_db.py::test_ddl_alignment_guard_fails_when_column_missing (monkeypatch 抽掉 qty 列 → 断言建库即 AssertionError)
  • scripts/core/01-ddl.sql 与 EXPECTED_CORE_HOLDING_COLUMNS 逐列对照,写入 _ddl.py 注释

风险:低。唯一变量是那 2 处测试 INSERT(已在 F-5 定位)。 回滚点:本任务单独一个 commit;回滚即恢复到现状(convert 尚未开始,无下游依赖)。


3.2 T-0b · DB 账号分离(D20)

目标:把「Core 只读」「审计只 INSERT」从代码约定升级为 DB 级强制。

涉及文件

文件 动作
scripts/core/00-grant.sql 【新增】3 账号 + 逐表最小权限(管理员执行)
app/config/settings.py 【改】新增 6 个配置项(3 组账号密码,默认 "")
app/utils/db.py 【改】get_engine(database, role="rw"),缓存键 → (database, role)
app/repository/core_ro.py:55 【改】get_engine(core_db, "ro")
app/gateway/gateway_repository.py:24 【改】get_engine(core_db, "rw")
app/repository/risk_repository.py:37 · session_repository.py:25 【改】get_engine(agent_db, "rw")(显式,可读性)
tests/conftest.py:183/184/232/233 【改】显式 role="admin"(R-e)
tests/test_db.py 【改】追加 2 条用例:同库不同角色 → 不同 engine;角色未配置 → 回退 mysql_user
docs/项目框架设计/架构设计-基金转换交易.md §11.1 【改】root 用途行补「真 MySQL 集成测试 setup/teardown」(R-e 附带)

改法(逐项)

  1. 00-grant.sql(不进 reset.ps1:DROP DATABASE 不清 mysql.db 授权行,授权一次即可;且它需管理员执行):

    -- 逐表授权:MySQL 无法用「库级 GRANT + 表级 REVOKE」做部分撤销,必须枚举
    -- jinrong_core:只读账号
    CREATE USER IF NOT EXISTS 'xh_core_ro'@'%' IDENTIFIED BY '<从 .env 取,禁止写死在仓库>';
    GRANT SELECT ON jinrong_core.* TO 'xh_core_ro'@'%';
    -- jinrong_core:写账号,限 4 表、无 DELETE、无 DDL
    CREATE USER IF NOT EXISTS 'xh_core_rw'@'%' IDENTIFIED BY '<...>';
    GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_trade TO 'xh_core_rw'@'%';
    GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_share_lot TO 'xh_core_rw'@'%';
    GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_holding TO 'xh_core_rw'@'%';
    GRANT SELECT, INSERT, UPDATE ON jinrong_core.core_convert_lot_detail TO 'xh_core_rw'@'%';
    -- jinrong_agent:业务表逐表授权,audit_log 只授 INSERT(红线升级为 DB 强制)
    CREATE USER IF NOT EXISTS 'xh_agent_rw'@'%' IDENTIFIED BY '<...>';
    GRANT SELECT, INSERT, UPDATE ON jinrong_agent.<各业务表,逐一枚举> TO 'xh_agent_rw'@'%';
    -- ⚠️ 口径修正(实施时发现):架构 §11.1 原文「audit_log 只授 INSERT」若**字面执行**,
    -- 会连 SELECT 一并剥夺 → has_engine_error_audit(:231) 与 list_audit_events(:420) 双双失败。
    -- 红线本意是「只能追加,不能修改或删除」→ 正确授权 = SELECT + INSERT(无 UPDATE/DELETE)。
    GRANT SELECT, INSERT ON jinrong_agent.audit_log TO 'xh_agent_rw'@'%';
    GRANT SELECT, INSERT, UPDATE ON jinrong_agent.risk_convert_detail TO 'xh_agent_rw'@'%';
    

    表级 GRANT 可针对尚不存在的表(T-1 才建),故 T-0b 可先于 T-1 执行 ✓

  2. settings.py 新增(默认空 = 回退 mysql_user,不阻塞本地开发): mysql_core_ro_user / mysql_core_ro_password / mysql_core_rw_user / mysql_core_rw_password / mysql_agent_user / mysql_agent_password

  3. db.py:

    def get_engine(database: str, role: str = "rw") -> Engine:
        """按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。
        对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
    
    • role="ro" → 仅 core 库有意义(用 mysql_core_ro_user);agent 库无 ro 账号,回落 mysql_agent_user(或 mysql_user)
    • role="admin" → 恒用 mysql_user
    • 缓存键 f"{database}:{role}";dispose_engines() 语义不变
  4. 关键边界(架构 §11.1 明确):gateway 的读走 ro、写走 rw —— trade_gateway.py:105 的校验读 core_ro or CoreReadOnlyRepository() 保持 ro 不变,只有 gateway_repository.insert_trade 与新增的 convert_core_repository 用 rw。否则 gateway 顺带获得全库读权限,最小权限落空。

依赖:无(可与 T-0 并行;但两者都必须在 T-1 之前) 完成标准(DoD)

  • pytest -q 全量全绿(批 0 前基线 510 → 516;默认配置下账号为空 → 全走 mysql_user,与现状完全一致); tests/conftest.py:185/186/236/237 四处已显式 role="admin"(R-e,防 teardown 失权)
  • 本地配置 3 组账号后,真 MySQL 三条权限断言绿(账号未配置或真库不可达时自动 skip, 不阻塞默认基线):
    • test_db.py::test_core_ro_account_is_readonly(ro 执行 INSERT → 被拒)
    • test_db.py::test_audit_log_append_only(audit_log UPDATE/DELETE → 被拒)
    • test_db.py::test_core_rw_scope(rw 对第 5 张表 core_product UPDATE → 被拒)
  • test_db.py::test_get_engine_role_isolation(同库不同角色 → 不同 engine)
  • grep -rn "get_engine(" 全部调用点逐点有明确角色(F-11 清单:app 4 处 = ro/rw;conftest 4 处 = admin;其余为 test_db.py 用例自身)
  • 账号未配置时回退路径有单测(回退 = 现状行为)

风险:低,但若漏掉 conftest 的 admin 角色(R-e),真库集成测试会在 T-13 集体失败——这是本任务最易漏的一处。 回滚点:单独 commit;回滚 = 恢复单账号(get_engine 保留 role 参数但全部回落,无副作用)。


3.3 第 0 批执行记录(2026-09-10 完成)

基线:开工前 pytest -q → 510 passed。

任务 落地文件 结果
T-0 tests/_ddl.py(重写 core_holding + 新增 core_product_nav + EXPECTED_CORE_HOLDING_COLUMNS / REQUIRED_CORE_TABLES + _assert_ddl_aligned())· tests/test_chat_tools.py · tests/test_concentration_c4.py · tests/test_db.py(+3 用例) ✅
T-0b scripts/core/00-grant.sql【新增】· app/config/settings.py(+6 项)· app/utils/db.py(get_engine(db, role) + _resolve_credentials)· core_ro.py→ro · gateway_repository.py→rw · risk_repository.py/session_repository.py→rw 显式 · tests/conftest.py 4 处→admin · tests/test_db.py(+3 引擎用例 +3 权限断言) ✅

测试结果:pytest -q → 516 passed / 3 skipped(510 基线 + 3 条 T-0 用例 + 3 条 T-0b 引擎用例; 3 条真 MySQL 权限断言因账号未配置按设计 skip)。

实施中发现并已回写本计划的 3 处偏差:

  1. 2 处测试 INSERT 缺 3 个 NOT NULL 列(原计划只提「改列名」)→ 详见 §3.1 第 5 点。
  2. audit_log 授权口径:架构 §11.1 原文「只授 INSERT」若字面执行会让 has_engine_error_audit / list_audit_events 失权 → 实际授权 SELECT, INSERT(无 UPDATE/DELETE)。
  3. 门禁反向验证改为自动化用例(test_ddl_alignment_guard_fails_when_column_missing), 不再需要「手工删列 → 验证 → 恢复」三步。

开工前置完成情况:T-0 与 T-0b 代码侧已全部落地,默认配置下全绿。 00-grant.sql 的实际执行(建账号)与 .env 写入密码属环境操作 —— 执行后 3 条权限断言才会由 skip 转为真实运行;未执行不影响 T-1 开工(默认回退单账号)。



4. 第 1 批 · 数据与纯函数

4.1 T-1 · DDL + 种子 + sqlite 同步

目标:真库结构就位,种子可复现,sqlite 与之对齐。

涉及文件与改动

文件 动作 要点
scripts/core/01-ddl.sql 【改】 ① 新建 core_fee_rule / core_share_lot / core_convert_lot_detail(含 nav/nav_date,D6)② core_trade 只加 convert_group_id VARCHAR(64) NULL + KEY idx_convert_group(qty 已存在,F-3)③ core_product 加 8 列:can_subscribe/can_redeem(TINYINT DEFAULT 1)、min_hold_qty/min_redeem_qty(DECIMAL DEFAULT 0)、min_hold_action(VARCHAR DEFAULT 'force_transfer')、subscribe_fee_rate(DECIMAL(6,4) DEFAULT 0)、fund_company/ta_code(VARCHAR NULL)④ core_product.fee_rate 补 COMMENT(Q8)
scripts/core/07-seed-fee-rule.sql 【新增】 赎回费 5 档:0.0150 / 0.0100 / 0.0050 / 0.0025 / 0,to_fund_ratio = 1.0;文件头带版本注释块
scripts/core/08-seed-share-lot.sql 【新增】 按 core_holding 反推批次,按客户/产品错开持有期以覆盖多档;文件头版本块
scripts/core/09-seed-org.sql 【新增】 fund_company/ta_code + subscribe_fee_rate;费率档严格按 product_type 取(stock=0.0080 / mixed=0.0050 / bond·index=0.0030 / money=0),造出不同费率对:PROD-110022(bond)=0.0030 / PROD-003095(stock)=0.0080(同属华夏组、可互转);同费率对照 PROD-510300(index)=0.0030、零费率对照 PROD-000001=0;分组按真实「管理人全产品线」重排;文件头版本块
scripts/core/reset.ps1 【改】 $files 追加 07/08/09(00-grant 不加入)
docs/项目框架设计/表设计/02-mysql-agent专用.sql 【改】 追加 risk_convert_detail 建表(PRD §4.1 DDL 原文,status ENUM 5 值含 expired)
tests/_ddl.py 【改】 core_trade 补 qty/convert_group_id;core_product 补 8 列;新增 core_fee_rule/core_share_lot/core_convert_lot_detail/risk_convert_detail(共 4 张)

DoD(全部达成,见下方执行记录)

  • reset.ps1 全流程跑通,且可用 SQL 逐条断言(判定阈值明确,非人工目测): ① SELECT COUNT(DISTINCT min_hold_days) FROM core_fee_rule WHERE fee_type='redeem' = 5(且每只产品各 5 档); ② 按 (customer_id, product_id) 汇总 core_share_lot.remain_qty = core_holding.qty(逐行相等); ③ fund_company / ta_code / subscribe_fee_rate 三列无 NULL,且存在 subscribe_fee_rate 不同的产品对 ※ 执行方式:本机 mysql 客户端不在 PATH 且 reset.ps1 的 -p 需交互密码, 改用等价流程(pymysql 执行同一组 00~09 SQL)并固化为 scripts/dev/verify_convert_seed.py。
  • pytest -q 全量全绿(当前基线 516;sqlite 加表不改任何既有断言)
  • 老 INSERT(不带新列)仍可执行 → 向后兼容成立(架构风险 #9)
  • risk_convert_detail.status ENUM 建表即含 5 值(免二期 ALTER)

执行记录(2026-09-10)

项 内容
落地文件 scripts/core/01-ddl.sql(3 新表 + core_trade.convert_group_id + core_product 8 列 + fee_rate COMMENT)· 07/08/09-seed-*.sql【新增】· reset.ps1($files 追加 07/08/09)· docs/项目框架设计/表设计/02-mysql-agent专用.sql(追加 risk_convert_detail)· tests/_ddl.py(core_trade 补 qty/convert_group_id、core_product 补 8 列、新增 4 表、REQUIRED_CONVERT_TABLES 门禁)· scripts/dev/verify_convert_seed.py【新增】
断言结果 8/8 PASS:① 5 档 × 14 产品(70 行,无产品档数≠5)② 58 行持仓 vs 61 行批次,失配 0 ③ NULL 0 行、DISTINCT 费率 4 ④ 批次覆盖 5 档(13/12/12/12/12)⑤ CUST-9527 跨批次(005827×3、110022×2)⑥ 老 INSERT 兼容(新列取默认值 None/1/force_transfer/0)⑦ status = enum('pending','completed','failed','cancelled','expired') ⑧ 费率档 ↔ product_type 匹配(越档 0 · 主示例两端同主体「华夏模拟基金/TA-CN-001」· 华夏组费率档 4)
pytest 516 passed / 3 skipped(与批 0 后基线完全一致 → 零回归;sqlite 加表与既有断言无耦合)
真库落位 jinrong_agent.risk_convert_detail 已实际建表(SHOW COLUMNS 实测 status = enum('pending','completed','failed','cancelled','expired'));jinrong_core 已按 00~09 重灌并通过断言
文档同步 scripts/core/README.md(手动顺序 + 种子规模:持仓 ~45→58、新增费率/批次两行)· docs/项目框架设计/Core模拟底座/00-方案总览.md(目录树补 00-grant/07/08/09)· 演示SOP-风控模块.md(for 循环补 07/08/09)· docs/PRD/PRD-基金转换交易.md(§4.3 费率分类归位 + §5.3 主示例转入方改 PROD-003095 + §2.1.1/§9 引用同步)

实施中发现的 3 处需注意项:

  1. reset.ps1 的可执行性缺口(实跑踩坑):需 mysql 在 PATH 且 -p 交互输密码 → 非交互/CI 场景不可用。 已用 scripts/dev/verify_convert_seed.py 提供等价无交互路径(含 7 条 DoD 断言)。 更关键的连带坑:重建 jinrong_core 后必须补跑 scripts/demo/prepare_risk_demo.sql (reset 的既定伴随步骤,SOP §2)—— 否则 03-seed-customers.sql 写死的风评有效期会让 tests/conftest.py::ensure_risk_demo_ready 判定演示数据未就位,整个真库集成模块被 skip (实测 516 → 505 passed)。本脚本已内置该步骤,避免二次踩坑。计划外增补,非返工。
  2. core_fee_rule 的读取路径已明确(自检第 2 问「谁读」):00-grant.sql 中 xh_core_ro 为 SELECT ON jinrong_core.*,库级授权自动覆盖 T-1 新建表; xh_core_rw 刻意不授该表读权限 → convert 费率读取必须走只读账号(T-6 遵守)。无需改 grant。
  3. 费率分类错误 —— 已按真实业务修正(2026-09-10 用户裁定):v1.0 种子把 PROD-005827(product_type='mixed',平衡混合一号)按「主动偏股」取到 0.0080 上限, 目的是凑出 0.50% 的补差幅度。这是分类错误 —— 22 号文 §8 的费率档由产品类型决定, mixed 只能套「其他混合型 ≤0.5%」。用户驳回原话:「那是两种类型,怎么能混呢?」 修正内容(三处联动,缺一不可):
    • PROD-005827 → 0.0050(其他混合型);主示例转入方改用真正的主动偏股产品 PROD-003095(stock / R4 / 净值 0.9500)→ 与原示例除数相同,§5.3 全部派生数字 (in_qty=53456.95 / diff_fee=252.40 等)一个都不用改(已跑 calc_convert_demo.py 复核);
    • 分组按真实业务重排:v1.0 的「华夏组全低风险、易方组全股票型」不符合真实 (真实基金管理人是全产品线)→ 现两组各 6 只、各覆盖多个风险层级; 理财/私募单列(非公募主体,天然不可与公募互转);
    • 连带约束核对:PROD-003095 与转出方 PROD-110022 同属华夏模拟基金 / TA-CN-001 (同管理人 + 同 TA → 满足转换前置硬约束);C3 客户 → R4 产品为 allowed_with_disclosure(需签揭示书后放行,非阻断)。 机制落地:该约束已固化为断言 ⑧「费率档 ↔ product_type 匹配」(越档即 FAIL), 不再依赖人工比对;并写入项目记忆自检第 12 问:类型与口径必须匹配。

风险:低。注意 08-seed-share-lot.sql 的持有期错开必须真的落在不同档位(否则费率档单测无数据)。


4.2 T-2 · service/convert/ 纯函数包

目标:所有金额/费率/份额逻辑落到可穷举单测的纯函数里,不查库、不碰 SQL。

新增文件(app/service/convert/)

文件 内容(架构 §7 签名)
__init__.py 空
types.py @dataclass(frozen=True) 定义 Lot / FeeRule / PlanResult(评审 S3,不用裸 dict)
calc.py plan_lots() · lot_amount() · lot_fee() · convert_amount() · diff_fee(mode) · in_qty()
fee.py pick_fee_rate(rules, hold_days)(区间左闭右开)
nav.py is_stale() + NAV_NOT_READY 判定(输入已取到的净值行,不查库)
lot_bootstrap.py bootstrap_lots(holding_row) -> list[Lot](D18:gateway 兜底补建与 rebuild_lots.py 同源)
errors.py 8 个 convert 专属异常(继承 ApiError 语义,§8.3 映射)

必须写死的实现细节

  1. 精度:所有量化处显式 rounding=ROUND_HALF_UP(Decimal 默认是 ROUND_HALF_EVEN,架构风险 #3);逐批先舍入后求和。
  2. 补差费双口径(D13):
    • mode="amount_diff"(默认,口径 B):Max[conv×in/(1+in) − conv×out/(1+out), 0]
    • mode="rate_diff"(口径 A):conv × max(in−out,0) / (1 + max(in−out,0))
  3. in_qty 2 位 HALF_UP(v1.0 勘误:v0.2 的「4 位 ROUND_FLOOR」是残留,已废)。
  4. pick_fee_rate 边界:[min_hold_days, max_hold_days);hold_days = (交易日 − confirmed_at).days(自然日,不含申请日;满 7 日归 7–30 档)。
  5. plan_lots 一次性返回 PlanResult(含 actual_qty/forced_full_transfer/action/batch_count)——TOO_MANY_LOTS 必须先规划再判上限,避免半途失败(§8.3)。

另增 3 个纯函数(架构 §7 签名清单未列,T-2 执行期补入并已回填架构 §7)

函数 归属 为什么必须有
hold_days(trade_date, confirmed_at) calc.py pick_fee_rate 的入参是持有天数,而 confirmed_at 是时间戳;日期差必须可单测(T+1 起算、满 7 日归 7–30 档都靠它),不能散落在 service 里
rounding_diff(in_amount, in_nav, actual_in_qty) calc.py PRD §5.3 响应字段,需与 in_qty 同一套舍入算差值,独立实现在脚本侧会漂移
ensure_batch_limit(plan, max_lots) calc.py 把「批次数超上限」的判据固定在 plan_lots 的产物上(batch_count),避免调用方各写一遍 > 判断

DoD(全部达成,见下方执行记录)

  • tests/test_convert_calc.py ~35 条绿:FIFO / 跨批次计费 / 双口径各一组 / .5 边界(HALF_UP vs HALF_EVEN 结果必须不同处命中 HALF_UP) / 分档边界 6/7/29/30/179/180/364/365 / 强制全转 + 强制赎回双动作 / T+1 起算 / 份额 2 位
  • plan_lots 的 batch_count 在 > 200 时可供调用方抛 TOO_MANY_LOTS
  • 纯函数零 import 仓储/引擎(grep -n "import" calc.py 人工确认)

执行记录(2026-09-10)

新增 7 个文件(app/service/convert/):

文件 内容
__init__.py 包职责 docstring(纯函数约束:不查库 / 不碰 SQL / 不读配置 / 不写日志)
types.py Lot / FeeRule / LotAllocation / PlanResult(@dataclass(frozen=True))+ to_decimal / to_datetime / to_date 三个归一工具
calc.py round2 / lot_amount / lot_fee / convert_amount / in_qty / rounding_diff / diff_fee / hold_days / plan_lots / ensure_batch_limit
fee.py matches / pick_fee_rate(左闭右开、多档命中取最具体档)
nav.py ensure_nav_ready(无净值 → 503)/ is_stale / evaluate_nav
lot_bootstrap.py offset_for / bootstrap_lot_id / bootstrap_lots(D18 单点)
errors.py ConvertError 基类 + 11 个子类(见下方差异说明)

关键实现点

  1. 精度:所有量化点显式 rounding=ROUND_HALF_UP(Decimal 默认 ROUND_HALF_EVEN);测试里有一条反向自证用例——先证明 HALF_EVEN 会得到不同结果,再断言 round2 不等于它(漏传 rounding 时必红)。
  2. plan_lots 不判上限:只做 FIFO 分配 + 最低持有处置,上限判据由 ensure_batch_limit 在规划之后执行(§8.3「先规划再判上限」)。
  3. _fifo_order 内部再排一次序:SQL 已 ORDER BY confirmed_at, lot_id,但纯函数不依赖调用方是否记得写 ORDER BY(否则同注册日多批次顺序不可复现,评审 S1)。
  4. bootstrap_lots 用 zlib.crc32 而非内置 hash:hash 受 PYTHONHASHSEED 随机化,会让 gateway 与 rebuild_lots.py(两个进程)算出不同 confirmed_at,D18 的同源断言会随机失败。
  5. pick_fee_rate 无命中 → 500 FeeRuleMissing,不降级为 0:静默按 0 计费会少收赎回费且不留痕。

与 §8.3 / 开发计划的两处差异(均已核实、非笔误)

项 文档原写法 实际实现 说明
异常个数 开发计划 §4.2 写「8 个」 11 个 §8.3 表实有 10 条(7 个业务 400 + LOT_CONFLICT 409 + 2 个 503);另加 1 个 §8.3 表外的内部兜底 FeeRuleMissing(500)。开发计划「8 个」为计数笔误,以 §8.3 表为准
hold_days 等 架构 §7 未列 见上方「另增 3 个纯函数」 已回填架构 §7(纯函数签名清单补 2 行)+ §8.3(补 FEE_RULE_MISSING 注)

验证证据

项 结果
pytest tests/test_convert_calc.py -q 93 passed(12 个测试类,参数化展开后 93 条)
pytest -q 全量 609 passed / 3 skipped(T-1 后基线 516 → +93,零回归)
纯函数零 IO 依赖 由 TestPurity 3 条断言强制(import 白名单 + 禁用符号 + hash( 禁用),不留人工核对口子
PRD §5.3 数字自证 见 T-2b 执行记录(15/15 一致,脚本退出码 0)

回归面(§12):T-2 为纯新增包,未修改任何既有文件(app/service/convert/ 下 7 个新文件 + 1 个新测试文件), 故 §12 十六条回归面一项都未触发;全量 609 passed 中的 516 条既有用例逐条原样通过,证明这一点。

依赖:无(可与 T-1 并行)


4.3 T-2b · 示例实算回填(禁止手算)

目标:PRD §5.3 主示例的每个数字由生产同一套 calc.py 实算产出,回填 PRD 与验收断言。

新增文件:scripts/dev/calc_convert_demo.py 输出:主示例(口径 B,in_qty = 53456.95)+ 口径 A 对照(53455.36)+ 同费率对照(diff_fee = 0.00)

DoD(全部达成,见下方执行记录)

  • 脚本输出与 PRD §5.3 表格逐行一致(不一致则说明公式或示例数据有问题 → 停下来查,不得改示例数字迁就脚本,也不得手算改脚本)
  • PRD §5.3 表格标注「由 calc_convert_demo.py 实算回填」
  • 脚本纳入 docs/项目框架设计/ 引用,供审核复核

执行记录(2026-09-10)

脚本被重写,不只是「跑一遍」——原实现有一处结构性隐患

改前(T-1 期的临时脚本) 改后
公式来源 脚本内自带一份副本(q2() + 逐批循环 + 两个口径的算式) 只调用生产 app.service.convert.calc / fee,脚本仅做输入准备与打印
风险 与生产实现两处公式必然漂移(生产 calc.py 改了、脚本没改 → PRD 里的数字变成假证据)——与 D18「规则只留一个副本」是同一类问题 单点来源,无副本可漂移
自校验 无(人工比对输出与 PRD) 15 项期望值逐项比对,不一致即 exit 1 → 脚本同时是「PRD 数字 ↔ 生产口径」一致性门禁

顺带修正:脚本补 sys.path 引导项目根(ROOT = parents[2],同 verify_convert_seed.py 先例)——改前脚本不 import 项目代码,故未暴露;一旦改成调用生产函数,直跑会 ModuleNotFoundError: No module named 'app'。

验证结果(python scripts/dev/calc_convert_demo.py,退出码 0):

批次 1(LOT-DEMO-01)30000.0000 份 / 持有 100 天 / 费率 0.0050 → amount=30900.00  fee=154.50
批次 2(LOT-DEMO-02)20000.0000 份 / 持有   3 天 / 费率 0.0150 → amount=20600.00  fee=309.00
✅ 全部 15 项与 PRD §5.3 一致(口径 B in_qty=53456.95)

15 项 = 逐批 4 项(两批各 amount/fee)+ 主链 7 项(out_amount 51500.00 / redeem_fee 463.50 / convert_amount 51036.50 / diff_fee(B) 252.40 / in_amount 50784.10 / in_qty(B) 53456.95 / rounding_diff -0.0026)+ 口径 A 对照 2 项(253.91 / 53455.36)+ 同费率对照 2 项(0.00 / 53722.63)。

依赖:T-2


5. 第 2 批 · 仓储与锁(并行组 A)

5.1 T-3 · core_ro 五个新方法 + share_lot_repository

文件 动作 要点
app/repository/core_ro.py 【改】新增 5 方法 get_nav_as_of(pid, trade_date)(nav_date <= :d 降序取 1,D10)· get_redeem_fee_rules(pid) · list_share_lots(cid, pid, max_lots)(ORDER BY confirmed_at ASC, lot_id ASC,S1 确定性 tiebreaker)· sum_remain_qty(cid, pid) · get_holding(cid, pid)
【改】不动 get_latest_nav(D10:改它会动既有调用方)· list_trades_range 的 trade_type 白名单(F-8:convert 两条流水本就是 redeem/subscribe,无需改)
app/repository/share_lot_repository.py 【新增】 core_share_lot 读侧:FIFO 选批 + 汇总(写侧归 convert_core_repository,D2)

DoD(全部达成,见下方执行记录)

  • 5 个方法各有 sqlite 单测(落在独立 tests/test_share_lot.py 中)
  • list_share_lots 在同一 confirmed_at 多批次时顺序可复现(构造两行同 confirmed_at,断言按 lot_id 升序,S1)
  • sum_remain_qty 只统计 remain_qty > 0(归零批不计入)
  • pytest -q 全绿(新增方法不动既有行为)

执行记录(2026-09-10)

项 内容
改动文件 app/repository/core_ro.py【改·+5 方法】· app/repository/share_lot_repository.py【新增】· tests/test_share_lot.py【新增 15 用例】
5 方法 get_nav_as_of(pid, d)(D10:nav_date <= :d 降序取 1)· get_redeem_fee_rules(pid)(仅 redeem 档、按 min_hold_days 升序)· list_share_lots(cid, pid, max_lots)(ORDER BY confirmed_at ASC, lot_id ASC,S1)· sum_remain_qty(cid, pid)(仅 remain_qty > 0)· get_holding(cid, pid)
不动 get_latest_nav(D10:语义不同,已并存)· list_trades_range 的 trade_type 白名单(F-8:convert 两条流水本就是 redeem/subscribe)
share_lot_repository core_share_lot 读侧:select_for_convert FIFO 贪心选批 + available_qty 汇总(写侧归 convert_core_repository,D2)。D18 单一副本:排序规则复用 CoreReadOnlyRepository.list_share_lots 的 ORDER BY,不另写一份 SQL
测试踩坑(已修) ① sqlite 不直接绑定 Decimal 参数 → 插入一律转 float(与既有 Decimal(total) 读回约定一致);② sqlite DATE/TIMESTAMP 读回为字符串 → 用项目内 _as_date / 测试内 _as_dt 规范化(与 MySQL 行为差异,B5 评审 P3-4 同口径)
验证结果 pytest -q 全量 624 passed / 3 skipped(基线 609 + 本次 15,零回归)

依赖:T-1

下一步:T-4(convert_repository 代理侧)/ T-5(locks.try_lock)可并行(并行组 A);关键路径 T-6(apply_convert 单事务)→ T-7(convert_service 编排)


5.2 T-4 · convert_repository(agent 侧)

方法 用途
insert_placeholder(group_id, client_request_id) 阶段零占位(uk_idem 兜底)
complete_convert(group_id, ...) 阶段二回写 completed + 详情
mark_failed(group_id) 阶段一失败 → 占位置 failed
get_by_group_id(group_id) 重试判定读取
get_by_client_request_id(cid_req) 幂等命中读取
list_expired_candidates(hours) 供 cleanup_pending_convert.py 取超 24h 的 pending
mark_expired(group_id) 置 expired(标记不硬删,S2)

DoD(全部达成,见下方执行记录)

  • sqlite 单测覆盖:占位 → completed / 占位 → failed / 超时 → expired
  • client_request_id 为 None 时不受 uk_idem 约束(MySQL 允许多个 NULL;sqlite 侧断言一致)
  • 不与 risk_repository 混职责(后者不动)

执行记录(2026-09-10)

项 内容
新增文件 app/repository/convert_repository.py【agent 库 risk_convert_detail 读写】· tests/test_convert_repository.py【6 用例】
方法 insert_placeholder(占位 pending,estimated=0 真实请求)· complete_convert(回写 completed+折算详情)· mark_failed · get_by_group_id · get_by_client_request_id(None 直接返回 None)· list_expired_candidates(hours)(pending 且 created_at < now-hours)· mark_expired(S2 标记不硬删)
status 枚举 5 值 pending/completed/failed/cancelled/expired(架构 §9,建表即全量、零 ALTER)
引擎 get_engine(settings.mysql_database, "rw")(D20:xh_agent_rw 含 risk_convert_detail)
踩坑(同 T-3) complete_convert 的 nav/fee_amount 为 Decimal → 经 _to_bind 转 float 绑定(sqlite 不支持直接绑 Decimal)
验证 pytest tests/test_convert_repository.py 6 passed;全量 634 passed / 3 skipped(基线 624 + 10)

依赖:T-1


5.3 T-5 · locks.try_lock

def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
    """单次尝试抢锁:抢到返回 token,抢不到立即 None(不等、不降级)。
    Redis 不可用 → 退回进程内 Lock.acquire(blocking=False),语义一致。"""
  • 不改 run_locked(D4:run_locked 会等 2s 并降级 fn(False),与「抢不到立即 202」语义相反;且它已有 3 处调用:alert_service.py:180 的 agg:event: / :239 的 agg:suitability: 等)
  • 返回 _NoLock 哨兵对象,__enter__ 返回 False,避免 with None 报错

DoD(全部达成,见下方执行记录)

  • 抢到 / 抢不到 / Redis 不可用回退 三条路径单测
  • key 形态覆盖:convert:idem:{cid_req} / convert:rerun:{gid}
  • run_locked 的既有用例零改动

执行记录(2026-09-10)

项 内容
改动文件 app/service/risk/locks.py【改·+_Token/_NoLock + try_lock】· tests/test_locks_redis.py【+4 用例】
语义 try_lock 单次非阻塞:抢到返回 _Token(with 进入 True),抢不到立即 _NoLock(进入 False,不等、不降级);Redis 不可用 → 退回进程内 Lock.acquire(blocking=False)(语义一致)
不改 run_locked(D4:其会等 2s 后降级 fn(False),与「抢不到立即 202」相反;3 处调用点零改动)
哨兵 _NoLock.__enter__ 返回 False,避免 with None 报错;_Token.__exit__ 按来源释放 Redis 或进程内锁
验证 pytest tests/test_locks_redis.py 10 passed(既有 6 + 新增 4);全量 634 passed / 3 skipped

依赖:无


6. 第 3 批 · 事务与编排

6.1 T-6 · convert_core_repository.apply_convert(阶段一单事务 · 高风险)

新增文件:app/gateway/convert_core_repository.py

单事务内容(with engine.begin() 串行,D3)

序 SQL 备注
1-2 INSERT core_trade × 2 同 convert_group_id;trade_type 分别为 redeem(转出)/subscribe(转入)——R-b
3 UPDATE core_share_lot SET remain_qty = remain_qty - :q WHERE lot_id=:lot AND remain_qty >= :q × N 条件 UPDATE,rowcount != 1 → 抛 LotConflict(409)
4 INSERT core_share_lot × 1 转入新批次(confirmed_at = T+1,D14)
5-6 UPDATE core_holding ... × 2 → rowcount == 0 则 INSERT 不用方言 UPSERT(R-a)
7 INSERT core_convert_lot_detail × N 含 nav/nav_date(D6)

必须守住

  • 引擎必须用 get_engine(core_db, "rw")(T-0b)
  • 阶段一任何一步失败 → 整体回滚(engine.begin() 异常即 rollback)
  • 转出端 qty 归零保留行(D9/P2),不 DELETE(xh_core_rw 本就无 DELETE 权限)
  • cost_amount 等比例结转:cost × (1 − actual/原qty);pnl_pct = (mv − cost)/cost,cost=0 置 0
  • 两端 core_holding 写入必须走 R-a 的三步(条件 UPDATE → 首次 INSERT → IntegrityError 回退 UPDATE), 不得用 ON DUPLICATE KEY UPDATE(sqlite 语法错)也不得用 WHERE NOT EXISTS / FROM DUAL(方言互斥,§1.5 R-a 已实测排除)

DoD

  • sqlite 单测:正常路径落 2 流水 + N 明细 + 两端持仓
  • 冲突路径:构造 remain_qty < q → rowcount=0 → LotConflict
  • 回滚路径:在明细 INSERT 处注入异常 → 断言 core_trade / core_share_lot / core_holding 全部无残留(这是「同事务」的硬证据)
  • 两端 core_holding:转出端归零保留行;转入端首次 INSERT、再次 UPDATE
  • 并发首次建行(R-a 兜底验证 · 第二轮审核补入):模拟两笔并发首次转入同一 (cid, pid) → 断言最终只有一行、金额为两笔之和、两笔都成功(不得出现 5xx)

依赖:T-1 / T-3


6.2 T-7 · convert_service 编排(关键路径 · 高风险)

新增文件:app/service/convert/convert_service.py + __init__.py 导出

八步顺序(架构 §3 / PRD §7.0,前四步不落库)

① 参数与产品校验(from != to / can_redeem / can_subscribe / 同 fund_company+ta_code)→ 4xx
② 份额校验(Σ share_lot.remain_qty 为权威源,非 core_holding.qty)+ 最低份额(全额豁免)→ 4xx
③ 净值取数与折算(纯函数)→ 无净值 503 NAV_NOT_READY
④ 适当性校验(转入端,唯一业务阻断点)→ blocked 出 R-02 预警 + 审计 → return(不占位)
⑤ 阶段零:try_lock("convert:idem:{cid_req}") 未抢到 → 202 + group_id;占位失败(带键)→ 503
⑥ 阶段一:apply_convert(失败 → 占位 failed + 4xx/5xx)
⑦ 阶段 1.5:process_convert_event(out, in) → 异常落 engine_error + 本地日志,**不阻断**
⑧ 阶段二:complete_convert + 审计(失败 → decision='convert_detail_write_failed' + logger.exception)

关键实现点

  • 重试判定:取得执行权后 SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1 → 有行 = 阶段一已成,只补跑阶段二(走 core_ro 只读;依赖 idx_convert_group)
  • 补跑阶段二必须 try_lock("convert:rerun:{gid}")(PRD §11:两个并发重试会审计双写,审计表只 INSERT 无法去重,只能靠锁串行)
  • nav_stale:nav_date 距交易日 > convert_nav_stale_seconds(默认 3 天)→ 额外落 nav_stale 副审计
  • rounding_diff 落审计(正负向 + 金额,Q10)
  • 本地日志兜底:阶段二失败时 logger.exception 输出 group_id + 全部折算参数(PRD §7.1 第三轮第 8 条)

DoD

  • tests/test_convert_service.py ~22 条:八步顺序(blocked 不占位)/ 三阶段 / 幂等命中 / 阶段二失败补偿 / nav_stale 审计条数
  • 幂等:同 client_request_id 重复提交 → 只一组流水(验收 12)
  • 幂等窗口闭合:阶段二失败后带同键重试 → 不产生第二组流水,且 RISK-002 当日累计不翻倍(验收 15)
  • 未抢到执行权 → 202 {convert_group_id, status: "processing"}
  • 全额转出豁免:持有 6000 / 申请全转 6000 / min_redeem_qty=10000 → 成功(验收 16)

依赖:T-2 ~ T-6


7. 第 4 批 · 引擎与网关

7.1 T-8 · 规则引擎改造

文件 动作
app/service/risk/rules.py 【改】新增 _amount_view(trades);run_rules 内部:eligible(全量)供 RISK-001/003/004,_amount_view(eligible) 供 RISK-002/005(RISK-006 读持仓快照,不受影响)
app/service/risk/engine.py 【改】新增 process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None, thresholds=None, on_error_hook=None),与 process_trade_event 共用内部 _run()(不改现有签名,W-2)
app/service/risk/alert_service.py 【改】`record_trade_alerts(..., events: list[dict]

关键实现点

  • _amount_view:同 convert_group_id 组内只保留 trade_type='redeem' 那条;无 gid 的交易原样通过(非 convert 场景恒等 → 510 零影响);组内无 redeem 时取第一条(防御)
  • process_convert_event:取当日全量流水(已含两条)→ run_rules → record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)]) → 一张单、payload.events 两条
  • on_error_hook(D19):一期传 None;hook 调用必须包 try/except,hook 自身失败不得反噬主流程(与「阶段 1.5 不阻断交易」同原则)——必须有单测

DoD

  • _amount_view 对无 gid 交易恒等(assert _amount_view(x) == x 型用例)
  • 一组 convert 两条流水 → RISK-002 只计一次;RISK-001/RISK-003 仍看到两条(验收 5/6)
  • process_convert_event 只出一条预警单,payload.events 长度 2(验收 7)
  • hook 抛异常时主流程正常返回(断言不抛)
  • pytest -q 全绿(引擎既有 13 处 run_rules 调用零改动)

依赖:无(可与 T-2 之后任意阶段并行)


7.2 T-9 · API 模型与网关分派

文件 动作
app/api/simulate.py 【改】TradeRequest:product_id 改 Optional、amount 改 `Decimal
���改】错误映射:UnsupportedTradeType → 400 保留(未知类型);新增 convert 异常 → ApiError(§8.3 映射表)
app/gateway/trade_gateway.py 【改】三处并列:① 移除 :112-113 的 convert 拒绝 ② subscribe/redeem 分支增补批次维护(T-10)③ 新增 convert 分派(调 convert_service)
app/gateway/gateway_repository.py 【改】insert_trade 增可选参数 qty=None / convert_group_id=None(默认 None → 既有调用零改动)

关键实现点

  • client_request_id 校验复用 app/main.py:44 的 _TRACE_ID_PATTERN(^[A-Za-z0-9._-]{1,64}$),不自造正则(S4;避免两套白名单漂移)
  • amount: Decimal | None = Field(None, gt=0):保住「amount=0 → 422」(F-12 第 4 条);pydantic v2 中 gt 对 None 不校验、对 0 校验 → 该用例自动通过
  • convert 响应:纯 dict + 全部 Decimal 已 str(),含 lot_breakdown/out_amount/convert_amount/in_amount/in_qty/rounding_diff/batch_count/max_lots
  • TOO_MANY_LOTS 错误体带 batch_count + max_lots(前端提示「请拆分多笔申请」)
  • 不改路由:仍为 POST /api/simulate/trade(F-12/§1.2,路由清单断言零影响)

DoD

  • tests/test_trade_gateway.py 的 4 处断言按 §12 清单改写完毕(R3/R4/R5 + R7 补批次断言)
  • tests/test_integration_risk.py:426-437 的 test_convert_400_and_no_new_trade_audit 按 §12 R15 处置(改写为端到端走通,或迁入 test_convert_integration.py)
  • test_convert_integration.py:真 MySQL CNV-TEST-/TRD-TEST- 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合(~6 条)
  • 错误码映射 8 条各有断言(含 CROSS_ENTITY_NOT_SUPPORTED,验收 14)
  • 未知 trade_type(如 purchase)仍 400

依赖:T-7


7.3 T-11 · 工具汇总去重 + SQL 求和去重

文件 动作
app/tool/core_tools.py 【改】query_recent_trades 汇总走 _amount_view(FR-C15);持仓查询(query_holdings)过滤 qty <= 0(P2 归零行保留但需过滤)
app/repository/core_ro.py 【改】sum_trades_on_date 加 convert 去重条件(R-d,架构 §6 未覆盖)
tests/test_core_ro_sum.py 【改】追加 1 条 convert 去重用例

DoD

  • core_tools 汇总不翻倍(验收 11)
  • 持仓查询不返回 qty = 0 的行
  • sum_trades_on_date 既有 5 条断言零改动 + 新 1 条去重用例绿
  • pytest -q 全绿

依赖:T-8


8. 第 5 批 · 高风险专项

T-10 · 普通申赎批次维护(FR-C16)+ rebuild_lots.py

单列原因:这是唯一直接改 trade_gateway 主流程的任务,而 trade_gateway 是 test_trade_gateway.py(~20 条)与 test_integration_risk.py、test_audit_middleware.py 的公共入口 → 510 用例直接受影响。架构风险表 #6 定为「先跑基线后改造」。

目标:批次表由交易统一出入口维护,覆盖全部交易类型;消除 PRD §4.4 描述的「普通赎回后批次与持仓失配 → convert 超扣」。

涉及文件

文件 动作
app/gateway/trade_gateway.py 【改】subscribe 分支:新增批次;redeem 分支:FIFO 扣减;两者均含 D8 兜底补建
scripts/core/rebuild_lots.py 【新增】按 core_holding 重建批次(快照重建,非交易回滚,L-7);与 gateway 兜底补建同调 lot_bootstrap.bootstrap_lots(D18)
tests/test_share_lot.py 【新增】~12 条:普通申赎批次维护 / 无批次兜底补建 / rebuild_lots 入口
tests/test_trade_gateway.py 【改】补批次维护断言

风险控制(五道)

  1. 开工前跑全量全绿基线并快照(当前 516;架构风险 #6 硬要求)
  2. 先写测试再改:test_share_lot.py 先行
  3. D18 同源断言:构造同一 core_holding 行,断言「gateway 兜底补建」与「rebuild_lots.py」算出的批次完全一致(confirmed_at 必须相同——这是 D18 设立的唯一目的)
  4. 降级规则(R-c(1)):subscribe 取不到当日净值 → warning + 不建批次;redeem 既无批次也无持仓行 → warning + 跳过扣减;两者均不抛异常、不阻断交易(既有 510 用例的 env 无持仓无净值,F-13)
  5. 覆盖率补偿(R-c(2),第一轮审核补入):test_share_lot.py 自建完整种子(core_holding + core_share_lot + core_product_nav),覆盖两条真实路径——redeem FIFO 扣减(含 D8 兜底补建再扣)与 subscribe 建批次;不得让降级路径充当测试覆盖

D8 兜底补建规则(lot_bootstrap.bootstrap_lots 单点)

  • qty = core_holding.qty
  • confirmed_at 按错开规则由 core_holding.as_of 反推(与 08-seed-share-lot.sql 同口径)
  • 无批次时补建再扣,不跳过、不阻断

DoD

  • 开工前:全量全绿基线已记录(当前 516)
  • 正常赎回后再 convert,批次与持仓一致、不超扣(验收 9)
  • 真实路径全覆盖(R-c(2)):test_share_lot.py 自建种子 → subscribe 建批次(qty = amount ÷ nav,2 位 HALF_UP,confirmed_at = T)· redeem FIFO 扣减 · 无批次有持仓 → D8 兜底补建后再扣 · rebuild_lots.py 入口
  • 降级路径单测(R-c(1)):subscribe 无净值 → warning + 不建批次 + 不抛异常;redeem 无持仓无批次 → warning + 跳过扣减
  • D18 同源断言绿(两侧 confirmed_at 逐一相等)
  • pytest -q 510 + 新增全绿
  • test_trade_gateway.py 既有断言按 §12 处置完毕(R16 应零改动通过)

依赖:T-3(排期置于 T-7 之后)


9. 第 6 批 · 补偿脚本(T-12)

文件 动作
scripts/demo/rebuild_alerts.py 【改】支持 --convert-group CNV-xxx:仅凭 Core 侧(core_trade + core_convert_lot_detail 的 nav/nav_date)重算详情并回写 → completed;必须 try_lock("convert:rerun:{gid}")
scripts/agent/cleanup_pending_convert.py 【新增】超 convert_compensate_sla_hours(24h) 的 pending 占位 → 置 status='expired'(标记不硬删,S2)

幂等锚点(Q4):以转出端 out_trade_id 为锚点 —— 复用 rebuild_alerts.py:54-66 的 find_alerts_by_trade(trade_id),命中即 skipped;一次转换有两条流水,只认转出端,避免重复出单。该脚本本就幂等(其 :8-10 docstring),不需要再加 uk_idem。

DoD

  • 阶段二失败 → decision='convert_detail_write_failed' 审计存在(验收 17 前置)
  • rebuild_alerts --convert-group 能仅凭 Core 侧数据补出完整详情 → completed(验收 17)
  • 重复执行补偿 → skipped,不产生第二张预警单(幂等)
  • cleanup_pending_convert.py 把超 24h pending 置 expired,行仍在(不硬删)

依赖:T-4 / T-7


10. 第 7 批 · 回归与实测(T-13)

10.1 全量回归

  • pytest -q 全绿,计数 ≥ 585(CI 内预计约 597,压测 10 条不进门禁)
  • 集成测试(真 MySQL,CNV-TEST-/TRD-TEST- 前缀)绿
  • 按 SOP 重灌双库后复跑一次(避免残留数据污染)

10.2 50 并发压测(评审 Q6 · 不进 CI 门禁)

同一 (customer_id, product_id) 上 50 并发争抢同一批份额,断言三件事:

  1. LotConflict(409) 命中数与剩余可转份额一致 → 不许超卖
  2. 按 100/200/400ms 退避重试 ≤3 次后的最终成功率(验证 §8.3 建议间隔是否够)
  3. core_trade 中 convert_group_id 无重复;core_share_lot.remain_qty 之和 = 初始值 − 实际成交份额

10.3 性能实测补录(PRD §9 第 18 条)

  • 端到端响应 < 2s(本地模拟库)
  • 阶段一单库事务实测耗时补录真实值(PRD 原文的「< 100ms」是预估值、非验收硬指标)
  • 若实测超阈值 → 优化索引/锁策略后重定阈值;不得反向修改实测数据迁就指标

DoD

  • 三项完成且落点明确:① 性能实测数据回填 docs/PRD/PRD-基金转换交易.md §9 第 18 条(用实测值替换「预估 < 100ms」); ② 50 并发结论(是否超卖 / 退避间隔够不够)写入 项目根 交接文档.md §B;③ 若实测超阈值 → 优化索引/锁策略后重定阈值(不得反向修改实测数据迁就指标)
  • 50 并发结果写入交接文档(含退避间隔是否够用的结论)

11. 测试增量与基线预测

层 文件 覆盖点 预计
纯函数 test_convert_calc.py FIFO / 跨批计费 / 双口径 A·B / 舍入 .5 边界 / 分档边界(6/7/29/30/179/180/364/365)/ 双动作 / T+1 ~35
编排 test_convert_service.py 八步 / 三阶段 + 阶段 1.5 / 幂等命中 / 阶段二失败 / nav_stale 条数 ~22
并发 test_convert_concurrency.py 条件 UPDATE rowcount=0 → 409 / 同键并发 → 202 / 补跑加锁 / 50 并发 ~10
批次 test_share_lot.py 普通申赎批次维护 / 兜底补建 / rebuild_lots ~12
集成 test_convert_integration.py 真 MySQL 端到端,与 §5.3 逐项吻合 ~6
回归 test_trade_gateway.py 批次维护断言 + convert 走通改写 +5
门禁 test_db.py 列名/PK/表存在性 + 角色隔离 + 3 权限断言 +6
仓储 test_core_ro_sum.py convert 去重 +1
合计 +97(含不进 CI 的压测 10 条)→ 516 → 613;扣除压测后 CI 内 ≈603;落地区间 **591616**

12. 回归面清单(既有用例的逐条处置 · 本计划核心交付物之一)

下表每一条都是必然会被本次改造打穿的既有断言。不做处置 = 510 会红。 核对方式:grep + 读源码(行号为撰写时实测值)。

# 位置 现有断言 为何失效 处置
R1 tests/test_chat_tools.py:81 INSERT INTO core_holding (customer_id, product_id, market_value, quantity) T-0 删 quantity 列 → no such column;且新 DDL 的 cost_amount/pnl_pct/as_of 为 NOT NULL 改为 ... market_value, qty, cost_amount, pnl_pct, as_of) 并补 3 个 NOT NULL 值;market_value/qty 数值不变
R2 tests/test_concentration_c4.py:74 同上 同上 同上
R3 tests/test_trade_gateway.py:92-99 test_convert_rejected pytest.raises(UnsupportedTradeType, match="转换交易暂不支持") T-9 移除了 convert 拒绝 拆分:① 未知类型 purchase → 仍 400(保留该半条断言)② convert → 改由新用例覆盖走通路径;:98 的 core_trade == 0 断言随 ① 保留
R4 tests/test_trade_gateway.py:215-219 test_api_convert_returns_400 convert → 400 + BAD_REQUEST 同上 改写为 test_api_convert_returns_200(需真 MySQL 或 sqlite 造齐产品/持仓/费率/净值数据)→ 若成本高,降级为集成测试用例(test_convert_integration.py)并在单测层删除该条
R5 tests/test_trade_gateway.py:261-263 test_api_non_positive_amount_returns_422 amount=0 → 422 amount 变 Optional(若 gt 被顺手删掉才会失效) 保住:amount: Decimal | None = Field(None, gt=0)。已实测(pydantic 2.13.4):0/-1 → greater_than 校验失败(422);None → 放行;100 → 正常 → 零改动通过。这是 gt 必须保留的理由
R6 tests/test_trade_gateway.py:40-72 env fixture 只灌 customer/customer_risk/product,无 holding、无 nav T-10 后 subscribe 建批次需净值、redeem 兜底补建需持仓 按 R-c 降级(取不到净值 → warning 不建批次);不改该 fixture
R7 tests/test_trade_gateway.py 全文件 — T-10 改 trade_gateway 主流程 补批次维护断言(+5);T-10 单独结项、先跑基线
R8 tests/test_db.py:30-54 3 条既有用例 get_engine(database) 缓存单例 + dispose T-0b 缓存键变 (db, role) len(created) == 2 等断言仍成立(不同 db → 不同 engine);追加 2 条新用例(角色隔离 / 回退),不覆盖既有 3 条
R9 tests/test_main.py:58-74 test_all_routers_mounted 硬编码路由清单 不新增路由(convert 复用 /api/simulate/trade) 零改动(需在评审中显式声明,防被误判为漏项)
R10 tests/test_core_ro_sum.py 5 条断言 sum_trades_on_date SQL 求和 R-d 加了 convert_group_id 条件 既有数据无 gid → 恒等通过;追加 1 条去重用例
R11 tests/conftest.py:183/184/232/233 get_engine(db) 默认角色 T-0b 后 core 默认角色 = xh_core_rw(无 DELETE)→ teardown 失败 显式 role="admin"(R-e)
R12 app/service/risk/rules.py:78-83 _eligible trade_type in ('subscribe','redeem') convert 两条流水若是 convert 类型 → 被过滤 不改 _eligible;改为约束写入端(R-b:流水写 redeem/subscribe)
R13 app/repository/core_ro.py:390/432 trade_type IN ('subscribe','redeem') 硬写 —(不需改:convert 两条流水本就是这两类) 不改(:432 的求和在 R-d 中另加 gid 条件)
R14 app/main.py:88/94 中间件顺序 audit 先注册 / trace 后注册(T-202 守卫) 本次不动 main.py 零改动(若因 T-0b 误改 main.py,test_main.py 的守卫用例会红 → 属自发现)
R15 tests/test_integration_risk.py:426-437 test_convert_400_and_no_new_trade_audit convert → 400 + BAD_REQUEST + 不落审计(after["n"] == before["n"]) T-9 移除 convert 拒绝后,状态码与审计计数双重失败 改写为端到端走通用例(convert → 200 + 断言落一条 trade_accepted 审计),或整体迁入 test_convert_integration.py 后从本文件删除。
※ 本条为第一轮审核补入(初版 §12 漏列该真 MySQL 集成用例)
R16 tests/test_trade_gateway.py:145-154 test_redeem_accepted_without_alert redeem 1000 元放行、core_trade 计 1 条、无预警 T-10 给 redeem 加 FIFO 扣减后,env 无持仓无批次(F-13)→ 无 R-c(1) 降级则直接失败 由 R-c(1)「redeem 既无批次也无持仓 → warning 跳过扣减」 保住,该用例零改动;其真实扣减路径由 test_share_lot.py 自建种子覆盖(R-c(2))
※ 本条为第一轮审核补入

13. 验收清单(PRD §9 19 条 → 任务/用例映射)

# 验收条目 承载任务 用例/证据
1 pytest 全绿(516 + 新增) T-13 pytest -q ≥591(CI 内约 603)
2 两条流水同 convert_group_id、同事务 T-6 回滚路径用例(断言无残留)
3 折算金额与 §2.1 逐项吻合 T-2/T-2b/T-6 test_convert_calc.py + calc_convert_demo.py 输出比对
4 转入端适当性不匹配 → blocked,两条流水都不落库 T-7 八步顺序用例(blocked 不占位)
5 RISK-002 对转换只计一次 T-8 _amount_view 用例
6 RISK-001/003 仍看到两条(未删行) T-8 eligible 全量用例
7 一次转换只产生一条预警事件 T-8 process_convert_event 用例(payload.events 长度 2,单数 1)
8 跨批次:各批按各自持有期计费,明细落库 T-2/T-6 跨批用例 + core_convert_lot_detail 行数断言
9 普通赎回后再 convert,批次与持仓一致、不超扣 T-10 test_share_lot.py + 端到端
10 转换后 core_holding 两端已更新 T-6 两端持仓断言
11 core_tools 汇总不翻倍 T-11 工具汇总用例
12 同 client_request_id 重复提交不产生第二组流水 T-7 幂等用例
13 强制全转 actual_qty != requested_qty,响应与审计均记录 T-2/T-7 双动作用例
14 CROSS_ENTITY_NOT_SUPPORTED 有用例覆盖 T-9 跨机构用例(依赖 09-seed-org.sql)
15 幂等窗口闭合:阶段二失败后同键重试不翻倍 T-7 阶段二失败补偿用例
16 全额转出豁免(≥ min_redeem_qty 校验豁免) T-2/T-7 豁免用例
17 阶段二失败可补偿(仅凭 Core 侧数据) T-12 rebuild_alerts --convert-group
18 性能:端到端 < 2s;阶段一实测补录 T-13 实测数据回填 PRD
19 补差费非零场景有覆盖 + 同费率对照 T-2/T-1 双口径用例(依赖 09-seed-org.sql 的费率对)

14. 风险登记与门禁总则

沿架构 §12 的 10 条风险,每条的「验收点」已前移到具体任务的 DoD:

# 风险 门禁落点(本计划)
1 sqlite/MySQL 列名失配(阻断) T-0:_ddl.py 自校验 + test_db.py::test_core_holding_columns;未绿不得启动 T-1
2 批次补建规则写两处 → 漂移 T-10:lot_bootstrap.bootstrap_lots 单点 + 两侧一致性断言
3 Decimal 默认 HALF_EVEN 与四舍五入不符 T-2:所有量化处显式 ROUND_HALF_UP + .5 边界断言
4 阶段 1.5 引擎异常不丢预警 T-8:on_error_hook 存在且一期 None + hook 抛异常不反噬单测
5 TOO_MANY_LOTS 超限场景 T-2/T-9:PlanResult.batch_count 先规划后判限 + 契约含 max_lots + 文案
6 批次表改造打穿 510 T-10:开工前基线快照 + 单独结项 + 五道风险控制(第五道 = 覆盖率补偿,见 §8)
7 双库不一致 T-6/T-7/T-12:回滚断言 + convert_detail_write_failed + 补偿脚本可跑通
8 锁 TTL 30s 被阶段一超时突破 T-6/T-7:抢到锁后二次校验占位与 Core 流水
9 合并前引入 DDL T-1:DDL 全追加式(新表 + 可空列 + DEFAULT);老 INSERT 仍可跑
10 Core「只读」仅代码约定(阻断) T-0b:3 条真 MySQL 权限断言;账号未配置回退(不阻塞开发)

本计划新增的实现级风险(架构 §12 未列,两轮审核产出)

# 风险 应对 验收点
R-a1 并发首次建行撞 UNIQUE(customer_id, product_id):执行权锁是请求粒度(convert:idem:{cid_req}),同一 (cid, pid)、不同 cid_req 的两笔并发转换会双双进入阶段一,首次建行时并发 INSERT 撞唯一键 → IntegrityError → 事务回滚返回 5xx(非数据损坏) R-a 第 ③ 步「IntegrityError 回退 UPDATE」(方言无关,已实测同事务内可继续执行) T-6 DoD:「并发首次建行」用例 → 断言最终只有一行、金额为两笔之和、两笔都成功(无 5xx)
R-a2 core_holding 写入若误用 ON DUPLICATE KEY UPDATE / WHERE NOT EXISTS / FROM DUAL,会在 sqlite 或 MySQL 单侧语法错(方言互斥) §1.5 R-a 已列三种候选写法的实测对照表 T-6「必须守住」已写死必须走 R-a 三步;sqlite 单测天然覆盖

门禁总则:#1 与 #10 是硬门禁(T-0 / T-0b 双双绿才放行 T-1);#2~#5 是开发中持续守; 任意一条失守都在集成测试期才暴露(成本最高),故已前移到各任务 DoD。


15. 待确认

15.1 需要用户拍板

# 事项 本计划默认 若改判的影响
Q-a R-c:批次维护的「降级规则 + 覆盖率补偿」双条方案(第一轮审核后修订) 接受双条(推荐) 若只接受降级(不接受 (2) 补偿),则批次维护在单测层零覆盖,验收第 9 条失效;若两条都不接受,则须给 test_trade_gateway.py 等多个 fixture 补灌持仓与净值种子 → T-10 改动面从 1 个文件扩到 3+ 个测试文件
Q-b R-f:core_product_nav 并入 T-0 并入(推荐) 若移回 T-1,T-0 的 DoD 缩小;T-1 工作量增加
Q-c R-d:sum_trades_on_date 是否顺带修 修(推荐) 若不修,需在 docstring 标注「不适用 convert」,接受同类方法在二期再修的债务

15.2 不需要拍板(架构/PRD 已定,仅登记)

  • 补差费默认口径 = B(P4)· 最低持有双动作(P5)· 份额 2 位 HALF_UP(P7)· T+1 建模(P8)
  • 单笔 = 单事务 = 单 convert_group_id,超限不自动分拆(原则 12 / R4)
  • 补偿一期人工 + SLA 24h,pending 超时置 expired(S2)

16. 给审核 AI 的检查清单

审核要求:全新上下文、只报告不改码;按「阻断级 / 重要 / 可选」分档。 背景包见随本计划一并提供的代码事实附录(§1.4 的全部行号可直接核对)。

A. 事实核验(最重要 —— 请优先挑战这部分)

  1. §1.4 的 15 条事实是否与代码一致?尤其 F-7(UPSERT 方言)、F-8(流水 trade_type 必须是 redeem/subscribe)、F-9(sum_trades_on_date 双计)、F-11(get_engine 调用点,批 0 前 13 处)、F-13(env fixture 无持仓/净值)。
  2. §12 的 16 条回归面是否完整?有没有被我漏掉的既有断言会被打穿?(请用 grep 自查 UnsupportedTradeType / convert / core_holding / amount 等关键词)
  3. §13 的 19 条验收是否条条有承载任务?有没有验收条目实际无实现落点?

B. 裁定合理性

  1. §1.5 的 7 条实现级裁定(R-a~R-g)是否成立?哪一条你认为是错的或代价过高的?
  2. §1.3 的 2 条文档差异裁定(W-1/W-2)以架构为准,是否同意?

C. 依赖与排序

  1. §2 的批间依赖是否成立?T-2 声称「不依赖表结构,可与 T-1 并行」是否属实?
  2. T-10 排期置于 T-7 之后、T-13 之前,是否最优?(它是否其实应该更早做,以免 T-7 的批次逻辑建立在未维护的批次表上?)

D. 遗漏面

  1. 除本计划列出的文件外,还有哪些读取方会因 convert 或其他改动而语义变化?(自检第 2 问「谁读」)
  2. 有没有任务在「改完 A 之后,B 必须同步改」的隐形耦合,而本计划把它排成串行且顺序错了?
  3. 一期不做的项(自动分拆 / 撤销 / 巨额赎回比例 / 二期补偿自动化)是否被意外卷入本期范围?

E. 可执行性

  1. 每个任务的 DoD 是否可机械验证(有明确命令或断言)?有没有「模糊 DoD」(如"确认正确")?

附:本计划引用的关键文件与行号索引

文件 相关行 用途
tests/_ddl.py :15-138(表字典)、:50-54(core_holding)、:55-60(core_trade)、:141-149(建库函数) T-0 主战场
tests/test_db.py :30-54(既有 3 用例) T-0/T-0b 追加用例
tests/test_chat_tools.py :81 R1 待改
tests/test_concentration_c4.py :74 R2 待改
tests/test_trade_gateway.py :40-72(env)、:92-99、:145-154、:215-219、:261-263 R3~R7 / R16 待改
tests/test_integration_risk.py :426-437(convert 400 集成用例) R15 待改
tests/test_main.py :58-74(路由清单) R9 零改动
tests/test_core_ro_sum.py :53/61/66/73/74 R10 恒等通过
tests/conftest.py :181(bootstrap 链)、:183/184/232/233(engine)、:216-223(清理) R11 / R-e
app/gateway/trade_gateway.py :36-37(白名单/文案)、:105(校验读)、:112-113(convert 拒绝)、:167-169(insert_trade 调用) T-9 / T-10
app/gateway/gateway_repository.py :24(engine)、:26-57(insert_trade 7 列) T-0b / T-9
app/repository/core_ro.py :55(engine)、:276-298(list_holdings SELECT h.*)、:300-340(concentration)、:378-404(list_trades_range)、:420-442(sum_trades_on_date)、:450-460(get_latest_nav) T-3 / T-11
app/service/risk/rules.py :78-83(_eligible)、:86-88(_amount)、:102-105(RISK-002)、:201-219(run_rules) T-8
app/service/risk/engine.py :114-192(process_trade_event) T-8
app/service/risk/alert_service.py :96-180(record_trade_alerts)、:180(agg:event: 锁) T-8
app/service/risk/locks.py :24(TTL)、:69+(run_locked) T-5
app/api/simulate.py :36-42(TradeRequest)、:45-60(路由) T-9
app/utils/db.py :19-49(引擎缓存) T-0b
app/config/settings.py :13-18(mysql_*) T-0b
scripts/core/01-ddl.sql core_holding / core_trade / core_product / core_product_nav 段 T-1 权威
scripts/core/reset.ps1 :24-31($files) T-1
docs/项目框架设计/表设计/02-mysql-agent专用.sql 6 张表 T-1 落点