docs: 基金转换(convert)设计阶段成果入库
此前 AIcoding 第 1~3 步的产出仅存在于本地磁盘、未纳入版本库,本次一次性入库, 补齐「设计可追溯」。本提交为纯文档 + 一个实算脚本,**代码零改动**,接口契约与表结构不变。 入库 6 个文件: - docs/PRD/PRD-基金转换交易.md(v0.9 · 需求权威:FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收) - docs/项目框架设计/架构设计-基金转换交易.md(v1.0 · 实现依据:D1~D20 / §5 事务补偿 / §11.1 DB 账号分离 / §15 任务映射 T-0~T-13 与 §15.1 依赖拓扑) - docs/项目框架设计/基金转换-审查意见处置表.md(33 条外审处置 + §九 用户拍板记录) - docs/项目框架设计/评审待办-风控主架构与基金转换.md(评审逐条判定 + 核实证据) - docs/交接文档-基金转换.md(本线交接入口,读完即可开工) - scripts/dev/calc_convert_demo.py(PRD §5.3 主示例实算回填脚本——派生值禁手算) 门控:PRD 已定稿并经独立评审闭环(M-7 已满足);架构 v1.0 独立评审 13 条建议 0 悬空。 下一步:AIcoding 第 4 步「产出开发计划」,输入为架构 §15 / §15.1。
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
# 架构设计说明书 · 基金转换(convert)交易
|
||||
|
||||
> 版本:**v1.0(PRD v0.9 配套 · 已过独立评审)** · 日期:2026-09-10
|
||||
> 上游:`docs/PRD/PRD-基金转换交易.md`(**v0.9** —— v0.8 经 33 条外审闭环;v0.9 为架构评审回填 2 处契约)
|
||||
> 分支:`risk-control-agent` · 关联:`docs/项目框架设计/架构设计-风控模块.md`
|
||||
> 技术选型不变:FastAPI + SQLAlchemy Core(text SQL) + MySQL 双库 + Redis + LangGraph + DeepSeek。
|
||||
> 本文定义**目录划分、模块职责、核心时序、技术决策、事务与并发、测试策略、任务映射**。
|
||||
>
|
||||
> ✅ **门控(M-7)已满足**:**独立评审通过**(2026-09-10,接受 10 / 修正性接受 3 / 驳回 0),
|
||||
> 唯一硬前置 **T-0(R1:sqlite/MySQL 列名统一 + 启动断言)**;可进第 4 步开发计划。
|
||||
> 逐条判定见 `docs/项目框架设计/评审待办-风控主架构与基金转换.md` §二。
|
||||
> ✅ **评审建议已全部闭合**(R2~R5 / S1~S5 / Q4·Q6·Q10 共 13 条,落点索引见 **§13.2**,0 条悬空)。
|
||||
>
|
||||
> **版本演进**:v0.1(初稿)→ v0.2(真实业务校准版,2026-09-10 因外审 B-1 冻结)→ **v1.0(本版:按 PRD v0.8 + 处置表结论全面重写)**。
|
||||
>
|
||||
> **v1.0 内三次增补(同日)**:① 评审 13 条落地 → **§13.2 索引**;② 执行期风险 5 条 + **D18/D19** + **§15.1 依赖拓扑**;
|
||||
> ③ **DB 账号分离 D20 / §11.1 / T-0b**(主架构评审 C1 · 用户拍板「按真实项目走」)。
|
||||
>
|
||||
> **v0.2 → v1.0 重写要点**
|
||||
>
|
||||
> | # | 变更 | 来源 |
|
||||
> | --- | --- | --- |
|
||||
> | 1 | 上游由 PRD v0.7 改为 **v0.8**;§0 由「校准报告」改为「**基准声明**」(问题已在 PRD 修复) | B-1 / M-7 |
|
||||
> | 2 | 补差费**口径 B 定稿**(A 保留配置);最低持有**两种都做** | 处置表 §九 |
|
||||
> | 3 | **新增阶段 1.5(同步跑规则引擎)** 到时序与事务 | PRD X-1 / D17 |
|
||||
> | 4 | 补偿明确「**一期手动 + SLA 24h**」 | PRD I-3 |
|
||||
> | 5 | `in_qty` 由「4 位向下」修正为 **2 位 ROUND_HALF_UP**(v0.2 §7 与 §1 原则 10 **自相矛盾**) | B-4 / P7 |
|
||||
> | 6 | 种子 SQL **文件头版本注释约定** | PRD L-6 |
|
||||
> | 7 | §13 P1~P8 **全部转为已拍板结论**;§14 自检扩为**十问** | 处置表 §九 |
|
||||
> | 8 | `core_product` 新列数勘误:**8 列**(v0.2 §9 误写「7 列 + min_hold_action」) | — |
|
||||
|
||||
---
|
||||
|
||||
## 0. 合规与真实业务基准
|
||||
|
||||
> 本节是**基准声明**:v0.2 查证发现的合规问题**已在 PRD v0.8 全部修复**,此处固化口径供开发直接引用。
|
||||
> 来源:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行)
|
||||
> + 汇添富/中欧/鹏华/国投瑞银/东海/华商/金鹰/财通/中银/东方证券资管 10 家转换业务公告。清单见 **§0.5**。
|
||||
|
||||
### 0.1 法规硬约束(已落 PRD v0.8)
|
||||
|
||||
| 项 | 22 号文规定 | 本项目取值 | 落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 申购费率上限 | 主动偏股 ≤**0.8%** / 其他混合 ≤**0.5%** / **指数·债券 ≤0.3%** / 货基 0(§8) | 种子 **0.0080 / 0.0050 / 0.0030 / 0** | PRD §4.3 |
|
||||
| 赎回费下限 | <7 日 ≥**1.5%** / 7–30 日 ≥**1.0%** / 30–180 日 ≥**0.5%**(§10) | 档表见 **§7**(+180–365 0.25% / ≥365 0) | PRD §2.1.3 |
|
||||
| 赎回费归属 | **全额计入基金财产**(§10,旧规 75%/50%/25% 分级已废止) | `core_fee_rule.to_fund_ratio` = **1.0**(D16,留痕、不参与计算) | PRD §4.1 |
|
||||
|
||||
> **豁免不启用**:22 号文 §10 允许个人持满 7 日的指数/债基、机构持满 30 日的债基另约赎回费。
|
||||
> 本项目**不走豁免**,保留完整费率梯度以便演示与测试。
|
||||
|
||||
### 0.2 行业惯例(已落 PRD v0.8)
|
||||
|
||||
| 项 | 真实做法(多份公告一致) | 本项目 |
|
||||
| --- | --- | --- |
|
||||
| 份额精度 | 转入份额**四舍五入保留 2 位**,「误差在基金资产中列支」 | `in_qty` **2 位 ROUND_HALF_UP**(§7) |
|
||||
| 金额精度 | 转出金额/赎回费/补差费**保留 2 位、四舍五入** | 同(逐批先舍入后求和) |
|
||||
| T+1 确认 | T 申请(未知价法)→ **T+1 权益登记** → T+2 可用;**持有期自确认日起算** | 转入新批次 `confirmed_at = T+1`(D14;不模拟权益登记延迟,份额立即可用) |
|
||||
| 先进先出 / 逐批计费 | 注册日期在前先转出;不同持有期分别计费 | FIFO + 逐批计费 |
|
||||
| 单笔计算法 | 当日多笔转换**单笔**计费,不合并 | 逐笔 |
|
||||
| 未知价法 | 以申请受理**当日**净值为基准 | PRD §2.2 |
|
||||
| 转出可赎回 / 转入可申购 | 双方状态校验 | FR-C9a |
|
||||
| 同机构限制 | 同一销售机构 + 同一管理人 + 同一 TA | `CROSS_ENTITY_NOT_SUPPORTED` |
|
||||
| 冻结份额 / 撤单 | 冻结不可转;T 日结束前可撤 | 一期不做(撤单**字段已预留**,PRD §4.1) |
|
||||
|
||||
### 0.3 已拍板的多口径决策(处置表 §九 · 2026-09-10)
|
||||
|
||||
| 项 | 拍板结论 | 落点 |
|
||||
| --- | --- | --- |
|
||||
| 补差费口径 | **B(价外法两端差)为默认**;A 以 `convert_diff_fee_mode='rate_diff'` 保留 | D13 / §7 / §11 |
|
||||
| 最低持有余额处置 | **两种都做**:`min_hold_action ∈ {force_transfer, force_redeem}` | D15 / §7 |
|
||||
| P1 普通申赎无批次 | **不跳过**(批次表覆盖全部交易类型;遇历史持仓无批次**兜底补建**) | D8 / §7 |
|
||||
| P2 转出归零 | **保留(`remain_qty`/`qty` = 0),不删行** | D9 / §7 |
|
||||
| P3 Core 侧明细 | **仅补偿,不对外查询** | D6 / §7 |
|
||||
|
||||
> ⚠️ **法规澄清(防误判)**:22 号文**不涉及「最低持有余额」**——它只规范销售费用。
|
||||
> 「转出后余额低于最低持有份额怎么处理」属**基金合同/招募说明书约定**,法规**无强制**,故**不存在「按法律走」的答案**。
|
||||
|
||||
### 0.4 本期建模取舍(模拟库现实约束)
|
||||
|
||||
| 真实机制 | 是否建模 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T+1 权益登记 | **是(简化)** | `confirmed_at = T+1 自然日`(无交易日历,自然日近似并注释);转入份额**立即可用**(不建模在途份额) |
|
||||
| 交易日历 / 非交易日顺延 | 否 | 需日历表,模拟库无;标注为真实 Core 接入后补齐 |
|
||||
| 赎回费归入基金财产比例 | 否(留字段) | 全额计入(恒 1.0),收益低;预留 `to_fund_ratio`。**二期启用时**:`fee.py` 增加「赎回费 ≠ 基金财产留存额」的分录,同时改 §7 计算链与审计字段(评审 R5,本期不参与计算) |
|
||||
| 巨额赎回比例确认 | 否 | PRD 非目标;真实为「转出与赎回同优先级、同比例确认、**未确认部分不予顺延**」,写入注释 |
|
||||
| 同一基金 A/C 份额互转 | 否 | 产品表无 `share_class` |
|
||||
| 转换撤单 | 否 | 字段已预留(`risk_convert_detail.status` 含 `cancelled` + `cancelled_at`),二期启用 |
|
||||
|
||||
### 0.5 来源清单
|
||||
|
||||
| # | 来源 | 关键结论 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行) | 申购费上限 §8、赎回费下限与全额计入基金财产 §10 |
|
||||
| 2 | 上交所 501048 号公告(汇添富) | 口径 A 补差费公式 + 完整算例;0.1 份阈值强制全转 |
|
||||
| 3 | 中国证券报 2015-11-24(中欧) | 口径 A + T+1 权益登记、T+2 可查询 |
|
||||
| 4 | 中国证券报 2026-08-19(鹏华) | **口径 B** 外扣法 + 同销售机构 + 持有期自确认日重算 |
|
||||
| 5 | 中国证券报 2026-07-08(国投瑞银) | **口径 B** 价外法两端差 + 单笔计算法 + 逐批计费 |
|
||||
| 6 | 中国金融信息网(泰信/东海) | 未知价法、先进先出、**2 位四舍五入、误差在基金资产列支** |
|
||||
| 7 | 上海证券报 2026-07-30 | 巨额赎回时转出与赎回同优先级;**余额不足最低持有 → 强制赎回** |
|
||||
| 8 | 中国证券报 2026-09-01(中银) | 申请份额精确到 2 位、单笔 ≥1000 份、余额低于 1000 份须全部转出 |
|
||||
| 9 | 深交所 43da8375(东方证券资管) | 口径 A + 赎回费部分计入基金财产(旧规,已被 22 号文取代) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则(继承 + 本期新增)
|
||||
|
||||
**继承既有(不可违反):**
|
||||
|
||||
1. 分层 `api(薄)→ service(业务)→ repository/tool`;**唯一例外** `app/gateway/`(模拟外部 Core 交易系统,仅此层可写 `jinrong_core`)
|
||||
2. 规则即纯函数(`rules.py`),便于单测与阈值调整
|
||||
3. 双库无跨库事务(`jinrong_core` / `jinrong_agent` 各自 engine)
|
||||
4. 审计表**只 INSERT**;trace_id 用 contextvars 贯通
|
||||
5. 不新增 ORM(`app/model/entities.py` 不动),沿用 **text SQL + 返回 dict**
|
||||
|
||||
**本期新增(7 条):**
|
||||
|
||||
6. **计算与编排分离**:折算/费率/舍入做成**无 IO 纯函数**(`service/convert/calc.py`),编排层只取数、调纯函数、管事务。金额算错最难查,纯函数可穷举单测
|
||||
7. **阶段一事务收口在单一仓储**:阶段一涉 5 类 core 写入,跨 repository 无法共享事务,故集中在 `convert_core_repository.apply_convert()` 的**一个方法 + 一个 connection** 内(D3)
|
||||
8. **批次表必存 + 兜底补建(P1 已拍板)**:`core_share_lot` 覆盖**全部交易类型**;普通申赎有批次则 FIFO 扣/增,**无批次则兜底补建**(按 `core_holding.as_of` 建初始批次,与 `rebuild_lots.py` 同源规则),**不跳过、不阻断**。理由:真实 TA 份额必有注册批次;两表口径必须一致
|
||||
9. **锁原语只增不改**:`locks.run_locked`(等待 + 降级)语义不动,新增 `try_lock`(单次尝试),避免影响现有 3 处调用点
|
||||
10. **`Decimal` 一律显式 `quantize`,禁止裸浮点**:**金额 2 位、份额 2 位,均 `ROUND_HALF_UP`**;
|
||||
舍入误差**在基金资产列支**,响应可选回传 `rounding_diff`(**可正可负**)。
|
||||
⚠️ Python `Decimal.quantize()` 默认 `ROUND_HALF_EVEN`(银行家舍入),**必须显式传 `ROUND_HALF_UP`**(D·红线)
|
||||
11. **响应金额全部 `str()` 化**:路由未声明 `response_model` 时 FastAPI `jsonable_encoder` 会把 `Decimal` 转 float(精度风险)。convert 分支返回前全部显式 `str()`
|
||||
12. **单次转换最多跨 `convert_batch_max_lots` 个批次**(默认 200):超限返回 400 `TOO_MANY_LOTS`,防事务膨胀。
|
||||
一期**不做自动分拆**(一次请求 = 一个 `convert_group_id` = 一个 core 事务);自动拆成多笔留二期(评审 R4)
|
||||
13. **Core 读写账号物理分离**(主架构评审 **C1** / §三 交叉点 · **已拍板「按真实项目走」**):
|
||||
`jinrong_core` 拆**只读账号**(Agent 侧全部读路径)与**最小写权限账号**(**仅 `app/gateway/`** 持有);
|
||||
`get_engine(database, role)` 缓存键由「库名」改为「**(库名, 角色)**」。
|
||||
**应用账号一律不持有 DDL / DELETE / GRANT 权限**,DDL 由 `scripts/core/01-ddl.sql` 经管理员账号执行。
|
||||
这是「Core 只读」从**代码约定**升级为 **DB 级强制**的唯一手段(详见 D20)
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录与文件划分
|
||||
|
||||
```text
|
||||
app/
|
||||
├── api/
|
||||
│ └── simulate.py # 【改】TradeRequest 按 trade_type 分支校验;
|
||||
│ # convert 分支调 convert_service;错误码映射 ApiError
|
||||
│
|
||||
├── gateway/ # 【core 库写侧唯一出口】
|
||||
│ ├── trade_gateway.py # 【改】三处并列改动:
|
||||
│ │ # (1) 移除 convert 的 400 拒绝(一期显式拒收 convert,本次放开)
|
||||
│ │ # (2) subscribe/redeem 分支:增补批次维护(FR-C16,含兜底补建 D8)
|
||||
│ │ # (3) 新增 convert 分派(调 convert_service)
|
||||
│ ├── gateway_repository.py # 【改】insert_trade 增 qty / convert_group_id 两列写入
|
||||
│ └── convert_core_repository.py # 【新增】阶段一 core 单库事务(D3):
|
||||
│ # 2×INSERT core_trade + N×条件 UPDATE share_lot
|
||||
│ # + 1×INSERT share_lot + 2×UPSERT core_holding
|
||||
│ # + N×INSERT core_convert_lot_detail
|
||||
│
|
||||
├── service/
|
||||
│ ├── convert/ # 【新增包】
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── types.py # 【类型】Lot / FeeRule / PlanResult,@dataclass(frozen=True)(评审 S3)
|
||||
│ │ ├── calc.py # 【纯函数】FIFO 分配 / 逐批计费 / 补差费(A/B 双口径)/ 份额折算 / 舍入
|
||||
│ │ ├── nav.py # 【纯函数】净值口径:stale 判定 + NAV_NOT_READY 判定
|
||||
│ │ ├── fee.py # 【纯函数】持有期→赎回费率查表(输入规则行,不查库)
|
||||
│ │ ├── lot_bootstrap.py # 【纯函数】批次补建规则(D18):gateway 兜底补建 与 rebuild_lots.py 同源
|
||||
│ │ ├── errors.py # 【新增】convert 专属异常(继承 ApiError,§8.3)
|
||||
│ │ └── convert_service.py # 【编排】§3 八步顺序 + 执行权 + 幂等 + 三阶段 + 阶段1.5 + 补偿日志
|
||||
│ └── risk/
|
||||
│ ├── rules.py # 【改】新增 _amount_view();RISK-002/005 改走金额视图
|
||||
│ ├── engine.py # 【改】新增 process_convert_event(out, in),与
|
||||
│ │ # process_trade_event 共用内部 _run()
|
||||
│ ├── alert_service.py # 【改】record_trade_alerts 增 events 可选参数(一次入多事件)
|
||||
│ └── locks.py # 【改】新增 try_lock()(单次尝试,不等待、不降级)
|
||||
│
|
||||
├── repository/
|
||||
│ ├── core_ro.py # 【改】增 get_nav_as_of / get_redeem_fee_rules /
|
||||
│ │ # list_share_lots / sum_remain_qty / get_holding(均纯 SELECT)
|
||||
│ ├── share_lot_repository.py # 【新增】core_share_lot 读:FIFO 选批 + 汇总
|
||||
│ ├── convert_repository.py # 【新增】agent 库 risk_convert_detail:占位 / 回写 / 查询 / 清理
|
||||
│ └── risk_repository.py # 【不改】复用 insert_audit_log
|
||||
│
|
||||
├── tool/
|
||||
│ └── core_tools.py # 【改】query_recent_trades 汇总走 _amount_view(FR-C15);
|
||||
│ # 持仓查询过滤 qty <= 0(P2:归零行保留,需过滤)
|
||||
│
|
||||
├── config/
|
||||
│ └── settings.py # 【改】§11 全部 convert_* 配置项 + §11.1 四个 DB 账号(D20)
|
||||
└── utils/
|
||||
└── db.py # 【改】get_engine(database, role="rw")(D20):
|
||||
# 缓存键由「库名」改为「(库名, 角色)」
|
||||
|
||||
scripts/
|
||||
├── core/
|
||||
│ ├── 00-grant.sql # 【新增】建 3 个应用账号 + 最小权限 GRANT(D20,管理员执行)
|
||||
│ │ # xh_core_ro(SELECT)/ xh_core_rw(4 表写)/ xh_agent_rw(audit 仅 INSERT)
|
||||
│ ├── 01-ddl.sql # 【改】core_fee_rule / core_share_lot / core_convert_lot_detail 三新表
|
||||
│ │ # + core_trade 增 convert_group_id + idx
|
||||
│ │ # + core_product 增 8 列 + fee_rate COMMENT
|
||||
│ ├── 07-seed-fee-rule.sql # 【新增】赎回费持有期分档(5 档)※文件头带版本注释块(§9)
|
||||
│ ├── 08-seed-share-lot.sql # 【新增】按持仓反推批次,持有期错开覆盖多档 ※同上
|
||||
│ ├── 09-seed-org.sql # 【新增】fund_company / ta_code / subscribe_fee_rate ※同上
|
||||
│ ├── rebuild_lots.py # 【新增】按 core_holding 重建批次(**快照重建,非交易回滚**,PRD L-7)
|
||||
│ └── reset.ps1 # 【改】$files 追加 07 / 08 / 09
|
||||
├── agent/
|
||||
│ └── cleanup_pending_convert.py # 【新增】超 24h 的 pending 占位 → 置 status='expired'
|
||||
# (**标记不硬删**,留痕供对账;评审 S2)
|
||||
├── dev/
|
||||
│ └── calc_convert_demo.py # 【新增】与生产同一套 calc.py 纯函数实算主示例/同费率对照,
|
||||
│ # 输出回填 PRD §5.3 与验收断言(禁止手算)
|
||||
└── demo/
|
||||
└── rebuild_alerts.py # 【改】支持 --convert-group 按 Core 侧数据补写阶段二(D6)
|
||||
|
||||
docs/项目框架设计/表设计/
|
||||
└── 02-mysql-agent专用.sql # 【改】追加 risk_convert_detail 建表(PRD §4.1 DDL 原文)
|
||||
|
||||
tests/
|
||||
├── _ddl.py # 【改】新增 4 表;core_trade 补 qty/convert_group_id;
|
||||
│ # core_product 补 8 列;core_holding 补 qty/cost_amount/as_of/pnl_pct
|
||||
├── test_db.py # 【新增】**T-0 门禁**:`test_core_holding_columns` 断言 sqlite 列名/PK
|
||||
│ # 与 MySQL 一致(CI 强制,未绿不得跑后续集成测试)
|
||||
├── test_convert_calc.py # 【新增】纯函数:FIFO / 分档费率 / 补差费双口径 / 舍入 / 边界
|
||||
├── test_convert_service.py # 【新增】编排:八步顺序 / 幂等 / 执行权 / 阶段二失败补偿
|
||||
├── test_convert_concurrency.py # 【新增】批次条件 UPDATE 冲突 → 409 LOT_CONFLICT
|
||||
├── test_share_lot.py # 【新增】普通申赎批次维护(含兜底补建)+ rebuild_lots
|
||||
├── test_convert_integration.py # 【新增】真 MySQL(CNV-TEST- 前缀隔离)
|
||||
└── test_trade_gateway.py # 【改】补批次维护断言(回归)
|
||||
```
|
||||
|
||||
**不新增**:ORM 实体、`app/service/risk/` 新规则(RISK-001~006 口径不变,仅金额视图去重)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心时序(对齐 PRD §7.0 固定顺序)
|
||||
|
||||
```text
|
||||
POST /api/simulate/trade {trade_type: "convert", from_product_id, to_product_id, qty, client_request_id?}
|
||||
│
|
||||
├─ ① 参数与产品校验(不落库)
|
||||
│ from != to(SAME_PRODUCT)· can_redeem / can_subscribe · fund_company+ta_code 同机构
|
||||
│ → 4xx
|
||||
├─ ② 份额校验(不落库)
|
||||
│ Σ share_lot.remain_qty(权威源,非 core_holding.qty)· 最低转出份额(全额豁免)
|
||||
│ · 余额 < min_hold_qty → 按 min_hold_action 强制处置(D15)
|
||||
│ → 4xx INSUFFICIENT_SHARES / BELOW_MIN_QTY
|
||||
├─ ③ 净值取数与折算(不落库,纯函数 calc.py)
|
||||
│ 无净值记录 → 503 NAV_NOT_READY
|
||||
├─ ④ 适当性校验(转入端,唯一业务阻断点)
|
||||
│ suitability_check(customer, to_product) → blocked → R-02 预警 + 审计 → return(不占位)
|
||||
│
|
||||
├─ ⑤ 阶段零(agent 库):预生成 convert_group_id + 占位 INSERT(仅带 client_request_id 时)
|
||||
│ 执行权:try_lock("convert:idem:{cid_req}") 未抢到 → 202 + group_id,不进阶段一
|
||||
│ 占位失败(带键)→ 503 不放行;未带键 → 免占位直跑
|
||||
│ 重试判定:completed → 返回首次结果;pending/failed → SELECT 1 FROM core_trade WHERE gid
|
||||
├─ ⑥ 阶段一(core 库 · 单事务):2 流水 + 批次扣/增 + lot 明细 + 两端 holding
|
||||
│ 失败 → 整体回滚 + 占位标记 failed + 4xx/5xx
|
||||
├─ ⑦ 阶段 1.5(同步跑规则引擎 · 不参与 core 事务)
|
||||
│ process_convert_event(out, in):读全量两条流水 + 已更新持仓 → 出 1 条预警事件
|
||||
│ 失败 → engine_error + 本地日志,**不阻断已成立的交易**(D17)
|
||||
└─ ⑧ 阶段二(agent 库):回写 risk_convert_detail completed + 详情 + 主审计(+ nav_stale 副审计)
|
||||
失败 → 不回滚 Core,落 decision='convert_detail_write_failed' + logger.exception 本地兜底
|
||||
补跑阶段二必须 try_lock("convert:rerun:{gid}")
|
||||
```
|
||||
|
||||
> **阶段 1.5 的位置(D17)**:**阶段一提交后、阶段二之前**同步执行——此时两条流水已持久化、
|
||||
> `core_holding` 已更新,集中度规则(RISK-006)读到的是**已更新**持仓,转换的连带效应天然被覆盖。
|
||||
> 引擎只调用**一次** `process_convert_event()`,传入两条流水(PRD §6.2)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键技术决策(D1~D17)
|
||||
|
||||
| # | 决策点 | 选型 | 备选 | 理由 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| D1 | 折算计算放哪 | `service/convert/calc.py` 纯函数 | 内联在 service | 金额逻辑最易错,必须可穷举单测 |
|
||||
| D2 | 批次读写分工 | 读(选批/汇总)→ `share_lot_repository`;写(扣减/新增)→ `convert_core_repository`(阶段一同事务) | 全塞一个 repository | 写必须与两条流水同事务;读需在事务外预检(步骤②) |
|
||||
| D3 | 阶段一事务实现 | `convert_core_repository.apply_convert()`,内部 `with engine.begin() as conn` 串行执行全部 SQL | 各 repository 各开事务 / 传 conn | 现有 repository 均自建连接,无法共享事务;集中一个方法边界最清晰,不改既有签名 |
|
||||
| D4 | 幂等执行权 | 新增 `try_lock`(单次尝试) | 复用 `run_locked` | `run_locked` 会等 2s 并降级 `fn(False)`,与本场景「抢不到立即 202」语义相反;改它影响现有 3 处调用 |
|
||||
| D5 | 引擎只跑一次 | 新增 `process_convert_event(out, in)`,与 `process_trade_event` 共用内部 `_run()` | 给现有函数加 `sibling_trade` 参数 | 语义清晰、现有函数零风险 |
|
||||
| D6 | 补偿数据来源 | `core_convert_lot_detail` 含 `nav`/`nav_date`,`rebuild_alerts.py --convert-group` 仅凭 Core 侧补写;**Core 明细仅补偿、不对外查询(P3)** | 靠 agent 库残留 | 阶段二失败时 agent 库可能为空,Core 侧必须自包含 |
|
||||
| D7 | 金额去重 | `rules._amount_view()`:同 `convert_group_id` 组内**保留转出端(`trade_type='redeem'`)**那条 | 删行 / 求和 | PRD §6.3「绝不删行」;转出端 = 客户实际减少的权益额,语义唯一。组内无 redeem 时取第一条(防御) |
|
||||
| D8 | 普通申赎批次维护 | **兜底补建**:有批次则 FIFO 扣/增;无批次 → 按 `core_holding.as_of` 补建初始批次再扣(与 `rebuild_lots.py` 同源规则),**不跳过、不阻断** | 跳过 + warning / 报错 | **P1 已拍板**:真实 TA 份额必有注册批次;跳过会让批次表长期空洞、convert 永不触发 FIFO;报错会打穿存量种子与 510 用例 |
|
||||
| D9 | `core_holding` 同步 | UPSERT(`INSERT ... ON DUPLICATE KEY UPDATE`);转出后 `qty=0` **保留行** | 删除行 / 先 SELECT 后 UPDATE | **P2 已拍板**:保留行避免 UNIQUE 冲突与历史查询断裂;UPSERT 原子。持仓查询需过滤 `qty <= 0`。⚠️ sqlite ≥3.24 支持该语法 |
|
||||
| D10 | 净值查询 | 新增 `core_ro.get_nav_as_of(pid, trade_date)`(`nav_date <= :d` 降序取 1) | 改 `get_latest_nav` | 现有方法无条件取最新(可能取到未来日期),改它会动既有调用方 |
|
||||
| D11 | 费率来源 | 运行时读 `core_fee_rule`(redeem 分档)+ `core_product.subscribe_fee_rate`(补差) | 硬编码 | 数据驱动便于演示调档;`core_fee_rule.fee_type='subscribe'` 本期不启用 |
|
||||
| D12 | 请求模型 | 扩展现有 `TradeRequest`(`product_id` 改 Optional + 加 4 个可选字段)+ `model_validator` 按 `trade_type` 校验 | 新增 `ConvertRequest` + 分端点 | 端点契约不变;分端点让调用方多记一个 URL |
|
||||
| D13 | **补差费公式** | **口径 B(价外法两端差)为默认**,A 用配置保留 | 只用 A | **已拍板**:B 与申购费价外法数学自洽、为新公募主流;实现为 `convert_diff_fee_mode`,两口径均有单测 |
|
||||
| D14 | **T+1 确认建模** | `confirmed_at = T+1`(自然日近似,注释标明真实为**工作日** + T+2 可用) | 沿用「不模拟 T+1」 | **已拍板**:真实为 T+1 权益登记、持有期自确认日起算;只改一个日期字段 |
|
||||
| D15 | **最低持有余额处置** | 两种都实现:`min_hold_action ∈ {force_transfer, force_redeem}`,默认 `force_transfer` | 只做强制全转 | **已拍板**:两种都真实(阈值大→强制赎回、阈值小→强制全转);加一列即可 |
|
||||
| D16 | **赎回费归属** | `core_fee_rule.to_fund_ratio` 置 **1.0**(22 号文 §10),本期不参与计算 | 不建模 | 字段留痕;注释写明旧规 75%/50%/25% 已废止 |
|
||||
| **D17** | **规则引擎执行时序** | **阶段 1.5**:阶段一提交后、阶段二之前**同步**跑 `process_convert_event`;失败落 `engine_error` + 本地日志,**不参与 core 事务、不阻断交易** | 放阶段二之后 / 异步 | v0.2 时序里**完全没有跑引擎这一步**(PRD X-1);放在阶段一后保证 RISK-006 读到已更新持仓 |
|
||||
| **D18** | **批次补建规则复用**(执行期风险 #2 新增) | 抽**纯函数** `service/convert/lot_bootstrap.py::bootstrap_lots(holding_row) -> list[Lot]`;**`trade_gateway` 兜底补建(D8)与 `scripts/core/rebuild_lots.py` 同调此函数** | 两处各写一份规则 | 「按 `core_holding.as_of` 反推初始批次」若在兜底补建与重建脚本各写一遍,**必然漂移**(同一持仓两处算出不同 `confirmed_at`);抽纯函数后两侧共用 + 可单测 |
|
||||
| **D19** | **引擎异常钩子预留**(执行期风险 #4 新增) | `process_convert_event(..., on_error_hook=None)` —— 一期 `None`(仅落 `engine_error` 审计),二期可注入 Redis 事件 / 补偿队列,**不写死** | 直接写死二期逻辑 / 完全不留口 | 二期补偿增强(R3)若届时改函数签名,会牵动 T-8 全部调用方;一个**可选关键字参数**零成本预留 |
|
||||
| **D20** | **Core 读写账号分离**(主架构 **C1** · 用户拍板「按真实项目走」) | `jinrong_core` 两账号:**`xh_core_ro`**(`GRANT SELECT`)+ **`xh_core_rw`**(SELECT/INSERT/UPDATE,**限 4 张表**,无 DELETE、无 DDL);`jinrong_agent` 另配 `xh_agent_rw`,**`audit_log` 只授 INSERT**;`get_engine(db, role)` 按 **`(db, role)`** 缓存 | ① 单 root 账号(现状)② 仅代码层 SQL 白名单 | 现状 `settings.py:17` 单账号 + `db.py:23` 仅按库名缓存 → `core_ro.py:55` 与 `gateway_repository.py:24` **共用同一 root 连接池**,「只读」纯属约定;代码白名单(评审 B 方案)易被注释/子查询/存储过程绕过,**评审已否决**。DB 级授权是唯一「不可逆」保障 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 事务、并发与补偿(落地版)
|
||||
|
||||
### 5.1 三阶段 + 阶段 1.5 与 SQL 清单
|
||||
|
||||
| 阶段 | 库 | 入口 | SQL / 动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 零 | agent | `convert_repository.insert_placeholder` | 1×`INSERT risk_convert_detail`(`uk_idem` 兜底) |
|
||||
| 一 | core | `convert_core_repository.apply_convert`(**单事务**) | 2×`INSERT core_trade`(同 `convert_group_id`)· N×`UPDATE core_share_lot SET remain_qty=remain_qty-:q WHERE lot_id=:lot AND remain_qty>=:q` · 1×`INSERT core_share_lot`(转入新批)· 2×`UPSERT core_holding` · N×`INSERT core_convert_lot_detail` |
|
||||
| **1.5** | — | `engine.process_convert_event(out, in)` | **规则引擎(无 SQL 写)**:读全量两条流水 + 已更新持仓 → 预警事件;异常落 `engine_error`,**不阻断** |
|
||||
| 二 | agent | `convert_repository.complete_convert` | 1×`UPDATE risk_convert_detail SET status='completed'` · 1~2×`INSERT audit_log` |
|
||||
|
||||
阶段一**任一 SQL 失败 → 整体回滚**(`with engine.begin()` 异常即 rollback),占位在 `finally` 侧标记 `failed`。
|
||||
|
||||
### 5.2 阶段一之外的一次读(重试判定)
|
||||
|
||||
```sql
|
||||
-- 取得执行权之后执行;有行 = 阶段一已成,只需补跑阶段二
|
||||
SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;
|
||||
```
|
||||
|
||||
走 `core_ro`(只读)。依赖 `core_trade.idx_convert_group`(§9),否则全表扫。
|
||||
|
||||
### 5.3 锁原语扩展
|
||||
|
||||
```python
|
||||
# app/service/risk/locks.py 新增(不动 run_locked)
|
||||
def try_lock(key: str, ttl_seconds: int = LOCK_TTL_SECONDS) -> _Token | None:
|
||||
"""单次尝试抢锁:抢到返回可释放的 token,抢不到立即返回 None(不等、不降级)。
|
||||
Redis 不可用 → 退回进程内 threading.Lock.acquire(blocking=False),语义一致。
|
||||
"""
|
||||
```
|
||||
|
||||
用法:返回 `_NoLock` 哨兵对象,`__enter__` 给 `False`,避免 `with None` 报错。
|
||||
|
||||
### 5.4 补偿(一期:监控告警 + 人工脚本,SLA 24h)
|
||||
|
||||
| 项 | 口径 |
|
||||
| --- | --- |
|
||||
| 触发者 | `risk_convert_detail.status='failed'`(或 `pending` 超时)的巡检/告警 + 人工判断 |
|
||||
| 补偿工具 | `scripts/demo/rebuild_alerts.py --convert-group CNV-xxx` —— 从 `core_trade` + `core_convert_lot_detail` 重算详情并回写 → `completed`(**必须 `try_lock("convert:rerun:{gid}")`**) |
|
||||
| **SLA** | 失败记录须在 **24h** 内完成补偿或人工确认;`cleanup_pending_convert.py` 把超 24h 的 `pending` 孤儿置 **`status='expired'`**(**标记不硬删**,留痕供对账;评审 S2) |
|
||||
| 兜底 | 阶段二失败 → `decision='convert_detail_write_failed'` 审计;写审计本身失败 → `logger.exception` 输出 group_id + 全部折算参数到本地日志 |
|
||||
| 为何不自动化 | 自动补跑需解决「补跑 vs 客户端重试」并发(已由 `convert:rerun:` 锁串行);一期以人工介入降低误补风险,**二期评估自动化** |
|
||||
|
||||
> **幂等锚点(评审 Q4)**:convert 补偿**以转出端 `out_trade_id` 为幂等锚点** —— 复用现有
|
||||
> `scripts/demo/rebuild_alerts.py:54-66` 的 `find_alerts_by_trade(trade_id)`,命中即 `skipped`。
|
||||
> **该脚本本就幂等**(见其 `:8-10` docstring),评审 Q4 提出的「再加 `uk_idem`」**不必要**;
|
||||
> 注意一次转换有**两条流水**,故只认转出端,避免重复出单。
|
||||
>
|
||||
> **`rounding_diff` 落审计(评审 Q10)**:舍入尾差在审计中记**正负向与金额**,供二期对账
|
||||
> (尾差由基金资产列支,PRD §2.5)。
|
||||
>
|
||||
> **二期补偿增强(评审 R3)**:阶段 1.5 引擎失败时,除现有 `engine_error` 审计外,可增发 Redis 事件 /
|
||||
> 写补偿队列表,并让 `cleanup_pending_convert.py` **顺带巡检补跑**,降低人工介入频次。
|
||||
|
||||
---
|
||||
|
||||
## 6. 规则引擎改造(FR-C11 / FR-C15)
|
||||
|
||||
### 6.1 `_amount_view`
|
||||
|
||||
```python
|
||||
def _amount_view(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""金额聚合视图:同 convert_group_id 的组内只保留转出端(trade_type='redeem')。
|
||||
无 gid 的交易原样通过 —— 非 convert 场景下本函数恒等,现有 510 用例零影响。
|
||||
"""
|
||||
```
|
||||
|
||||
`run_rules` 内部:`eligible`(全量)供 RISK-001/003/004;`amount_view(eligible)` 供 RISK-002/005。
|
||||
RISK-006 读持仓快照(阶段 1.5 时已更新),不受影响。
|
||||
|
||||
### 6.2 一次转换 = 一条预警事件
|
||||
|
||||
```python
|
||||
def process_convert_event(out_trade, in_trade, *, core_ro=None, risk_repo=None,
|
||||
thresholds=None, on_error_hook=None) -> dict:
|
||||
```
|
||||
|
||||
> **`on_error_hook`(D19 · 执行期风险 #4)**:**一期传 `None`** —— 引擎异常时仅落 `engine_error` 审计 + 本地日志;
|
||||
> 参数本身存在的意义是**把二期的「Redis 事件 / 补偿队列」留成可插拔点**,避免届时改签名牵动 T-8 全部调用方。
|
||||
> 一期实现里 hook 调用必须包 `try/except`(hook 自身失败**不得**反噬主流程,与「阶段 1.5 不阻断交易」同原则)。
|
||||
|
||||
- 取当日全量流水(已含两条),跑 `run_rules`(内部按 §6.1 分流)
|
||||
- `record_trade_alerts(primary=out_trade, hits, events=[event_of(out), event_of(in)])`
|
||||
→ **一张单、payload.events 两条**
|
||||
- `alert_service.record_trade_alerts` 新增可选参数 `events: list[dict] | None = None`,
|
||||
缺省 `None` → 退化为现有「单 event」行为,**现有调用零改动**
|
||||
|
||||
---
|
||||
|
||||
## 7. 计算口径落地(纯函数签名)
|
||||
|
||||
> **类型定义(评审 S3)**:`Lot` / `FeeRule` / `PlanResult` 统一放 `service/convert/types.py`,
|
||||
> 用 `@dataclass(frozen=True)`(或 `TypedDict`)定义,**不用裸 dict** —— 防键名拼写错误
|
||||
> (呼应主架构 `entities.py` 31 个 ORM 类闲置、全链路 dict 传参的问题)。
|
||||
|
||||
```python
|
||||
# service/convert/calc.py
|
||||
def plan_lots(lots: list[Lot], requested_qty: Decimal, min_hold_qty: Decimal,
|
||||
min_hold_action: str) -> PlanResult:
|
||||
"""FIFO 分配 + 最低持有处置判定(actual_qty / forced_full_transfer / action)"""
|
||||
def lot_amount(qty: Decimal, nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP
|
||||
def lot_fee(amount: Decimal, rate: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP
|
||||
def pick_fee_rate(rules: list[FeeRule], hold_days: int) -> Decimal: # 分档匹配(左闭右开)
|
||||
def convert_amount(out_amount: Decimal, redeem_fee: Decimal) -> Decimal
|
||||
def diff_fee(conv_amount: Decimal, out_rate: Decimal, in_rate: Decimal,
|
||||
mode: str = "amount_diff") -> Decimal:
|
||||
"""补差费,双口径(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))
|
||||
结果 2 位 ROUND_HALF_UP。"""
|
||||
def in_qty(in_amount: Decimal, in_nav: Decimal) -> Decimal: # 2 位 ROUND_HALF_UP(v1.0 修正)
|
||||
```
|
||||
|
||||
> ⚠️ **v1.0 勘误**:v0.2 此处写的是 `4 位 ROUND_FLOOR`,与 §1 原则 10「份额 2 位 ROUND_HALF_UP」**直接矛盾**,
|
||||
> 属 v0.7 残留。PRD v0.8 已定 **2 位四舍五入**,v1.0 一并修正。示例数字由 `scripts/dev/calc_convert_demo.py` 实算回填:
|
||||
> `in_qty = 53456.95`(口径 B)/ `53455.36`(口径 A)。
|
||||
|
||||
**赎回费分档(区间左闭右开 `[min_hold_days, max_hold_days)`)**
|
||||
|
||||
| 档 | min_hold_days | max_hold_days | rate | 依据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| < 7 日 | 0 | 7 | **0.0150** | 22 号文 §10 法定下限 1.5% |
|
||||
| 7–30 日 | 7 | 30 | **0.0100** | 22 号文 §10 法定下限 1% |
|
||||
| 30–180 日 | 30 | 180 | **0.0050** | 22 号文 §10 法定下限 0.5% |
|
||||
| 180–365 日 | 180 | 365 | 0.0025 | 合同约定(法规无下限) |
|
||||
| ≥ 365 日 | 365 | NULL | 0.0000 | 合同约定(真实多数基金满 1 年免赎回费) |
|
||||
|
||||
匹配 SQL:`WHERE product_id=:pid AND fee_type='redeem' AND min_hold_days <= :d AND (max_hold_days IS NULL OR :d < max_hold_days)`
|
||||
—— 同批次 `hold_days = (交易日 − confirmed_at).days`(自然日,**不含申请日**;满 7 日归 7–30 档)。
|
||||
|
||||
**`core_holding` 更新口径**(PRD FR-C13,含 M-6 的 `pnl_pct`)
|
||||
|
||||
| 端 | qty | cost_amount | market_value | as_of | pnl_pct |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 转出 | `qty − actual_qty` | `cost × (1 − actual/原qty)`(等比例结转成本) | `新qty × out_nav` | nav_date | `(mv − cost)/cost`,cost=0 置 0 |
|
||||
| 转入 | `qty + in_qty` | `cost + in_amount` | `新qty × in_nav` | nav_date | 同上 |
|
||||
|
||||
> 转入端**首次**建行 → `INSERT`;已有行 → `UPDATE`。转出端 `qty` 归零**保留行**(D9/P2)。
|
||||
|
||||
**FIFO 选批 SQL(评审 S1 · 确定性 tiebreaker)**
|
||||
|
||||
```sql
|
||||
SELECT lot_id, confirmed_at, remain_qty
|
||||
FROM core_share_lot
|
||||
WHERE customer_id = :cid AND product_id = :pid AND remain_qty > 0
|
||||
ORDER BY confirmed_at ASC, lot_id ASC -- confirmed_at 为主序;lot_id 仅作同行确定性兜底
|
||||
LIMIT :max_lots; -- = convert_batch_max_lots (§11)
|
||||
```
|
||||
|
||||
> 排序**主依据是 `confirmed_at`**(注册日期,PRD §4.1 已建 `KEY(customer_id, product_id, confirmed_at)`),
|
||||
> **`lot_id` 只当同 `confirmed_at` 的确定性 tiebreaker** —— 否则同一注册日内多批次的先后
|
||||
> 由存储引擎决定、**不可复现**(重跑结果漂移,`test_convert_calc.py` 会间歇性失败)。
|
||||
> 评审 S1 建议的「`lot_id` 改 BIGINT + UNIQUE」**不采纳**:`lot_id` **已是主键(天然唯一)**,
|
||||
> 且真实 TA 注册登记批次号是字符串,改类型反而偏离真实 Core。
|
||||
|
||||
---
|
||||
|
||||
## 8. 接口契约落地
|
||||
|
||||
### 8.1 请求模型(`api/simulate.py`)
|
||||
|
||||
```python
|
||||
class TradeRequest(BaseModel):
|
||||
customer_id: str
|
||||
product_id: str | None = None # subscribe/redeem 必填
|
||||
trade_type: str # subscribe | redeem | convert
|
||||
amount: Decimal | None = None # subscribe/redeem 必填
|
||||
from_product_id: str | None = None # convert 必填
|
||||
to_product_id: str | None = None # convert 必填
|
||||
qty: Decimal | None = None # convert 必填(>0)
|
||||
client_request_id: str | None = None # 幂等键(选填);复用 app/main.py:44 的
|
||||
# _TRACE_ID_PATTERN(^[A-Za-z0-9._-]{1,64}$)
|
||||
# 校验,越界/非法字符 → 422(评审 S4)
|
||||
|
||||
@model_validator(mode="after") # 按 trade_type 分派校验,失败抛 ValueError → 422
|
||||
```
|
||||
|
||||
> 422 由 FastAPI 统一错误体处理(T-02 已覆盖);convert 语义错误走 `ApiError`(§8.3)。
|
||||
>
|
||||
> **`client_request_id` 校验(评审 S4)**:**不自造正则** —— 直接复用 `app/main.py:44` 既有的
|
||||
> `_TRACE_ID_PATTERN`(`^[A-Za-z0-9._-]{1,64}$`)。该正则同时约束 trace 头与幂等键,
|
||||
> 口径统一、避免两套白名单漂移。
|
||||
|
||||
### 8.2 响应
|
||||
|
||||
`convert` 分支返回**纯 dict,全部 Decimal 已 `str()`**(§1 原则 11),字段与 PRD §5.3 逐项对齐,
|
||||
含 `lot_breakdown` / `out_amount` / `convert_amount` / `in_amount` / `in_qty` / `rounding_diff` 等。
|
||||
另含 **`batch_count`**(本次实际跨越批次数)与 `max_lots`(上限),供前端在接近上限时提示「请拆分多笔申请」。
|
||||
|
||||
### 8.3 错误码映射
|
||||
|
||||
| 异常 | HTTP | error_code |
|
||||
| --- | --- | --- |
|
||||
| `ProductNotRedeemable` | 400 | `PRODUCT_NOT_REDEEMABLE` |
|
||||
| `ProductNotSubscribable` | 400 | `PRODUCT_NOT_SUBSCRIBABLE` |
|
||||
| `InsufficientShares` | 400 | `INSUFFICIENT_SHARES` |
|
||||
| `BelowMinQty` | 400 | `BELOW_MIN_QTY` |
|
||||
| `SameProduct` | 400 | `SAME_PRODUCT` |
|
||||
| `CrossEntityNotSupported` | 400 | `CROSS_ENTITY_NOT_SUPPORTED` |
|
||||
| `TooManyLots` | 400 | `TOO_MANY_LOTS`(超 `convert_batch_max_lots=200`;**自动分拆多笔留二期**,评审 R4) |
|
||||
| `NavNotReady` | 503 | `NAV_NOT_READY` |
|
||||
| `LotConflict` | 409 | `LOT_CONFLICT`(调用方重试,建议 ≤3 次、间隔 100/200/400ms) |
|
||||
| `IdempotencyUnavailable` | 503 | `IDEMPOTENCY_UNAVAILABLE` |
|
||||
| 未抢到执行权 | **202** | 返回 `{convert_group_id, status: "processing"}` |
|
||||
| 幂等命中 completed | **200** | 返回首次结果 |
|
||||
|
||||
> **非业务阻断声明**:`NAV_NOT_READY`(503)、`LOT_CONFLICT`(409) 属**技术故障码**,
|
||||
> **不适用「仅 R-02 可阻断交易」业务铁律**——它们是系统瞬时状态,恢复后重试即可成功。
|
||||
|
||||
> **`TOO_MANY_LOTS` 响应体与文案(执行期风险 #5)**:400 错误体携带
|
||||
> `{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`(`batch_count` 为**继续转换所需批次数**,
|
||||
> 可能大于 `max_lots`),前端据此提示「**本次转换需跨 N 个批次,超过上限 200,请拆分多笔申请**」。
|
||||
> **语义铁律**:一期**单笔请求 = 单事务 = 单 `convert_group_id`**,不做自动分拆(§1 原则 12)。
|
||||
> 该字段由 `plan_lots()` 的 `PlanResult` 提供 —— 即**先规划再判上限**,避免半途失败。
|
||||
|
||||
异常类定义在 `app/utils/exceptions.py`(复用现有 `ApiError`),convert 专属异常放 `service/convert/errors.py`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型落点清单(DDL 与种子)
|
||||
|
||||
| 对象 | 落点文件 | 动作 |
|
||||
| --- | --- | --- |
|
||||
| `core_fee_rule` | `scripts/core/01-ddl.sql` | 新建(含 `to_fund_ratio DECIMAL(5,4) DEFAULT 1.0`,D16) |
|
||||
| `core_share_lot` | `scripts/core/01-ddl.sql` | 新建 |
|
||||
| `core_convert_lot_detail` | `scripts/core/01-ddl.sql` | 新建(含 `nav`/`nav_date`,D6) |
|
||||
| `core_trade.convert_group_id` + `KEY idx_convert_group` | `scripts/core/01-ddl.sql` | 改表(NULL,向后兼容) |
|
||||
| `core_product` **8** 新列 + `fee_rate` COMMENT | `scripts/core/01-ddl.sql` | 改表(均有 DEFAULT,老 INSERT 不受影响)。8 列 = `can_subscribe`/`can_redeem`/`min_hold_qty`/**`min_hold_action`**/`min_redeem_qty`/`subscribe_fee_rate`/`fund_company`/`ta_code` |
|
||||
| `risk_convert_detail` | `docs/项目框架设计/表设计/02-mysql-agent专用.sql` | 新建(PRD §4.1 DDL 原文,`status` ENUM 含 `pending`/`completed`/`failed`/`cancelled`/**`expired`** + `cancelled_at`;**建表即含全部 5 值,零 ALTER**,评审 S2) |
|
||||
| 赎回费分档种子 | `scripts/core/07-seed-fee-rule.sql` | 新建 |
|
||||
| 份额批次种子 | `scripts/core/08-seed-share-lot.sql` | 新建 |
|
||||
| 机构 + 申购费率种子 | `scripts/core/09-seed-org.sql` | 新建 |
|
||||
| reset 流程 | `scripts/core/reset.ps1` | `$files` 追加 07/08/09 |
|
||||
|
||||
**种子文件版本注释约定(PRD L-6)**:`07`/`08`/`09` 三个新增种子 SQL 的**文件头必须带版本注释块**
|
||||
(`版本 / 日期 / 依据 / 变更摘要`),不单独维护 CHANGELOG 文件(避免与 SQL 漂移)。
|
||||
模板见 PRD §4.3。
|
||||
|
||||
**sqlite 测试 DDL 同步(`tests/_ddl.py`,四张新表 + 列并集)**:
|
||||
`core_trade` 补 `qty`/`convert_group_id`;`core_product` 补 **8 列**;`core_holding` 补 `qty`/`cost_amount`/`as_of`/`pnl_pct`;
|
||||
新增 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`。
|
||||
配套 **`tests/test_db.py::test_core_holding_columns`**(断言列名/PK,T-0 门禁用例)。
|
||||
|
||||
> ⚠️ **已知坑 · 独立评审 R1(升级为 T-0 阻断前置)**:现有 sqlite 版 `core_holding` **只有** `market_value`/`quantity`
|
||||
> (实测 `tests/_ddl.py:50-54`,且**无 PK / 唯一约束**),MySQL 用 `qty`/`cost_amount`/`as_of`/`pnl_pct`,**列名不同名**。
|
||||
> sqlite 与 MySQL DDL 靠人工同步(无迁移工具),**列名不一致会在集成测试期才炸**——`rebuild_lots.py` / 批次维护 / `core_ro` 查询**全链路受影响**。
|
||||
> **处置(评审 R1)**:独立为 **T-0、先于 T-1** —— 以 MySQL 为准统一 sqlite DDL(含 PK),并在 `conftest.py` 加**启动期列名断言**(缺列即 fail fast,不留到集成测试)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
| 层 | 文件 | 覆盖点 | 预计用例 |
|
||||
| --- | --- | --- | --- |
|
||||
| 纯函数 | `test_convert_calc.py` | FIFO 分配 / 跨批次计费 / **补差费双口径(A/B 各一组)** / 舍入边界(HALF_UP vs HALF_EVEN)· **份额 2 位** / 分档边界(6/7/29/30/**179/180**/364/365 天)/ **强制全转 + 强制赎回双动作** / T+1 起算 | ~35 |
|
||||
| 编排 | `test_convert_service.py` | 八步顺序(blocked 不占位)/ 三阶段 + 阶段 1.5 / 幂等命中 / 阶段二失败补偿 / nav_stale 审计条数 | ~22 |
|
||||
| 并发 | `test_convert_concurrency.py` | 条件 UPDATE rowcount=0 → 409;同键并发 → 202;补跑阶段二加锁;**50 并发压测(评审 Q6)** | ~10 |
|
||||
| 批次 | `test_share_lot.py` | 普通申赎批次维护 / **无批次兜底补建** / `rebuild_lots.py` | ~12 |
|
||||
| 集成 | `test_convert_integration.py` | 真 MySQL,`CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 §5.3 示例逐项吻合 | ~6 |
|
||||
| 回归 | `test_trade_gateway.py` | 补批次维护断言;现有 510 用例**必须全绿** | +5 |
|
||||
|
||||
基线:510 → 预计 **580~600**。
|
||||
|
||||
> **50 并发压测口径(评审 Q6)**:同一 `(customer_id, product_id)` 上 **50 个并发请求**争抢同一批份额,
|
||||
> 断言三件事 —— ①`LotConflict`(409) 命中数与 `剩余可转份额` 一致(**不许超卖**);
|
||||
> ②按 `100/200/400ms` 退避重试 ≤3 次后**最终成功率**(验证 §8.3 建议间隔是否够);
|
||||
> ③`core_trade` 中 `convert_group_id` **无重复**、`core_share_lot.remain_qty` 之和 = 初始值 − 实际成交份额。
|
||||
> 该用例**不进 CI 常规门禁**(需真 MySQL + 并发,耗时长),归入 T-13 实测性能补录一并跑。
|
||||
|
||||
**测试隔离**:convert 集成测试用 `CNV-TEST-` 前缀 group_id + `TRD-TEST-` 前缀 trade_id,
|
||||
teardown 按前缀清理 `core_trade`/`core_share_lot`/`core_convert_lot_detail`/`risk_convert_detail`/`audit_log`,
|
||||
沿用 `tests/conftest.py::risk_demo_env` 的时间窗清理约定。
|
||||
|
||||
---
|
||||
|
||||
## 11. 配置新增(`app/config/settings.py`)
|
||||
|
||||
| 项 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `convert_nav_stale_days` | `3` | `nav_date` 距交易日超过此值 → 额外落 `nav_stale` 审计(PRD §2.2)。**全局单阈值即可**:模拟库 14 只产品均**日频净值**、无频次差异;真实 Core 接入后按产品类型(货基/债基/股基)分档,届时把本项由 `int` 改为 `dict[str,int]`(评审 S5,一期不做) |
|
||||
| `convert_lock_ttl_seconds` | `30` | 幂等执行权锁 TTL |
|
||||
| `convert_batch_max_lots` | `200` | 单次转换最多跨越批次数,超限 400 `TOO_MANY_LOTS` |
|
||||
| `convert_diff_fee_mode` | `"amount_diff"` | 补差费口径(D13):`amount_diff` = 价外法两端差(**默认 B**)· `rate_diff` = 费率差法(A) |
|
||||
| `convert_confirm_offset_days` | `1` | T+N 确认偏移(D14):真实为 T+1 **工作日**,模拟库无日历故用自然日,注释写明 |
|
||||
| `convert_compensate_sla_hours` | `24` | 阶段二失败补偿 SLA(§5.4),`cleanup_pending_convert.py` 据此清理孤儿占位 |
|
||||
|
||||
### 11.1 DB 账号与最小权限(D20 · 主架构评审 C1 · 用户拍板「按真实项目走」)
|
||||
|
||||
**问题**:现状 `settings.py:13-18` 只有单账号 `mysql_user='root'`,`db.py:23` 仅按**库名**缓存
|
||||
→ `core_ro.py:55` 与 `gateway_repository.py:24` **共用同一 root 连接池**,「Core 只读」纯属代码约定,
|
||||
没有任何 DB 级强制。任何持有 core engine 的代码都能 `INSERT/UPDATE`。
|
||||
|
||||
**账号矩阵(`scripts/core/00-grant.sql`,管理员执行)**
|
||||
|
||||
| 账号 | 库 | 授权 | 使用者 |
|
||||
| --- | --- | --- | --- |
|
||||
| `xh_core_ro` | `jinrong_core` | `SELECT`(全库) | `core_ro` · `core_tools` · `deps` 归属校验 · `risk` 扫描 · **`trade_gateway` 的校验读** |
|
||||
| `xh_core_rw` | `jinrong_core` | `SELECT/INSERT/UPDATE`,**限 4 表**:`core_trade` / `core_share_lot` / `core_holding` / `core_convert_lot_detail`;**无 DELETE、无 DDL、无 GRANT** | **仅 `app/gateway/` 的写路径**(`gateway_repository.insert_trade` · `convert_core_repository.apply_convert`) |
|
||||
| `xh_agent_rw` | `jinrong_agent` | `SELECT/INSERT/UPDATE` 业务表;**`audit_log` 只授 `INSERT`** | Agent 侧全部(`risk_repository` / `session_repository` / `convert_repository`) |
|
||||
| `root`(管理员) | 两库 | 全权 | **仅** `00-grant.sql` / `01-ddl.sql` / `reset.ps1`,**应用运行时不持有** |
|
||||
|
||||
> ⚠️ **关键边界**:**gateway 的读走 `ro`、写走 `rw`**,两个 engine 并存 ——
|
||||
> `trade_gateway.py:105` 现有的 `core_ro or CoreReadOnlyRepository()`(校验读)**保持只读账号不变**,
|
||||
> 只有 `convert_core_repository` / `gateway_repository.insert_trade` 换用 `rw`。
|
||||
> 否则 gateway 会顺带获得全库读权限,「最小权限」落空。
|
||||
|
||||
**配置(`settings.py` 新增,默认空 = 向后兼容)**
|
||||
|
||||
| 项 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `mysql_core_ro_user` / `mysql_core_ro_password` | `""` | 空 → **回退** `mysql_user`(渐进启用,**不阻塞开发**) |
|
||||
| `mysql_core_rw_user` / `mysql_core_rw_password` | `""` | 同上 |
|
||||
| `mysql_agent_user` / `mysql_agent_password` | `""` | 同上 |
|
||||
|
||||
**`db.py` 改造**
|
||||
|
||||
```python
|
||||
def get_engine(database: str, role: str = "rw") -> Engine:
|
||||
"""按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。
|
||||
对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""
|
||||
```
|
||||
|
||||
> `dispose_engines()` 语义不变(清空全部缓存);`tests/conftest.py:183-233` 的两处调用
|
||||
> 保持单参数默认值即可,**现有 510 用例零改动**。
|
||||
|
||||
**验收(并入 T-0b,真 MySQL 才跑)**
|
||||
|
||||
| 用例 | 断言 |
|
||||
| --- | --- |
|
||||
| `test_db.py::test_core_ro_account_is_readonly` | 用 **ro 账号**执行 `INSERT` → **权限被拒**(证明「只读」真的生效,而非注释) |
|
||||
| `test_db.py::test_audit_log_append_only` | 对 `audit_log` 执行 `UPDATE` / `DELETE` → **被拒**(把项目红线「审计表只 INSERT」从约定**升级为 DB 强制**) |
|
||||
| `test_db.py::test_core_rw_scope` | `rw` 账号对**第 5 张表**(如 `core_product`)执行 `UPDATE` → **被拒**(证明「4 表」边界生效) |
|
||||
|
||||
> **sqlite 测试路径零影响**:`tests/conftest.py` 走 sqlite + `tests/_ddl.py`,**无账号概念**;
|
||||
> 账号分离只在真 MySQL(集成测试 / 生产)生效,不改变任何现有用例的连接方式。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与应对
|
||||
|
||||
| # | 风险 | 应对 | **验收点(开发中必须守住)** |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | sqlite/MySQL DDL 列名失配(评审 **R1 · 阻断**) | **T-0 先统一**(以 MySQL 为准)+ `conftest.py` 启动期列名断言;先于 T-1 | **强制 CI 门禁**:`pytest tests/test_db.py::test_core_holding_columns`(断言列名/PK)**必绿**才允许跑 T-1 之后的集成测试;T-0 完成前该用例全绿 = 通过 |
|
||||
| 2 | **批次补建规则写两处 → 漂移**(执行期风险 #2) | **D18**:抽纯函数 `lot_bootstrap.bootstrap_lots()`,`trade_gateway` 兜底补建与 `rebuild_lots.py` **同调此函数** | `test_share_lot.py` 覆盖 `rebuild_lots.py` 入口;断言**同一 `core_holding` 行经两侧算出的批次完全一致**(同源) |
|
||||
| 3 | `Decimal` 默认 `ROUND_HALF_EVEN` 与「四舍五入」不符 | §1 原则 10:`calc.py`/`fee.py`/`nav.py` **所有量化处**显式传 `rounding=ROUND_HALF_UP`(含逐批先舍入后求和的中间步骤) | `test_convert_calc.py` 专门断言 `.5` 边界(HALF_UP vs HALF_EVEN 结果不同处必须命中 HALF_UP) |
|
||||
| 4 | **阶段 1.5 引擎异常不丢预警**(执行期风险 #4) | 不阻断交易,落 `engine_error` + 本地日志(D17);**D19 预留 `on_error_hook` 可插拔**,二期注入 Redis 事件/补偿队列 | `engine.py` 存在 `on_error_hook` 参数且一期传 `None`;hook 自身抛异常**不得**反噬主流程(有单测);确认 `alert_service` 异步补算不丢预警 |
|
||||
| 5 | `convert_batch_max_lots=200` 超限场景(执行期风险 #5) | 一期**返回 400、不自动分拆**(§1 原则 12 / §8.3);错误体带 `batch_count`/`max_lots`,前端提示「请拆分多笔申请」 | §8.3 契约含这两个字段;`TOO_MANY_LOTS` 有单测;文档与响应均明确 **「单笔 = 单事务 = 单 `convert_group_id`」** |
|
||||
| 6 | 批次表改造打穿现有 510 用例 | D8 兜底补建 + `rebuild_lots.py`;先跑全量基线确认 | **T-10 开工前先跑 510 全绿**,作为回归基线快照 |
|
||||
| 7 | 双库不一致 | 三阶段 + 占位 + 补偿脚本 + 本地日志兜底(§5.4) | 阶段二失败 → `convert_detail_write_failed` 审计存在;`rebuild_alerts --convert-group` 可补跑至 `completed` |
|
||||
| 8 | 锁 TTL 30s 被阶段一超时突破 | 阶段一单库短事务、无外部 IO;抢到锁后**二次校验**占位与 Core 流水 | 并发用例覆盖「锁过期后重入」:二次校验拦住重复扣减 |
|
||||
| 9 | 合并 main 前引入 DDL | 用户已拍板现在做;DDL 全部追加式(新表 + 可空列 + DEFAULT),无破坏性变更 | 老 INSERT 语句(不带新列)跑通不回退 |
|
||||
| 10 | **Core「只读」仅代码约定、无 DB 级强制**(主架构评审 **C1**) | **D20 双账号分离**:`xh_core_ro`(`SELECT`)/ `xh_core_rw`(4 表写);`scripts/core/00-grant.sql` 建号授权;DDL 走管理员账号 | `test_core_ro_account_is_readonly` + `test_audit_log_append_only` + `test_core_rw_scope` 在**真 MySQL** 断言权限被拒;账号未配置时**回退单账号**(不阻塞本地开发) |
|
||||
|
||||
> **执行期门禁总则**:上表 #1 是**硬门禁**(T-0 未绿不得继续),#2/#3/#4/#5 是**开发中持续守**的
|
||||
> ——#2 靠「同源单测」、#3 靠「`.5` 边界断言」、#4 靠「接口预留 + hook 不反噬单测」、#5 靠「契约字段 + 单测 +
|
||||
> 文案」三重约束。**#10 是开工前置**(T-0b,与 T-0 并列)。任意一条失守都在**集成测试期才暴露**(成本最高),
|
||||
> 故前移到各自任务的完成标准里。
|
||||
|
||||
---
|
||||
|
||||
## 13. 拍板结论与溯源(P1~P8 · 全部已拍板)
|
||||
|
||||
> 2026-09-10 用户确认「按建议」,记录见 `docs/项目框架设计/基金转换-审查意见处置表.md` **§九**。
|
||||
|
||||
| # | 事项 | 拍板结论 | 依据 |
|
||||
| --- | --- | --- | --- |
|
||||
| **P1** | 普通申赎无批次 | **不跳过**:批次表覆盖全部交易类型;遇历史持仓无批次 → **兜底补建** | 真实 TA 份额必有注册批次;跳过致两表失配 |
|
||||
| **P2** | 转出后归零 | **保留 `qty=0` 行**;持仓查询过滤 `qty <= 0` | 真实账户归零后仍存在;避免 UNIQUE 冲突与历史断裂 |
|
||||
| **P3** | Core 明细是否对外 | **仅补偿,不对外查询** | 保持 Core 只读边界最小;对话侧走 agent 库粗粒度详情 |
|
||||
| **P4** | 补差费口径 | **B 为默认**,A 保留 `convert_diff_fee_mode` | 与申购费价外法自洽;A 属近似写法 |
|
||||
| **P5** | 最低持有处置 | **两种都做**(`min_hold_action`) | 两种均真实合同约定;法规无强制 |
|
||||
| **P6** | 申购费率 / 赎回费档 | **按 22 号文改**(合规硬约束) | §0.1 |
|
||||
| **P7** | 份额精度 | **2 位 ROUND_HALF_UP** | §0.2;`rounding_diff` 可正可负 |
|
||||
| **P8** | T+1 确认 | **建模**(`confirmed_at = T+1`,自然日近似) | §0.2 / D14 |
|
||||
|
||||
### 13.1 独立评审结论(2026-09-10)
|
||||
|
||||
外部独立评审(全新上下文、只报告不改码)结论:**✅ 通过(M-7 满足),允许进第 4 步开发计划**。
|
||||
判定:**接受 10 · 修正性接受 3 · 驳回 0**;唯一**硬性前置 = T-0(R1 列名统一 + 启动断言)**。
|
||||
其余 R2~R5 / S1~S5 / Q4·Q6·Q10 已在同一版本内**全部落地**(索引见 §13.2),不阻塞启动。
|
||||
逐条判定与核实证据见 `docs/项目框架设计/评审待办-风控主架构与基金转换.md` §二。
|
||||
|
||||
### 13.2 评审建议落地清单(R2~R5 · S1~S5 · Q4/Q6/Q10 · 全部闭环)
|
||||
|
||||
> **13 条建议全部落点可查**(R1 走 T-0 阻断前置,其余 12 条落本文各节 + PRD v0.9),
|
||||
> 本表即「评审 → 架构/PRD」的对应索引;**0 条悬空**。
|
||||
|
||||
| # | 建议(简述) | 判定 | 落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| **R1** | sqlite/MySQL 列名统一 | 接受(**升级 T-0 阻断**) | §9 末「已知坑」段 · §12 风险表 · §15 任务 T-0 |
|
||||
| R2 | T+1 自然日近似需声明 | 接受 | PRD v0.9 §5.3 响应 `confirm_basis="natural_day_approx"` · §0.4 建模取舍表 |
|
||||
| R3 | 引擎异常不阻断/不重试 | 接受(备注) | §5.4 「二期补偿增强」段 |
|
||||
| R4 | 批次超限 400 不自动分拆 | 接受 | §1 原则 12 · §8.3 `TOO_MANY_LOTS` 行 |
|
||||
| R5 | `to_fund_ratio` 不参与计算 | 接受 | §0.4 建模取舍表 · D16 |
|
||||
| **S1** | `lot_id` 主键类型/排序 | 修正性接受(**建议不采纳**) | §7 「FIFO 选批 SQL」段(`ORDER BY confirmed_at, lot_id` tiebreaker;主键类型不动) |
|
||||
| **S2** | cleanup 不硬删 | 接受 | §9 `risk_convert_detail` 行 · §2 `cleanup_pending_convert.py` 注释 · §5.4 SLA 行 · PRD v0.9 §4.1 ENUM |
|
||||
| **S3** | `PlanResult` 用结构化类型 | 接受 | §7 开头「类型定义」引块(`service/convert/types.py`) |
|
||||
| **S4** | `client_request_id` 加正则 | 接受(**复用现成**) | §8.1 字段注释 + 引块(复用 `app/main.py:44` `_TRACE_ID_PATTERN`) |
|
||||
| **S5** | nav stale 按产品类型 | 修正性接受(**一期不做**) | §11 `convert_nav_stale_days` 行 |
|
||||
| **Q4** | 补偿幂等 | 修正性接受(**原意见不成立**) | §5.4 「幂等锚点」引块(以转出端 `out_trade_id` 锚定;`rebuild_alerts.py` 本就幂等) |
|
||||
| **Q6** | 重试间隔需压测验证 | 接受 | §10 「50 并发压测口径」段 · 并发用例 ~10 条 |
|
||||
| **Q10** | `rounding_diff` 归属 | 接受 | §5.4 「`rounding_diff` 落审计」引块 |
|
||||
|
||||
> 另:主架构评审 **C1(`core_ro` 无 DB 级只读账号)** 属**跨线交叉项**(不在上表 13 条内),
|
||||
> 已单独落 **D20 / §11.1 / T-0b**,并与 `开发计划-架构改进.md §5.3` 对齐(评审前置结论:推荐独立只读账号)。
|
||||
|
||||
---
|
||||
|
||||
## 14. 设计自检十问复核(项目强制)
|
||||
|
||||
| # | 问 | 本期答案 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 新增表/字段**谁写**? | `core_share_lot`:`convert_core_repository`(convert)+ `trade_gateway`(普通申赎,**含兜底补建** D8,其规则单点来自 `lot_bootstrap.bootstrap_lots`,D18)· `core_convert_lot_detail`:阶段一同事务 · `risk_convert_detail`:`convert_repository`(阶段零/二)· `min_hold_action`/`to_fund_ratio`:种子写入,代码只读 |
|
||||
| 2 | **谁读**? | `core_ro`(批次/净值/费率/持仓)· `rules._amount_view`(消费 `convert_group_id`)· `core_tools.query_recent_trades`(汇总去重)· `rebuild_alerts.py`(补偿)· `cleanup_pending_convert.py`(超时置 `expired`) |
|
||||
| 3 | **枚举/常量 DDL**? | `risk_convert_detail.status` **ENUM 5 值**(`pending`/`completed`/`failed`/`cancelled`/**`expired`**,建表即全量、零 ALTER,S2)· `core_trade.trade_type` ENUM **已含 convert** · `audit_log.decision` **VARCHAR(64)**,`nav_stale`/`convert_detail_write_failed`/`engine_error` 加值不改 DDL |
|
||||
| 4 | **事务跨库**? | 跨 → 三阶段 + 阶段 1.5(不参与事务)+ 补偿(一期手动、SLA 24h) |
|
||||
| 5 | **种子数据**? | 07/08/09 三新种子(**文件头带版本注释块**);`subscribe_fee_rate` 造出**合规上限内**的不同费率对(债基 0.0030 vs 主动偏股 0.0080),否则补差费恒 0;赎回费种子覆盖 5 档且 7–30 日档 ≥1% |
|
||||
| 6 | **并发安全**? | 批次条件 UPDATE + rowcount 校验 · 执行权 `try_lock` · `uk_idem` 兜底 · 补跑阶段二加锁 |
|
||||
| 7 | **汇总语义唯一**? | 响应不返回汇总 `hold_days` · `_amount_view` 组内取转出端 · `out_amount`/`convert_amount`/`in_amount` 三者语义分立(PRD §5.3) |
|
||||
| 8 | **向后兼容**? | `core_trade` 加可空列 + 索引 · `core_product` 8 列均有 DEFAULT · `get_latest_nav` 不动改新增 · `record_trade_alerts` 新增可选参数缺省退化 · **`get_engine(db, role="rw")` 新参数带默认值、角色账号未配置回退 `mysql_user`**(D20,现有调用点零改动) |
|
||||
| 9 | **示例自证 / 步骤可执行**? | §7 给出全部纯函数签名与分档表 · §5.2 给出重试判定 SQL · §9 给出 DDL 落点清单 · `scripts/dev/calc_convert_demo.py` 实算回填示例(禁手算) · §0.5 每条真实口径均附来源 |
|
||||
| **10** | **外部事实核验**?(v1.0 新增) | §0.1/§0.2/§0.5 列出 22 号文 + 9 条公告;申购费率上限、赎回费下限、份额精度、T+1 四处的取值**均有法规条款或公告原文对应**,非「看起来合理」 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 与第 4 步的衔接(任务映射 · 已定稿,可直接作为开发计划输入)
|
||||
|
||||
| 任务 | 内容 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| **T-0** | **列名统一 + 启动断言(评审 R1 · 阻断前置)**:以 MySQL 为准重写 `tests/_ddl.py` 的 `core_holding`(`qty`/`cost_amount`/`as_of`/`pnl_pct` + PK),`conftest.py` 加启动期列名断言,新增 **`tests/test_db.py::test_core_holding_columns`** | 无 |
|
||||
| **T-0b** | **DB 账号分离(D20 · 主架构 C1 · 阻断前置)**:`scripts/core/00-grant.sql` 建 3 账号授权;`settings.py` 加 3 组账号;`db.py` 改 `get_engine(db, role)`;`core_ro` 走 `ro`、gateway 写路径走 `rw`(读仍 `ro`);新增 3 个权限断言用例(§11.1) | 无(可与 T-0 并行) |
|
||||
| T-1 | DDL + 种子(**00-grant** / 01-ddl / 07 / 08 / 09 / 02-agent)+ reset.ps1 | **T-0 + T-0b(门禁绿)** |
|
||||
| T-2 | `service/convert/` 纯函数:`types.py` + `calc.py` + `fee.py` + `nav.py` + `lot_bootstrap.py`(D18) | 无 |
|
||||
| T-2b | `scripts/dev/calc_convert_demo.py` 实算主示例 + 同费率对照,**回填 PRD §5.3 与验收断言** | T-2 |
|
||||
| T-3 | `core_ro` 五个新方法 + `share_lot_repository` | T-1 |
|
||||
| T-4 | `convert_repository`(agent 侧占位/回写/查询/清理) | T-1 |
|
||||
| T-5 | `locks.try_lock` + 单测 | 无 |
|
||||
| T-6 | `convert_core_repository.apply_convert`(阶段一事务) | T-1/T-3 |
|
||||
| T-7 | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5) | T-2~T-6 |
|
||||
| T-8 | `rules._amount_view` + `engine.process_convert_event`(含 **D19 `on_error_hook` 预留**)+ `alert_service.events` 参数 | 无(可与 T-2 并行) |
|
||||
| T-9 | `api/simulate.py` 模型与错误码(含 `TOO_MANY_LOTS`)+ `trade_gateway` 分派 | T-7 |
|
||||
| T-10 | 普通申赎批次维护(FR-C16,**含兜底补建 D8,调 `lot_bootstrap`**)+ `rebuild_lots.py`(同调,D18)。**开工前先跑 510 全绿基线** | T-3(排期置于 T-7 后) |
|
||||
| T-11 | `core_tools.query_recent_trades` 汇总去重(FR-C15)+ 持仓过滤 `qty <= 0` | T-8 |
|
||||
| T-12 | 补偿脚本 `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 |
|
||||
| T-13 | 全量回归 + 集成测试 + **50 并发压测(评审 Q6)** + 实测性能补录(PRD §9 第 18 条) | 全部 |
|
||||
|
||||
### 15.1 依赖拓扑与并行分组
|
||||
|
||||
```text
|
||||
T-0 (⛔ 阻断:列名统一 + 门禁 test_core_holding_columns)─┐
|
||||
T-0b(⛔ 阻断:DB 账号分离 D20 + 3 个权限断言)─────────────┴─► T-1(DDL / 种子)
|
||||
└─► T-2(纯函数 types/calc/fee/nav/lot_bootstrap)─► T-2b(示例实算回填)
|
||||
├─► T-3 ─┐
|
||||
├─► T-4 ─┼─► T-6(阶段一事务)─► T-7(编排 · 关键路径)
|
||||
└─► T-5 ─┘ ├─► T-9(API + gateway 分派)
|
||||
└─► T-12(补偿脚本)
|
||||
T-8(引擎改造 · 可全程与上述并行)─► T-11(工具汇总去重)
|
||||
T-10(批次维护 · 回归风险最大:先跑 510 基线再动)
|
||||
└─► T-13(全量回归 + 50 并发压测 + 性能补录)
|
||||
```
|
||||
|
||||
- **并行组 A**:T-3 / T-4 / T-5(T-1 完成后可同时开工)
|
||||
- **并行组 B**:T-8 与 T-2 之后任意阶段并行(无依赖,仅需 `run_rules` 既有接口)
|
||||
- **关键路径**:T-0 → T-1 → T-2 → T-6 → T-7 → T-13
|
||||
- **高风险任务**:**T-10**(改 `trade_gateway` 主流程,510 用例直接受影响)→ 单独结项、先基线后改造
|
||||
- **门禁**:**T-0 与 T-0b 双双绿**才允许启动 T-1 及之后(CI 强制);
|
||||
账号未配置时回退单账号(本地开发不被阻塞),但**真 MySQL 集成测试必须跑在拆分账号下**
|
||||
- **基线**:510 → 预计 **580~600** 用例(§10)
|
||||
Reference in New Issue
Block a user