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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user