From 9f1e35d71a21ed59a33502809c18c9cc4f7524b4 Mon Sep 17 00:00:00 2001 From: Neo Date: Tue, 5 May 2026 23:53:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20F1-6=20sprint1=20sandbox=5Frep?= =?UTF-8?q?lay=20=E6=A8=A1=E5=9D=97=E8=84=9A=E6=89=8B=E6=9E=B6=20+=20get?= =?UTF-8?q?=5Flast=5Fvisit=5Fdays=20=E8=BF=81=E7=A7=BB=E8=AF=95=E7=82=B9?= =?UTF-8?q?=20(W1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/backend/app/services/fdw_queries.py | 65 ++------ .../app/services/sandbox_replay/__init__.py | 42 +++++ .../app/services/sandbox_replay/_decorator.py | 104 +++++++++++++ .../sandbox_replay/consumption_replay.py | 97 ++++++++++++ docs/_overview/wave1-findings/F1-6-tasks.md | 146 ++++++++++++++++++ ...05__f1_6_sprint1_sandbox_replay_kickoff.md | 119 ++++++++++++++ 6 files changed, 522 insertions(+), 51 deletions(-) create mode 100644 apps/backend/app/services/sandbox_replay/__init__.py create mode 100644 apps/backend/app/services/sandbox_replay/_decorator.py create mode 100644 apps/backend/app/services/sandbox_replay/consumption_replay.py create mode 100644 docs/_overview/wave1-findings/F1-6-tasks.md create mode 100644 docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md diff --git a/apps/backend/app/services/fdw_queries.py b/apps/backend/app/services/fdw_queries.py index 79035e4..71bf1ed 100644 --- a/apps/backend/app/services/fdw_queries.py +++ b/apps/backend/app/services/fdw_queries.py @@ -216,63 +216,26 @@ def get_member_balance( return result -@trace_service(description_zh="获取最后到店天数", description_en="Get last visit days") def get_last_visit_days( conn: Any, site_id: int, member_ids: list[int], *, etl_conn: Any = None, ) -> dict[int, int | None]: + """批量查询客户距上次到店天数(thin wrapper,实际实现已迁移)。 + + F1-6 Sprint 1: 实现迁移到 sandbox_replay/consumption_replay.py。 + 本函数保留为 thin wrapper,兼容现有 75+ 调用点。 + + 来源:app.v_dws_member_consumption_summary。 + 实时计算:``business_date - last_consume_date``,仅取 + ``stat_date <= business_date`` 的快照行,sandbox 模式自动按 sandbox_date 截断。 + + 返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。 """ - 批量查询客户距上次到店天数。 + from app.services.sandbox_replay.consumption_replay import ( + get_last_visit_days as _replay_impl, + ) - 来源: app.v_dws_member_consumption_summary。 - consumption_summary 按 stat_date 有多行快照,取最新一行。 - - CHANGE 2026-05-02 | 修复客户看板「最近到店」数据不准的问题: - - 旧版直接用 days_since_last(ETL 在 stat_date 那天预计算的快照值)。 - 若 ETL 没跑、跑得迟、或 sandbox_date 与 stat_date 不一致,结果就会严重失真。 - - 新版改为实时计算:``business_date - last_consume_date``, - 仅取 ``stat_date <= business_date`` 的快照行,沙箱模式下也能拿到一致结果。 - - 返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。 - """ - if not member_ids: - return {} - - from app.services.runtime_context import as_runtime_today_param - - ref_date = as_runtime_today_param(site_id, conn=conn) - - result: dict[int, int | None] = {} - with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur: - cur.execute( - """ - SELECT DISTINCT ON (member_id) - member_id, - last_consume_date, - stat_date - FROM app.v_dws_member_consumption_summary - WHERE member_id = ANY(%s) - AND stat_date <= %s - ORDER BY member_id, stat_date DESC - """, - (member_ids, ref_date), - ) - for row in cur.fetchall(): - mid = row[0] - last_consume = row[1] - if last_consume is None: - result[mid] = None - continue - try: - # last_consume_date 在 DWS 中是 date;少数实现可能给 timestamp,统一裁剪 - if hasattr(last_consume, "date"): - last_consume = last_consume.date() - days = (ref_date - last_consume).days - result[mid] = max(days, 0) - except Exception: - result[mid] = None - - return result + return _replay_impl(conn, site_id, member_ids, etl_conn=etl_conn) @trace_service(description_zh="获取工资计算数据", description_en="Get salary calculation") diff --git a/apps/backend/app/services/sandbox_replay/__init__.py b/apps/backend/app/services/sandbox_replay/__init__.py new file mode 100644 index 0000000..8207969 --- /dev/null +++ b/apps/backend/app/services/sandbox_replay/__init__.py @@ -0,0 +1,42 @@ +"""沙箱时光机重算引擎 (Sandbox Replay Engine) + +F1-6 沙箱时光机阶段 B 启动模块。 + +设计意图: + P20 沙箱(`runtime_mode='sandbox' + sandbox_business_date`)的"时光机"语义 + 要求所有业务指标按 sandbox_business_date 重新累计/截断,而非展示 ETL + 跑批的最新累计。F1-5a/F1-5b 已建立基础设施(RuntimeContext + app 视图 + 业务日上界),本模块统一所有"runtime-aware"业务读取路径。 + +完整 spec: + docs/_overview/sandbox-replay-engine-spec.md + +模块结构(随 sprint 推进逐步填充): + sandbox_replay/ + ├── __init__.py # 本文件,re-export 主要 API + ├── _decorator.py # @runtime_aware decorator + ├── consumption_replay.py # 消费/到店/累计交易(P1-2/3/4/12/13) + ├── balance_replay.py # 会员余额(P1-1)— sprint 2 + ├── assistant_metrics_replay.py # 助教课时/收入/客户(P1-5/6/7/8)— sprint 3 + ├── salary_replay.py # MP-2 完整 daily salary(P2-17)— sprint 3 + ├── adjustments_replay.py # Excel 修正截断(P2-19)— sprint 3 + ├── tasks_replay.py # 任务完成率(P2-18)— sprint 4 + ├── rs_replay.py # RS 算法重算(P2-15)— sprint 4 + └── intimacy_replay.py # 客户黏性(P2-16)— sprint 4 + +接口契约(sprint 1): + @runtime_aware(metric='last_visit_days') + def get_last_visit_days(conn, site_id: int, member_ids: list[int]) -> dict[int, int | None]: + '''函数体内自动接收 RuntimeContext,可读 ctx.is_sandbox / ctx.business_date。''' + ... +""" + +from app.services.sandbox_replay._decorator import runtime_aware +from app.services.sandbox_replay.consumption_replay import ( + get_last_visit_days as get_last_visit_days_replay, +) + +__all__ = [ + "runtime_aware", + "get_last_visit_days_replay", +] diff --git a/apps/backend/app/services/sandbox_replay/_decorator.py b/apps/backend/app/services/sandbox_replay/_decorator.py new file mode 100644 index 0000000..e661015 --- /dev/null +++ b/apps/backend/app/services/sandbox_replay/_decorator.py @@ -0,0 +1,104 @@ +"""runtime_aware decorator: 自动注入 RuntimeContext 的函数装饰器。 + +F1-6 沙箱时光机阶段 B Sprint 1 核心组件。 + +使用模式: + @runtime_aware(metric='last_visit_days') + def get_last_visit_days( + conn, + site_id: int, + member_ids: list[int], + *, + ctx: RuntimeContext, # 由 decorator 自动注入 + ) -> dict[int, int | None]: + # 函数内可直接读 ctx.is_sandbox / ctx.business_date / + # ctx.sandbox_instance_id 等 + ref_date = ctx.business_date # sandbox 模式下取 sandbox_date + ... + +设计要点: + 1. decorator 通过 site_id 参数(必须显式传)调用 get_runtime_context + 2. ctx 作为 keyword-only 参数注入,被装饰函数显式接收 + 3. 兼容现有调用:若调用方已传 ctx kwarg,跳过自动注入(便于测试 mock) + 4. 不强制 cache RuntimeContext(get_runtime_context 内部已无缓存,每次查 DB + 是预期行为,F1-5a 共识) + +性能考虑: + 每次调用查 biz.site_runtime_context 一次(单行 SELECT,~1ms), + 可接受。后续若需性能优化,可在 _fdw_context 层做 connection-level 缓存。 +""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable + +from app.services.runtime_context import RuntimeContext, get_runtime_context + +# 全局开关:metric_name → 监控/审计用,目前仅记录到 logger,后续可接 trace +_REGISTERED_METRICS: dict[str, Callable] = {} + + +def runtime_aware(metric: str) -> Callable[[Callable], Callable]: + """decorator 工厂:自动注入 RuntimeContext。 + + 被装饰函数签名约束: + - 第一个位置参数 conn(数据库连接) + - 第二个位置参数 site_id(多门店隔离 + ctx 查询键) + - 接受 keyword-only `ctx: RuntimeContext` 参数 + + Args: + metric: 指标名,用于注册表 + 日志 + 后续 trace 标注 + + Returns: + 装饰器 + """ + + def _decorator(func: Callable) -> Callable: + sig = inspect.signature(func) + if "ctx" not in sig.parameters: + raise TypeError( + f"runtime_aware: {func.__name__} 必须接受 keyword-only `ctx: RuntimeContext` 参数" + ) + + _REGISTERED_METRICS[metric] = func + + def _wrapper(*args: Any, **kwargs: Any) -> Any: + # 若调用方显式传了 ctx(测试场景),跳过自动注入 + if "ctx" in kwargs: + return func(*args, **kwargs) + + # 解析 conn + site_id(必须是前两个位置参数) + if len(args) < 2: + raise TypeError( + f"runtime_aware {func.__name__}: 必须传 (conn, site_id, ...)" + ) + conn, site_id = args[0], args[1] + + ctx = get_runtime_context(site_id, conn=conn) + return func(*args, ctx=ctx, **kwargs) + + # 手动复制元信息(不用 functools.wraps,避免 __wrapped__ 让外层 + # @trace_service 的 inspect.signature 追溯到原函数,导致 sig.bind + # 因 ctx 必传参数缺失而抛 TypeError) + _wrapper.__name__ = func.__name__ + _wrapper.__qualname__ = func.__qualname__ + _wrapper.__module__ = func.__module__ + _wrapper.__doc__ = func.__doc__ + # 暴露注册元数据,便于测试 / 调试 + _wrapper.__runtime_aware_metric__ = metric # type: ignore[attr-defined] + return _wrapper + + return _decorator + + +def list_registered_metrics() -> list[str]: + """返回所有已注册的 runtime_aware 指标名,用于审计/调试。""" + return sorted(_REGISTERED_METRICS.keys()) + + +__all__ = [ + "runtime_aware", + "list_registered_metrics", + "RuntimeContext", +] diff --git a/apps/backend/app/services/sandbox_replay/consumption_replay.py b/apps/backend/app/services/sandbox_replay/consumption_replay.py new file mode 100644 index 0000000..681980c --- /dev/null +++ b/apps/backend/app/services/sandbox_replay/consumption_replay.py @@ -0,0 +1,97 @@ +"""消费/到店相关 sandbox 重算实现。 + +F1-6 沙箱时光机阶段 B Sprint 1 试点迁移。 + +覆盖指标(渐进): + - P1-4 距上次到店天数(get_last_visit_days)— sprint 1 已迁移 + - P1-2 60 天消费 — sprint 2 + - P1-3 累计消费总额 — sprint 2 + - P1-12 累计交易笔数 — sprint 2 + - P1-13 累计 GMV — sprint 2 + +设计原则: + - 通过 @runtime_aware decorator 自动注入 RuntimeContext + - 函数体内显式使用 ctx.business_date / ctx.is_sandbox + - 与 fdw_queries 中的原始实现保持完全行为一致(回归安全) + - 原 fdw_queries.get_last_visit_days 保留为 thin wrapper 兼容现有调用 +""" + +from __future__ import annotations + +from typing import Any + +from app.services.runtime_context import RuntimeContext +from app.services.sandbox_replay._decorator import runtime_aware +from app.trace.decorators import trace_service + + +@trace_service( + description_zh="获取最后到店天数(sandbox_replay)", + description_en="Get last visit days (sandbox_replay)", +) +@runtime_aware(metric="last_visit_days") +def get_last_visit_days( + conn: Any, + site_id: int, + member_ids: list[int], + *, + etl_conn: Any = None, + ctx: RuntimeContext, +) -> dict[int, int | None]: + """批量查询客户距上次到店天数(sandbox_replay 版本)。 + + 迁移自 fdw_queries.get_last_visit_days(F1-5b 已实现 sandbox 行为)。 + 本函数纯重构,行为完全一致: + - 取业务日为基准(ctx.business_date,sandbox 模式下为 sandbox_date) + - 仅查 stat_date <= business_date 的快照行 + - 实时计算 days = business_date - last_consume_date + + Args: + conn: zqyy_app 业务库连接 + site_id: 门店 ID + member_ids: 待查会员列表 + etl_conn: 可选,显式传 ETL 连接(便于测试 mock) + ctx: RuntimeContext(由 @runtime_aware 自动注入,测试时也可显式传) + + Returns: + {member_id: days_since_visit} 映射,无记录的会员不在结果中。 + last_consume_date 为 NULL 时,值为 None。 + """ + if not member_ids: + return {} + + # 延迟 import 避免循环依赖 + from app.services.fdw_queries import _fdw_context + + ref_date = ctx.business_date + + result: dict[int, int | None] = {} + with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur: + cur.execute( + """ + SELECT DISTINCT ON (member_id) + member_id, + last_consume_date, + stat_date + FROM app.v_dws_member_consumption_summary + WHERE member_id = ANY(%s) + AND stat_date <= %s + ORDER BY member_id, stat_date DESC + """, + (member_ids, ref_date), + ) + for row in cur.fetchall(): + mid = row[0] + last_consume = row[1] + if last_consume is None: + result[mid] = None + continue + try: + if hasattr(last_consume, "date"): + last_consume = last_consume.date() + days = (ref_date - last_consume).days + result[mid] = max(days, 0) + except Exception: + result[mid] = None + + return result diff --git a/docs/_overview/wave1-findings/F1-6-tasks.md b/docs/_overview/wave1-findings/F1-6-tasks.md new file mode 100644 index 0000000..9a4d68e --- /dev/null +++ b/docs/_overview/wave1-findings/F1-6-tasks.md @@ -0,0 +1,146 @@ +# F1-6 沙箱时光机阶段 B 任务清单 + +> 创建日期:2026-05-05 (F1-5b 收尾后启动) +> 完整模块 spec:[`docs/_overview/sandbox-replay-engine-spec.md`](../sandbox-replay-engine-spec.md) +> 上游:F1-5b 全部完成(commit `5d4da0a`) +> +> **状态**:Sprint 1 已启动并完成,Sprint 2-4 待启动 + +## 一、阶段 B 总览 + +按 sandbox-replay-engine-spec 阶段 B 拆 4 个 sprint: + +| Sprint | 范围 | 工作量 | 状态 | +|--------|------|------|------| +| **Sprint 1** | 框架(sandbox_replay 模块 + runtime_aware decorator) + 1 个试点指标(距上次到店天数迁移) | M ~ 4-5h | ✅ 完成(2026-05-05) | +| Sprint 2 | 5 个会员相关 P1 指标(余额 / 60d 消费 / 累计交易 / GMV / 累计服务客户数) | M ~ 4h | ⏳ 待启动 | +| Sprint 3 | 5 个助教/门店相关 P1 + **MP-2 完整**(daily salary 含 ETL 改造) | L ~ 8-10h | ⏳ 待启动 | +| Sprint 4 | 5 个 P2 指标(RS 重算 / 客户黏性 / 任务完成率 / Excel 修正 / 月度新增流失) | M-L ~ 6-8h | ⏳ 待启动 | + +## 二、Sprint 1 收口(2026-05-05) + +### 范围 +1. ✅ 建立 `apps/backend/app/services/sandbox_replay/` 模块脚手架 +2. ✅ 实现 `@runtime_aware(metric=...)` decorator(自动注入 RuntimeContext) +3. ✅ 试点迁移:`get_last_visit_days`(距上次到店天数,P1-4) + - `consumption_replay.py` 新实现 + - `fdw_queries.get_last_visit_days` 改 thin wrapper(兼容 75+ 现有调用) +4. ✅ Unit test 10/10 PASS +5. ✅ MCP 端到端 4a/4b 双口径 PASS + +### 关键 bug 发现 + 修复 + +**bug**:`@trace_service` + `@runtime_aware` 嵌套时,trace_service 的 `_build_params_dict` 用 `inspect.signature(func).bind(*args, **kwargs)` 做参数 redact。`functools.wraps` 默认设置 `__wrapped__` 让 `inspect.signature` 追溯到原函数(含 keyword-only 必传 ctx 参数),导致 sig.bind 抛 `TypeError: missing a required argument: 'ctx'`。 + +只在 FastAPI 请求 + 有活跃 TraceContext 时触发(直接 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 永远成功。 + +### 双口径验证证据(关键样本) + +**目标 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 返回 | 32 ✓ | 31 ✓ | + +测试快照已清理,sandbox 已切回 live。 + +### 走查脚本 + +`_DEL/walkthrough_f1_6/`: +- `step_sprint1_probe_member.py` — 找有 last_consume 的会员 +- `step_sprint1_check_view.py` — 视图层 RLS 行为 +- `step_sprint1_direct_call.py` — 直接调 sandbox_replay 函数 +- `step_sprint1_repro_etl_conn.py` — etl_conn 复用模式 +- `step_sprint1_call_service.py` — 直接调 customer_service.get_customer_detail +- `step_sprint1_4b_probe.py` — sandbox=4-20 视图行为 +- `step_sprint1_4b_find_member.py` — 找 sandbox 下有数据的 member +- `step_sprint1_4b_stat_dist.py` — stat_date 分布 +- `step_sprint1_4b_seed_data.py` — 插测试快照 +- `step_sprint1_cleanup.py` — 清理 + 切回 live + +### Sprint 1 commit + +`feat(backend): F1-6 sprint1 sandbox_replay 模块脚手架 + get_last_visit_days 迁移试点` + +--- + +## 三、Sprint 2 范围(待启动) + +### 5 个会员相关 P1 指标 + +| # | 指标 | 当前实现位置 | 目标迁移到 | 复杂度 | +|---|------|-------------|----------|------| +| 1 | 会员储值卡余额 | `fdw_queries.get_member_balance` | `sandbox_replay/balance_replay.py` | S | +| 2 | 60 天消费 | `fdw_queries.get_consumption_60d` | `sandbox_replay/consumption_replay.py`(扩展) | S | +| 3 | 累计消费总额 | (待补,目前无独立查询) | `sandbox_replay/consumption_replay.py` | S | +| 4 | 累计交易笔数 | (待补,后端无实现) | `sandbox_replay/consumption_replay.py` | S | +| 5 | 累计 GMV | `board_service` 财务总额 | `sandbox_replay/finance_replay.py`(新建) | S | + +### Sprint 2 实施模式 +所有指标走 `@runtime_aware` decorator + `app.v_dws_*` 视图查询。每个指标: +- 1 个新函数 to sandbox_replay/ +- 1 个 thin wrapper in fdw_queries.py(兼容) +- 单元测试 + MCP 走查 +- 审计 + 单独 commit + +### 估算 +5 指标 × 30-50min = 3-4h + +--- + +## 四、Sprint 3 范围(待启动) + +### 5 个助教/门店相关 P1 + MP-2 完整 + +| # | 指标 | 备注 | +|---|------|------| +| 6 | 累计服务客户数(助教) | dws_assistant_customer_stats daily | +| 7 | 助教等级 | dws_assistant_daily_detail.assistant_level_code | +| 8 | 月度课时(助教) | dws_assistant_daily_detail.base_hours 聚合 | +| 9 | 月度计费金额(助教) | dws_assistant_daily_detail.total_ledger_amount 聚合 | +| 10 | 月度新增/流失会员 | dws_member_*_summary daily | +| **MP-2 完整 daily salary** | board-coach 月度面板 | **需 ETL 团队配合**:新建 dws_assistant_daily_salary 表 + ETL Excel 上传 UI 改造 | + +### Sprint 3 估算 +5 指标 ~ 3-4h + MP-2 完整 5-6h(含 ETL 改造) = **L ~ 8-10h** + +--- + +## 五、Sprint 4 范围(待启动) + +### 5 个 P2 算法重算指标 + +| # | 指标 | 难点 | +|---|------|------| +| 11 | RS 关系指数 | 算法窗口期 + 衰减函数,daily 重算需重做累计 | +| 12 | 客户黏性指数 | 类似 RS,有时间衰减 | +| 13 | 任务完成率(coach_tasks) | 需补 dws daily 聚合视图 | +| 14 | Excel 修正(扣款/奖励/支出/收入) | 依赖 effective_date(F1-5b prep 已就位),后端 SQL 加截断 | +| 15 | 月度新增/流失会员(算法版) | dws_member_lifecycle 重算 | + +### Sprint 4 估算 +M-L ~ 6-8h + +--- + +## 六、阶段 C(F1-7+ 远期) + +详见 sandbox-replay-engine-spec.md §11.5: +- 3 个 P3 指标(门店等级 / 助教星级 / 累计 KPI) +- `biz.sandbox_audit_log` 用户行为审计表 +- AI app8_consolidate prompt audit + +--- + +## 七、关联 + +- 完整模块 spec:`docs/_overview/sandbox-replay-engine-spec.md` +- F1-5b 任务清单:`docs/_overview/wave1-findings/F1-5b-tasks.md` +- F1-5b MP-2 prep 审计:`docs/audit/changes/2026-05-05__wave1_f1_5b_mp2_prep.md` +- P20 SPEC §11.4 + §11.5(F1-6 待办登记):`docs/prd/specs/P20-runtime-context-sandbox.md` +- Sprint 1 审计:`docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md` diff --git a/docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md b/docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md new file mode 100644 index 0000000..a9e6a6d --- /dev/null +++ b/docs/audit/changes/2026-05-05__f1_6_sprint1_sandbox_replay_kickoff.md @@ -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 +``` + +回滚后: +- 删除 sandbox_replay/ 目录 +- fdw_queries.get_last_visit_days 恢复原直接 SQL 实现 +- 75+ 调用点无影响(thin wrapper 透明) +- 测试文件本地保留(.gitignore 范围内) + +## Co-Authored-By + +Claude Opus 4.7 (1M context)