背景:交接文档的定位是「给 AI 接手用的入口」,此前三份分散(项目根 交接文档.md
+ docs/交接文档-基金转换.md + docs/交接文档-架构改进.md),且 docs/ 两份停留在旧版
(v1.0 / v1.1,不含 T-0~T-2b、609 passed、D20 实施等进度),新会话极易被误导。
改动:
- 三份合并为项目根 交接文档.md v3.0(545 行 = §0 公共层 + §A 风控主线 + §B 基金转换线
+ §C 架构改进线);该文件在 .gitignore:47 内,按用户要求不入库(交接文档只留本地)
- 全仓指向统一到 交接文档.md §A/§B/§C:AGENTS.md(含顶部新增「接手先读交接文档.md」)、
docs/memory/{MEMORY,TODO,FRAMEWORK,ITERATION}、PRD-架构改进与稳定性加固、
开发计划-架构改进、TODO-架构改进、开发计划-基金转换交易
- 两份 docs/交接文档-*.md 加「已废弃(2026-09-10)· 勿读」横幅并指向新入口,
保留作历史留档(不删除)
- 记录事故:对 docs/ 下中文名文件使用 git rm 会静默抹除整个 docs/ 目录(复现 2 次、
退出码 0),已零损失恢复;纪律写入 交接文档.md §0.4 与工作区记忆
无代码改动;pytest 609 passed / 3 skipped。
102 KiB
开发计划 · 基金转换交易(convert)
文档状态:开发计划,尚未动代码。待独立审核 + 用户确认后按批次实施。 代码基线:分支
risk-control-agent,HEADea9a132(含 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 步「讨论需求」→ 第 2 步 PRD(v0.9,经 5 轮外部审查 + 架构校准轮,接受 27 / 修正性接受 5 / 驳回 0)
- 第 3 步架构设计(v1.0,独立评审 M-7 通过:接受 10 / 修正性接受 3 / 驳回 0,T-0 为唯一硬前置)
- 用户拍板 P1~P8(处置表 §九):批次兜底补建 / 归零保留行 / Core 明细仅补偿 / 补差费 B 默认 / 最低持有双动作 / 22 号文费率 / 份额 2 位 / T+1 建模
- 主架构评审 C1(
core_ro无 DB 级只读强制)跨线并入 → D20 / T-0b - 本计划:把上述结论拆成 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在语句级失败后不标记事务失效(该行为属 ORMSession的 flush 机制); 本项目全链路用text()+ CoreConnection,不涉及 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_typeENUM 已含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的envfixture(: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_enginefixture 只是其中一个建库入口——测试文件各自create_sqlite_engine()的场景更多(如test_trade_gateway.py:42)。 - 裁定:双落点、同源常量——
- 主:
tests/_ddl.py::create_sqlite_engine()建表后调用新增的_assert_ddl_aligned()(读权威列/表清单常量,缺项即AssertionError)。所有建库路径自动被覆盖,无需改 conftest。 - 辅:
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) |
改法(逐项)
-
core_holdingDDL 重写(以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,保持一致,避免插入顺序约束)。 -
新增
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)) """, -
新增权威清单常量 + 自校验(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()之后调用它。 -
新增门禁用例
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)唯一约束
- 断言
-
同步改 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 附带) |
改法(逐项)
-
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 执行 ✓
-
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 -
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()语义不变
-
关键边界(架构 §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_logUPDATE/DELETE → 被拒)test_db.py::test_core_rw_scope(rw 对第 5 张表core_productUPDATE → 被拒)
test_db.py::test_get_engine_role_isolation(同库不同角色 → 不同 engine)grep -rn "get_engine("全部调用点逐点有明确角色(F-11 清单:app 4 处 =ro/rw;conftest4 处 =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 处偏差:
- 2 处测试 INSERT 缺 3 个 NOT NULL 列(原计划只提「改列名」)→ 详见 §3.1 第 5 点。
audit_log授权口径:架构 §11.1 原文「只授 INSERT」若字面执行会让has_engine_error_audit/list_audit_events失权 → 实际授权SELECT, INSERT(无 UPDATE/DELETE)。- 门禁反向验证改为自动化用例(
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.statusENUM 建表即含 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 处需注意项:
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)。本脚本已内置该步骤,避免二次踩坑。计划外增补,非返工。core_fee_rule的读取路径已明确(自检第 2 问「谁读」):00-grant.sql中xh_core_ro为SELECT ON jinrong_core.*,库级授权自动覆盖 T-1 新建表;xh_core_rw刻意不授该表读权限 → convert 费率读取必须走只读账号(T-6 遵守)。无需改 grant。- 费率分类错误 —— 已按真实业务修正(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 映射) |
必须写死的实现细节
- 精度:所有量化处显式
rounding=ROUND_HALF_UP(Decimal默认是ROUND_HALF_EVEN,架构风险 #3);逐批先舍入后求和。 - 补差费双口径(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))
in_qty2 位 HALF_UP(v1.0 勘误:v0.2 的「4 位 ROUND_FLOOR」是残留,已废)。pick_fee_rate边界:[min_hold_days, max_hold_days);hold_days = (交易日 − confirmed_at).days(自然日,不含申请日;满 7 日归 7–30 档)。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 个子类(见下方差异说明) |
关键实现点
- 精度:所有量化点显式
rounding=ROUND_HALF_UP(Decimal默认ROUND_HALF_EVEN);测试里有一条反向自证用例——先证明HALF_EVEN会得到不同结果,再断言round2不等于它(漏传rounding时必红)。 plan_lots不判上限:只做 FIFO 分配 + 最低持有处置,上限判据由ensure_batch_limit在规划之后执行(§8.3「先规划再判上限」)。_fifo_order内部再排一次序:SQL 已ORDER BY confirmed_at, lot_id,但纯函数不依赖调用方是否记得写 ORDER BY(否则同注册日多批次顺序不可复现,评审 S1)。bootstrap_lots用zlib.crc32而非内置hash:hash受PYTHONHASHSEED随机化,会让 gateway 与rebuild_lots.py(两个进程)算出不同confirmed_at,D18 的同源断言会随机失败。pick_fee_rate无命中 → 500FeeRuleMissing,不降级为 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 单测(
test_convert_calc.py或独立test_share_lot.py中) list_share_lots在同一confirmed_at多批次时顺序可复现(构造两行同confirmed_at,断言按lot_id升序)sum_remain_qty只统计remain_qty > 0pytest -q全绿(新增方法不动既有行为)
依赖:T-1
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混职责(后者不动)
依赖: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的既有用例零改动
依赖:无
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:真 MySQLCNV-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 |
【改】补批次维护断言 |
风险控制(五道)
- 开工前跑全量全绿基线并快照(当前 516;架构风险 #6 硬要求)
- 先写测试再改:
test_share_lot.py先行 - D18 同源断言:构造同一
core_holding行,断言「gateway 兜底补建」与「rebuild_lots.py」算出的批次完全一致(confirmed_at必须相同——这是 D18 设立的唯一目的) - 降级规则(R-c(1)):
subscribe取不到当日净值 → warning + 不建批次;redeem既无批次也无持仓行 → warning + 跳过扣减;两者均不抛异常、不阻断交易(既有 510 用例的env无持仓无净值,F-13) - 覆盖率补偿(R-c(2),第一轮审核补入):
test_share_lot.py自建完整种子(core_holding+core_share_lot+core_product_nav),覆盖两条真实路径——redeemFIFO 扣减(含 D8 兜底补建再扣)与subscribe建批次;不得让降级路径充当测试覆盖
D8 兜底补建规则(lot_bootstrap.bootstrap_lots 单点)
qty=core_holding.qtyconfirmed_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)·redeemFIFO 扣减 · 无批次有持仓 → D8 兜底补建后再扣 ·rebuild_lots.py入口 - 降级路径单测(R-c(1)):
subscribe无净值 → warning + 不建批次 + 不抛异常;redeem无持仓无批次 → warning + 跳过扣减 - D18 同源断言绿(两侧
confirmed_at逐一相等) pytest -q510 + 新增全绿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把超 24hpending置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 并发争抢同一批份额,断言三件事:
LotConflict(409) 命中数与剩余可转份额一致 → 不许超卖- 按
100/200/400ms退避重试 ≤3 次后的最终成功率(验证 §8.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 的压测 |
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.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(envfixture 无持仓/净值)。 - §12 的 16 条回归面是否完整?有没有被我漏掉的既有断言会被打穿?(请用
grep自查UnsupportedTradeType/convert/core_holding/amount等关键词) - §13 的 19 条验收是否条条有承载任务?有没有验收条目实际无实现落点?
B. 裁定合理性
- §1.5 的 7 条实现级裁定(R-a~R-g)是否成立?哪一条你认为是错的或代价过高的?
- §1.3 的 2 条文档差异裁定(W-1/W-2)以架构为准,是否同意?
C. 依赖与排序
- §2 的批间依赖是否成立?T-2 声称「不依赖表结构,可与 T-1 并行」是否属实?
- T-10 排期置于 T-7 之后、T-13 之前,是否最优?(它是否其实应该更早做,以免 T-7 的批次逻辑建立在未维护的批次表上?)
D. 遗漏面
- 除本计划列出的文件外,还有哪些读取方会因 convert 或其他改动而语义变化?(自检第 2 问「谁读」)
- 有没有任务在「改完 A 之后,B 必须同步改」的隐形耦合,而本计划把它排成串行且顺序错了?
- 一期不做的项(自动分拆 / 撤销 / 巨额赎回比例 / 二期补偿自动化)是否被意外卷入本期范围?
E. 可执行性
- 每个任务的 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 落点 |