# 2026-09-10 工作日志
> 本日两条线:**架构改进线收尾闭环**(本文主体)+ 基金转换线**第 4 步开发计划待启动**。
---
## 一、接手与状态核对(会话起始)
读入:`docs/交接文档-基金转换.md`(v1.0)· `docs/memory/MEMORY.md §0` · `docs/交接文档-架构改进.md`(v1.1)
· `docs/项目框架设计/架构设计-基金转换交易.md §15/§15.1` · `docs/memory/{TODO,ITERATION}.md` · `AGENTS.md`。
**基线复核**:`python -m pytest -q`(系统 Python 3.13.14)→ **510 passed**(9.93s)。
**设计资产核对**:`tests/_ddl.py:50-54` 的 `core_holding` 仍为 `market_value`/`quantity` 且**无 PK** ——
与 T-0 描述**一致**,架构文档无误。
### ⚠️ 核对中发现的两处「文档与实况不符」(以实况为准)
1. **`037ce7e`「未 push」是过期表述。**
实测 `git ls-remote origin`:`refs/heads/risk-control-agent` = **`fffb78a`** = 本地 HEAD;
`git merge-base --is-ancestor 037ce7e fffb78a` → 成立。即**架构改进线全部提交(含其后 5 个文档提交)早已推送**。
本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` **只是本地远程跟踪引用失效**,非远程分支被删。
2. **基金转换线 6 个设计文件全部 untracked**(未纳入版本库):`docs/PRD/PRD-基金转换交易.md`、
`docs/项目框架设计/架构设计-基金转换交易.md`、`基金转换-审查意见处置表.md`、
`评审待办-风控主架构与基金转换.md`、`docs/交接文档-基金转换.md`、`scripts/dev/calc_convert_demo.py`。
→ 用户拍板:**先 commit 入库(只 commit,不 push)**。
---
## 二、架构改进线 §7.2 手工冒烟(七项,全部代跑,2026-09-10)
用户授权「七项全部代跑」。所有临时改动**跑完即恢复**。
### 第 1 项 · 无 key 启动告警 ✅ PASS
- 本机 `.env` 的 `DEEPSEEK_API_KEY` **本就为空**(实测 `raw_value_len = 0`),故「清空」态即当前态。
- `uvicorn app.main:app`(端口 8123)→ **1 秒就绪**,`/health` → `200 {"status":"ok","env":"development"}`。
- 进程存活 → **告警不阻塞启动** ✓。
- 日志命中:`DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 (LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)),LLM 能力不可用`(`app/main.py:66-72`)。
### 第 2 项 · 恢复 key → 无告警 ✅ PASS(**口径替代,已留痕**)
- **替代说明**:本机无真实 DeepSeek key,「恢复 key」一支改用**非空占位值**(`sk-smoke-placeholder-not-a-real-key`)。
告警分支只判 `not settings.deepseek_api_key`,**不校验 key 有效性**,故该替代足以验证条件分支。
- 改后 `key_present: True` → 启动(端口 8124)→ `/health` 200 → 日志**未出现**该告警 ✓。
- 顺带验证 trace 中间件:响应头 `x-trace-id: trc-48349abc526e4055` / `x-request-id: req-612a3f2acb8349d6` ✓。
- **`.env` 已还原**,`key_present: False`(原状)✓。
### 第 3 项 · 交易阻断 + 放行各一次 ✅ PASS
| 场景 | 请求(debug 头 `X-Debug-Role: risk_demo` / `X-Debug-Actor: STAFF-DEMO`) | 响应 |
| --- | --- | --- |
| 阻断(SOP A-2) | CUST-4001 / PROD-161725 / subscribe / 20000 | `blocked=true`,`block_response_code=SUIT_AGE_CONFIRM`,`needs_branch_confirm=true`,`rule_refs=["FM-01"]` |
| 放行(SOP A-3) | CUST-3001 / PROD-510300 / subscribe / 500000 | `blocked=false`,`triggered_rules=["RISK-001","RISK-002"]`,`alert_ids=["ALT-20260910-7BCBBC94"]` |
**与 SOP 记录的预期逐字一致。** 库内核验:
- `core_trade`:阻断单 `TRD-20260910-C1D8AA26` **未落库** ✓;放行单 `TRD-20260910-FE545DF0` 已落库(`confirmed`, 500000.00)✓
- `risk_suitability_log`:CUST-4001 `is_blocked=1` ✓;`risk_alert` 出 `suitability` 单 `ALT-20260910-652280E1`(`pending_review`, score 90)✓
- `audit_log`:`suitability_block / suitability_alert_created`、`trade_request / suitability_blocked`(`rule_id=SUIT_AGE_CONFIRM`)、
`trade_request / trade_accepted`(放行)、`risk_judgement / alert_created` ✓
### 第 4 项 · 预警聚合行为不变 ✅ PASS
SOP A-4:CUST-9527 / PROD-510300 / subscribe / 1000 **连发 4 笔**:
| 笔次 | `triggered_rules` | `alert_ids` |
| --- | --- | --- |
| 1 | `[]` | `[]` |
| 2 | `[]` | `[]` |
| 3 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` |
| 4 | `["RISK-003"]` | `["ALT-20260910-C8600425"]` ← **与第 3 笔同一张单(并入,非新建)** |
库内核验:
- `risk_alert` CUST-9527 当日 **`pending_review` 单数 = 1** ✓;`payload.events` **长度 = 2** ✓
- `audit_log` 呈 **`alert_created`(1184) → `alert_appended`(1187)`** 序列,聚合语义在审计层可证 ✓
- 4 笔交易全部落库(`TRD-20260910-B66CB0D8` / `B39E1456` / `83D58A41` / `79EDC16E`)✓
> 注:`risk_alert.status` 实际取值为 **`pending_review`**(文档口头简称「pending」)。首次核库我按 `status='pending'` 查得 0 条,
> 系**断言写错**而非系统问题,修正后为 1 ✓。
### 第 5 项 · Redis 不可用 → 退回进程内锁 ✅ PASS(**口径替代,已留痕**)
- **替代说明**:本机 Redis 为 `redis-server.exe`(PID 7716,Services 会话),**无文档化启动方式、`redis-server` 不在 PATH、
`sc.exe` 被本机安全策略拉黑**,杀掉后无法保证原样拉起(且 `FLOW.md` 明示其非权威数据源)。
改用 **`REDIS_URL=redis://127.0.0.1:6399/0`(死端口)**:应用侧抛 `ConnectionError`,与真宕机**同一条代码路径**
(`redis_gateway._ensure()` → 命令异常 → `locks._acquire_redis` 的 `except Exception` → `unavailable` → 进程内锁),且完全可逆。
- 结果:启动**不被阻塞**(1s,`/health` 200)→ 交易返回 `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`,**业务照常完成** ✓
- 日志**明确出现 2 次** `Redis 锁不可用,退回进程内锁`:
`lock:agg:event:CUST-3001:2026-09-10`(`alert_service` 聚合锁)、`lock:l3:CUST-3001`(`profile_l3` L3 锁)✓
- 底层异常:`redis.exceptions.ConnectionError: Error 10061 connecting to 127.0.0.1:6399`,被正确吞噬未上抛 ✓
### 第 6 项 · Redis 恢复 → 行为一致 ✅ PASS
- `redis.Redis.from_url(settings.redis_url).ping()` → **True**(`redis://127.0.0.1:6379/0`)。
- 同笔交易 → `blocked=false`,`alert_ids=["ALT-20260910-7BCBBC94"]`(并入同单)✓
- 日志中 `退回进程内锁` **命中 0 行** → 确实走 Redis 锁路径,**未降级** ✓
- `triggered_rules` 由 `["RISK-001","RISK-002"]` 变为 `+RISK-003`:CUST-3001 当日累计至第 3 笔触发频次规则,
**属预期累积行为,非不一致**。
### 第 7 项 · 调换装饰器验证 T-202 守卫变红 ✅ PASS(本项价值最高)
守用例:`tests/test_audit_middleware.py::test_audit_middleware_runs_inside_trace_middleware:210`。
| 步骤 | 结果 |
| --- | --- |
| ① 基线 | `1 passed`(绿) |
| ② 调换 `main.py` 两装饰器(trace 先注册 → 变最内层;audit 后注册 → 变最外层) | 已调换,行序:`trace_middleware`@89 / `audit_middleware_entry`@117 |
| ③ 跑守卫 | **`1 failed`** ✓ 变红 |
| ④ 失败断言原文 | `AssertionError: audit 若先于 trace 执行,此处会静默为空` / `assert ''` @ `test_audit_middleware.py:221` |
| ⑤ 还原 | `sha256` 前后同为 `d9d7559445dffd2a4576cdc5454030d3acae5fd4c076de8daa5a35f53802510a` → **字节级一致** ✓ |
| ⑥ 再跑守卫 | `1 passed`(转绿)✓ |
| ⑦ 现场核对 | `git status --short app/main.py` 空、无残留 `*.smoke.bak` ✓ |
→ **守卫确实能抓住「audit 先于 trace 执行 → 全站审计静默丢 trace_id 且不报错」这一回归**。设计意图达成。
---
## 三、冒烟副产物与现场恢复
### 3.1 集成测试失败(防护行为,非回归)
冒烟后在真 MySQL 上跑全量:**507 passed / 3 failed**,全部落在 `tests/test_integration_risk.py`:
- `_assert_a3_clean` → `CUST-3001 今日已有 confirmed 交易(影响 RISK-002 累计/RISK-003 频次口径)`(我的 `TRD-20260910-FE545DF0`)
- `_assert_a4_clean` → `CUST-9527 今日已有 confirmed 交易(A-4 频次计数失真)`(我的 `TRD-20260910-B66CB0D8`)
- `test_a7_handle_state_machine_compliance_forbidden` → 级联 `KeyError: 'a3_alert_id'`
→ 这正是**核查单⑥「演示/测试同日交叉防护」前置断言**在按设计工作(交接文档已预警)。
### 3.2 演示库重灌(用户拍板:现在重灌)
按 `docs/项目框架设计/演示SOP-风控模块.md §2` 脚本化方式执行(`MYSQL_PWD` 传密码,避开 `reset.ps1` 交互 `-p` 卡死):
1. `DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;`
2. 依序灌 11 个 SQL:`scripts/core/00~06` → `表设计/01-mysql-共用底座.sql` → `表设计/02-mysql-agent专用.sql`
→ `scripts/agent/seed-aml-list.sql` → `scripts/demo/prepare_risk_demo.sql`
3. `python scripts/sync/sync_advisor_rel.py`
**完成标志三项全部吻合**:AML 名单 **8** 条 · `prepare_risk_demo` **7** 行测评(`expires_at` 剩余 **275** 天)·
`sync_advisor_rel` upserted **33** rows。
### 3.3 全量复跑(用户要求「改完后再跑一次」)
**`510 passed, 656 warnings in 9.52s`** → **复绿** ✓
现场状态:`.env` 已还原(key 为空、`REDIS_URL` 为 6379)✓ · `app/main.py` 字节级还原且 git 无改动 ✓ · 无残留 `.smoke.bak` ✓。
---
## 四、回写清单
| 文档 | 改动 |
| --- | --- |
| `docs/项目框架设计/TODO-架构改进.md` | T-107 手工冒烟勾选 + 完成判定块三勾 + 状态块改「全部闭环」+ push 状态纠正 |
| `docs/交接文档-架构改进.md` | **v1.1 → v1.2**;状态块重写(push 纠正 + 两处替代口径留痕);§7.3 全勾;§10 重写为结项 |
| `docs/交接文档-基金转换.md` | §9「另一条线的遗留」改为**已闭环**,纠正 push 表述 |
| `docs/memory/MEMORY.md` | §0 末尾「待办两条线」改为「架构改进线已结项」+ push 纠正常见坑 |
| `docs/memory/TODO.md` | 模块侧开放项「架构改进线 · 收尾」勾选并附七项证据摘要 |
| `docs/memory/ITERATION.md` | 新增 **2026-09-10 架构改进线收尾闭环** 迭代行 |
| `docs/memory/2026-09-10.md` | **本文件(新建)** |
---
## 五、下一步
**架构改进线:结项,无待办。**(可选:push 后打里程碑 tag,待用户拍板)
**基金转换线:第 4 步「产出开发计划」** —— 输入已齐备:
`docs/项目框架设计/架构设计-基金转换交易.md` §15(任务映射 T-0~T-13)+ §15.1(依赖拓扑与并行分组)+ §12(风险表);
产出落点 `docs/项目框架设计/开发计划-基金转换交易.md`(待创建)。
---
## 六、交接文档合并为单一入口 + 全仓指向统一(2026-09-10 晚)
### 6.1 用户指令与判定
- 指令:「由版本库的交接文档为主,这两个交接文档你合并成一个新的放到这次更新」+ 指向项目根 `交接文档.md`。
- 追加纠正:**「统一一下,不是早都说过交接文档是给你 AI 接手用的吗」** ——
**交接文档的定位 = AI 接手入口**,所以全仓指向必须**唯一**,且**绝不能指向过期文件**,否则新会话会被误导。
(`docs/` 下两份留档停在 v1.0 / v1.1,不含 T-2/T-2b、609 passed、D20 实施等进度 —— 是个真实陷阱。)
### 6.2 落地
**项目根 `交接文档.md` → v3.0(545 行)**,三线合并为单一入口:
**§0 公共层**(项目是什么 / 铁律与禁止事项 / 开发运行手册 / 已知坑 10 条 / 发现问题怎么办)·
**§A 风控模块主线**(原主干)· **§B 基金转换线**(含**工作区最新版 v1.4**)· **§C 架构改进线**(含**工作区最新版 v1.2**)。
最大价值:这两份工作区最新版**从未提交**,随时可能丢,现已完整固化。
**全仓 10 处指向统一** → `交接文档.md` §A/§B/§C:
`AGENTS.md`(含顶部新增「⚡ 需要接手/开工先读交接文档.md」)· `docs/memory/{MEMORY,TODO,FRAMEWORK}` ·
`docs/PRD/PRD-架构改进与稳定性加固.md` · `docs/项目框架设计/{开发计划-架构改进,TODO-架构改进,开发计划-基金转换交易}.md`。
**两份 `docs/交接文档-*.md` 保留为历史留档**,顶部加醒目横幅:**「本文件已废弃(2026-09-10)—— 请勿据此开工,
正确入口 `交接文档.md` §B/§C,内容已过期,勿读」** —— 不删除(保留仓库历史),但明确失效。
**未入库**:`交接文档.md` 在 `.gitignore:47` 内,用户确认「不进提交」。
### 6.3 ⚠️ 事故:`git rm` 静默抹除整个 `docs/`(复现 2 次,已零损失恢复)
- **现象**:`git rm`(带不带 `-f` 均如此)删除 `docs/` 下两个**中文名**文件 → **`docs/` 全部 54 个文件从工作区消失**,
命令**返回退出码 0 不报错**。
- **定位**:① `ls`/`find`/Read 三方确认真实删除;② `git checkout HEAD -- docs/` 完整还原 54 文件(索引与 HEAD blob 完好);
③ 无自定义 hooks(`core.hooksPath` 空);④ **同一中文路径 `ls` 完全正常** → **触发条件就是 `git rm` 本身**;
⑤ 换 **纯 `rm -f <精确路径>` → 正常**。
- **损失**:**零内容丢失**(HEAD blob 完好)。代价:本轮引用修改被连带还原 2 次(重做 3 次)。
- **纪律**(已写入 `交接文档.md` §0.4 第 10 条 + `.workbuddy/memory/MEMORY.md`):
- ❌ 本仓库**禁止对 `docs/` 下文件用 `git rm`**;
- ✅ 删文件用**纯 `rm -f <精确路径>`** + `git add -A` 记录删除;
- ✅ 批量删除后**立刻** `find
-type f | wc -l` 复核,异常立即 `git checkout HEAD -- `。
- 根因未查明,**规避即可**。
---
## 七、设计方法论固化为 skill + 记忆精简(2026-09-10 晚)
**动机**:项目记忆 `MEMORY.md` 与交接文档膨胀——13 问 / AIcoding 六步 / 独立审查协议 / 写计划 4 法 等项目无关的方法论每个项目都重写一遍,且挤占了项目状态信息。
**落地**:
1. **新建用户级 skill `design-doc-selfcheck`**(`~/.workbuddy/skills/design-doc-selfcheck/SKILL.md`)——
固化:AIcoding 六步流程 · 设计自检 13 问(含 why/翻车案例)· PRD 独立 AI 审查协议(自包含包 + 只报告不改动 + 逐条判定)· 写开发计划 4 法 · 真实翻车案例库(4 处外部事实漏网 / 分类混用 / 公式副本 / 重置伴随数据 / 方言互斥)。
2. **精简项目 `MEMORY.md`**(205 行 → ~70 行):删除已入 skill 的方法论 verbatim,只留三条线状态、`git rm` 禁忌、交接文档定位、基金转换核心事实与业务口径、交易发起主体合规、D20 账号,并指向 skill。
3. **交接文档 §B 顶部加 skill 指针**:本线设计方法论已固化,避免重复维护。
**效果**:方法论跨项目可复用(新项目直接加载 skill);本项目记忆回到「状态 + 红线 + 口径」本职。
---
## 深夜 · 基金转换线 T-8(规则引擎改造)完成
**范围**:开发计划 §7.1 —— `rules._amount_view` + `engine.process_convert_event` + `alert_service.events`(并行组 B)。
### 改码三处(均在代码注释留痕)
- `app/service/risk/rules.py`:+`_amount_view(trades)`(同 `convert_group_id` 组内只留 `redeem`;**无 gid 恒等通过**;组内无 redeem 保首条);`run_rules` **双视图分流** —— `eligible`(全量)供 RISK-001/003/004,`_amount_view(eligible)` 供 RISK-002/005。
- `app/service/risk/alert_service.py`:+**公开** `build_trade_event(trade, hits)`(结构定义唯一副本);`record_trade_alerts(..., events=None)` 缺省退化为单条,既有调用零改动;新建单落 `payload.events` 全部,**聚合追加只追首条**(与架构 §5.4「只认转出端」同口径)。
- `app/service/risk/engine.py`:抽 `_run()` 共用实现;`process_trade_event` 变薄封装(**签名与行为不变**);新增 `process_convert_event(out_trade, in_trade, *, core_ro, risk_repo, thresholds, on_error_hook=None)` + `_notify_error_hook`(hook 自身异常吞掉、原始异常照常上抛)。
### ⭐ 实质影响:阶段 1.5 从「静默跳过」变「真跑」
T-7 落地时 `process_convert_event` 不存在 → `_run_engine` 走 `ImportError` 分支**跳过**;
**T-8 落地后同一笔转换会真实出单 + 写 `customer_profile_l3` + 落审计**。
→ `verify_convert_service.py` 的执行语义随之变化,复跑仍 **35/35 绿**(无连带破坏)。
### 验证
- 新增 `tests/test_convert_engine.py` **15 用例**;`test_convert_service.py` **+1 条接线回归**(大额转换真出单,防 `_run_engine` 退回跳过)。
- `pytest -q` → **672 passed / 3 skipped**(基线 656 **+16**,零回归)。
- **突变验证(防假绿)**:临时把 `amount_view = eligible` → **4 条变红**,其中 `detail` 直接暴露 `当日申赎累计 599000 元(共 2 笔)` 的翻倍错误;恢复后复绿。
- **新增 `scripts/dev/verify_convert_engine.py` 真库 31/31**(A 引擎真跑出单 / B **RISK-002 不翻倍夹逼**:单条 512000 < 800000 < 两条之和 1019975.33 / C 不删行 + DECIMAL 零漂移 / D 幂等重试不重复出单 / E 无命中只落 pass 审计 / F 六表零残留)。
### 真库脚本两条踩坑(与 T-7 同款 + 新一条,供后续 `verify_*.py` 参考)
1. **`id_factory` 必须注入**:默认生成 `CNV-<日期>-`,与清理口径 `LIKE 'CNV-T8M%'` 不匹配 → 重跑遗留行撞 `uk_group`。注入 `_t8m_id` 并把清理条件补 `client_request_id LIKE 'T8M-%'` 兜底。
2. **`core_trade.trade_type` 在 MySQL 是 ENUM,`ORDER BY` 按定义序**(实测 `[subscribe, redeem]`)→ 断言改用 `{trade_type: row}` 字典定位,不依赖排序。
### 发现的既有行为(不在本任务范围,**未顺手改**)
> **更正(同日)**:初次记录误写成「RISK-001 实为当日累计口径」,**错误**。RISK-001 与 RISK-002
> 是两条独立规则——前者**单笔**(`rules.py:128-133`)、后者**当日累计**(`:137-145`)。T-8 只切后者。
真实根因在**引擎入参范围**:`process_trade_event` 拉**当日全量**流水重跑规则
(`engine.py` 的 `list_trades_range(customer, day_start, day_end)`)→ **触发这笔**与**命中那笔**
可以不是同一笔:真库 E 组实测,同客户同日先有一笔大额转换时,后续一笔 1000 元小额交易也带出 RISK-001。
既有聚合逻辑(同客户同日一张 pending 单)兜住了重复出单,属**既有设计**,T-8 不改变也不修。
### 文档回写
开发计划(§0 速览标 ✅ + 测试基线改 672 · **§7.1 DoD 全勾 + 新增执行记录 + 3 条实施级收敛 + 2 条脚本坑**)·
根 `交接文档.md` §B → **v1.9**(§0 导航 · §B 头部 · §B.1 状态与代码改动 · **新增 §B.6.1 T-8 小节** · §B.6 任务树与基线)·
`docs/memory/{MEMORY,TODO}` · 本条。
**下一步 = T-9(`api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 · 关键路径)/
T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。
---
## 深夜 · 基金转换线 T-9(API 模型 + 网关分派 · 关键路径)完成
**范围**:开发计划 §7.2 —— `api/simulate.py` 三型模型 + `trade_gateway` convert 分派 + 错误码映射。**至此 HTTP 层 convert 端到端走通**。
### 改码 5 处
- `app/utils/trace.py`:`_HEADER_ID_PATTERN` → **公开 `HEADER_ID_PATTERN`**(单点定义)。
执行期发现 `trace.py:16` 与 `main.py:44` **各有一份内容完全相同的正则** —— S4 要防的「白名单漂移」**其实已经发生**。
- `app/main.py`:删掉那份重复副本 + `import re`,改 import 上面的常量。
- `app/utils/response.py`:`_api_error_handler` 合入 `exc.extra`(`getattr` 取;既有 `ApiError` 无此属性 → 错误体逐字节不变)。
- `app/gateway/trade_gateway.py`:移除 convert 显式拒绝;新增 `_submit_convert`(只做参数映射 + 仓储装配,模块级符号作 monkeypatch 注入点)。
- `app/api/simulate.py`:`TradeRequest` 三型字段分池 + `@model_validator` 分支校验(`client_request_id` 复用同一白名单);
`model_dump(exclude_none=True)`;**`except LookupError` 收窄为 `except NotFoundError`**;`PROCESSING` → 202。
### ⚠️ 3 条与计划原文的出入(已写入开发计划 §7.2 执行记录)
1. **正则落点**:计划写「复用 `main.py:44`」,但 `app.main` → `app.api.simulate` 单向导入链,反向 import 成环
→ 上移 `trace.py`。连带突破 §12 **R14「本次不动 main.py」**(中间件注册顺序未动,守卫用例仍绿)。
2. **`gateway_repository.insert_trade` 加列:裁定不需要** —— convert 写路径在 `convert_core_repository.apply_convert`,该文件零改动。
3. **R7 归 T-10**;**R16 零改动通过**。
### 另一条实施级发现(**已上报,未顺手改**)
**幂等重放响应的数值位数与首次不一致**:首次走 `calc`(2 位)vs 重放 `_rebuild_quote` 直读 `DECIMAL(18,4)`(4 位)
→ `"53456.95"` vs `"53456.9500"`(**数值相等**)。违反 PRD「对外一律 2 位」展示契约,属 T-7 范畴。
集成测试已改为**比数值不比字符串**并在 docstring 钉住偏差。
### 验证
- 新增 **`tests/test_convert_integration.py`(7 条真 MySQL 集成)**:端到端与 PRD §5.3 逐项吻合(调生产纯函数算期望,禁手算)
· 两条流水同组同前缀 · 持仓与批次如实变动(转出归零保留行 / 转入新建)· 明细 completed + 审计
· 幂等重试不产生第二组 · 跨主体 400 · 未知类型 400。
- `test_trade_gateway.py` **+17**(11 条错误码映射全表参数化含 `extra` 展开 · 202 · 200 透传 · 不写 `trade_request` 审计 · 3 条 422 分支)。
- `test_integration_risk.py` **R15 处置**:端到端已迁入新文件;原槽位**未删除**,改造为
`test_invalid_type_400_and_no_new_trade_audit`(改用 `purchase` 触发),保住「校验失败不落审计」不变量。
- `pytest -q` → **696 passed / 3 skipped**(基线 672 **+24**,零回归)。
- **突变验证 3 组**:① 关掉 convert 分派 → **21 条红**;② 关掉错误体 `extra` 展开 → **精准 1 条**;
③ 关掉 `client_request_id` 正则 → **精准 1 条**。三处已恢复,`grep MUTATION-TEST app/` 为空。
- **真库验证载体 = 集成测试本身**(非脚本):T-9 **不新增 SQL、不涉方言语义**,故无需另写 `verify_convert_*.py`;
该文件经 `ensure_risk_demo_ready()` 在无 MySQL 环境整模块 skip,不炸 CI。
### 🔴 本日第二次误报「远程分支不见了」(教训)
提交 T-8 时依据 `git branch -vv` / `git branch -r` 判定远程 `risk-control-agent` gone、仅剩 `main`/`dev`,
据此向用户报警。**结论错误** —— `git ls-remote --heads origin` 实测远程 `risk-control-agent` = **`fffb78a`,健在**;
`git branch -r` 只剩两个分支纯属**本地远程跟踪引用 stale**。
**本日第 368-371 行已记录同款教训(`037ce7e` 误报),当日复发第二次** → 已升格为
`.workbuddy/memory/MEMORY.md` 铁律 + `docs/memory/MEMORY.md` 交接清单第 7 问。
### 文档回写
开发计划(§0 速览标 ✅ + 基线 696 · **§7.2 DoD 全勾 + 执行记录 + 4 条裁定 + 1 条发现 + 3 组突变** · §12 R15 补实际处置
并订正事件名 `trade_accepted`→`convert_accepted`)· 根 `交接文档.md` §B → **v2.0**(§0 导航 · §B 头部 · §B.1 状态与代码改动 ·
**新增 §B.6.2 T-9 小节** · §B.6 任务树与基线)· `docs/memory/{MEMORY,TODO}` · `.workbuddy/memory/{MEMORY,2026-09-10}` · 本条。
**下一步 = T-10(普通申赎批次维护 · 改 `trade_gateway` 主流程 · **回归风险最大,先跑基线再动**)/
T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。
---
## 深夜 · T-9 收尾:**展示位数口径修复**(用户「你先改问题,按照贴近现实业务改」)
**问题**:幂等重放响应的数值位数与首次不一致(首次 `"53456.95"` vs 重放 `"53456.9500"`,**数值相等、字符串不等**)。
### 根因不是 T-7 写错,是**契约缺位**
§2.5 只规定了「金额/份额 2 位」,**净值、费率、申请份额的回显位数根本没定义** →
实现只能把 `Decimal` 原样 `str()` 出网 → **位数随数据来源漂移**:
- 首次路径走 `calc` 纯函数(已 2 位量化)
- 重放路径由 `_rebuild_quote` 从 `core_trade`/`core_convert_lot_detail`(`DECIMAL(18,4)`)重建后直读
### 联网核验(7 家管理人公告,2026-09-10)
| 口径 | 依据 |
| --- | --- |
| 金额 2 位四舍五入 | 「转出金额以四舍五入的方式保留至小数点后两位」(中银/人保/浦银安盛/中欧/南方/申万菱信一致) |
| 份额 2 位四舍五入 | 「转入份额以四舍五入的方式保留至小数点后两位」;「**申请转换份额精确到小数点后两位**」(中银) |
| 净值 4 位 | 「份额净值保留 4 位、第 5 位四舍五入」(中欧/国泰公告:**由 3 位提高至 4 位**);巨额赎回极端可 8 位 |
| 费率 4 位 | 公告以百分比 2 位表示(`0.30%` ↔ `0.0030`) |
| ⚠️ **已知不统一** | **易方达(ETF 场外)份额取整数位**、**南方基金取截断**(非四舍五入)→ 本期取主流口径 + 记入 PRD §10 已知差异 |
### 修复(贴近现实业务)
`convert_service` 新增 **`_q(value, unit)` + `_D2`/`_D4` 规格常量**作**对外唯一出口**:
- **金额 / 份额 → 2 位**(`_D2`)
- **净值 / 费率 / 份额尾差 → 4 位**(`_D4`)
- 响应、审计 `summary`、异常日志**共用同一出口**;原 `_s()` 已全部替换(`grep _s(` 为空)
- 首次路径**幂等**:除 `requested_qty`/`actual_qty`/`lot[].qty` 由 4 位**补齐至 2 位**外逐字节不变
### 文档订正(用户「文档不准,你去联网查」)
- **PRD → v0.9.2**:§2.5 拆 **2.5.1 计算精度 / 2.5.2 展示位数**(新增分类规格表 + 外部依据 + 已知差异);
§5.3 示例 `requested_qty`/`actual_qty`/`lot_breakdown[].qty` **4 位 → 2 位**(原示例与 §2.5「对外展示按 2 位」**自相矛盾**,属漏改);
§5.3 字段类型约定补「位数不自由 + 两条产出路径必须逐字节一致」;新增 v0.9.1→v0.9.2 变更表与根因
- **架构 → v1.0.1**:§1 原则 11 补「`str()` 前必须按 §2.5.2 量化」
- 开发计划 §7.2「发现」条改为「**已修复**」并补完整证据链;交接文档 §B.6.2 同步
### 验证
- 集成测试改用**逐字段逐字节比对**(`REPLAY_IDENTICAL_FIELDS` + `lot_breakdown` 整体相等)
+ 新增**位数规格断言**(`test_convert_response_field_scales`,逐字段验 `split(".")[1]` 位数,并卡住 `requested_qty == "50000.00"`)
- `pytest -q` → **697 passed / 3 skipped**(零回归)
- **真库全复跑**:T-6 `verify_convert_apply` **24/24** · T-7 `verify_convert_service` **35/35** · T-8 `verify_convert_engine` **31/31**
- `calc_convert_demo.py` **15/15** 与 PRD §5.3 一致
- **突变验证**:把 `_q()` 的量化去掉 → **2 条红**,`assert '50000.0000' == '50000'` 直接复现修复前现象;已恢复,`grep MUTATION-TEST` 为空
### 教训已固化
skill `design-doc-selfcheck` → **v1.2.0**:新增 **「二补 · 格式契约最低三问」** + **铁律 6(格式契约铁律)**:
**凡 `str(Decimal)` 直接出网的字段,先问「这个字段的展示位数写在哪」—— 没答案就是契约缺位;
存储精度 ≠ 展示精度;多条产出路径必须共用一个格式出口 + 写逐字节相等断言。**
---
## 深夜 · 收尾:**`refs/remotes` 写入不落盘**根因排查(推翻此前「stale ref」结论)
**背景**:提交 T-8/T-9 时两次依据 `git branch -r` / `git branch -vv` 判定「远程分支不见了」并报警,
两次均**误报**(用户被迫去仓库核实两次)。用户指令:「你先改问题……改完确认没问题再提交」→ 先做根因排查。
### 已确认事实(可复现)
| # | 事实 | 证据 |
| --- | --- | --- |
| 1 | **远程健在** | `git ls-remote --heads origin` → `refs/heads/risk-control-agent` = `fffb78a`;另有 6 个分支 |
| 2 | **fetch 抓取正常,引用不落盘** | `git fetch origin` 报 `* [new branch] risk-control-agent -> origin/risk-control-agent`,`FETCH_HEAD` 写对;`.git/refs/remotes/` 仍为空 |
| 3 | **写 remotes 会删整个 `origin` 目录** | `git update-ref refs/remotes/origin/X ` → **EXIT=0**、reflog 建了,但松散引用消失,**连手工 `echo >` 进去的文件一起被删** |
| 4 | **只 remotes namespace 有问题** | 写 `refs/heads/tmpX` 时,放在 `refs/remotes/origin/` 的 marker **存活**;写 remotes 才触发删除 |
| 5 | **只此仓库特有** | 同一 git(`2.55.0.windows.3`)在 `/tmp` 新建仓库写 `refs/remotes/origin/probeX` **完全正常** |
| 6 | **排除外部进程** | marker 静置 6 秒 + 跑只读 git 命令均存活,**只有写 remotes 的那一下才被删** |
| 7 | **排除目录属性** | Python `lstat` 查 `refs/remotes`:**非重解析点**(`FILE_ATTRIBUTE_REPARSE_POINT = False`);无 hooks、无 commondir、非浅克隆 |
**根因未定位**(倾向 git 写 remotes 引用时被本地环境干预)。**此前的记录写「stale ref,`git fetch` 即恢复」是错的** —— fetch 并不修复。
### ✅ 绕过方案(已验证)
`.git/packed-refs` 是**可靠存储**(`origin/dev`、`origin/main` 一直从这里读到)。
手工追加 `fffb78a11c7d4e05f2aae53c76ceec3ad5d7d33c refs/remotes/origin/risk-control-agent` 后:
`git branch -r` 立即显示 `origin/risk-control-agent`、`git rev-parse` 可解析。还原 = 删掉该行。
### 影响面(重要)
- **不受影响**:提交(`refs/heads/**` 正常)、`git fetch` 抓对象、**push**
(`git push --dry-run origin risk-control-agent:refs/heads/risk-control-agent` 实测 `fffb78a..d0097d6` 成功)。
- **受影响**:仅本地「显示 / 上游跟踪」——`git branch -r`、`git branch -vv`、`git pull`(无参) 的默认上游推断。
### 状态
本地 HEAD `d0097d6`(T-9)· 领先远程 `fffb78a` **9 个提交** · 工作区干净 · 探针与临时文件已全部清理。
### 文档回写
`.workbuddy/memory/MEMORY.md`(远程铁律条**重写**,含 7 条证据 + 绕过方案)·
`docs/memory/MEMORY.md`(交接清单**第 7 问重写** + 顺手订正验证条目里过期的「516 passed」→ **697** + 第 73 行旧表述订正)· 本条。
---
## T-10(普通申赎批次维护 FR-C16)完成(2026-09-10 · 用户「进入 T10 阶段」)
**开工前基线快照**(计划硬要求):`pytest -q` → **697 passed / 3 skipped**
(开发计划 §8 写的「当前 516」是撰写期快照,已过期 → 已在文档中订正)。
**改码 4 处**:`gateway_repository.py`(+`insert_share_lots`/`deduct_share_lots`,各单事务、走 `rw`,
**不含分配逻辑**)· `share_lot_repository.py`(`__init__` 增可选 `core_ro` → 复用**同一份** FIFO 口径)·
`trade_gateway.py`(+`_nav_as_of`/`_maintain_lots`,在落流水后、引擎前调用,整段 try 包住)·
**新增 `scripts/core/rebuild_lots.py`**(快照重建批次 L-7,与网关 D8 同调 `bootstrap_lots`,`role="admin"`)。
**两条执行期裁定(计划未点明,已留痕)**
1. **`redeem` 份额折算 = `amount ÷ 净值`(2 位 HALF_UP)** —— 计划只写「FIFO 扣减」,
未说扣减的**份额**从哪来(赎回请求体给的是金额)。取不到净值 → 降级跳过。
> **⚠️ 2026-09-10 18:10 订正(联网查证 4 家管理人业务规则后)**:本条初稿写「按未知价法」,
> **定性错误**。真实行业铁律 = 「**金额申购、份额赎回**」—— 投资者以**份额**申报,
> 登记机构按 T 日净值**算金额**(睿远 §65 / 华泰保兴 §69 / 东方基金 §57 / 国投瑞银(货基)§33,
> **无一家公募支持「按金额赎回」**)。本项目是**简化建模**(`TradeRequest.amount` 为申赎共用
> 入参,`app/api/simulate.py:60`),方向与行业相反 → 已记入 **PRD §10.1 已知差异 ①②**。
> 另发现:**T 日净值当日不可得**(T+1 公告),`_nav_as_of` 实取 **T−1 净值** →
> **折算份额只是估算值**,与最终确认份额有偏差。折算逻辑**保留**,定性已同步订正
> (`trade_gateway.py` 注释 / 开发计划 §8 / 交接文档 §B / PRD §10.1)。
2. **`rebuild_lots.py` 默认只补建无批次行**(幂等、不误删转换转入的真实批次),`--force` 才先删后建。
**R-c 两条落地**:R-c(1) 降级(整段 try,源数据缺失只 warning,**绝不阻断交易**)→
既有 `env`(无净值无持仓)自然走降级,**§12 R16 `test_redeem_accepted_without_alert` 零改动通过**;
R-c(2) 覆盖率补偿(真实路径自建完整种子,**不得让降级充当覆盖**)→ `test_share_lot.py` 新增 14 条。
**验证**:`pytest -q` → **714 passed / 3 skipped**(**+17**,零回归);
**新增 `scripts/dev/verify_convert_lots.py` 真库 20/20**(隔离 `CUST-LOTT`/`PROD-LOTT*`,跑完零残留);
T-6 `24/24` · T-7 `35/35` 复跑零回归;**突变验证 2 组**(切断接线 → 精准 2 条红;关 D8 兜底 → 精准 2 条红含 D18),
均已恢复、无残留。
**★ 真库脚本的核心价值**:扣减 SQL 的 `(:q + 0.0)` 是**为绕开 sqlite 的 TEXT 绑定坑**而加(§B.8 第 11 条);
它在 **MySQL 上是否同样生效,sqlite 侧无从证明** —— 若 MySQL 未隐式转数值,扣减会静默失效
(批次永不减少 → convert 超扣)。**这正是用户 2026-09-10 定的「涉及方言语义的任务必须上真库」的意义**。
**D18 双保险**:除「同一持仓两侧算出的批次逐字段相等」外,另加**机制断言**
(`rebuild.bootstrap_lots is bootstrap_lots`)—— 防「两侧各写一份但碰巧同值」的假绿。
**未顺手改(已上报)**:`core_holding` 不随普通申赎更新(T-10 只维护 `core_share_lot`,计划范围即如此)。
**文档回写**:开发计划 §8(DoD 全勾 + 执行记录 + 2 条裁定 + 基线订正)· `交接文档.md`(§0 导航 / §B **v2.1** /
B.1 状态 / B.6 拓扑与基线 / **新增 B.6.3**)· `docs/memory/{TODO,MEMORY}` · `.workbuddy/memory/2026-09-10.md` · 本条。
**下一步 = T-11**(`core_tools.query_recent_trades` 汇总走 `_amount_view` FR-C15 + 持仓过滤 `qty <= 0` +
`core_ro.sum_trades_on_date` 加 convert 去重 **R-d**;DoD 见开发计划 §7.3)。
---
## ✅ T-10 已提交(18:30)
**提交**:`5e6fa06`「基金转换 T-10:普通申赎批次维护(FR-C16)」—— **12 文件 / +1383 −22**。
**工作区干净**;本地 HEAD `5e6fa06`,**领先远程 `fffb78a` 11 个提交,未推送**(推送与否由用户拍板)。
**本次提交含三部分**:① T-10 主改动(3 处改码 + 2 个新脚本 + 2 个测试文件);
② 口径订正(`trade_gateway` 注释 / **PRD §10.1 新增已知差异清单** / 开发计划 §8 / 本条日志);
③ 文档状态回写(交接文档 §B、`docs/memory/{TODO,MEMORY}`)。
**订正后的口径(后续任务按此)**:`redeem` 的 `amount ÷ 净值` 是**本项目简化**(申赎共用 `amount` 入参),
**不是行业标准**;行业铁律是「**金额申购、份额赎回**」。派生风险:未知价法下 T 日净值当日不可得
(T+1 公告),实取 **T−1** → **折算份额只是估算值**。详见 **PRD §10.1 已知差异 ①②**。
**顺带订正**:交接文档 §B 两处状态漏改(第 208 行「下一步 = T-10」、第 210 行右列「下一步 T-10 / T-11」)
已改为 T-10 ✅ / 下一步 T-11。
**下一步 = T-11**(`core_tools` 汇总去重 + 持仓过滤 `qty <= 0` + `sum_trades_on_date` 加 convert 去重 R-d)。
---
## ✅ T-11 已完成(2026-09-10 · 尚未提交)
**任务**:工具与 SQL 汇总去重(FR-C15 / R-d)—— 让 convert 落两条流水后,**所有金额聚合读取方都只计一次**。
**改码 3 处 + 新增 1 脚本 + 测试 3 文件**
1. `app/service/risk/rules.py` —— `_amount_view` **提升为公开 `amount_view`**(跨层复用;全仓唯一金额聚合口径);
`run_rules` 内局部变量改名 `amt_view`;docstring 补 SQL 等价条件。
2. `app/tool/core_tools.py` —— `query_recent_trades` 的 `sum_amount` 走 `amount_view`
(**明细与 `total_count` 保持全量**:一次转换两条流水是真实的**两笔**权益变动);
`query_holdings` docstring 说明归零行已在 SQL 层过滤。
3. `app/repository/core_ro.py` —— `list_holdings` 加 `AND h.qty > 0`;
`sum_trades_on_date` 加 convert 去重条件(**R-d**)。
4. `scripts/dev/verify_convert_tools.py`【新增】—— 真库 **14/14**。
5. `tests/test_core_tools.py`【新增】3 条;`tests/test_core_ro_sum.py` fixture 增 `gid` + 追加 1 条;
`tests/test_convert_engine.py` 随改名同步(**断言零改动**)。
**执行期裁定 2 条(计划未点明,已留痕)**
1. **`_amount_view` 提升为公开,而非在 `core_tools` 复制一份** —— 计划只说「汇总走 `_amount_view`」,
但它是私有名。按自检第 13 问**提升为公开保单一实现**;否决「公开别名」方案(一函数两名 = 新混淆源)。
依赖方向已核实:`app/tool/kb_tools.py:22` 早有 tool→service 先例,无循环导入。
2. ⭐ **SQL 去重条件从计划的 `IS NULL` 扩为 `IS NULL OR convert_group_id = ''`**。
依据:Python 侧 `if not gid` **把空串也当无组**,而 SQL 三值逻辑下 `'' IS NULL` **恒为假**。
**真库实证**(脚本 [C] 对照组):只写 `IS NULL` → `400000`,正确口径 → `450000`,
**差额恰为空串那笔 50000**。不加则该笔从当日累计里消失 → **RISK-002 漏算**,
且只在「普通交易被写成空串」时才暴露(sqlite 单测若只造 NULL 数据**永远发现不了**)。
**验证**
- `pytest -q` → **718 passed / 3 skipped**(基线 714 **+4**,零回归)
- `sum_trades_on_date` 既有 4 条用例(5 条断言)**零改动通过**;`test_convert_engine.py` 15 条断言零改动
- **突变验证 3 组**:① 去掉 gid 条件 → **精准 2 红**;② 去掉 `qty > 0` → **精准 1 红**;
③ 不走 `amount_view` → **精准 2 红**。均已恢复,`grep "1 = 1\|MUTATION"` **无残留**
- **真库**:`verify_convert_tools.py` **14/14**、跑完**零残留**
- **回归复跑**(铁律 2:接线变化必跑):T-8 `31/31`(`amount_view` 改名影响面)· T-7 `35/35` · T-10 `20/20` 均零回归
**未顺手改(已上报)**:`concentration_profile` 走**独立 SQL**、未过滤 `qty = 0`
(归零行市值为 0,对 R4+R5 占比**无实际影响**,但与「与 `list_holdings` 同源同口径」的注释有**措辞落差**)。
**文档回写**:开发计划 §7.3(DoD 全勾 + 执行记录 + 2 条裁定 + 未顺手改)· `交接文档.md` **v2.2**
(§0 导航 / §B 状态 / B.1 / B.6 拓扑与基线 / **新增 B.6.4**)· `docs/memory/{TODO,MEMORY}` · 本条。
**下一步 = T-12(补偿脚本)→ T-13(全量回归 + 50 并发压测 + PRD §5.3 数字回填)**。
---
## ✅ T-12 补偿脚本 已完成(2026-09-10 · 本线第 6 批 · **已提交 `22f2a41`**)
**目标**:阶段二失败(或阶段 1.5 引擎失败)后,能**仅凭 Core 侧数据**把「详情 + 预警」两件事补回来
(PRD §7.1 / 架构 §5.4)。
**交付(改 2 + 增 2 脚本 + 测试 2 文件)**
| 文件 | 动作 |
| --- | --- |
| `app/service/convert/convert_service.py` | 【改】新增公开 **`compensate_convert(group_id, ...)`** —— 补偿的**服务端单点入口** |
| `app/repository/risk_repository.py` | 【改】`has_engine_error_audit(trade_id, decision=...)` 加 decision 参数(默认值不变) |
| `scripts/demo/rebuild_alerts.py` | 【改】新增 `--convert-group CNV-xxx`(与位置参数 trade_ids 互斥) |
| `scripts/agent/cleanup_pending_convert.py` | 【新增】超 SLA 的 `pending` → `expired`(**标记不硬删**,S2) |
| `scripts/dev/verify_convert_compensate.py` | 【新增】真 MySQL 验证脚本(34 项) |
| `tests/test_convert_service.py` / `tests/test_demo_scripts.py` | 【改】+4 / +8 用例 |
**执行期裁定 3 条(留痕)**
1. **补偿逻辑收敛到 service 单点**(自检第 13 问):`rebuild_alerts --convert-group` 只是**薄封装**,
脚本单测用「注入假实现验证参数转交」钉住这条约束,防止日后长成第二份实现。
2. **锁与幂等锚点都沿用既有口径,不新造**:锁键 `convert:rerun:{gid}` 与 `convert_fund`
幂等重试路径**同一个键**(人工补跑 vs 客户端重试不并发重复出单);幂等锚点 = **转出端
`out_trade_id`**(一次转换两条流水,只认转出端;复用 `find_alerts_by_trade`)。
3. **`has_engine_error_audit` 加 `decision` 参数而非另写一份查询**:convert 线阶段 1.5 失败的
审计决策码是 **`engine_error`**,与普通交易的 **`risk_engine_error` 不是同一个码**;
默认值保持 `risk_engine_error`,B9a 既有路径零改动。
**`state` 四态 + CLI 退出码**:`rebuilt`(0) / `skipped`(0,幂等) / `missing`(1,Core 侧不足两条流水,
零写入) / `locked`(3,未抢到 `convert:rerun` 锁)。**2 保留给 argparse 用法错误**,故 locked 取 3。
**验证**
- `pytest -q` → **731 passed / 3 skipped**(基线 719 **+12**,零回归)
- **突变验证 3 组**:① 去掉预警侧幂等锚点(恒跑引擎)→ **精准 1 红**;
② 去掉 `list_expired_candidates` 的 `status` 过滤 → **精准 2 红**;③ 补偿无视抢锁结果 → **精准 1 红**。
均已恢复,`grep "MUTATION\|1 = 1"` **无残留**
- **真库**:`verify_convert_compensate.py` **34/34**、跑完**零残留**
- **全套真库脚本复跑**(铁律 2:`convert_service` / `risk_repository` / `core_ro` 三处均被触及):
seed `全部 PASS` · apply `24/24` · service `35/35` · engine `31/31` · lots `20/20` · tools `14/14` ·
compensate `34/34`,**均零回归**
**真库专属证据(sqlite 给不了的,本任务核心增量)**
1. `status='expired'` 在 MySQL **ENUM** 上被接受 —— sqlite 该列是 `VARCHAR(16)`,**写什么都收**,
「能写进 expired」只能在真库证明。
2. `created_at < cutoff` 在 **DATETIME(3)** 上的时间边界正确(超时进候选 / 未超时不进 / 复跑幂等)。
3. ⭐ **`input_summary` 是真 JSON 列**,而 `has_engine_error_audit` 用 `LIKE '%gid%'` 判定。
脚本先断言 `information_schema` 里 `DATA_TYPE='json'` 再验命中,并**反向断言**
「决策码不匹配则不命中」,证明过滤真的按 `decision` 走(sqlite 是 TEXT,此风险不可见)。
**新增目录 `scripts/agent/`**:按架构 §2 与开发计划 §9 指定路径落地。`scripts/` 下原有
`core`/`cron`/`demo`/`dev`/`kb`/`sync`,其中 `cron` 已承载两个巡检脚本 —— 若后续要统一巡检脚本的家,
可评估合并;**本次不擅自偏离已评审的架构文档**。
**顺带收口(用户指示「顺手改完」)**:`core_ro.concentration_profile` 补 `h.qty > 0`,
与 `list_holdings` **真正同口径**(`ratio` 不变,但 `rows` 不再多出已清仓产品、不虚占截断位)。
新用例含**跨出口一致性断言**;突变验证:去掉 `h.qty > 0` → **精准 1 红**(已恢复)。
**文档回写**:开发计划 §9(DoD 全勾 + **新增 §9.1 执行记录**)+ §7.3 收口段 · `交接文档.md` **v2.3**
(表头 / §0 三线状态 / B.1 / B.6 拓扑与基线 / **新增 B.6.5**)· `docs/memory/{TODO,MEMORY}` · 本条。
## ✅ T-13 全量回归 + 50 并发压测 + 性能补录 已完成(2026-09-10 · 本线最后一个任务 · 已结项)
**执行期发现并修复 2 处并发缺陷**(压测暴露,非需求变更;sqlite 单测均不可见)
1. **阶段一失败后同键重试 → 永久 503**:阶段一 `LotConflict`(409) → 占位 `mark_failed` → 同键重试
复用 group_id 重入阶段零 → `insert_placeholder` 朴素 INSERT 撞 `uk_group`/`uk_idem` →
`IdempotencyUnavailable`(503)。§8.3 明说 409 可重试,此路径**重试永不成功**。
修法:`ConvertRepository.insert_placeholder` 改**三步法**(R-a),已存在则置回 `pending`。
2. **同键并发的两个竞态子窗口**(**资金安全**):
- 子窗口 A(T2 在占位后进入):各自 `group_id` 不同 → 旧代码把 `pending` 当"未成"重跑阶段一
→ **真·双扣**(突变实测扣 120→**240**、两组流水)。
- 子窗口 B(T2 在占位前进入):撞 `uk_idem` → `IntegrityError` 直穿 503。
- 修法:① 幂等判定 `pending` → **202**(不当"未成"重跑);② `insert_placeholder` 返回"是否持有占位",
撞键即**让路** → 调用方 **202**。**两处缺一不可**。
- 复现用**确定性交错**(`threading.Event` / `Barrier` 拦住 T1 阶段一)——把"碰运气"变成"必现"。
**50 并发压测结论(评审 Q6)**:50 × 2000 争 50000 → **不超卖硬不变量成立**(实测成交 20~25,理论上限 25)·
**紧池退避 40/50 = 80%(不够用)** · **松池 50/50 = 100%** · `convert_group_id` 无重复、`remain_qty` 守恒。
> **⚠️ 新增待评估项**:跨客户并发触发 InnoDB **1213 死锁**(唯一索引上插入意向锁互斥,8 路实测 3~5 次);
> `apply_convert` **不重试死锁**,上层按 §8.3 当可重试异常收敛后 8/8 成功。**服务端是否补重试待用户评估**。
> **退避不够用的根因不在间隔**:一笔需求被拆成 `400+600` 碎片后,碎片所在批次随时被清零 → 重试仍抢不到;
> 要收敛到 100% 需**增加重试次数**或**冲突后换批次重规划**,单纯拉长间隔无效。
**性能实测(PRD §9 第 18 条,v0.9.3)**:端到端 **P50 44.2 / P95 51.5 / max 59.8 ms**(阈值 2s)·
阶段一 **P50 9.8 / P95 12.8 / max 16.7 ms**(预估 <100ms)→ **远未触阈值、未改任何实测值、无需重定阈值**。
**验证**:`pytest -q` **736 passed / 10 skipped**(731 **+5**,两遍稳定)· `CONVERT_STRESS=1` 并发 **9 passed** ·
按 SOP 重灌双库后复跑零回归 · 7 个真库脚本复跑零回归 · **突变 4 组全部精准命中**(超卖 50/50+余 −50000 /
IntegrityError / 真·双扣 240 份 / UNIQUE constraint failed),已恢复无残留。
**门禁语义**:`CONVERT_STRESS=1` 才跑 7 条重载用例(另 2 条常跑);常规 CI 逐条 skip —— 与架构 §10「不进 CI」一致。
**文档回写**:开发计划 **§10**(10.1~10.5)· PRD **v0.9.3** · `交接文档.md` **v2.4**(新增 **B.6.6** + B.8 补 3 条坑)·
`docs/memory/{TODO,MEMORY,FRAMEWORK,ITERATION}` · 本条。
**✅ 基金转换线全部结项(T-0 ~ T-13)**,基线 **510 → 736 passed / 10 skipped**,**无待办**。
**T-13 已提交 `c56ea8d`**(11 个文件,+1225/−50;工作区干净);本地 HEAD 领先远程 `fffb78a` **16 个提交、未推送**;提交与否由用户决定,**不擅自 push**。