feat(backend): F1-6 sprint1 sandbox_replay 模块脚手架 + get_last_visit_days 迁移试点 (W1)

F1-5b 完成后启动 F1-6 沙箱时光机阶段 B,Sprint 1 范围:
1. 建立 sandbox_replay 模块脚手架
2. 实现 @runtime_aware decorator(自动注入 RuntimeContext)
3. 试点迁移 1 个指标:get_last_visit_days(P1-4 距上次到店天数)
4. MCP 端到端 4a/4b 双口径走查

新增文件:
- apps/backend/app/services/sandbox_replay/__init__.py(模块入口 + re-export)
- apps/backend/app/services/sandbox_replay/_decorator.py(@runtime_aware 实现)
- apps/backend/app/services/sandbox_replay/consumption_replay.py(get_last_visit_days 试点)
- docs/_overview/wave1-findings/F1-6-tasks.md(F1-6 任务清单 4 个 sprint)
- docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md(Sprint 1 审计)

修改:
- apps/backend/app/services/fdw_queries.py:218-238
  get_last_visit_days 改 thin wrapper,委托 sandbox_replay.consumption_replay
  保持 75+ 现有调用点无感兼容

关键 bug 修复:@trace_service + @runtime_aware 嵌套 sig.bind 失败
- 现象:仅 FastAPI 请求 + 有活跃 TraceContext 时复现
  (直接 Python 脚本 get_current_trace 返回 None,跳过 _build_params_dict)
- 根因:functools.wraps 设置 __wrapped__ 让 inspect.signature 追溯原函数
  → bind 时缺 keyword-only 必传 ctx → TypeError
- 修复:_decorator.py 不用 functools.wraps,手动复制元信息但不设
  __wrapped__,inspect.signature 看到 wrapper 自身 (*args, **kwargs)

测试:
- unit test 10/10 PASS(本地 .gitignore:71 不入仓)
- 直接 customer_service.get_customer_detail PASS(独立诊断脚本)
- etl_conn 复用模式 PASS

MCP 双口径(member=2799207087163141 黄先生):
- 4a live (today=2026-05-05): daysSinceVisit=32(05-05 - 04-03)
- 4b sandbox=2026-04-20: daysSinceVisit=31(04-20 - 03-20)
  通过插 walkthrough 测试快照 stat_date=2026-04-15 演示
  (测试库 dws_member_consumption_summary 仅 stat_date=2026-05-01 一行,
   sandbox=4-20 时被视图层 stat_date <= business_date_now() 过滤)
- 测试快照已清理,sandbox 已切回 live

Sprint 2-4 待启动(见 F1-6-tasks.md):
- Sprint 2:5 个会员 P1 指标(余额/60d 消费/累计交易/GMV/累计服务客户数)
- Sprint 3:5 个助教/门店 P1 + MP-2 完整(含 ETL 改造)
- Sprint 4:5 个 P2 算法重算指标

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-05 23:53:19 +08:00
parent 5d4da0ae8c
commit 9f1e35d71a
6 changed files with 522 additions and 51 deletions

View File

@@ -0,0 +1,119 @@
# 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移
> F1-6 沙箱时光机阶段 B Sprint 1(详见 `docs/_overview/wave1-findings/F1-6-tasks.md`)
>
> 工作量评估 M / 4-5h(实际 ~ 5h,含 trace_service + runtime_aware 嵌套 bug 排查 1.5h)
>
> 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md`
## 背景
F1-5b 收尾后启动 F1-6 沙箱时光机阶段 B,Sprint 1 范围:
1. 建立 `sandbox_replay/` 模块脚手架
2. 实现 `@runtime_aware` decorator(自动注入 RuntimeContext)
3. 试点迁移 1 个指标:`get_last_visit_days`(距上次到店天数,P1-4)
4. MCP 端到端走查 4a/4b 双口径
## 改动清单
### Step 3 实施
**新增文件**(3 个):
- [apps/backend/app/services/sandbox_replay/__init__.py](apps/backend/app/services/sandbox_replay/__init__.py) — 模块入口 + re-export
- [apps/backend/app/services/sandbox_replay/_decorator.py](apps/backend/app/services/sandbox_replay/_decorator.py) — `runtime_aware` decorator 实现
- [apps/backend/app/services/sandbox_replay/consumption_replay.py](apps/backend/app/services/sandbox_replay/consumption_replay.py) — 消费/到店相关 sandbox 重算实现(`get_last_visit_days` 试点)
**修改文件**:
- [apps/backend/app/services/fdw_queries.py](apps/backend/app/services/fdw_queries.py)(L218-238)`get_last_visit_days` 改为 thin wrapper,委托给 sandbox_replay 实现,保持 75+ 现有调用兼容
**测试文件**(本地不入仓):
- [apps/backend/tests/test_sandbox_replay_sprint1.py](apps/backend/tests/test_sandbox_replay_sprint1.py)
- 10 case 全 PASS:decorator 契约 5 case + consumption_replay 行为 3 case + thin wrapper 链路 1 case + (含 1 个 negative case)
### Step 4 双口径验证 — 关键 bug 发现 + 修复
**首次走查发现** API `daysSinceVisit` 返回 null,但直接 Python 调用返回正确值 32 → 后端 reload 看似生效但 sandbox_replay 链路未被触发。
经多轮诊断:
1. 排除 reload 卡死(后端 /health 200,Neo 完整重启)
2. 排除 junction 穿透(本机无 D 盘)
3. 排除 schema/字段映射(CamelModel 输出正确)
4. 在 customer_service.py:138 加临时 diag 写文件,捕获异常:
```
[before] cust=2799207087163141 site=2790685415443269 etl_conn_type=connection
[exception] TypeError: missing a required argument: 'ctx'
```
**根因**:`@trace_service` + `@runtime_aware` 嵌套时:
- `runtime_aware` wrapper 用 `functools.wraps(func)` 复制元数据 → 设置 `__wrapped__` 属性
- 外层 `trace_service` 在有活跃 TraceContext 时调 `_build_params_dict(func, args, kwargs)`,内部用 `inspect.signature(func).bind(*args, **kwargs)` 做参数 redact
- `inspect.signature` 默认追溯 `__wrapped__` 链,看到原函数签名(含 keyword-only 必传 `ctx` 参数)
- 调用时 args/kwargs 不含 ctx(因为 wrapper 应该自动注入)→ sig.bind 抛 TypeError
- trace_service 让该 TypeError 冒泡 → customer_service except 捕获 → days_since_visit=None
**关键诊断点**:仅在 FastAPI 请求 + 有活跃 trace 时复现(直接 Python 脚本 get_current_trace 返回 None,跳过 _build_params_dict)。
**修复**:`runtime_aware` wrapper 不用 `functools.wraps`,改手动复制 `__name__/__qualname__/__module__/__doc__`,**不设 `__wrapped__`**,让 `inspect.signature` 看到 wrapper 自身 `(*args, **kwargs)` 签名,sig.bind 永远成功。
修复后 unit test 10/10 仍 PASS(decorator 契约不变)。
### Step 4 双口径走查证据(关键样本)
**目标 member**: 2799207087163141(黄先生)
| 维度 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 |
|------|---------------------------|----------------------|
| stat_date 数据 | 2026-05-01(真实) | 2026-04-15(walkthrough 测试快照) |
| last_consume_date | 2026-04-03 | 2026-03-20 |
| daysSinceVisit 计算 | **32** = 05-05 - 04-03 | **31** = 04-20 - 03-20 |
| API `/api/xcx/customers/2799207087163141` 返回 | 32 ✓ | 31 ✓ |
**关键证据**:sandbox 切换时,sandbox_replay 框架按 `RuntimeContext.business_date` 实时计算,完全符合"沙箱时光机"语义。
测试快照已清理:`DELETE FROM dws.dws_member_consumption_summary WHERE member_id=2799207087163141 AND stat_date='2026-04-15'`(1 行)。
sandbox 已切回 live。
### Step 5 审计
本文件 + F1-6-tasks.md 任务清单。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 sandbox_replay 模块 | **新增** 3 个文件 | unit test 10/10 PASS |
| 后端 fdw_queries.get_last_visit_days | 改 thin wrapper(行为完全一致) | MCP 4a/4b 双口径 PASS |
| 75+ 现有 fdw_queries.get_last_visit_days 调用点 | **无感**(thin wrapper 透明委托) | customer-detail.daysSinceVisit live=32 / sandbox=31 |
| 其他 backend service | 无影响 | — |
| admin-web / 小程序 / ETL | 无影响 | — |
## 测试
- 后端 unit test 10/10 PASS(本地 `apps/backend/tests/test_sandbox_replay_sprint1.py`,因 .gitignore:71 不入仓)
- MCP 端到端 4a/4b 双口径 PASS
- 直接 Python 调用 customer_service.get_customer_detail PASS
- 直接 sandbox_replay.consumption_replay.get_last_visit_days PASS
- etl_conn 复用模式 PASS
## 风险与未覆盖
- **本 sprint 仅迁移 1 个指标**:试点验证框架可用,Sprint 2-4 批量迁移其他 18 个指标
- **decorator 元信息不完整**:不用 functools.wraps 后,`inspect.signature(wrapper)` 看到 (*args, **kwargs),无法看到原函数参数。如果未来其他 decorator 也依赖原函数签名(如 OpenAPI 生成),可能需要更复杂方案。当前 trace_service 是唯一已知会用 inspect.signature 的外层装饰器
- **测试库 dws_member_consumption_summary 数据局限**:仅 stat_date=2026-05-01 一个快照(205 行),sandbox=4-20 时所有快照都被视图层过滤。本次 4b 走查通过插测试快照(stat_date=2026-04-15)演示框架行为,实际生产数据应有连续 daily 快照不会有此问题
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后:
- 删除 sandbox_replay/ 目录
- fdw_queries.get_last_visit_days 恢复原直接 SQL 实现
- 75+ 调用点无影响(thin wrapper 透明)
- 测试文件本地保留(.gitignore 范围内)
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>