feat(backend): F1-6 sprint2 #4 储值卡余额迁移 sandbox_replay (SCD2 时光机)

新建 sandbox_replay/balance_replay.py 模块,迁移 fdw_queries.get_member_balance,
fdw_queries 改 thin wrapper 保持 5 处现有调用(chat/coach/customer x2/task_manager)
透明兼容。

数据源 dim_member_card_account 是 SCD2 维度表(原生支持时光机),sandbox 改造
关键是替换 scd2_is_current=1 过滤为 scd2_start_time + scd2_end_time 时间过滤
(ref_date+1day 边界 = 当天结束时仍 active 的版本,timestamptz 比较稳定)。

双口径 UI 走查 PASS(member=2799207363643141 葛先生,SCD2 历史余额变化样本):
- 4a live(today=2026-05-05): 储值余额 ¥6,602
- 4b sandbox=2026-04-20: 储值余额 ¥18,080(差异 1.1w+,时光机效果显著)

unit test sprint1+sprint2 累计 24/24 PASS,无回归。

附带本次 sprint 2 触发的架构级登记:
- 新建 docs/_overview/architecture-evolution-backlog.md(DWD 孤立 + Core 中间件 +
  库重组,长远架构演进 backlog)
- F1-6-tasks.md 登记 #3 累计交易笔数推迟 Sprint 3(ETL 配合新增
  total_open_table_count,因现有 total_visit_count 实算 COUNT(settle_type IN (1,3))
  含商城订单,不符 Neo "开台次数"业务语义)
- sandbox-replay-engine-spec §5.5 thin wrapper 决策原则(已在 #2 commit)

详见 docs/audit/changes/2026-05-06__f1_6_sprint2_member_balance.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neo
2026-05-06 01:26:18 +08:00
parent 32716bc71a
commit 7b1cfadc2e
7 changed files with 299 additions and 32 deletions

View File

@@ -1,12 +1,13 @@
# 审计一览表
> 自动生成于 2026-05-06 00:51:54,请勿手动编辑。
> 自动生成于 2026-05-06 01:25:49,请勿手动编辑。
## 时间线视图
| 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|------|----------|----------|----------|------|------|
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 项目级 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 项目级 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 项目级 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |
@@ -291,6 +292,7 @@
| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |
|------|----------|----------|----------|------|------|
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 其他 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 其他 | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |
@@ -464,6 +466,7 @@
| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |
|------|----------|----------|------|------|
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #1 — 60 天消费迁移到 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_consumption_60d.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机) | 功能 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_member_balance.md) |
| 2026-05-06 | 2026-05-06 · F1-6 Sprint 2 #2 — 累计消费总额加入 sandbox_replay | 清理 | 未知 | [链接](changes/2026-05-06__f1_6_sprint2_total_consume_amount.md) |
| 2026-05-05 | 2026-05-05 · F1-6 Sprint 1 沙箱时光机引擎启动 + get_last_visit_days 试点迁移 | bugfix | 未知 | [链接](changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md) |
| 2026-05-05 | 2026-05-05 — Wave 1 F1-5a 完整走查(应查尽查版) | bugfix | 未知 | [链接](changes/2026-05-05__wave1_f1_5a_backend_walkthrough.md) |

View File

@@ -0,0 +1,121 @@
# 2026-05-06 · F1-6 Sprint 2 #4 — 储值卡余额迁移到 sandbox_replay(SCD2 时光机)
> F1-6 沙箱时光机阶段 B Sprint 2 #4(详见 `docs/_overview/wave1-findings/F1-6-tasks.md` §3)
>
> 工作量评估 S / 30-50min(实际 ~ 40min)
>
> Sprint 2 进度:#1 ✅ → #2 ✅ → #3 推迟 Sprint 3 → **#4 储值卡余额 ☚** → #5 累计 GMV
>
> 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md` §5.5(thin wrapper 决策)
## 背景
#4 储值卡余额是 Sprint 2 第 4 个迁移指标。**与 #1 #2 不同,数据源是 SCD2 维度表 `dim_member_card_account`,非 daily 累计快照表**。SCD2 维度表原生支持时光机回溯,sandbox 改造关键在替换 `scd2_is_current=1` 过滤为 `scd2_start_time` + `scd2_end_time` 时间过滤。
## 改动清单
### Step 3 实施
**新增文件**(1 个):
- [apps/backend/app/services/sandbox_replay/balance_replay.py](apps/backend/app/services/sandbox_replay/balance_replay.py) — 新建模块,实现 `get_member_balance` 函数
**修改文件**(2 个):
- [apps/backend/app/services/sandbox_replay/__init__.py](apps/backend/app/services/sandbox_replay/__init__.py) — re-export `get_member_balance_replay`
- [apps/backend/app/services/fdw_queries.py](apps/backend/app/services/fdw_queries.py)`get_member_balance`(行 ~185)改 thin wrapper,委托给 sandbox_replay 实现,保持 5 处现有调用兼容
**测试文件**(本地不入仓 `.gitignore:71`):
- [apps/backend/tests/test_sandbox_replay_sprint2.py](apps/backend/tests/test_sandbox_replay_sprint2.py) — 累计 14 case(本指标 5 case)
测试覆盖:
- 空 member_ids 返回空 dict
- 正常返回 {member_id: Decimal} 映射
- sandbox 模式 SQL 用 SCD2 时光机过滤(`scd2_start_time` + `scd2_end_time`),不再含 `scd2_is_current`,SUM(balance) 多卡聚合保留
- SUM(balance) 为 NULL 时返回 Decimal('0')(沿用原行为)
- thin wrapper 委托链路验证
合并 sprint 1 + sprint 2 #1/#2/#4 测试套件:**24/24 PASS,无回归**。
### SCD2 时光机 SQL 关键差异
```sql
-- 原 fdw_queries(live only):
WHERE tenant_member_id = ANY(%s)
AND scd2_is_current = 1
GROUP BY tenant_member_id
-- 新 sandbox_replay(时光机):
WHERE tenant_member_id = ANY(%s)
AND scd2_start_time < (%s::date + INTERVAL '1 day')
AND (scd2_end_time IS NULL
OR scd2_end_time >= (%s::date + INTERVAL '1 day'))
GROUP BY tenant_member_id
```
`ref_date = ctx.business_date`,`ref_date+1day` 边界 = `ref_date` 当天结束时仍 active 的版本。
live 模式 ref_date=today,效果等同 `scd2_is_current=1`(实测验证)。
### Step 4 双口径 UI 走查证据
**目标 member**: 2799207363643141(葛先生,138****8071)
> ⚠️ 切换 member 原因:黄先生(2799207087163141)在测试库 SCD2 历史中 `dim_member_card_account` 自 2026-04-06 起无变更,sandbox=2026-04-20 与 live 都返回 ¥547.29(无差异),不能演示时光机效果。葛先生在 04-20 与 05-05 之间有大额余额变化,差异 1.1w+,演示效果最显著。
**双截图对照**:
| 卡条位置 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 |
|---------|---------------------------|----------------------|
| **#1 储值余额 ☚** | **¥6,602** | **¥18,080** |
| #2 60天消费 | ¥73,614 | --(测试库 dws 无 stat_date<=04-20 数据)|
| #3 理想间隔 | 1天 | 1天 |
| #4 距今到店 | 11天 | --(同上 dws 数据局限)|
走查截图(双口径 #1 格 sandbox 时光机数值变化清晰可见):
- `_DEL/walkthrough_f1_6/sprint2_4a_live_balance.png` — 顶部 stat 卡条 `¥6,602 / ¥73,614 / 1天 / 11天`
- `_DEL/walkthrough_f1_6/sprint2_4b_sandbox_balance.png` — 顶部 stat 卡条 `¥18,080 / -- / 1天 / --`
**关键证据**:同一会员葛先生,SCD2 维度表里 2026-04-20 时余额 ¥18,079.55,2026-05-05(live)余额 ¥6,602.07,sandbox 切换准确还原历史余额。**差异 1.1w+,SCD2 时光机过滤生效铁证**。
### Step 5 审计
本文件 + F1-6-tasks.md Sprint 2 进度推进 + audit dashboard 刷新。
## 影响范围
| 端 | 影响 | 验证 |
|----|------|------|
| 后端 sandbox_replay.balance_replay | **新增** 模块 + `get_member_balance` 函数 | unit test 5/5 + 累计 24/24 PASS |
| 后端 fdw_queries.get_member_balance | 改 thin wrapper(行为完全一致) | UI 4a/4b 双口径 PASS |
| 后端 5 处调用方(chat / coach / customer ×2 / task_manager)| 无感(thin wrapper 透明委托,签名不变)| 4a=¥6,602 / 4b=¥18,080 |
| 小程序 customer-detail | 无感(API 字段名不变)| UI 双截图实地确认 |
| admin-web / 租户后台 / ETL | 无影响 | — |
## 测试
- 后端 unit test 24/24 PASS(本地 `apps/backend/tests/test_sandbox_replay_sprint{1,2}.py`,因 `.gitignore:71` 不入仓)
- MCP 端到端 4a/4b 双口径 PASS(含 navigate_to + snapshot + screenshot **完整 UI 验证**)
- 直接 SQL 实测 SCD2 时光机过滤行为验证
- 时区敏感性验证(Asia/Shanghai 一致 + PG date+1d vs timestamptz 比较语义稳定)
## 风险与未覆盖
- **现有口径未拆分储值卡 vs 赠送卡**:`get_member_balance` 直接 SUM 全部 balance,本 sprint 保持口径不变。如未来需展示"储值卡余额"+"赠送卡余额"分项(参考 dws_finance_recharge_summary.cash_card_balance / gift_card_balance 字段),登记到架构演进 backlog
- **`scd2_end_time` 当前 ETL 默认值是 `'9999-12-31'` 而非 NULL**:SQL 用 `IS NULL OR >= ref_date+1` 兼容两种约定,防御未来 ETL 改动
- **测试库 SCD2 历史数据局限**:并非所有会员都有 sandbox 期内的余额变化历史,需选择有差异的样本验证(本次选葛先生)
- **多连接器架构演进**:本 SCD2 时光机查询直接读 dwd 维度表(通过 app.v_dim_member_card_account 视图),长远应通过 Core 层统一接口(详见 [架构演进 backlog](../../_overview/architecture-evolution-backlog.md) 第 4 项)
## 回滚策略
```bash
git revert <commit_hash>
```
回滚后:
- 删除 `sandbox_replay/balance_replay.py` 新建文件
- `sandbox_replay/__init__.py` 移除 re-export
- `fdw_queries.get_member_balance` 恢复原 `scd2_is_current=1` 实现
- 5 处调用点无影响(thin wrapper 透明)
- 测试文件本地保留(`.gitignore` 范围内)
## Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>