# 沙箱「不看未来」完整修复清单

> 日期：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 之后的数据。
