Files
group_xinghuo_jinrong/docs/项目框架设计/架构设计-基金转换交易.md
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
T-0 / T-0b(门禁 · 2026-09-10)
- T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct
  + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验
  _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效)
- T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权);
  settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色),
  账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw;
  tests/conftest.py 四处显式 role="admin"(R-e)

T-1(数据层)
- scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail;
  core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT
- 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql
  (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA +
  申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排)
- reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail
- tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁
- 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言,
  含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL)

T-2 / T-2b(纯函数包 + 示例实算回填)
- 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors
  (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32
   保证 D18 跨进程同源)
- 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 /
  FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 /
  纯函数零 IO 依赖断言)
- 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py,
  末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁

验证
- pytest 609 passed / 3 skipped(516 → +93,零回归)
- verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致

文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 ·
开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
2026-09-10 14:45:55 +08:00

66 KiB
Raw Blame History

架构设计说明书 · 基金转换(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 §二。 ✅ 评审建议已全部闭合(R2R5 / S1S5 / 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 条):

  1. 计算与编排分离:折算/费率/舍入做成无 IO 纯函数(service/convert/calc.py),编排层只取数、调纯函数、管事务。金额算错最难查,纯函数可穷举单测
  2. 阶段一事务收口在单一仓储:阶段一涉 5 类 core 写入,跨 repository 无法共享事务,故集中在 convert_core_repository.apply_convert() 的一个方法 + 一个 connection 内(D3)
  3. 批次表必存 + 兜底补建(P1 已拍板):core_share_lot 覆盖全部交易类型;普通申赎有批次则 FIFO 扣/增,无批次则兜底补建(按 core_holding.as_of 建初始批次,与 rebuild_lots.py 同源规则),不跳过、不阻断。理由:真实 TA 份额必有注册批次;两表口径必须一致
  4. 锁原语只增不改:locks.run_locked(等待 + 降级)语义不动,新增 try_lock(单次尝试),避免影响现有 3 处调用点
  5. Decimal 一律显式 quantize,禁止裸浮点:金额 2 位、份额 2 位,均 ROUND_HALF_UP; 舍入误差在基金资产列支,响应可选回传 rounding_diff(可正可负)。 ⚠️ Python Decimal.quantize() 默认 ROUND_HALF_EVEN(银行家舍入),必须显式传 ROUND_HALF_UP(D·红线)
  6. 响应金额全部 str() 化:路由未声明 response_model 时 FastAPI jsonable_encoder 会把 Decimal 转 float(精度风险)。convert 分支返回前全部显式 str()
  7. 单次转换最多跨 convert_batch_max_lots 个批次(默认 200):超限返回 400 TOO_MANY_LOTS,防事务膨胀。 一期不做自动分拆(一次请求 = 一个 convert_group_id = 一个 core 事务);自动拆成多笔留二期(评审 R4)
  8. Core 读写账号物理分离(主架构评审 C1 / §三 交叉点 · 已拍板「按真实项目走」): jinrong_core 拆只读账号(Agent 侧全部读路径)与最小写权限账号(仅 app/gateway/ 持有); get_engine(database, role) 缓存键由「库名」改为「(库名, 角色)」。 应用账号一律不持有 DDL / DELETE / GRANT 权限,DDL 由 scripts/core/01-ddl.sql 经管理员账号执行。 这是「Core 只读」从代码约定升级为 DB 级强制的唯一手段(详见 D20)

2. 目录与文件划分

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 固定顺序)

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 阶段一之外的一次读(重试判定)

-- 取得执行权之后执行;有行 = 阶段一已成,只需补跑阶段二
SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;

走 core_ro(只读)。依赖 core_trade.idx_convert_group(§9),否则全表扫。

5.3 锁原语扩展

# 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

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 一次转换 = 一条预警事件

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 传参的问题)。

# 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 修正)
def hold_days(trade_date: date, confirmed_at: datetime) -> int:   # (交易日 − 确认日).days,不含申请日
def rounding_diff(in_amount: Decimal, in_nav: Decimal, actual_in_qty: Decimal,
                  places: int = 4) -> Decimal:                    # 理论份额 − 实得份额(响应字段)
def ensure_batch_limit(plan: PlanResult, max_lots: int) -> None:  # 批次数超限 → TooManyLots(§8.3)

T-2 落地补充(2026-09-10):末三行签名由 T-2 执行期补入,hold_days 是 pick_fee_rate 的入参来源(T+1 起算、满 7 日归 7–30 档都靠它),rounding_diff 对应 PRD §5.3 响应字段,ensure_batch_limit 把「先规划再判上限」固定成一步。 三者均在 calc.py,纯函数、零 IO,已在 tests/test_convert_calc.py 覆盖。

⚠️ 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)

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)

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。

T-2 落地补充(2026-09-10):errors.py 实际实现 11 个异常 —— 上表 10 条 全部落地(含 LotConflict / IdempotencyUnavailable 两个机制类),另加 1 个上表之外的 内部兜底:FeeRuleMissing(500 / FEE_RULE_MISSING),仅在 core_fee_rule 缺少 [0, 7) 档(种子漏灌)时触发。它不是业务错误体契约的一部分,列在这里只为 「触发条件可查」:pick_fee_rate 无命中时绝不返回 0 费率——静默按 0 计费会少收 赎回费且不留痕,比直接失败危险得多。(开发计划 §4.2 原文写「8 个」,为该文档的计数笔误, 以本表为准;差异已在开发计划执行记录中留痕。)


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 只授 SELECT, INSERT(无 UPDATE/DELETE —— 只能追加、不可改删) Agent 侧全部(risk_repository / session_repository / convert_repository)
root(管理员) 两库 全权 仅 00-grant.sql / 01-ddl.sql / reset.ps1,以及 tests/conftest.py 真 MySQL 集成测试的 setup/teardown(teardown 需 DELETE,见下方修正);应用运行时不持有

⚠️ 关键边界: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 改造

def get_engine(database: str, role: str = "rw") -> Engine:
    """按 (库名, 角色) 缓存单例 Engine;role ∈ {"ro","rw","admin"}。
    对应角色账号未配置 → 回退 settings.mysql_user(行为与现状一致)。"""

dispose_engines() 语义不变(清空全部缓存)。

⚠️ 修正(T-0b 实施时发现):tests/conftest.py 的四处调用不能沿用默认角色 —— teardown 要 DELETE FROM core_trade / risk_alert / audit_log,而 xh_core_rw 与 xh_agent_rw 均无 DELETE 权限,沿用默认角色会让真 MySQL 集成测试在 setup/teardown 失权。 故四处显式 role="admin"(对应开发计划 R-e);app/ 内业务调用一律不用 admin。 sqlite 单测路径无账号概念,默认配置下现有用例仍零改动。

验收(并入 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(集成测试 / 生产)生效,不改变任何现有用例的连接方式。

实施记录(T-0b · 2026-09-10 完成)

  • scripts/core/00-grant.sql 已落地:3 账号 + 逐表授权(agent 库 17 张业务表 + risk_convert_detail), 不进 reset.ps1(DROP DATABASE 不清 mysql.db 授权行,授权一次即可)。
  • 两处口径修正(实施时发现,已同步 00-grant.sql 注释):
    1. audit_log 实际授权为 SELECT, INSERT —— 原文「只授 INSERT」若字面执行会一并剥夺 SELECT,使 risk_repository.has_engine_error_audit(:231)与 list_audit_events(:420) 双双失败;红线本意是「只能追加、不可改删」,故正确授权含 SELECT。
    2. tests/conftest.py 四处改 role="admin"(见上方修正框,对应开发计划 R-e)。
  • 不授 DELETE 的依据:app/ 与 scripts/ 全仓无任何 DELETE / TRUNCATE / DROP(已 grep 核实), 故 xh_core_rw / xh_agent_rw 去掉 DELETE 不影响任何现有代码路径。
  • 3 条权限断言用例已写入 tests/test_db.py,账号未配置或真库不可达时自动 skip; 默认配置下全量测试保持全绿(本任务完成时 516 passed / 3 skipped,基线 510)。

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 列名统一 + 启动断言)。 其余 R2R5 / S1S5 / Q4·Q6·Q10 已在同一版本内全部落地(索引见 §13.2),不阻塞启动。 逐条判定与核实证据见 docs/项目框架设计/评审待办-风控主架构与基金转换.md §二。

13.2 评审建议落地清单(R2R5 · S1S5 · 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 严格按 product_type 取档(stock 0.0080 / mixed 0.0050 / bond·index 0.0030 / money 0)造出合规上限内且同主体可互转的不同费率对(PROD-110022 债基 0.0030 → PROD-003095 主动偏股 0.0080,同属华夏模拟基金/TA-CN-001),否则补差费恒 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)+ 新增 core_product_nav + 建库自校验 _assert_ddl_aligned(),新增 tests/test_db.py::test_core_holding_columns。2026-09-10 完成(另发现:2 处测试 INSERT 还需补 3 个 NOT NULL 列) 无
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)。2026-09-10 完成(audit_log 授权修正为 SELECT, INSERT;conftest 四处改 role="admin") 无(可与 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 + errors.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 依赖拓扑与并行分组

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)