# 2026-05-02 沙箱「不看未来」彻底改造（A+B+C 全做）

## 目标

让 sandbox 真正模拟"设定历史日 sandbox_date 当时所有数据状态"——
后台读取层、AI prompts、**小程序**业务看板/绩效/客户/任务页全部按 business_date 截断，
不再读取 sandbox_date 之后的真实生产数据。

> **端的归类（重要更正 2026-05-02）**：
> - **小程序** 才是业务看板（`board-finance / board-customer / board-coach`）和绩效/客户/任务页面所在，
>   是沙箱「不看未来」的主要受益方。
> - **admin-web** 是开发/运维向，**不展示业务看板**；沙箱在它这边主要表现为 `RuntimeContext` 开关、
>   `AIDashboard / AIOperations / AIRunLogs / TaskManager / TriggerManager` 等管理页能看到 sandbox 实例下的
>   AI 调用、任务写入与触发记录是隔离的（但 AI 计费/调度时间仍按真实系统时间，不受沙箱影响）。
> - **tenant-admin** 几乎不涉及业务数据展示，本轮基本不在沙箱范围。

## 总览：三层方案

| 层 | 范围 | 方法 | 状态 |
|---|------|------|------|
| **A 文档/UI** | admin-web、BD_Manual | 顶部 Alert + 路线章节，提示"读取层修复进行中" | ✅ |
| **B 应用层** | backend service / AI prompts & fetchers / fdw_queries / 小程序 | 时间锚替换为 RuntimeContext.business_date / business_now，SQL 补上界 | ✅ |
| **C 数据层** | etl_feiqiu app schema RLS 视图 | 引入 GUC ``app.current_business_date`` + ``app.business_date_now()`` 函数 + 关键视图 WHERE 上界 | ✅ |

## 关键改动

### A 层

- `apps/admin-web/src/pages/RuntimeContext.tsx` — 顶部 Alert 增加"读取层修复进行中"+ plan 链接。
- `docs/database/BD_Manual_runtime_context_sandbox.md` — 第 7 节新增"读取层不看未来路线"。

### B 层 (后端)

- `apps/backend/app/services/runtime_context.py` 新增 helpers：
  - `as_runtime_year_month_param(site_id) -> 'YYYY-MM'`
  - `as_runtime_business_now_str(site_id, fmt) -> str`
  - `business_date_upper_bound_sql(site_id, column, alias, cast)` 返回 SQL 片段
  - `apply_runtime_session_vars(conn, ctx | site_id)` 设置 GUC（C 层基础）
- AI prompts：app3/4/5/6/7 的 `current_time` 改用 `as_runtime_business_now_str`，不再 `datetime.now()`。
- AI data_fetchers：
  - `member_data._query_consumption_records` / `_query_visit_info` 接受 `ref_date`，所有窗口加业务日上界。
  - `assistant_data._fetch_assistant_info_sync` / `_fetch_service_history_sync` 用业务日。
  - `page_context._text_board_finance/customer/coach/customer_service_records` 全部上界化。
  - 所有直连 ETL 库的 cursor 在 `SET LOCAL app.current_site_id` 之后再下发 `app.current_business_date`，供 RLS 视图 GUC 读取。
- service：
  - `board_service._batch_coach_details` 接受 ref_date，60 天消费窗口按业务日截。
  - `chat_service._get_consumption_30d` / `_get_visit_count_30d` 业务日 30 天窗口。
  - `coach_service.get_coach_detail` / `_build_history_months` 用业务日年月。
  - `customer_service` 60 天助教统计上界化。
  - `task_generator` 转移子流程的 `now` 改用 business_now。
  - `task_manager.batch_query_for_task_list` / `build_performance_summary` / 任务详情 60 天窗口全部业务日。
  - `tenant_users.py` SCD2 配置（cfg_assistant_level_price）用业务日。
- **fdw_queries**（关键修复）：
  - `_fdw_context` 进入事务后下发 `app.current_business_date` + `app.current_runtime_mode` GUC。
  - **客户看板「最近到店」bug 修复**：`get_last_visit_days` / `batch_query_for_task_list`（last_visit 计算）改为 ETL `last_consume_date` + `business_date - last_consume_date` 实时计算，不再依赖 ETL 预计算的 `days_since_last`，沙箱场景与 ETL 跑批延迟下都能正确显示"距上次到店 N 天"。
  - `get_customer_board_recent` / `get_customer_board_recharge` / `get_customer_board_freq60` / `get_customer_board_recall` / `_get_weekly_visits_batch` / `get_coach_60d_stats` / `batch_query_for_task_list` 60 天窗口 / SCD2 配置等全部用业务日。

### B 层 (小程序)

- `apps/backend/app/routers/xcx_runtime_clock.py` 新增端点 `GET /api/xcx/runtime/clock`，返回 mode/business_date/business_year/business_month/business_year_month/business_now/is_sandbox/sandbox_date。
- `apps/miniprogram/miniprogram/services/api.ts` 增加 `fetchRuntimeClock`。
- `apps/miniprogram/miniprogram/utils/runtime-clock.ts`（新增）—— 60s 缓存 + 失败降级到本地时间。
- 关键页面切换为业务时钟：
  - `pages/performance/performance.ts` —— G2 当月预估判断
  - `pages/performance-records/performance-records.ts` —— onLoad / loadData / switchMonth
  - `pages/task-list/task-list.ts` —— 月度判断
  - `pages/customer-records/customer-records.ts` —— onLoad
  - `pages/customer-service-records/customer-service-records.ts` —— onLoad

### C 层 (RLS 视图)

- 新增迁移 `db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql`：
  - 注册 STABLE SQL 函数 `app.business_date_now()`：从 GUC `app.current_business_date` 读取业务日，未设置时回退 `CURRENT_DATE`。
  - **21 个视图**重写 WHERE，加 `<日期列> <= app.business_date_now()`：
    - 财务事实 6 个：`v_dws_finance_area_daily / daily_summary / discount_detail / expense_summary / income_structure / recharge_summary`
    - 助教汇总 5 个：`v_assistant_daily / v_dws_assistant_daily_detail / monthly_summary / salary_calc / finance_analysis`
    - 客户事实 3 个：`v_dws_member_consumption_summary / visit_detail / winback_index`
    - DWD 事实 5 个：`v_dwd_settlement_head / assistant_service_log / recharge_order / store_goods_sale / table_fee_log`
    - SCD2 配置 2 个：`v_cfg_assistant_level_price / performance_tier`
  - 列签名通过 `pg_get_viewdef` 实时从测试库读取，确保 `CREATE OR REPLACE VIEW` 不会因列签名漂移而失败。
- 生成脚本：`scripts/ops/gen_rls_business_date_migration.py`（可重复执行）。
- DDL 同步：`docs/database/ddl/etl_feiqiu__app.sql`、`db/etl_feiqiu/schemas/app.sql` 已同步。

## 验证

### 测试库迁移结果

```
test site_id = 2790685415443269
live: max(stat_date)=2026-04-27, count=2439
sandbox(=2025-09-01):
  max(stat_date) finance_area_daily = 2025-09-01, count=432
  max(visit_date) member_visit       = 2025-09-01
  max(create_time::date) settlement  = 2025-09-01
RESULT: PASS
```

live 模式行为不变；sandbox 模式下所有事实视图严格不返回 sandbox_date 之后的数据。

### 静态检查

- 后端 99 个改动文件 AST 解析全部通过。
- 前端 admin-web、小程序关键页面 lint 无新增错误。

## 兼容性 / 回滚

- live 模式下 GUC 不设置 → `app.business_date_now()` 回退 `CURRENT_DATE`，行为完全等同于改造前。
- 回滚：`DROP FUNCTION app.business_date_now() CASCADE;`（视图会一并被 DROP），然后重新执行 `db/etl_feiqiu/schemas/app.sql` 即可恢复 live 行为。
- B 层 / 小程序的时间锚替换全部走 RuntimeContext（fail-soft 降级 live），不影响生产链路。

## 已知未覆盖

- **page_context.py** 中 7 处直连 ETL 的查询，已加 SQL 上界（B 层），但部分位置依赖 GUC（C 层）即可，未单独传 ref_date。
- 写入时间戳（`created_at`、`updated_at`、`finished_at`、调度 `last_run_at`、ai_run_logs 写入）保持系统真实时间，**不应**被沙箱影响（这是审计/运行时元数据），保留现状。
- 小程序 chat / customer-detail 页面用于"展示当前操作时间"的 `new Date()` 保留（与会话/操作记录关联）。
- AI 调度的预算计算、限流仍按真实系统时间。
- DIM SCD2 维度（v_dim_assistant / v_dim_member / v_dim_member_card_account / v_dim_staff / v_dim_staff_ex / v_dim_table）保留 ``scd2_is_current=1`` 当前快照语义，未按 sandbox_date 重建历史维度行；如需"sandbox 当时维度状态"另行评估。

## 2026-05-02 后续追加

### B-2 / C 层补强
- 18 个非关键视图补业务日上界（详见 `gen_rls_business_date_migration.py` 的 `VIEWS_WITH_BD`）：覆盖 `v_cfg_bonus_rules` / `v_cfg_index_parameters` 两个配置维度，及 16 个 DWS 业务事实/汇总（如 `v_dws_assistant_customer_stats`、`v_dws_member_assistant_intimacy`、`v_dws_finance_board_cache`、`v_finance_daily` 等）。**总计 39 个 RLS 视图带业务日上界**。
- 端到端验证：`tools/db/verify_sandbox_end_to_end.py` 一键跑 live + sandbox(2025-09-01) 对比，输出 `2026-05-02__sandbox_e2e_verify_report.md`。本轮结果 31/31 PASS。
  - 注意：脚本里测的 `get_customer_board_recent / recharge / freq60 / recall` 是 `fdw_queries` 函数，**实际服务的是小程序 `board-customer`**，不是 admin-web。验证脚本同时覆盖 RLS 视图层（21+18=39 个视图），与端无关。

### log 警告止血（独立于沙箱）
- `apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`: 拓宽 `_is_all_only_area`，把 `补时长N`/`虚拟台N` 编号变体、`area_name=None & table_id 不空` 都归入 INFO（不再 WARNING），消除噪音。
- `apps/etl/connectors/feiqiu/tasks/dws/task_engine.py`: ETL → backend HTTP `_TIMEOUT` 由 `(5, 30)` 改 `(10, 600)`，与 `flow_runner` 对齐，止血 30s 读超时。**根因（同步长任务+30s timeout）已记录，长期方案是 `/api/internal/run-job` 改异步入队，待后续 PR。**

## 相关文件清单

- 主迁移：`db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql`
- 生成器：`scripts/ops/gen_rls_business_date_migration.py`
- 端到端验证：`tools/db/verify_sandbox_end_to_end.py`
- 验证报告：`docs/database/changes/2026-05-02__sandbox_e2e_verify_report.md`
- 文档：本文件 + `docs/database/BD_Manual_runtime_context_sandbox.md`
