① PRD §2.5.3 修订:删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位主流 + 舍去型真实存在」,版本块加注记 ② rounding_mode=truncate 消费接入(用户拍板补做): - core_ro.get_share_rounding = 位数+模式回退链单点(规则行 → core_product → 默认 2/half_up,mode 白名单读取即校验,get_share_digits 转薄包装) - calc.product_round/in_qty 加 mode 参数(truncate → ROUND_DOWN) - 确认段 + 申购折算两处透传;载体 09-seed-org.sql ⑦ 段 PROD-005828(product 层回退,零转换场景引用) - 测试 +7(842→849):calc 4 + 确认段 2(truncate 34945.53 vs half_up .54 分化 + 回退链遮蔽语义)+ 申购段 1 - 突变:两调用点同拆 mode 透传 → 2 红,还原绿;真库 9/9(seed 新增 ⑫ 载体在位断言) ③ R-12 口径裁定追认定稿:条目回写开发计划(NULL 不参与 A/C 判定 / 同类走一般转换规则) 审核教训留痕:PRD 独立 AI 三轮审查未抓出外部事实断言无出处 —— 联网核实铁律再添例证 文档:开发计划 T-14 开口闭环段 + 基线 849 · 日志新节 · TODO/MEMORY/AGENTS/交接文档开口清零
68 KiB
PRD · 基金转换(convert)交易
版本:v1.1(精简最终态 · T+1 受理/确认分离模型) · 2026-09-11 修订:§2.5.3 舍入口径按联网核实修订(2026-09-12 用户拍板)——删「易方达 ETF 场外取整数位」(无证据且有反证,真实整数位载体为场内),改记「2 位为主流 + 舍去型真实存在」 分支:
risk-control-agent阶段:第 2 步产出(v1.1 精简重写已通过独立 AI 联网审查,已定稿(2026-09-11 用户批准)) 关联:docs/项目框架设计/架构设计-基金转换交易.md(v2.1) 合规基准:证监会公告〔2025〕22 号《公开募集证券投资基金销售费用管理规定》(2026-01-01 施行) + 汇添富/中欧/鹏华/国投瑞银/东海/华商/金鹰/财通/中银/东方证券资管 10 家转换业务公告(来源清单见架构 §0.5)
1. 背景与目标
1.1 现状
trade_type 一期只接受 subscribe/redeem,convert 在 app/gateway/trade_gateway.py:112 被显式拒绝(HTTP 400)。数据库已支持:core_trade.trade_type ENUM 含 convert,qty 字段已预留。属能力收窄。
1.2 为什么要放开
真实业务中转换是常见业务,项目要贴近真实业务(用户拍板)。
1.3 目标
按真实基金转换业务语义实现:份额申请、净值折算、逐批次赎回费 + 申购补差费、转出视同赎回、转入视同申购、先进先出、持有期重置、持仓同步;受理/确认分离(T 日受理不扣份额、T+1 批量确认、T+2 可用)。
1.4 非目标
| 项 | 说明 |
|---|---|
| 跨 TA / 跨销售机构转换 | 真实业务支持,一期不做 |
| 基金层巨额赎回同比例确认 | 需基金级现金头寸,本期不做;仅做投资者层部分成交(§2.6.5) |
| 后端收费模式补差费 | 只做前端 |
| 司法 / 有权机关冻结 | 真实业务由人工柜台办理(华安 §90 / 富达 §46),非系统自动行为,明确不做 |
| 普通赎回计费 | 本期普通赎回只维护批次、不驱动赎回费计费;转换转出才计费(§4.4 口径差异) |
2. 业务口径
2.1 计算链路
转出金额 = Σ(各批次份额 × 转出基金成交净值) ← 逐批次
赎回费 = Σ(各批次金额 × 该批次适用赎回费率) ← 逐批次
转换金额 = 转出金额 − 赎回费
补差费 = 价外法两端差(口径 B,§2.1.1)
转入金额 = 转换金额 − 补差费
转入份额 = 转入金额 ÷ 转入基金成交净值(按产品舍入,§2.5)
与真实业务公告口径一致:转出视同赎回、转入视同申购、未知价法(以申请受理当日 T 日净值为基准)、单笔计算法(当日多笔转换各算各的,不合并)。
2.1.1 补差费口径
本项目采用**口径 B(价外法两端差)**为默认:
| 口径 | 公式 | 使用方 |
|---|---|---|
| B · 价外法两端差(默认) | 补差费 = Max[转换金额 × 转入费率 ÷(1+转入费率) − 转换金额 × 转出费率 ÷(1+转出费率), 0] |
国投瑞银、东海、华商、金鹰、财通证券资管、鹏华(新版) |
| A · 费率差法(配置保留) | 补差费率 = max(转入申购费率 − 转出申购费率, 0);补差费 = 转换金额 × 补差费率 ÷(1+补差费率) |
汇添富、中欧、财通基金、东方证券资管 |
为什么选 B:中国公募申购费的标准算法本身就是价外法(申购费 = 申购金额 × 费率 ÷(1+费率))。「两端申购费之差」按 B 计算才与申购费算法自洽;B 亦为新近公告的主流写法。A 以配置项 convert_diff_fee_mode = "rate_diff" 保留,两种口径均有用例覆盖。
数值差异(必须知晓):转换金额 10000、转入 1.5%、转出 0.8% 时,A = 69.51,B = 68.41(差 ~1.6%)。
2.1.2 补差费率来源
申购费率取自 core_product.subscribe_fee_rate(§4.2 新增列,单一费率 · 模拟假设),运行时计算,不落 core_fee_rule。core_fee_rule 的 fee_type='subscribe' 金额分档本期不启用,仅为真实 Core 接入后的扩展位预留。
⚠️ 补差费由两端产品的
subscribe_fee_rate决定,不得取fee_rate(后者是管理费率,语义不同,见 §4.2 的 COMMENT)。
2.1.3 赎回费分档
| 持有期(自然日) | 赎回费率 | 依据 |
|---|---|---|
| < 7 日 | 1.50% | 22 号文 §10 法定下限 |
| 7 日(含)–30 日 | 1.00% | 22 号文 §10 法定下限 |
| 30 日(含)–180 日 | 0.50% | 22 号文 §10 法定下限 |
| 180 日(含)–365 日 | 0.25% | 基金合同约定(法规无强制下限;真实常见档) |
| ≥ 365 日 | 0 | 基金合同约定(真实多数基金满 1 年免赎回费) |
区间口径:左闭右开
[min, max)—— 满 7 日归 1.0% 档,满 30 日归 0.5% 档,满 180 日归 0.25% 档。上表 <7/7–30/30–180 三档为 22 号文 §10 下限(法定最低),真实产品可高于下限;本 seed 取下限演示。hold_days定义见 §12。豁免不启用:22 号文 §10 允许个人持满 7 日的指数型/债券型及机构持满 30 日的债券型另行约定赎回费。本项目不走豁免,保留完整费率梯度以便演示与测试。
2.2 净值取值
受理环节完全不取净值,净值只在 T+1 确认时取用 —— 与真实业务「T 日净值 T+1 才公告」吻合。
| 环节 | 是否取净值 | 口径 |
|---|---|---|
| T 日受理 | ❌ 不取 | 只校验份额/产品/适当性;响应不含任何金额与净值字段 |
| T+1 确认 | ✅ 取 T 日净值(转出端、转入端各一只) | 命中 core_product_nav (product_id, nav_date = T) |
| 缺 T 日净值 | — | 受理单置 nav_pending,不 reject、不降级取旧净值;净值补齐后继续确认(§2.6.5) |
不回退取最近一条:未知价法的成交价就是 T 日净值,拿 T−1 净值折算的金额是错误的成交价。真实业务里净值没公告就不确认,而不是用旧值先算一个。
种子可用性:06-seed-nav.sql 为联网下载的真实净值序列(由 scripts/core/fetch_nav.py 生成,覆盖 2026-06-18 起 60 个净值发布日),可用同一脚本增量补拉(真实净值只到抓取当日,之后运行需重跑)。
2.3 逐批次计费与汇总口径
一次转换可能跨多个批次,各批次持有天数不同。
- 逐批次计算持有天数与赎回费,明细落
core_convert_lot_detail - 响应返回:
redeem_fee(总额)+lot_count(批次数)+convert_group_id(明细指针);不返回汇总hold_days(跨批次无单一值)
2.4 资金流特征
转出基金赎回款 → 注册登记账户 → 转入基金,不经投资者账户。这是「权益层记 2 笔、资金层计 1 笔」的依据。
模拟层建模:模拟库无真实注册登记账户,Core 侧建
core_cash_flow中转表还原中转语义(一次转换 1 笔资金划转,direction='NET',amount = in_amount;「一次划转」为建模假设,非法规明文)。规则层通过_amount_view去重,使 RISK-002 等金额聚合类规则只计一次(§6.3)。
2.5 舍入顺序与精度
逐批次先舍入、后求和(贴合真实 TA:每笔费用独立计算入账)。
2.5.1 计算精度(决定数值)
| 对象 | 精度 | 舍入方式 | 依据 |
|---|---|---|---|
每批次 amount / fee_amount |
2 位 | 四舍五入 | 真实公告:转出金额、赎回费、补差费均保留 2 位、第三位四舍五入 |
汇总 redeem_fee |
— | Σ(各批次已舍入的 fee_amount) |
逐批舍入后求和 |
转入份额 in_qty |
按产品 | 按产品(§2.5.3) | 主流 2 位四舍五入,差异产品按配置 |
存储精度仍为
DECIMAL(18,4)(真实 TA 内部精度高于展示位),计算与对外展示按 §2.5.2 分类。
2.5.2 展示位数(决定响应字符串)
按字段分类集中定义,实现见 app/service/convert/convert_service.py::_q(单一格式出口,响应/审计/日志共用)。
| 字段类(响应字段) | 位数 | 舍入 | 现实依据 |
|---|---|---|---|
金额:out_amount / redeem_fee / convert_amount / diff_fee / in_amount / 逐批 fee_amount |
2 | 四舍五入 | 「转出金额以四舍五入的方式保留至小数点后两位」 |
份额:requested_qty / actual_qty / in_qty / 逐批 qty |
2(可被产品配置覆盖) | 四舍五入 / 按产品 | 「申请转换份额精确到小数点后两位」(中银);主流 2 位四舍五入,差异见 §2.5.3 |
净值:out_nav / in_nav / 逐批 nav |
4 | 四舍五入 | 份额净值保留 4 位、第 5 位四舍五入(中欧、国泰) |
费率:out_subscribe_fee_rate / in_subscribe_fee_rate / 逐批 fee_rate |
4 | 四舍五入 | 公告以百分比 2 位表示(0.30% ↔ 0.0030) |
份额尾差:rounding_diff |
4 | 四舍五入 | 需与净值同级才能表达尾差 |
同一响应的两条产出路径(首次按公式算 / 按库
DECIMAL(18,4)重建)必须逐字节一致。
2.5.3 舍入差异按产品配置(D27)
真实业务不统一(2026-09-12 联网核实修订,用户拍板):主流转入份额 2 位四舍五入(各基金合同普遍约定,易方达/汇添富/华夏 ETF 联接合同均 2 位);舍去(TRUNCATE)型真实存在(南方利众/宝元、邮储、华宝、申万菱信、东方基金等,合同/公告明写「舍去法」);「取整数位」仅见于场内份额(交易所 100 份/手约束,LOF/ETF 场内先 2 位再截位整数,招商瑞智 LOF 合同)——场外无取整数位依据(原「易方达 ETF 场外取整数位」无证据且有反证,已删)。本项目按产品配置:
- 新增
core_share_rule(product_id + business_type)表(11-seed-share-rule.sql,逐产品列(share_digits, rounding_mode)组合,如南方=2 位+TRUNCATE、东方=TRUNCATE,禁错配) rounding_mode ∈ {HALF_UP, TRUNCATE(=ROUND_DOWN)}(东方/南方截断=ROUND_DOWN,须与种子一致)- calc 按产品取真实规则;老产品缺规则时回退主流 2 位 HALF_UP(显式标注)
⚠️ 实现红线:Python
Decimal.quantize()默认是ROUND_HALF_EVEN(银行家舍入),与"四舍五入"不符——所有quantize必须显式传rounding=ROUND_HALF_UP(或产品配置的 TRUNCATE)。
尾差处理:舍入产生的误差在基金资产中列支(不是"归基金资产"的单向归属)。本期不建「基金资产」表,响应可选返回 rounding_diff = 理论份额 − 实际份额(可正可负:正 = 客户少得、负 = 客户多得),仅供校验。
2.6 受理 / 确认时序
2.6.1 交易日与受理日判定(15:00 截点 + 非交易日顺延)
| 申请时刻 | 受理日 T | 依据 |
|---|---|---|
| 交易日 ≤ 15:00 | 当日 | 15:00 为当日受理截点 |
| 交易日 > 15:00 | 下一交易日 | 超过交易时间 → 顺延(不判失败,免客户重试) |
| 非交易日 | 下一交易日 | 真实业务只在开放日受理 |
- 交易日来源:新增
core_trade_calendar(§4.1),依上交所《休市安排》生成(730 天 / 503 交易日,与真实净值发布日交叉校验 0 偏差)。 - 15:00 为配置项
convert_cutoff_time(默认15:00),不得硬编码。 - 受理日 T 一经确定即写入受理单,后续 T+1 / T+2 全部以它为基准(按交易日历推算,不用自然日近似)。
2.6.2 生命周期与状态机
T 日 T+1 T+2
申请 ──► [accepted 已受理] ──► [confirmed 已确认] ──► 转入份额可赎回
│ ▲ │
│ │ 缺 T 日净值 │ 校验失败
│ └── [nav_pending] ───┤
│ ▼
撤单 ───────┴──► [cancelled] [rejected 原份额不变]
| 状态 | 含义 | 是否占用份额 | 终态 |
|---|---|---|---|
accepted |
T 日已受理,待 T+1 确认 | ✅ 占用 | 否 |
nav_pending |
T+1 确认时缺 T 日净值,挂起等净值 | ✅ 占用 | 否 |
confirmed |
权益已变更(转出批次已扣、转入批次已登记) | ❌(已转为真实扣减) | ✅ |
rejected |
确认失败,占用释放、原份额不变 | ❌ | ✅ |
cancelled |
T 日 15:00 前撤单,占用释放 | ❌ | ✅ |
expired |
超 SLA 仍未确认(兜底清理,标记不硬删) | ❌ | ✅ |
2.6.3 在途占用与可用份额(不新增冻结列)
口径:
可用份额(客户, 产品) = Σ core_share_lot.remain_qty − Σ(未终态受理单的 requested_qty)
占用由受理单推导,不写进批次表。
术语澄清:真实 TA 业务规则里的「冻结」是司法 / 有权机关冻结(华安 §90 / 富达 §46:人工柜台业务,只受理有权机关申请),与「申请未确认」是两回事。客户端看到的「冻结份额」只是在途未确认申请推导出的可用份额扣减。故本项目不引入
reserved_qty之类的冻结列,避免与司法冻结术语混淆。
并发安全:
| v0.x(T 日实时扣减,已弃) | 本项目(受理/确认分离) |
|---|---|
| 多笔转换同日争抢同一批次:条件 UPDATE 反复失败 → 退避成功率 80%(紧池病根) | T 日受理不扣批次,只在「客户 + 产品」维度短临界区内校验可用份额并落受理单;T+1 批处理单线程串行确认,同一时刻只有一个确认者改批次 → 无争抢 |
- T 日受理为 Core 单库事务(校验 + 落单),冲突窗口极短;
- T+1 确认的串行性由「批处理单线程 +
convert:confirm:{T}锁」双保险保证(架构定实现)。
2.6.4 T+2 可赎回
- 转入新批次
confirmed_at= T+1(确认日),持有期自该日起算; - 可赎回日 = 确认日之后的第 1 个交易日(F1:「投资者自 T+2 日起有权赎回转入部分的基金份额」);
- 不加列:由
confirmed_at+core_trade_calendar推算;赎回时校验该批次confirmed_at的下一交易日 ≤ 今日。
2.6.5 确认的失败与挂起
| 情形 | 处理 | 依据 |
|---|---|---|
| 缺 T 日净值 | 置 nav_pending,不 reject、不降级取旧净值;净值补齐后继续确认 |
净值 T+1 才公告,未公告即不确认 |
| 转入基金变为不可申购 / 转出变为不可赎回 | rejected + 释放占用 + 原份额不变 |
F5 |
| 投资者可用份额不足(部分成交,D28) | 确认段支持部分成交:实际 = min(申请, 可用) + 未确认部分占用释放;记录 partial 标记(不顺延至后续批处理,属本项目投资者层建模取舍;真实基金层巨额赎回为「部分延期赎回/顺延」,二者不可混淆) |
真实规则:可用份额不足部分成交 |
| 批次在 T+1 已不可扣(理论不应发生) | rejected(防御性)+ 告警 |
数据异常,需人工介入 |
| 超 SLA(默认 2 个交易日,配置项)仍未确认 | 置 expired + 释放占用 + 告警 |
兜底,防在途占用悬空 |
适当性 T+1 复核(D25):确认段复核转入端适当性(含测评版本号),不通过 →
rejected(份额不变)。受理时校验一次留痕,确认时二次复核。
3. 功能需求
| 编号 | 需求 | 优先级 |
|---|---|---|
| FR-C1 | 网关接受 convert,不再 400 |
P0 |
| FR-C2 | 请求以份额为单位,携带转出 / 转入两个产品 | P0 |
| FR-C3 | 折算在 T+1 确认时进行,取受理日 T 的两只产品净值(§2.2) | P0 |
| FR-C4 | 落两条 core_trade,共享 convert_group_id,同事务(确认事务) |
P0 |
| FR-C5 | 适当性:转入端完整校验且为唯一阻断点;T+1 复核(D25) | P0 |
| FR-C6 | 赎回费按持有期分档,逐批次计费 | P0 |
| FR-C7 | FIFO 扣减份额批次 | P1 |
| FR-C8 | 最低持有份额:转出后余额低于下限则强制全转,记 actual_qty |
P1 |
| FR-C9a | 产品可申购 / 可赎回校验 | P1 |
| FR-C10 | 审计:一次转换落 2 条(受理 + 确认),以 convert_group_id 关联 |
P0 |
| FR-C11 | 规则引擎:只跑一次,金额聚合去重但不删行 | P0 |
| FR-C12 | 转入份额持有期重置 | P1 |
| FR-C13 | 转换后同步更新 core_holding 两端持仓(qty / cost_amount / as_of / pnl_pct) |
P0 |
| FR-C14 | 逐批次计费明细落 core_convert_lot_detail |
P0 |
| FR-C15 | core_tools.query_recent_trades 汇总去重 |
P1 |
| FR-C16 | 份额批次全生命周期维护:普通申购落新批次、普通赎回 FIFO 扣批次 | P0 |
| FR-C17 | 幂等:支持 client_request_id,防重复提交产生第二组流水 |
P0 |
| FR-C18 | 批次扣减用条件 UPDATE 乐观并发控制 | P0 |
| FR-C19 | 受理/确认分离:T 日受理只校验 + 落受理单,不扣批次、不折算 | P0 |
| FR-C20 | 申请以份额为单位(requested_qty) |
P0 |
| FR-C21 | 在途占用:可用份额 = 批次余量 − Σ(未终态受理单份额);不新增冻结列 | P0 |
| FR-C22 | 撤单:T 日 15:00 前可撤销,占用释放、状态 cancelled |
P0 |
| FR-C23 | T+1 批量串行确认:批处理任务(可指定业务日)+ 幂等 | P0 |
| FR-C24 | 交易日历:受理日判定 / T+1 / T+2 / 15:00 顺延全部按 core_trade_calendar |
P0 |
| FR-C25 | T+2 可赎回:转入批次自 T+2 起计入可用份额(不加列) | P1 |
| FR-C26 | 确认失败 → rejected:占用释放、原份额不变、审计留痕 |
P0 |
| FR-C27 | 缺 T 日净值 → nav_pending 挂起,补齐后继续确认;不降级取旧净值 |
P0 |
| FR-C28 | 规则引擎移到确认事务提交后跑,仍只跑一次 | P0 |
| FR-C29 | redeem 份额申报(D26):qty 入参,赎回金额 = qty × T日净值 − 赎回费;加 T+2 可赎回校验 |
P0 |
| FR-C30 | 资金流中转(D29):确认事务写 core_cash_flow(注册登记账户中转建模) |
P0 |
| FR-C31 | A/C 份额互转(D30):share_class + allow_ac_convert 开关,同基金 A/C 互转 |
P1 |
4. 数据模型
4.1 新增表
core_fee_rule —— 费率规则
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | |
product_id |
VARCHAR(64) | |
fee_type |
ENUM('subscribe','redeem') | |
min_hold_days / max_hold_days |
INT / INT NULL | 仅 redeem:持有期分档 |
min_amount / max_amount |
DECIMAL(18,2) NULL | 仅 subscribe:金额分档(本期不启用) |
rate |
DECIMAL(6,4) | 费率(redeem:0.0150/0.0100/0.0050/0.0025/0) |
to_fund_ratio |
DECIMAL(5,4) NOT NULL DEFAULT 1.0 | 赎回费计入基金财产比例。22 号文 §10:全额计入基金财产,恒 1.0(旧规 75%/50%/25% 分级已废止)。本期不参与计算,仅留痕 |
core_share_lot —— 份额批次
| 字段 | 类型 | 说明 |
|---|---|---|
lot_id |
VARCHAR(64) PK | |
customer_id / product_id |
VARCHAR(64) | KEY(customer_id, product_id, confirmed_at) |
qty / remain_qty |
DECIMAL(18,4) | 原始 / 剩余 |
nav |
DECIMAL(10,4) | 成交净值 |
confirmed_at |
DATETIME(3) | 持有期起算日 |
source_trade_id |
VARCHAR(64) NULL |
core_convert_lot_detail —— 转换批次明细(Core 库)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | |
convert_group_id |
VARCHAR(64) NOT NULL | KEY |
lot_id |
VARCHAR(64) NOT NULL | |
qty |
DECIMAL(18,4) | 本批转出份额 |
hold_days |
INT | 本批持有天数 |
amount |
DECIMAL(18,2) | 本批金额(已舍入) |
fee_rate / fee_amount |
DECIMAL(6,4) / DECIMAL(18,2) | 本批费率与费用(已舍入) |
nav / nav_date |
DECIMAL(10,4) / DATE | 本批成交净值与净值日期 |
nav/nav_date放 Core 侧使 Core 自包含折算输入 —— agent 附加写入失败后rebuild_alerts才能补出完整详情(§7.1)。
core_convert_request —— 转换受理单(Core 库 · 权威状态机)
CREATE TABLE core_convert_request (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
convert_group_id VARCHAR(64) NOT NULL COMMENT '一次转换的全局 id(受理时预生成)',
client_request_id VARCHAR(64) NULL COMMENT '幂等键(选填)',
customer_id VARCHAR(64) NOT NULL,
out_product_id VARCHAR(64) NOT NULL COMMENT '转出',
in_product_id VARCHAR(64) NOT NULL COMMENT '转入',
requested_qty DECIMAL(18,4) NOT NULL COMMENT '申请转出份额(份额申报)',
status ENUM('accepted','nav_pending','confirmed','rejected','cancelled','expired')
NOT NULL COMMENT 'accepted=已受理待确认;nav_pending=缺T日净值挂起;confirmed=已确认;rejected=确认失败份额不变;cancelled=T日撤单;expired=超SLA兜底',
accept_date DATE NOT NULL COMMENT '受理日 T(经 15:00 截点与日历判定)',
confirm_date DATE NULL COMMENT '确认日 T+1(交易日历推得)',
available_date DATE NULL COMMENT '可赎回日 T+2(交易日历推得)',
accepted_at DATETIME(3) NOT NULL,
confirmed_at DATETIME(3) NULL,
cancelled_at DATETIME(3) NULL,
reject_reason VARCHAR(64) NULL COMMENT '拒绝原因码(§5.6)',
actor_id VARCHAR(64) NULL COMMENT '发起主体(客户本人 / risk_demo)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_group (convert_group_id),
UNIQUE KEY uk_idem (client_request_id),
KEY idx_confirm_queue (status, accept_date) COMMENT 'T+1 批处理捞待确认单',
KEY idx_cust_prod_status (customer_id, out_product_id, status) COMMENT '在途占用计算'
) ENGINE=InnoDB COMMENT='基金转换受理单(Core 侧 · 权威状态机)';
为什么受理单放 Core 库:① 在途占用必须与 core_share_lot 一起算可用份额 —— 同库才能单条 SQL 闭环;② T+1 确认要扣批次 + 写 core_trade —— 同库才能单事务;③ 受理与确认各自都是 Core 单库事务,不再需要跨库三阶段 + 补偿,agent 侧(详情 / 审计 / 预警)降级为附加写入,失败走补偿。
core_trade_calendar —— A 股交易日历(Core 库)
CREATE TABLE core_trade_calendar (
cal_date DATE NOT NULL PRIMARY KEY COMMENT '日历日',
is_open TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=交易日 0=休市',
remark VARCHAR(64) NULL COMMENT '休市事由(周末/元旦/春节/…)'
) ENGINE=InnoDB COMMENT='A股交易日历(依据上交所《休市安排》)';
数据来源(非人造):上交所官网《休市安排》(2026 年 7 个休市区间),由
scripts/core/gen_trade_calendar.py生成种子10-seed-trade-calendar.sql(730 天 / 503 交易日)。⚠️ 2027 年休市安排截至 2026-09-10 尚未公布,2027 年仅按周一至周五生成、不含节假日;公布后须补齐重跑。交叉校验:用 510300 的真实净值发布日(区间内 168 个)反查,0 个落在休市日。
core_cash_flow —— 资金流中转(Core 库,D29)
列:convert_group_id / from_account(转出基金托管户)/ to_account(转入基金托管户)/ amount DECIMAL(18,4) / direction(枚举 OUT_TO_TA/TA_TO_IN/NET,本项目落地为单笔 NET 行 amount=in_amount)/ trade_date。「一次转换 1 笔资金划转」为建模假设(非法规明文),还原中转语义即可。写入方 = confirm_convert(确认段同事务),读取方 = 报表/重建(core_ro)。
状态机的单一权威
| 表 | 角色 | 状态语义 |
|---|---|---|
core_convert_request(Core) |
权威 | 业务状态:accepted / nav_pending / confirmed / rejected / cancelled / expired |
risk_convert_detail(agent) |
从属 | 只表示 agent 侧详情写入进度(pending / completed / failed),不再承载业务状态与幂等 |
risk_convert_detail —— 转换详情(agent 库)
CREATE TABLE risk_convert_detail (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
convert_group_id VARCHAR(64) NOT NULL,
status ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending'
COMMENT '仅表示 agent 侧详情写入进度——pending=待写入;completed=详情与审计已写;failed=写入失败待补偿;cancelled/expired=保留值(免 ALTER,本版不使用)。业务状态一律以 core_convert_request.status 为准',
out_trade_id VARCHAR(64) NULL COMMENT '确认后写入(受理阶段为 NULL)',
in_trade_id VARCHAR(64) NULL COMMENT '确认后写入(受理阶段为 NULL)',
nav DECIMAL(10,4) NULL COMMENT '成交净值',
nav_date DATE NULL,
fee_amount DECIMAL(18,2) NULL COMMENT '本笔费用',
hold_days_min INT NULL COMMENT '跨批次时最短持有天数',
hold_days_max INT NULL COMMENT '跨批次时最长持有天数',
client_request_id VARCHAR(64) NULL COMMENT '仅留痕;幂等锚点在 core_convert_request.uk_idem',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_group (convert_group_id),
KEY idx_out (out_trade_id),
KEY idx_in (in_trade_id)
) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)';
唯一写入方 =
app/repository/convert_repository.py,只写「agent 侧详情写入进度」,业务状态不落此表。任何响应均不返回本表状态。
4.2 修改表
core_trade:加 1 列 convert_group_id VARCHAR(64) NULL + KEY idx_convert_group (convert_group_id)(重试判定 SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1 不能全表扫)。
core_product 新增列(合计 8 列,详见 01-ddl.sql):
can_subscribe/can_redeem(TINYINT DEFAULT 1,永不为 NULL)min_hold_qty/min_redeem_qtysubscribe_fee_rate(申购费率,独立列;22 号文 §8 上限内种子显式给值)fund_company/ta_codeshare_class(A/C 份额标识,D30)allow_ac_convert(TINYINT DEFAULT 0,A/C 互转开关,D30)rounding_mode/share_digits(舍入方式/位数,D27;亦可走core_share_rule表)
core_product.fee_rate 补 COMMENT(不得改名/改值):
ALTER TABLE core_product
MODIFY COLUMN fee_rate DECIMAL(6,4) NULL COMMENT '管理费率(年化),模拟库未参与任何计算;申购费率请用 subscribe_fee_rate,禁止取本列';
该列数值特征为管理费率(货基 0.0010 < 债基 0.0030 < 指数 0.0050 < 主动股混 0.0150),与申购费率是两回事——货基申购费为 0,而种子里货基
fee_rate=0.0010,可反证。不写死定义,后续实现者极易把它当申购费率填进补差费公式(算出来看着合理,语义全错)。
core_holding:不新增列,转换后按 §4.4 更新。
4.3 种子数据
06-seed-nav.sql(已有):真实净值(联网下载,60 个净值发布日)07-seed-fee-rule.sql:赎回费持有期分档(0.0150 / 0.0100 / 0.0050 / 0.0025 / 0,to_fund_ratio=1.0)08-seed-share-lot.sql:按持仓反推批次,按客户/产品错开持有期以覆盖多档费率09-seed-org.sql:补齐fund_company/ta_code(全部非 NULL,跨机构校验可测)+subscribe_fee_rate(22 号文 §8 上限内)10-seed-trade-calendar.sql:交易日历11-seed-share-rule.sql(新增,D27):真实产品舍入/位数规则,逐产品列(share_digits, rounding_mode)组合
申购费率种子(22 号文 §8 上限内,分类必须与 product_type 匹配):
| 产品 | 类型 | subscribe_fee_rate |
依据 |
|---|---|---|---|
PROD-110022(示例转出) |
bond | 0.0030 | 债券型 ≤0.3% |
PROD-005827 |
mixed(其他混合型) | 0.0050 | 其他混合型 ≤0.5% |
PROD-003095(示例转入) |
stock(主动偏股型) | 0.0080 | 主动偏股型 ≤0.8% |
PROD-000001(同费率对照) |
money | 0.0000 | 货基 0 |
PROD-510300(同费率对照) |
index | 0.0030 | 指数型 ≤0.3% |
⚠️ 费率档由产品类型决定,不得为凑数值差把
mixed产品按「主动偏股」取 0.8%(曾犯的分类错误,自检第 12 问)。 示例转出PROD-110022与转入PROD-003095同属华夏模拟基金 / TA-CN-001(同管理人 + 同 TA,转换前置硬约束)。§5.3 主示例即取这两只产品(补差 0.50%)。
4.4 份额批次生命周期
批次表由交易统一出入口维护,覆盖全部交易类型:
| 交易 | 批次动作 |
|---|---|
subscribe |
新增批次(qty = 确认份额,confirmed_at = 交易日 T) |
| 转换转入 | 新增批次(qty = 确认份额,confirmed_at = 确认日 T+1) |
redeem / 转换转出 |
FIFO 扣减 remain_qty(按 confirmed_at 升序),扣完置 0 |
| 转换 | 转出端 FIFO 扣 + 转入端新增 |
转换转入新批次
confirmed_at = T+1:持有期自确认日起算(比自 T 起算少 1 天、费率档更严,更贴近真实)。普通申购新批次维持 T(不动现有持仓与既有用例)。
计费口径差异(须知):本期普通赎回只维护批次、不驱动计费(沿用现有行为:amount 由请求传入,不查费率表、不写费用);转换转出才计赎回费。同一批份额普通赎回不计费、转换转出计费,是模拟简化(现有系统普通赎回本就不计费),真实业务两端都应计费。统一计费列二期,代码注释须写明此差异。
历史数据兜底:scripts/core/rebuild_lots.py 按 core_holding 重建批次(qty = 持仓份额,confirmed_at 按错开规则生成)。语义澄清:是「批次快照重建,不是交易回滚」——只重算 core_share_lot 使其与 core_holding 一致,不撤销任何已落库交易流水。
并发控制(FR-C18):批次扣减用条件 UPDATE,禁止先 SELECT 后 UPDATE:
UPDATE core_share_lot SET remain_qty = remain_qty - :q
WHERE lot_id = :lot AND remain_qty >= :q
检查 rowcount == 1,否则视为并发冲突,返回 409 LOT_CONFLICT 并重试。
5. 接口协议
5.1 请求
POST /api/simulate/trade
{
"customer_id": "CUST-9527",
"trade_type": "convert",
"from_product_id": "PROD-110022",
"to_product_id": "PROD-003095",
"qty": 50000.00, // 申请转出份额(份额申报)
"client_request_id": "CLI-20260909-0001" // 幂等键,选填
}
| 交易类型 | 入参 | 说明 |
|---|---|---|
| convert | qty(份额) |
忽略 amount |
| subscribe | amount(金额) |
金额申购(行业铁律) |
| redeem | qty(份额,D26) |
份额申报;赎回金额 = qty × T日净值 − 赎回费 |
受理响应语义:convert 受理成功返回 HTTP 202 Accepted,响应体为受理回执(§5.3.1),不含任何金额 / 净值 / 折算字段(T 日净值尚未公告)。折算结果在 T+1 确认后通过查询接口获取(§5.4)。
5.2 网关改造清单
| 文件 | 改造 |
|---|---|
trade_gateway.py:112 |
移除 convert 拒绝(400) |
trade_gateway.py:119 |
convert 分支只吃 qty,不再 amount ÷ 净值 反算份额 |
trade_gateway.py convert 分支 |
改为「校验 → 受理(落受理单,202)」,折算后移到 T+1 确认 |
trade_gateway.py subscribe/redeem 分支 |
维护批次(FR-C16);redeem 改份额申报 + T+2 可赎回校验(FR-C25) |
api/simulate.py |
convert 校验 from/to_product_id,响应状态改 202 |
| 新增 | 撤单接口(§5.5)、确认触发接口(§5.7)、受理单查询(§5.4) |
5.3 响应
5.3.1 受理回执(HTTP 202 Accepted)
{
"convert_group_id": "CNV-20260909-A1B2C3D4",
"client_request_id": "CLI-20260909-0001",
"status": "accepted",
"requested_qty": "50000.00",
"out_product_id": "PROD-110022",
"in_product_id": "PROD-003095",
"accept_date": "2026-09-09",
"confirm_date": "2026-09-10",
"available_date": "2026-09-11",
"cancel_deadline":"2026-09-09 15:00:00",
"accepted_at": "2026-09-09 10:12:33.456"
}
| 字段 | 说明 | 位数 |
|---|---|---|
status |
恒为 accepted(幂等命中已确认单时返回其当前状态) |
— |
requested_qty |
申请转出份额(客户申报,原样回显) | 2 位 |
accept_date / confirm_date / available_date |
T / T+1 / T+2,全部按交易日历推算 | — |
cancel_deadline |
撤单截止时刻(T 日 convert_cutoff_time,默认 15:00) |
— |
幂等:同
client_request_id重复提交 → 返回首次受理回执,不产生第二张受理单;锚点在core_convert_request.uk_idem。
5.3.2 确认结果(T+1 后查询 · 脚本实算)
示例自证:每个派生值都由本块数据算出(口径 B · 生产函数
calc.py实算,禁止手算)。输入取06-seed-nav.sql真实净值在 T = 2026-09-09 的两条记录:转出PROD-110022= 1.3604、转入PROD-003095= 1.9194。
| 步 | 计算 | 结果 |
|---|---|---|
| 批次 1 | 30000 份 × 1.3604 | amount 40812.00;赎回费 40812.00 × 0.5% = 204.06 |
| 批次 2 | 20000 份 × 1.3604 | amount 27208.00;赎回费 27208.00 × 1.5% = 408.12 |
| 转出金额 | 40812.00 + 27208.00 | 68020.00 |
| 赎回费 | 204.06 + 408.12(逐批舍入后求和) | 612.18 |
| 转换金额 | 68020.00 − 612.18 | 67407.82 |
| 转入端申购费 | 67407.82 × 0.80% ÷ 1.008 | q2 534.98 |
| 转出端申购费 | 67407.82 × 0.30% ÷ 1.003 | q2 201.62 |
diff_fee(B) |
Max(534.98 − 201.62, 0) | 333.36 |
in_amount |
67407.82 − 333.36 | 67074.46 |
in_qty |
67074.46 ÷ 1.9194 = 34945.5351 | q2 34945.54(2 位四舍五入) |
rounding_diff |
理论 34945.5351 − 实际 34945.54 | -0.0049(负 = 客户多得) |
两端申购费率:转出
PROD-110022(bond)0.30%、转入PROD-003095(stock,主动偏股型)0.80%(种子显式给定,22 号文 §8 上限内)。 口径 A 对照:同输入下 A = 335.36、in_qty = 34944.49(与 B 差约 0.6%),故必须显式选口径(§2.1.1)。 同费率对照:两端均为 0.30% 时补差 = 0(diff_fee = "0.00"),由 §9 用例单独覆盖。⚠️ 申购费率不得取
core_product.fee_rate(管理费率,§4.2)。 ⚠️ 示例与种子强绑定:净值种子刷新(重跑fetch_nav.py)后本表须重新实算,同步scripts/dev/calc_convert_demo.py与tests/test_convert_integration.py:278(硬断言本节数字)。
确认结果 JSON:
{
"convert_group_id": "CNV-20260909-A1B2C3D4",
"client_request_id": "CLI-20260909-0001",
"status": "confirmed",
"blocked": false,
"requested_qty": "50000.00",
"actual_qty": "50000.00",
"forced_full_transfer": false,
"accept_date": "2026-09-09",
"confirm_date": "2026-09-10",
"available_date": "2026-09-11",
"out_trade_id": "TRD-20260910-XXXXXXXX",
"out_nav": "1.3604",
"nav_date": "2026-09-09",
"out_amount": "68020.00",
"lot_count": 2,
"lot_breakdown": [
{"qty": "30000.00", "hold_days": 100, "fee_rate": "0.0050", "fee_amount": "204.06"},
{"qty": "20000.00", "hold_days": 3, "fee_rate": "0.0150", "fee_amount": "408.12"}
],
"redeem_fee": "612.18",
"in_trade_id": "TRD-20260910-YYYYYYYY",
"in_nav": "1.9194",
"convert_amount": "67407.82",
"diff_fee": "333.36",
"in_amount": "67074.46",
"in_qty": "34945.54",
"rounding_diff": "-0.0049",
"out_subscribe_fee_rate": "0.0030",
"in_subscribe_fee_rate": "0.0080",
"triggered_rules": [],
"alert_ids": [],
"aml_hit": false
}
字段语义去重(三处金额易混):
out_amount= 原始转出额(份额 × 转出净值,未扣任何费)= 68020.00convert_amount= 扣赎回费后可用于转入的金额 = out_amount − redeem_fee = 67407.82in_amount= 扣补差费后实际买入金额 = convert_amount − diff_fee = 67074.46
in_amount ÷ in_nav 才是 in_qty;三者不可混用。
字段类型约定:所有金额、费率、份额字段一律 JSON 字符串(对应后端
Decimal,Pydantic v2 默认字符串化)。字符串的位数不自由:按 §2.5.2 展示位数表分类。同一响应的两条产出路径(公式算 / 库DECIMAL(18,4)重建)必须逐字节一致;实现集中在convert_service._q。
5.3.3 状态枚举对外口径
对外 status |
含义 | 客户可见话术 |
|---|---|---|
accepted |
已受理,待确认 | 「您的转换申请已受理,将于 T+1 确认」 |
nav_pending |
待净值公告 | 「等待基金净值公告,公告后自动确认」 |
confirmed |
已确认 | 「转换成功,T+2 起可查询/赎回」 |
rejected |
确认失败 | 「转换未成功,原基金份额不变」+ reject_reason |
cancelled |
已撤单 | 「申请已撤销,份额已释放」 |
expired |
超期未确认 | 「申请已失效,份额已释放,请重新提交」 |
5.4 错误码
| 场景 | HTTP | error_code | 说明 |
|---|---|---|---|
| 转出不可赎回 | 400 | PRODUCT_NOT_REDEEMABLE |
|
| 转入不可申购 | 400 | PRODUCT_NOT_SUBSCRIBABLE |
|
| 可用份额不足(含在途占用) | 400 | INSUFFICIENT_SHARES |
判据 = 可用份额(§2.6.3),非批次余量 |
| 低于最低转出份额 | 400 | BELOW_MIN_QTY |
全额豁免除外(§12) |
| 同一产品自转换 | 400 | SAME_PRODUCT |
|
| 非同一管理人 / 同 TA | 400 | CROSS_ENTITY_NOT_SUPPORTED |
含跨 share_class 同基金判定(D30) |
超过 convert_batch_max_lots(默认 200 批次) |
400 | TOO_MANY_LOTS |
400 为 HTTP 状态码,阈值=200 |
| 非受理时间(无可用交易日,防御) | 400 | NO_TRADING_DAY |
|
| 撤单超窗口 / 已确认不可撤 | 409 | CANCEL_NOT_ALLOWED |
|
| 重复提交(幂等命中 / 在飞) | 202 | 返回首次受理回执或 {convert_group_id, status:"processing"},不重复落单 |
|
| 适当性不匹配(转入端) | 200 + blocked | 沿用 R-02 | 受理时校验;T+1 复核不通过 → rejected |
| 确认时批次冲突(防御性) | 409 | LOT_CONFLICT |
确认阶段条件 UPDATE 哨兵 |
5.5 撤单接口(FR-C22)
POST /api/simulate/trade/convert/{convert_group_id}/cancel
| 条件 | 结果 |
|---|---|
当前时间在 T 日 15:00 前 且状态为 accepted / nav_pending |
200,置 cancelled,释放占用 |
| 已过 T 日 15:00 / 已终态 | 409 CANCEL_NOT_ALLOWED |
- 撤单为 Core 单库事务:状态置终态 + 占用自然释放(占用由未终态受理单推导,无需额外扣减动作)。
- 撤单落审计(
decision='convert_cancelled'),审计表只 INSERT。
5.6 拒绝原因码(reject_reason)
reject_reason |
触发条件 |
|---|---|
IN_PRODUCT_NOT_SUBSCRIBABLE |
T+1 确认时转入基金已不可申购 |
OUT_PRODUCT_NOT_REDEEMABLE |
T+1 确认时转出基金已不可赎回 |
SUITABILITY_FAILED |
T+1 适当性复核不通过(D25) |
LOT_UNAVAILABLE |
批次在 T+1 已不可扣(理论不应发生,防御 + 告警) |
NAV_TIMEOUT |
超 SLA 仍无 T 日净值 → 转 expired(占用释放) |
共同语义:rejected / expired 一律释放占用、原份额不变(F5)。
5.7 确认触发(FR-C23 · 运维/批处理)
POST /api/admin/convert/confirm?accept_date=2026-09-09 # 按受理日批量确认
GET /api/simulate/trade/convert/{convert_group_id} # 查询受理单与确认结果
| 项 | 口径 |
|---|---|
| 触发方式 | 批处理任务,可指定业务日(演示/验证无需真等一天);串行执行 |
| 幂等 | 已 confirmed 的受理单重复触发 → 跳过,不产生第二组流水 |
| 锁 | convert:confirm:{accept_date},防并发批处理双跑 |
| 缺 T 日净值 | 置 nav_pending,本轮跳过,不 reject、不降级 |
| 结果 | 每条确认 = 一个 Core 单库事务(扣批次 + 写两条流水 + 更新两端持仓 + 置 confirmed) |
6. 规则引擎口径
6.1 两条流水都进事件线
转出 redeem、转入 subscribe 均为标准类型 → core_ro.py 写死的 trade_type IN ('subscribe','redeem') 无需改动。
6.2 引擎只跑一次
一次转换只调用一次 process_convert_event(),传入两条流水,在引擎内部用两条视角跑规则,出一条预警事件。不会出现同一规则重复命中(如 RISK-001 被转出 / 转入各命中一次),符合「一次投资动作」语义。
6.3 去重只在金额聚合处,绝不删行
run_rules() 收到全量两条;_amount_view() 只给金额聚合类规则用。
| 规则 | 视图 | 理由 |
|---|---|---|
| RISK-001 单笔大额 | 全量 | 逐笔判定 |
| RISK-002 当日累计 | _amount_view |
防翻倍 |
| RISK-003 频繁交易 | 全量 | 分产品各归各 |
| RISK-004 试探模式 | 全量 | 逐笔判定笔数 |
| RISK-005 先小后大 | _amount_view |
防重复计入铺垫 |
| RISK-006 集中度 | 不变 | 读持仓快照 |
6.4 对话侧汇总去重(FR-C15)
core_tools 读到两条是对的(真实业务两笔权益变动),但汇总金额必须用 _amount_view。对话侧对转换的任何状态展示 / 去重判断,一律以 core_convert_request.status 为权威,禁止用 risk_convert_detail.status 判断业务状态。
7. 事务、合规与审计
7.0 完整执行顺序
7.0.1 T 日 · 受理段(同步,HTTP 请求内完成)
| 步 | 内容 | 失败/阻断时 |
|---|---|---|
| 1 | 参数与产品校验(同产品、可申购/可赎回、同管理人/TA、share_class 同基金判定 D30) | 4xx,不落单 |
| 2 | 受理日判定:15:00 截点 + 交易日历 → 得出 T(§2.6.1) | 无可用交易日 → 400 NO_TRADING_DAY |
| 3 | 可用份额校验:Σ remain_qty − Σ(未终态受理单)(§2.6.3)+ 最低份额(含全额豁免) |
400 INSUFFICIENT_SHARES / BELOW_MIN_QTY,不落单 |
| 4 | 适当性校验(转入端) | blocked → 出 R-02 预警 + 审计,return,不落单 |
| 5 | 受理事务(Core 单库):INSERT core_convert_request(accepted,含 accept/confirm/available_date) |
撞 uk_idem → 返回首次受理回执(幂等);其他失败 → 整体回滚 |
| 6 | 返回 202 + 受理回执(§5.3.1) | — |
关键:1~4 步纯计算/校验,不产生任何持久化;受理段不取净值、不折算、不扣批次。
7.0.2 T+1 · 确认段(批处理,逐单串行)
| 步 | 内容 | 失败/阻断时 |
|---|---|---|
| 1 | 捞 accept_date = T 且 status ∈ (accepted, nav_pending) 的受理单(按受理先后排序) |
— |
| 2 | 取两端 T 日净值 | 缺失 → 置 nav_pending,本轮跳过(不 reject、不降级) |
| 3 | 复核产品可申赎状态 + 适当性 T+1 复核(D25) | 不满足 → rejected + reject_reason,占用释放、份额不变 |
| 4 | plan_lots(FIFO + 最低持有处置)+ 折算(calc.py,口径 B) |
超批次上限 → rejected(TOO_MANY_LOTS) |
| 4b | 部分成交(D28):若申请份额 > 可用份额,实际 = min(申请, 可用),未确认部分占用释放,记录 partial 标记(此为受理段③之外的二次兜底,覆盖 T 日~T+1 间其它在途单抢占致可用份额变少的场景) |
— |
| 5 | 确认事务(Core 单库):扣批次(条件 UPDATE 哨兵)+ 写两条流水 + core_convert_lot_detail + 更新两端 core_holding + 写 core_cash_flow(中转,D29)+ 置 confirmed |
整体回滚 → 保持 accepted,下轮重试(幂等) |
| 6 | 确认后同步跑规则引擎(一次) | 引擎异常落 engine_error + 本地日志,不阻断 |
| 7 | agent 侧写详情 + 主审计(附加写入) | 失败不回滚 Core,落待补偿(SLA 24h) |
为什么引擎在确认后跑:受理时无金额(净值未公告),金额类规则无输入可算。确认后跑,读到已更新持仓,集中度规则(RISK-006)连带效应仍被覆盖。引擎只调用一次,传入两条流水。
7.0.3 撤单段(T 日 15:00 前,同步)
| 步 | 内容 |
|---|---|
| 1 | 校验受理单存在 + 状态为 accepted / nav_pending |
| 2 | 校验当前时间 ≤ T 日 convert_cutoff_time(15:00) |
| 3 | Core 单库事务:置 cancelled + 写 cancelled_at;占用自然释放 |
| 4 | 落审计 decision='convert_cancelled' |
7.1 事务边界
两个 Core 单库事务 + agent 附加写入(双库无法单事务:core_trade/core_share_lot/core_holding 在 jinrong_core,risk_convert_detail/audit_log 在 jinrong_agent)。
| 段 | 库 | 事务内容 | 失败处理 |
|---|---|---|---|
| T 日受理 | jinrong_core(单库事务) | INSERT core_convert_request(accepted);幂等锚点 uk_idem 同库生效 |
整体回滚;撞幂等键 → 返回首次回执 |
| T+1 确认 | jinrong_core(单库事务) | 扣 core_share_lot(条件 UPDATE 哨兵)+ 新增转入批次 + 写两条 core_trade + core_convert_lot_detail + core_cash_flow + 更新两端 core_holding + 置 confirmed |
整体回滚 → 保持 accepted,下轮批处理自动重试(天然幂等) |
| agent 附加 | jinrong_agent | 写 risk_convert_detail 详情 + 主审计(§7.3) |
失败不回滚 Core,落待补偿(rebuild_alerts.py,SLA 24h) |
补偿:确认段整体是 Core 单库事务,失败即回滚、受理单仍为 accepted → 下轮批处理自动重试即可,无需人工补偿。只有 agent 侧详情 / 审计写入失败才需要 rebuild_alerts.py 补写(Core 侧含 core_convert_lot_detail.nav/nav_date 可自包含重建)。
本地日志兜底:agent 附加写入失败时,"落失败审计"自身也可能失败。必须 logger.exception() 输出 convert_group_id + 全部折算参数到本地日志,保证即使审计写不进去仍有可人工恢复的记录。
| 补偿项 | 口径 |
|---|---|
| 触发者 | risk_convert_detail.status='failed' 的巡检/告警 + 人工判断 |
| 补偿工具 | scripts/demo/rebuild_alerts.py —— 按 convert_group_id 从 Core 侧重建详情与预警 |
| SLA | 失败记录须在 24h 内完成补偿或人工确认;cleanup_pending_convert.py 将超 SLA 的异常单置 expired(标记不硬删,留痕供对账) |
7.2 适当性(FR-C5)
北京金融法院判例「转换即销售」:对转入基金必须重新履行适当性义务,不得沿用历史测评。转入端跑完整 suitability_check,forbidden / risk_expired 即阻断。只有转入端能阻断。T+1 确认段再复核一次(D25),审计留存测评版本号与拒绝原因。
7.3 审计
一次转换落 2 条审计(受理一条、确认一条),以 convert_group_id 关联。审计表只 INSERT。
| 时点 | decision |
input_summary 内容 |
|---|---|---|
| T 日受理 | convert_accepted |
两端 product_id、requested_qty、T/T+1/T+2 三个日期、convert_group_id |
| T+1 确认 | convert_confirmed |
上述 + actual_qty、四段金额、两端净值、nav_date、lot_count、out/in_trade_id |
| T 日撤单 | convert_cancelled |
受理摘要 + cancelled_at |
| 确认拒绝 | convert_rejected |
受理摘要 + reject_reason |
关联主键:
input_summary以convert_group_id为主键关联(一次转换 = 一组审计),并附out_trade_id/in_trade_id作明细指针;避免「按单条trade_id关联 → 一次转换被审计成两条」。
受理失败处理:
| 情形 | 处理 |
|---|---|
| 校验类 4xx | 不落受理单、不落审计 |
| 适当性 blocked | 落 R-02 预警 + 审计,不落受理单 |
| 受理事务本身失败(DB 异常) | 不落审计(事务回滚),返回 5xx,客户端可重试 |
未带 client_request_id |
无幂等语义,按现有逻辑执行 |
7.4 幂等与执行权(FR-C17)
幂等锚点前移到 Core 受理单(与流水同库):uk_idem 与流水同库,从结构上杜绝「幂等查不到 → 重跑产生第二组流水」。
| 阶段 | 库 | 动作 |
|---|---|---|
| T 日受理 | core | 预生成 convert_group_id;INSERT core_convert_request(含 client_request_id) |
| T+1 确认 | core | 同一 convert_group_id 下写两条流水;重复触发时先查是否已有流水,有则跳过 |
同键并发的两个竞态子窗口:
- 子窗口 A:T2 落在 T1「受理单已落、尚未确认」→ 判为在飞,返回 202,不重跑;
- 子窗口 B:T2 与 T1 同时 INSERT 受理单 → 撞
uk_idem→ 让路,返回 202(不是 5xx)。 - 判据:状态为
accepted/nav_pending一律视为在飞,绝不重跑(T-13 双扣事故的根因)。
执行权:复用 app/service/risk/locks.py 双层锁(Redis SET NX EX 为主,进程内线程锁为备):
run_locked(f"convert:idem:{client_request_id}", lambda acquired: ...)
| 抢锁结果 | 处理 |
|---|---|
抢到(acquired=True) |
查受理单,按下表判定 |
| 未抢到 | 有并发请求正在受理 → 直接返回 202 + 已有 convert_group_id;不查受理单、不落单 |
uk_idem 保留为兜底:即使锁退化,受理单 INSERT 撞唯一键的一方必须让路返回 202,不得重跑。
重试判定逻辑(T 日受理 · 仅在取得执行权后):
| 查受理单结果 | 处理 |
|---|---|
| 无受理单 | 全新请求 → 走受理流程 |
状态 ∈ accepted / nav_pending |
在飞 → 返回该单回执(202),不重跑 |
状态 ∈ confirmed / rejected / cancelled / expired |
终态 → 返回该单当前状态与结果(幂等命中,不落新单) |
确认段幂等(T+1):先查该 convert_group_id 是否已有流水,有则跳过。
SELECT 1 FROM core_trade WHERE convert_group_id = :gid LIMIT 1;
- 所在库:jinrong_core(与受理单同库);索引:
core_trade.convert_group_id必须建 KEY(§4.2),否则全表扫;批处理为串行,判据是convert_group_id,杜绝第二组流水。
受理单生命周期(谁清理):
| 场景 | 处置 |
|---|---|
| T+1 确认成功 | confirmed |
| 确认业务拒绝 | rejected + reject_reason(占用释放、份额不变) |
| T 日 15:00 前撤单 | cancelled |
| 缺 T 日净值 | nav_pending(等净值,不清理) |
超 SLA(convert_confirm_sla_days,默认 2 个交易日)仍非终态 |
expired(标记不硬删,留痕供对账);由 scripts/agent/cleanup_pending_convert.py 扫描 core_convert_request 执行 |
⚠️ 占用与终态强绑定:在途占用 = Σ(未终态受理单的份额)。任何非终态单长期滞留都会持续占用客户份额,故
expired清理是功能正确性要求,不是可选的运维优化。
8. 风险与取舍
| 风险 | 说明 | 应对 |
|---|---|---|
| DDL 变更 | 新增 5 表 + 改 2 表 | reset.ps1 重灌 |
| 批次表改造面扩大 | 普通申赎也要维护批次 | FR-C16 + rebuild_lots.py 兜底 |
| 在途占用滞留 | 非终态受理单持续占用客户份额 | expired 清理(SLA 2 个交易日)+ 告警;功能正确性要求 |
| 批处理可靠性 | T+1 批处理未跑/中断 → 受理单停在 accepted |
监控告警 + 手动触发(§5.7)+ 幂等(重复触发安全) |
| 净值依赖外部数据 | 真实净值只到抓取当日,之后 T 日净值缺失 → 全挂 nav_pending |
重跑 fetch_nav.py 增量补拉;不降级取旧净值 |
| 交易日历跨年 | 2027 年休市安排未公布 | 公布后补齐重跑生成脚本(§4.1 已标注) |
| 撤单时点争议 | 15:00 截点以服务器时间为准 | 配置项 convert_cutoff_time;一期不做时钟同步 |
| redeem 改份额申报打穿现有用例 | D26 全改 redeem 接口/网关/折算 | 现有申赎用例重跑基线 |
| 部分确认语义复杂 | D28 投资者层部分成交 + 占用释放 | 单测覆盖 + 显式标注与基金层巨额赎回顺延的区别 |
9. 验收标准
pytest全绿(基线 739 + 新增 convert 用例)- 一次转换在 T+1 确认事务内落两条流水,同
convert_group_id,同事务(确认失败则都不落) - 折算金额与 §2.1 逐项吻合(以 T 日真实净值折算,§5.3.2 示例)
- 转入端适当性不匹配 → 受理时 blocked,受理单都不落;T+1 复核不通过 →
rejected(D25) - RISK-002 对转换只计一次,不翻倍
- RISK-001 / RISK-003 仍能分别看到两条(验证去重未删行)
- 一次转换只产生一条预警事件(验证引擎只跑一次)
- 跨批次转换:各批按各自持有期计费,明细落库
- 普通赎回后再 convert,批次与持仓一致,不超扣
- 转换后
core_holding两端已更新 core_tools汇总不翻倍- 同一
client_request_id重复提交不产生第二组流水 - 强制全转时
actual_qty != requested_qty且响应与审计均记录 CROSS_ENTITY_NOT_SUPPORTED有用例覆盖- 幂等窗口闭合:确认事务失败(受理单仍为
accepted)后重跑确认 / 带同一键重试 → 均不产生第二组流水,且 RISK-002 当日累计不翻倍 - 全额转出豁免:持有 6000、申请全转 6000、
min_redeem=10000 → 受理成功,不得返回BELOW_MIN_QTY - agent 附加写入失败可补偿:
rebuild_alerts.py能仅凭 Core 侧数据(含core_convert_lot_detail.nav/nav_date)补出完整详情 - 性能(第 6 步实测补录,不得改数据迁就指标):一次转换端到端响应 < 2s;确认事务 / 受理事务均预估 < 100ms,实测补录真实耗时,超阈值需优化索引/锁策略后再定阈值
✅ 实测补录(T-13 · 2026-09-12,n=59 剔除冷启动首样本;本机 MySQL 8.0.46 / 模拟库 / 单线程顺序 / 每笔 1 份 / 空引擎 hook,载体
test_performance_probe_accept_confirm_end_to_end):受理段 P50 37.9 / P95 53.8 / max 57.9 ms;确认事务(apply_convert)P50 10.0 / P95 25.3 / max 25.5 ms;确认全程(折算+复核+扣减+流水+镜像+审计)P50 41.4 / P95 61.3 / max 74.4 ms;端到端(受理+确认两段)P50 79.2 / P95 110.3 / max 114.7 / mean 83.0 ms —— < 2s 硬门禁达成(余量约 17 倍)。受理/确认事务均 < 100ms 预估成立,无需优化索引/锁策略。 - 补差费非零场景有覆盖:存在两端
subscribe_fee_rate不同的产品对(PROD-1100220.30% /PROD-0030950.80%,同管理人 + 同一 TA),验证diff_fee > 0时 §2.1 公式成立;另有一条同费率对照(diff_fee = "0.00") - 示例与种子强绑定:净值种子刷新后 §5.3.2 示例数字须重新实算,且
calc_convert_demo.py与test_convert_integration.py:278同步更新,三者不一致即失败 - T 日受理不扣份额:受理成功后
core_share_lot.remain_qty不变、无core_trade流水、状态accepted、HTTP 202 - 在途占用:受理 50000 份后可用份额 = 余量 − 50000;再申请超出 → 400
INSUFFICIENT_SHARES;撤单后恢复 - 撤单:T 日 15:00 前撤单成功(
cancelled+ 占用释放);15:00 后或已确认 → 409CANCEL_NOT_ALLOWED - T+1 确认:批处理按受理日串行确认——扣批次 + 两条流水 + 两端持仓更新 +
confirmed;重复触发不产生第二组流水 - 缺 T 日净值:确认时无 T 日净值 →
nav_pending、不 reject、不取旧净值;补齐后重跑 →confirmed - 确认失败:转入基金不可申购 →
rejected+ 占用释放、原份额不变 + 审计留痕 - T+2 可赎回:转入批次在 T+2 之前不计入可用份额,T+2 起计入
- 交易日判定:非交易日 / 15:00 后提交 → 受理日顺延至下一交易日
- 引擎时序:确认后跑引擎且只跑一次;RISK-002 不翻倍
- 部分成交(D28):T 日~T+1 间可用份额被其它在途单抢占 → 确认时实际 = min(申请, 可用),未确认部分占用释放,记
partial - ⭐ 紧池成功率(本次改造核心收益):需求份额 = 供给份额的紧池场景下受理成功率目标 100%(v0.x 实时扣减实测 80%;本模型为设计目标,待第 6 步重测);不超卖硬不变量继续成立
✅ 实测补录(T-13 · 2026-09-12,载体
test_tight_pool_accept_all_then_confirm_100pct,CONVERT_STRESS=1 真 MySQL):50 并发受理各 1000、池恰 50000(需求=供给)→ 受理 50/50(100%)(R-3 占用校验下全 accepted 是确定性结果)→confirm_batch串行确认 50/50(100%),扣减恰 = 池、Σ remain 守恒、每组恰 2 条流水。v1.0 同场景实测 80%(40/50,实时扣减批次碎片争抢)→ T+1 100%,设计目标达成;病根(确认前就扣份额)由「受理不扣份额 + 确认串行(FR-C23)」结构性消除。另:并发受理允许 R-3 占用读数滞后超授(TOCTOU),兜底由确认段 FIFO 哨兵保证不超卖(test_concurrent_confirms_never_oversell:扣减 ≤ 池 + 守恒 + 让路单受理单保持 accepted)。
10. 待确认项(已拍板)
拍板状态(2026-09-10 ~ 09-11):Q1/Q2/Q11(T+1 模型、受理响应不返回折算金额、按交易日历真模拟)、Q13(适当性 T+1 复核)、Q15(redeem 份额申报)均经用户拍板「按真实业务走」;Q8/Q9/Q10/Q12/Q14 已定稿。
| # | 问题 | 定稿结论 |
|---|---|---|
| Q1 | 是否模拟 T+1 权益登记延迟? | 真模拟:T 日受理不扣份额,T+1 批量确认才做权益变更,T+2 可用(§2.6) |
| Q2 | 响应返回折算金额? | 受理响应不返回(T 日净值未公告);确认后由查询接口返回(§5.3) |
| Q3 | 批次 confirmed_at 如何反推? |
按客户/产品错开持有期 |
| Q4 | 跨机构校验? | 保留:补 09-seed-org.sql 种子,错误码可测 |
| Q5 | 巨额赎回比例确认? | 基金层不做(无现金头寸);投资者层部分成交(D28,§2.6.5) |
| Q6 | 关联字段放哪 | Core 加 convert_group_id,lot 明细及其净值落 Core,其余落 agent 库 |
| Q7 | 幂等键是否强制? | 选填,未带则按现有逻辑 |
| Q8 | 新增列 subscribe_fee_rate 与 fee_rate 关系 |
新增独立列,种子显式给值;fee_rate 保持原义并补 COMMENT = 管理费率 |
| Q9 | 补差费口径选 A 还是 B? | B(价外法两端差)为默认;A 以 convert_diff_fee_mode='rate_diff' 配置保留 |
| Q10 | 份额精度保留几位? | 主流 2 位四舍五入;按产品 core_share_rule 配置覆盖(D27,§2.5.3) |
| Q11 | T+1 确认时序怎么建模? | 按交易日历真模拟:T+1 = 受理日后第 1 个交易日(core_trade_calendar) |
| Q12 | 最低持有余额触发后怎么处置? | 两种都做:min_hold_action ∈ {force_transfer, force_redeem} |
| Q13 | 适当性是否 T+1 复核? | 复核(确认段第 2 步,D25)—— 用户拍板推翻「不复核」默认 |
| Q14 | 受理单超 SLA 多久置 expired? |
2 个交易日(配置项 convert_confirm_sla_days) |
| Q15 | redeem 是否改份额申报? |
本期改为份额申报(D26)—— 用户拍板推翻「本版不做」默认 |
11. 并发控制(FR-C18)
| 场景 | 机制 |
|---|---|
| T 日受理:可用份额校验 + 落单 | 「客户 + 产品」维度短临界区(run_locked(f"convert:avail:{customer_id}:{out_product_id}"))+ 单库事务 |
| T+1 确认:批次扣减 | 条件 UPDATE WHERE remain_qty >= :q,rowcount != 1 判冲突;批处理串行,此处冲突即数据异常信号,需告警 |
| 批处理串行 | run_locked(f"convert:confirm:{accept_date}"),防两个批处理同时跑同一受理日 |
| 重复提交 | core_convert_request.uk_idem(与流水同库) |
| 幂等执行权 | run_locked(f"convert:idem:{client_request_id}", ...),未抢到返回 202(§7.4) |
| agent 详情补写 | 必须 run_locked(f"convert:rerun:{convert_group_id}", ...);并发补跑会审计双写(审计表只 INSERT 无法去重) |
| 锁实现 | 复用 locks.py 双层锁(Redis 主 + 进程内备) |
锁 TTL 风险:
locks.py的 Redis 锁为SET NX EX,30 秒自动过期。受理段临界区不含折算/批次扣减,比旧模型更短;确认段批处理整体可能超过 30s → 锁提前释放会让第二个批处理进入。缓解:① 每条确认前先查该convert_group_id是否已有流水,有则跳过(幂等);② 抢到锁后重新查受理单状态(锁 + 状态二次校验双保险);③ 批处理默认单线程串行。⚠️ 可用份额校验禁止「先 SELECT 后 UPDATE」:校验与落单必须在同一临界区 + 同一事务内完成,否则并发两笔会同时通过校验 → 超额占用。
12. 边界口径
| 项 | 口径 |
|---|---|
hold_days 定义 |
= (赎回确认日 T+1 − 被赎批次 confirmed_at).days(不含申请日);第一参必须传本次转换受理单的确认日 T+1,严禁传受理日 T,亦严禁传被赎批次的申购 confirmed_at——否则边界少算 1 天(如真实满 7 日算成 6 日 → 误收 1.5%);分档左闭右开 [min, max);该公式仅适用于转换转入新批次,存量批次 / 普通申购以交易日 T 为准 |
| 舍入顺序 | 逐批次先舍入后求和(§2.5) |
| 金额舍入 | 2 位,四舍五入(ROUND_HALF_UP) |
| 份额舍入 | 2 位四舍五入为主,按产品 core_share_rule 覆盖(D27);尾差在基金资产列支 |
| 份额足够判据 | 可用份额 = Σ core_share_lot.remain_qty − Σ(未终态受理单 requested_qty);不用 core_holding.qty。批次表是权威源,两表失配时以批次为准并 logger.warning 记差异 |
| 校验优先级 | ① 份额足够 → ② 最低转出份额(申请 == 全部可转份额时豁免 min_redeem)→ ③ 转出后余额低于最低持有则强制全转 |
| 全额豁免理由 | 真实业务全额赎回/转出豁免最低份额门槛 |
fund_company/ta_code NULL |
种子补齐后全量非 NULL;未来扩展产品未补全时,残余 NULL 才跳过跨机构校验放行 |
can_subscribe/can_redeem |
永不为 NULL(DEFAULT 1),不适用 NULL 跳过规则 |
| 持有期起算日 | 转换转入新批次以确认日 T+1 为准(比 T 起算少 1 天、费率档更严);普通申购新批次与存量批次以交易日 T 为准 |
| 同产品自转换 | 400 SAME_PRODUCT |
| T+2 可赎回判据 | 转入批次 confirmed_at 的下一交易日 ≤ 今日才计入可用份额;由 core_trade_calendar 推算,不加列 |
强制全转判断流程:
① 可用份额足够? 可用份额 = Σ(remain_qty) − Σ(未终态受理单份额) >= 申请份额
├─ 否 → 400 INSUFFICIENT_SHARES
└─ 是 ↓
② 最低转出份额? 申请份额 >= min_redeem_qty
├─ 否,且 申请份额 == 全部可转份额 → 豁免通过(全额转出豁免)
├─ 否,且 申请份额 < 全部可转份额 → 400 BELOW_MIN_QTY
└─ 是 ↓
③ 余额 = 全部可转份额 − 申请份额; 余额 < min_hold_qty ?
├─ 否 → 正常转出「申请份额」
└─ 是 → 强制处置:actual_qty = 全部可转份额,forced_full_transfer = true
(min_hold_action = 'force_transfer' → 强制全转;= 'force_redeem' → 强制赎回剩余)
触发条件严格为 余额 <
min_hold_qty(不是 ≤),否则「恰好等于下限」会被误判为强制全转。例:持 6000、申请 5000、min_hold_qty = 1000→ 余额 = 1000,不触发,正常转 5000。
13. 评审记录
设计文档的自检与独立审查方法论见用户级 skill
design-doc-selfcheck(13 问 + 格式契约三问 + 独立 AI 联网审查协议)。本 PRD 的逐轮审查意见、判定与修订对照归档在docs/项目框架设计/基金转换-审查意见处置表.md;本节仅保留结论性记录。
| 轮次 | 时间 | 结论 |
|---|---|---|
| 立项与多轮审查 | ≤2026-09-10 | 五轮(v0.7→v0.8)+ 架构校准轮 33 条(接受 27 / 修正性接受 5 / 驳回 0)+ 自查:补差费率来源、持有期重置、资金流简化、批次生命周期、事务边界、幂等执行权、引擎只跑一次、nav 下沉 Core 等全部收敛(逐轮意见归档于处置表) |
| v1.0 立项轮 | 2026-09-10 | 用户逐项拍板「按真实业务走」10 项;独立子代理审查 4 重要全修 |
| v1.0 第 2 轮收口 | 2026-09-11 | 独立子代理 ×2:9 项修订确认 + 新增重要 2 + 可选 2 全修;达到可定稿标准 |
| 架构 v2.0 决策回填 | 2026-09-11 | 依架构 v2.0(D25~D30)回填本 PRD:适当性 T+1 复核、redeem 份额申报、按产品舍入、部分成交、资金流中转、A/C 互转 |
| v1.1 精简重写 | 2026-09-11 | 按用户要求整体重写为最终态:正文剥除全部历史版本堆叠与覆盖注(历史轮次仅在本节留结论),口径与架构 v2.1 一致;已通过独立 AI 联网审查,已定稿(2026-09-11 用户批准) |
14. 自检清单
| # | 问 | 本期答案 |
|---|---|---|
| 1 | 新增表/字段谁写? | core_convert_request(accept_convert)/ core_cash_flow(confirm_convert)/ core_share_lot(确认段 + 普通申赎)/ risk_convert_detail(agent 写入进度)/ share_class/rounding_mode/share_digits(种子写入,代码只读) |
| 2 | 谁读? | core_ro(批次/净值/费率/持仓/日历/产品规则)/ rules._amount_view / core_tools / rebuild_alerts / cleanup_pending_convert |
| 3 | 枚举/常量 DDL? | core_convert_request.status ENUM 6 值;risk_convert_detail.status ENUM 5 值;audit_log.decision VARCHAR |
| 4 | 事务跨库? | 两段 Core 单库事务 + agent 附加(补偿) |
| 5 | 种子数据? | 06~11 种子(含真实产品舍入规则 D27、share_class D30) |
| 6 | 并发安全? | 可用份额短临界区 + 确认条件 UPDATE 哨兵 + 批处理串行锁 + 幂等锚点同库 |
| 7 | 汇总语义唯一? | _amount_view 取转出端;out/convert/in_amount 语义分立 |
| 8 | 向后兼容? | core_trade 加可空列;core_product 新列有 DEFAULT;get_engine(role) 默认回退 |
| 9 | 示例自证? | §5.3.2 全部派生值由 calc.py 实算回填(calc_convert_demo,禁手算) |
| 10 | 外部事实核验? | 22 号文 + 10 家转换业务公告(来源清单见架构 §0.5);份额赎回/巨额赎回/资金流/舍入差异均有出处 |
| 11 | 重置类伴随数据? | cleanup_pending_convert 扫描 core_convert_request;reset.ps1 显式清单 |
| 12 | 类型与口径匹配? | 申购费率按 product_type 取档(§4.3);share_class 同基金判定(D30) |
| 13 | 同一规则/公式单副本? | 折算/费率/舍入抽 calc.py 纯函数;lot_bootstrap 单点;格式出口 _q 单点 |
二补 · 格式契约三问:
| # | 问 | 答案 |
|---|---|---|
| 补-1 | 展示位数/精度写在哪? | §2.5.2 分类规格表 + convert_service._q():金额/份额 2 位(按产品覆盖)、净值/费率/尾差 4 位 |
| 补-2 | 同一逻辑数据产出路径几条? | 公式算 / 库 DECIMAL(18,4) 重建 → 均经 _q(),逐字节一致 |
| 补-3 | 格式出口单点? | 响应/审计/日志共用 _q();禁止裸 str(Decimal) 出网 |
新持久对象谁清理:core_convert_request 生命周期 = accepted → confirmed/rejected/cancelled/nav_pending → expired;非终态超 SLA 由 cleanup_pending_convert.py 置 expired 释放占用(功能正确性要求)。