47 KiB
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 描述一致,架构文档无误。
⚠️ 核对中发现的两处「文档与实况不符」(以实况为准)
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只是本地远程跟踪引用失效,非远程分支被删。- 基金转换线 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)→/health200 → 日志未出现该告警 ✓。 - 顺带验证 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-4001is_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_alertCUST-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,
/health200)→ 交易返回blocked=false,alert_ids=["ALT-20260910-7BCBBC94"],业务照常完成 ✓ - 日志明确出现 2 次
Redis 锁不可用,退回进程内锁:lock:agg:event:CUST-3001:2026-09-10(alert_service聚合锁)、lock:l3:CUST-3001(profile_l3L3 锁)✓ - 底层异常:
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 卡死):
DROP DATABASE IF EXISTS jinrong_core; DROP DATABASE IF EXISTS jinrong_agent;- 依序灌 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 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 <dir> -type f | wc -l复核,异常立即git checkout HEAD -- <dir>。 - 根因未查明,规避即可。
- ❌ 本仓库禁止对
七、设计方法论固化为 skill + 记忆精简(2026-09-10 晚)
动机:项目记忆 MEMORY.md 与交接文档膨胀——13 问 / AIcoding 六步 / 独立审查协议 / 写计划 4 法 等项目无关的方法论每个项目都重写一遍,且挤占了项目状态信息。
落地:
- 新建用户级 skill
design-doc-selfcheck(~/.workbuddy/skills/design-doc-selfcheck/SKILL.md)—— 固化:AIcoding 六步流程 · 设计自检 13 问(含 why/翻车案例)· PRD 独立 AI 审查协议(自包含包 + 只报告不改动 + 逐条判定)· 写开发计划 4 法 · 真实翻车案例库(4 处外部事实漏网 / 分类混用 / 公式副本 / 重置伴随数据 / 方言互斥)。 - 精简项目
MEMORY.md(205 行 → ~70 行):删除已入 skill 的方法论 verbatim,只留三条线状态、git rm禁忌、交接文档定位、基金转换核心事实与业务口径、交易发起主体合规、D20 账号,并指向 skill。 - 交接文档 §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.py15 用例;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 参考)
id_factory必须注入:默认生成CNV-<日期>-<uuid>,与清理口径LIKE 'CNV-T8M%'不匹配 → 重跑遗留行撞uk_group。注入_t8m_id并把清理条件补client_request_id LIKE 'T8M-%'兜底。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 执行记录)
- 正则落点:计划写「复用
main.py:44」,但app.main→app.api.simulate单向导入链,反向 import 成环 → 上移trace.py。连带突破 §12 R14「本次不动 main.py」(中间件注册顺序未动,守卫用例仍绿)。 gateway_repository.insert_trade加列:裁定不需要 —— convert 写路径在convert_core_repository.apply_convert,该文件零改动。- 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.pyR15 处置:端到端已迁入新文件;原槽位未删除,改造为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[].qty4 位 → 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_apply24/24 · T-7verify_convert_service35/35 · T-8verify_convert_engine31/31 calc_convert_demo.py15/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 <sha> → 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")。
两条执行期裁定(计划未点明,已留痕)
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)。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 文件
app/service/risk/rules.py——_amount_view提升为公开amount_view(跨层复用;全仓唯一金额聚合口径);run_rules内局部变量改名amt_view;docstring 补 SQL 等价条件。app/tool/core_tools.py——query_recent_trades的sum_amount走amount_view(明细与total_count保持全量:一次转换两条流水是真实的两笔权益变动);query_holdingsdocstring 说明归零行已在 SQL 层过滤。app/repository/core_ro.py——list_holdings加AND h.qty > 0;sum_trades_on_date加 convert 去重条件(R-d)。scripts/dev/verify_convert_tools.py【新增】—— 真库 14/14。tests/test_core_tools.py【新增】3 条;tests/test_core_ro_sum.pyfixture 增gid+ 追加 1 条;tests/test_convert_engine.py随改名同步(断言零改动)。
执行期裁定 2 条(计划未点明,已留痕)
_amount_view提升为公开,而非在core_tools复制一份 —— 计划只说「汇总走_amount_view」, 但它是私有名。按自检第 13 问提升为公开保单一实现;否决「公开别名」方案(一函数两名 = 新混淆源)。 依赖方向已核实:app/tool/kb_tools.py:22早有 tool→service 先例,无循环导入。- ⭐ 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.py15 条断言零改动- 突变验证 3 组:① 去掉 gid 条件 → 精准 2 红;② 去掉
qty > 0→ 精准 1 红; ③ 不走amount_view→ 精准 2 红。均已恢复,grep "1 = 1\|MUTATION"无残留 - 真库:
verify_convert_tools.py14/14、跑完零残留 - 回归复跑(铁律 2:接线变化必跑):T-8
31/31(amount_view改名影响面)· T-735/35· T-1020/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 条(留痕)
- 补偿逻辑收敛到 service 单点(自检第 13 问):
rebuild_alerts --convert-group只是薄封装, 脚本单测用「注入假实现验证参数转交」钉住这条约束,防止日后长成第二份实现。 - 锁与幂等锚点都沿用既有口径,不新造:锁键
convert:rerun:{gid}与convert_fund幂等重试路径同一个键(人工补跑 vs 客户端重试不并发重复出单);幂等锚点 = 转出端out_trade_id(一次转换两条流水,只认转出端;复用find_alerts_by_trade)。 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.py34/34、跑完零残留 - 全套真库脚本复跑(铁律 2:
convert_service/risk_repository/core_ro三处均被触及): seed全部 PASS· apply24/24· service35/35· engine31/31· lots20/20· tools14/14· compensate34/34,均零回归
真库专属证据(sqlite 给不了的,本任务核心增量)
status='expired'在 MySQL ENUM 上被接受 —— sqlite 该列是VARCHAR(16),写什么都收, 「能写进 expired」只能在真库证明。created_at < cutoff在 DATETIME(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 单测均不可见)
- 阶段一失败后同键重试 → 永久 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。 - 同键并发的两个竞态子窗口(资金安全):
- 子窗口 A(T2 在占位后进入):各自
group_id不同 → 旧代码把pending当"未成"重跑阶段一 → 真·双扣(突变实测扣 120→240、两组流水)。 - 子窗口 B(T2 在占位前进入):撞
uk_idem→IntegrityError直穿 503。 - 修法:① 幂等判定
pending→ 202(不当"未成"重跑);②insert_placeholder返回"是否持有占位", 撞键即让路 → 调用方 202。两处缺一不可。 - 复现用确定性交错(
threading.Event/Barrier拦住 T1 阶段一)——把"碰运气"变成"必现"。
- 子窗口 A(T2 在占位后进入):各自
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。