# 沙箱「不看未来」完整修复清单 > 日期:2026-05-02 > 关联:[changes/2026-05-01__runtime_context_sandbox.md](2026-05-01__runtime_context_sandbox.md) > 关联代码: > - 后端:`apps/backend/app/services/runtime_context.py`、`fdw_queries.py`、`board_service.py`、`task_*.py`、`recall_detector.py`、`chat_service.py`、`coach_service.py`、`customer_service.py`、`performance_service.py`、`ai/data_fetchers/*`、`ai/prompts/*` > - 数据库:`db/etl_feiqiu/schemas/app.sql`(DWS/DWD/DIM RLS 视图) > - 小程序:`apps/miniprogram/miniprogram/pages/performance*`、`task-list`、`board-finance` 等 > 风险等级:**高**(核心业务读取层广泛假设「真实今天」) > 状态:**方案待用户确认**;实施前不动业务代码 --- ## 一、问题陈述 `R1 RuntimeContext 业务日期沙箱` 的初版只解决了 **写入隔离**:sandbox 行带 `runtime_mode='sandbox' + sandbox_instance_id='sbx_*'`,与 live 数据共存但不污染。 但读取层仍大量使用 **真实系统时间**,导致 sandbox 模式下: - `get_finance_board` 区间按 `business_date` 算(OK),但 prompts 内部的辅助 ETL 查询用 `_calc_date_range(time)` 漏传 `ref_date`,退回 `date.today()`,会拉「真实今天」的数据。 - AI data_fetchers(`member_data` / `assistant_data` / `page_context`)SQL 写死 `CURRENT_DATE - INTERVAL '60 days'` 等。 - App3-7 prompt `current_time` 字段是 `datetime.now()`,与沙箱业务时钟不一致。 - `fdw_queries.py` 大量 `CURRENT_DATE` 与 `ORDER BY stat_date DESC LIMIT N`,无业务日上界。 - 小程序 performance / performance-records 用本地 `Date` 推算 `year/month` 直接传给后端,绕过 RuntimeContext。 后果:sandbox 演示「以 2026-03-15 视角重放」时,看板/任务/AI 输出实际混合了截至真实今天的最新数据,纯净度被破坏。 --- ## 二、修复策略(A + B + C 三层) ### A 层:文档与 UI 警告(最轻,先上) 不改业务代码,只让用户清楚当前沙箱边界。 | 文件 | 改动 | |---|---| | `apps/admin-web/src/pages/RuntimeContext.tsx` | Alert 中追加「当前沙箱可能仍读取部分真实近期数据」的警告,并提示完整修复进度 | | `docs/database/BD_Manual_runtime_context_sandbox.md` | 新增「读取层局限与逐步修复路线」章节 | | `docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md` | 本文件,作为修复路线图 | ### B 层:后端 + 小程序代码层修复(核心) 按调用链分两组: #### B1 · 后端服务层时间锚替换 + SQL 上界 把 `date.today()` / `datetime.now()` / `CURRENT_DATE` / `NOW()` 在「业务窗口」语义里换成 `as_runtime_today_param` / `as_runtime_now_param` / `task_runtime_filter` 或参数化业务日;查询 DWS/DWD/FDW 时补 `stat_date <= business_date` / `pay_time <= business_now` 等上界。 调度元数据、审计、token TTL、写库 `updated_at = NOW()` 这类非业务窗口保持原状。 | 文件 | 行号 | 现状 | 改法 | |---|---|---|---| | `apps/backend/app/services/board_service.py` | 37 | `today = ref_date or date.today()` | 调用方必须传 `ref_date=runtime_ctx.business_date`(如 prompts 漏传需补) | | 同上 | 500 | SQL 写死 `create_time >= CURRENT_DATE - INTERVAL '60 days'` | 改 `create_time BETWEEN %s AND %s`,参数为 `business_date - 60d` 与 `business_now` | | `apps/backend/app/services/task_generator.py` | 231/845/884 | `datetime.now(timezone.utc)` | 业务窗口处改 `as_runtime_now_param(site_id)`;`run_started_at`(运行记录)保留真实时间 | | 同上 | 873 | 直连 `dwd.dim_assistant`(非 RLS 视图) | 切换 `app.v_dim_assistant` 或加 sandbox 上界 | | `apps/backend/app/services/task_expiry.py` | 63 | 注释 `expires_at < NOW()`;实际已用 `as_runtime_now_param` | 仅更新注释,无代码改动 | | `apps/backend/app/services/task_manager.py` | 680-682 | `datetime.now(timezone.utc).year/month` 用作工资月 | 改 `as_runtime_today_param(site_id).year/month` | | 同上 | 819-820 | `datetime.now()` 计算年月 | 同上 | | 同上 | 1199-1202 | `today = date.today(); cutoff_60d = today - 60d` | `today = as_runtime_today_param(site_id)` | | `apps/backend/app/services/coach_service.py` | 150/716 | `datetime.date.today()` | `as_runtime_today_param` | | 同上 | 198-207/744-756 | `biz.coach_tasks` 查询无 `task_runtime_filter` | 套 `task_runtime_filter(site_id)` | | 同上 | 550-566 | `_build_task_groups` 未带 `site_id` | 补 site_id 过滤 + runtime filter | | `apps/backend/app/services/customer_service.py` | 516 | `CURRENT_DATE - INTERVAL '60 days'` | 参数化为 `business_date - 60d` | | `apps/backend/app/services/performance_service.py` | 508-518 / 532-534 | `_calc_date_range` ref 来自 `next_month_start`,未对齐沙箱 | 参数链路改为 `business_date` 推导 | | `apps/backend/app/services/chat_service.py` | 195 | `NOW() - 3 days` 限制对话上下文 | 改 `business_now - 3 days` | | 同上 | 692/709 | `CURRENT_DATE - 30 days` | 改 `business_date - 30 days` | | 同上 | 602 | 写消息 `NOW()` | 写消息保留真实时间(持久化时钟应跟真实) | | `apps/backend/app/services/fdw_queries.py` | 196-200 / 489 / 567-568 / 650-651 / 688-689 / 924 / 1012-1016 / 等 | 大量 `CURRENT_DATE` 与 `ORDER BY stat_date DESC LIMIT` 无上界 | 函数签名增加 `business_date / business_now` 参数;SQL 加 `stat_date <= %s` / `create_time <= %s` | | `apps/backend/app/services/ai/admin_service.py` | 86-87 / 137 / 304-305 / 546-551 | AI 调用统计窗口 `CURRENT_DATE` | super-admin 后台是否也按门店沙箱口径——需产品确认。默认建议保留真实时间 | | `apps/backend/app/services/recall_detector.py` | 157-164 / 174-195 | `app.v_dws_*` 查询无 `stat_date` 上界 | 加 `stat_date <= business_date` | | `apps/backend/app/services/scheduler.py` / `trigger_scheduler.py` | — | 调度元数据(任务下次运行时间) | **不动**:调度本身按真实时钟工作 | | `apps/backend/app/routers/xcx_auth.py` | ~334-348 | `_dt.now().year/month` 给 `get_salary_calc` | `as_runtime_today_param(user.site_id).year/month` | | `apps/backend/app/routers/admin_runtime_context.py` | 111 | `sandbox_date > date.today()` 校验 | 保留:sandbox_date 的「未来」语义就是相对真实日历 | AI prompts / data_fetchers: | 文件 | 行号 | 改法 | |---|---|---| | `apps/backend/app/ai/prompts/app2_finance_prompt.py` | 817-818 / 841-846 | 调 `_calc_date_range(time, ref_date=runtime_ctx.business_date)` | | `apps/backend/app/ai/prompts/app2a_finance_area_prompt.py` | 466-468 | 同上 | | `apps/backend/app/ai/prompts/app3_clue_prompt.py` | 65-66 | `current_time = as_runtime_now_param(site_id)` | | `apps/backend/app/ai/prompts/app4_analysis_prompt.py` | 82-83 | 同上 | | `apps/backend/app/ai/prompts/app5_tactics_prompt.py` | 82-83 | 同上 | | `apps/backend/app/ai/prompts/app6_note_prompt.py` | 79-80 | 同上 | | `apps/backend/app/ai/prompts/app7_customer_prompt.py` | 79-80 | 同上 | | `apps/backend/app/ai/dispatcher.py` | 259/330 | 去重键的 `date.today()` 改为 `as_runtime_today_param(site_id)`,确保沙箱 vs live 去重不互相污染 | | `apps/backend/app/ai/data_fetchers/member_data.py` | 166/211/280/326/377-378 | `CURRENT_DATE` → 参数;`date.today()` → `business_date`;`ORDER BY ... DESC LIMIT N` 加上界 | | `apps/backend/app/ai/data_fetchers/assistant_data.py` | 92/105-106/212-213 | 同上 | | `apps/backend/app/ai/data_fetchers/page_context.py` | 140/154/218/243/364/413/415-416/465/467-468/518-519/602-603 | 同上;`ORDER BY DESC LIMIT N` 全部加 `<= business_now / business_date` 上界 | | `apps/backend/app/ai/cache_service.py` | 83/279 | `expires_at > now()` 与 TTL → 保留真实时钟(缓存 TTL 是真实时间维度) | | `apps/backend/app/ai/run_log_service.py` | 115/143/165/195-196/211-212 | run log `finished_at` 真实时钟;窗口聚合按真实时钟(运维口径) | #### B2 · 小程序绕过点修复 让小程序停止用本地 `Date` 算 `year/month` 直接传给后端;改为后端从 RuntimeContext 决定。 | 文件 | 行号 | 改法 | |---|---|---| | `apps/miniprogram/miniprogram/pages/performance/performance.ts` | 121-127 / 133-135 | 不再传 `year/month`,调用 `fetchPerformanceOverview()` 让后端按 `business_date` 决定;或先 `fetchRuntimeContext()` 拿业务年月 | | `apps/miniprogram/miniprogram/pages/performance-records/performance-records.ts` | 62-63 / 87-91 / 143-147 / 258-262 | 初始化用后端 `business_date.year/month`;`canGoNext` 上界改用 `business_date` | | `apps/miniprogram/miniprogram/pages/task-list/task-list.ts` | 389-411 | `isCurrentMonth` 通过后端返回字段或 `runtimeContext.business_date` 计算 | | `apps/miniprogram/miniprogram/pages/board-finance/board-finance.ts` | 17 | `isCurrentMonthFilter` 同上 | | `apps/miniprogram/miniprogram/services/api.ts` | (新增) | `fetchRuntimeContext()` 包装 `/api/config/runtime-context`,返回 `{ business_date, business_now, is_sandbox }`;缓存到全局 store | | `utils/time.ts` | 全文 | **不改**:相对时间/IM 时间/截止日全是显示文案,按真实本地时间合理 | | `chat.ts` 367/407、`customer-detail.ts` 216-219 等乐观 UI 时间戳 | — | **不改**:仅 UI 兜底,最终以后端时间为准 | #### B 层后端公共改造点 为减少散点改动,建议在 `apps/backend/app/services/runtime_context.py` 加两个工具: ```python def runtime_window_upper_bound(site_id, conn=None) -> tuple[date, datetime]: """返回 (business_date, business_now) 用作 SQL 上界。""" def runtime_year_month(site_id, conn=None) -> tuple[int, int]: """返回沙箱业务年月,用于绩效报表。""" ``` `fdw_queries.py` 函数签名增加可选 `business_date`、`business_now` 参数;调用方按需传入。 ### C 层:ETL RLS 视图业务日上界(最彻底) 利用现有 `app.current_site_id` 模式,引入 `app.current_business_date` 会话变量,在 `app.v_*` 视图层加上界。后端 `_fdw_context` 增加 `SET LOCAL app.current_business_date = %s`,sandbox 模式下传 `business_date`,live 模式下不设置或设置 `9999-12-31`。 #### C 方案 SQL 模式 ```sql -- 时间事实表(含 stat_date / pay_time / create_time) CREATE OR REPLACE VIEW app.v_dws_finance_daily_summary AS SELECT ... FROM dws.dws_finance_daily_summary WHERE site_id = current_setting('app.current_site_id')::bigint AND stat_date <= COALESCE( NULLIF(current_setting('app.current_business_date', true), '')::date, '9999-12-31'::date ); -- 维度表(含 scd2_effective_from) CREATE OR REPLACE VIEW app.v_dim_member AS SELECT ... FROM dwd.dim_member WHERE register_site_id = current_setting('app.current_site_id')::bigint AND COALESCE(scd2_effective_from, '0001-01-01'::date) <= COALESCE( NULLIF(current_setting('app.current_business_date', true), '')::date, '9999-12-31'::date ); ``` `current_setting('app.current_business_date', true)` 第二个参数 `true` 表示「未设置时返回空字符串而非报错」,配合 `NULLIF + COALESCE` 实现: - live 模式下 `app.current_business_date` 未设置 → 上界为 `9999-12-31` → 等同无限制 - sandbox 模式下后端 `SET LOCAL app.current_business_date = '2026-03-15'` → 视图自动截断 #### C 方案涉及范围 `db/etl_feiqiu/schemas/app.sql` 共 49 个 RLS 视图: | 类型 | 视图模式 | 上界字段 | |---|---|---| | 维度表 SCD2 | `v_dim_*`(10 个) | `scd2_effective_from` 或 `created_at` | | DWD 事实表 | `v_dwd_*`(6 个) | `create_time` 或 `pay_time` | | DWS 日粒度 | `v_dws_*_daily*`(约 10 个) | `stat_date` | | DWS 月粒度 | `v_dws_*_monthly*`(约 5 个) | `stat_month` | | DWS 索引/聚合 | `v_dws_*_index` 等 | `stat_date` | | 配置表 | `v_cfg_*` | 一般取「最新有效」,沙箱可保留真实最新(配置不该回放) | 需要按视图逐个判断时间上界字段。建议分批: 1. **C-1**:财务相关 `v_dws_finance_*`(5 视图)。 2. **C-2**:助教/任务相关 `v_dws_assistant_*`、`v_dws_member_assistant_*`(约 12 视图)。 3. **C-3**:DWD 事实表 `v_dwd_*`(6 视图)。 4. **C-4**:维度表 SCD2 `v_dim_*`(10 视图,需配合 SCD2 字段)。 5. **C-5**:配置表 `v_cfg_*`(一般保留真实最新,但确认是否需要按 `effective_to`)。 每批按 RLS 双 schema 规则同时改 `dws.v_*` 和 `app.v_*`。 #### C 方案后端改造 ```python # apps/backend/app/database.py 或 fdw_queries.py 内 def get_etl_readonly_connection(site_id, business_date=None): conn = ... with conn.cursor() as cur: cur.execute("SET default_transaction_read_only = on") cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),)) if business_date is not None: cur.execute("SET LOCAL app.current_business_date = %s", (str(business_date),)) ... ``` `_fdw_context` 同样改造:默认从 `get_runtime_context(site_id)` 取 `business_date`,sandbox 模式自动 SET。 --- ## 三、推荐实施顺序 | 步骤 | 工作量 | 价值 | 依赖 | |---|---|---|---| | A1 admin-web Alert 警告升级 | 0.5h | 立即让用户知道当前局限 | 无 | | A2 BD_Manual + 本修复路线图 | 0.5h | 后续工作可见 | 无 | | B1-后端 prompts ref_date 漏传补齐 | 1h | 立即修复 App2/App2a 漏洞 | 无 | | B1-后端 runtime helpers + service 层关键路径 | 4h | 修 task/board/coach/customer/performance 主链路 | 无 | | B1-后端 fdw_queries 上界改造 | 6h | 收口最大公约数 | 上一步 | | B1-后端 AI data_fetchers + prompts current_time | 3h | AI 链路对齐沙箱 | 无 | | B2-小程序 performance/year-month 改后端权威 | 2h | 小程序绕过点收口 | B1 后端 runtime helpers | | C-1 财务视图 RLS 上界 | 2h | 双 schema 规则;DDL 同步 | B 层验证通过 | | C-2 助教/任务视图 RLS 上界 | 3h | 同上 | 同上 | | C-3 DWD RLS 上界 | 2h | 同上 | 同上 | | C-4 维度 SCD2 上界 | 3h | 历史维度回放精度 | 同上 | | C-5 配置表评估(多数不改) | 0.5h | — | 同上 | | 同步主 DDL + 双 schema + DDL 副本 | 1h | 保证仓库 ddl 与测试库一致 | C 完成 | **总估时**:约 28-30 小时,单人执行;强烈建议分 4-5 个 PR:A、B-后端、B-小程序、C 财务视图、C 其他视图。 --- ## 四、风险与开发约束 1. **live 行为不能变**:所有 SQL 上界用 `COALESCE(... , '9999-12-31')` 形式,live 不设变量时等同无限制。 2. **双 schema 规则**:`db/etl_feiqiu/schemas/app.sql` 与 `dws.v_*` 必须同时更改。 3. **DDL 副本同步**:每批 C 改完跑 `python tools/db/gen_consolidated_ddl.py`,把 `docs/database/ddl/etl_feiqiu__app.sql` 等同步进 git。 4. **真实时钟字段保留**:`updated_at = NOW()`、`finished_at = NOW(timezone.utc)`、缓存 TTL `expires_at`、调度 `next_run_at`、AI run_logs 写入时间——这些**保留真实时钟**。 5. **去重键**:dispatcher / cache 的「当日去重」key 需带 `runtime_mode + business_date`,避免 sandbox 与 live 互相污染。 6. **配置表沙箱语义**:`v_cfg_*` 一般取「最新有效」;如需历史回放,需要单独评估 `scd2_effective_to` 上界。 7. **测试**:每批改完都要在 `test_zqyy_app` + `test_etl_feiqiu` 上运行至少一次端到端 sandbox 切换 + 看板抽查 + AI 触发。 --- ## 五、未覆盖项(需用户确认或单独立项) - **生产数据库执行**:本修复在 test 库通过后才能上生产;窗口需运维约定。 - **写入沙箱数据归零**:长期使用沙箱后会积累 sandbox 行(任务、AI cache、run logs、recall events),应有按 `runtime_mode='sandbox' AND sandbox_instance_id=...` 的清理脚本。 - **小程序 utils/time.ts**:当前不改;如需把「相对时间」也按沙箱算(如沙箱日下「3 天前」按沙箱日推算),属于另一议题。 - **跨多门店 sandbox**:当前未限制同时多门店进入 sandbox;若并行需求,需要约定每个 site 独立 RuntimeContext + 各自上界(架构已支持)。 - **Admin-Web AI 调用统计 / 监控页**:是否按真实时间口径,需产品决定。 --- ## 六、实施建议 1. **本文档已落地**(`docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md`),作为后续多 PR 的统一目录。 2. **不立即动业务代码**;等用户确认范围后开始。 3. **优先级建议**:A → B-后端关键路径(task_generator、board_service、prompts ref_date 漏传)→ B-AI prompts current_time → B-小程序 performance → B-fdw_queries → C-财务视图 → 其他 C。 4. **每个 PR 自带单测**:sandbox 模式下 SQL 不返回 sandbox_date 之后的数据。