# P20：Runtime Context 沙箱 — 虚拟时间机制

> 版本：v1.0 草案 | 日期：2026-05-04 | 来源：基于 2026-05-01 / 2026-05-02 落地代码与审计反推
> SPEC slug：`runtime-context-sandbox`
> 状态：**追溯型 SPEC**（功能已实现并通过单环境 e2e 验证；本文档将"已发生的事实"沉淀为权威规约，**深入测试与跨模块收口未完成**，详见同目录 `docs/_overview/04a-feedback/P0-7-runtime-context-todos.md`）

---

## 1. 需求概述

### 1.1 问题背景

NeoZQYY 平台与真实门店运营强耦合，在球房停业期、AI 演示、新功能演练、跨年度回放等场景下，必须在**不污染真实数据**的前提下，让全栈系统假装"今天是某个历史日期"。

直接修改服务器系统时钟或在每个调用点单独传日期参数，存在两个根本风险：

- **可见性失控**：业务读取仍然命中"sandbox_date 之后"的真实最新数据，让演示与真实数据混合，沙箱失去意义。
- **写入污染**：演示中产生的任务、AI 缓存、运行日志会与真实运营数据共存且无法区分，回收成本极高。

### 1.2 解决方案

引入一套**按门店隔离**的「业务运行上下文（RuntimeContext）」：

- 数据库层新增 `biz.site_runtime_context` 单门店一行的状态表，可在 `live` 与 `sandbox` 之间切换。
- 7 张业务/AI 表新增 `runtime_mode` + `sandbox_instance_id` 两列做**写入隔离**；唯一索引扩入这两列，允许 live 与 sandbox 共存。
- 后端封装 `RuntimeContext` 抽象，所有业务时钟读取统一通过 `as_runtime_today_param` / `as_runtime_now_param` 等 helper，禁止 `date.today()` / `NOW()` 直接出现在业务窗口语义中。
- ETL 库通过会话级 GUC `app.current_business_date` + `app.business_date_now()` 函数 + 39 个 RLS 视图业务日上界，实现**读取层不看未来**。
- admin-web 提供超级管理员可见的切换页面；小程序通过 `/api/xcx/runtime/clock` 获取业务时钟，停止在前端用 `new Date()` 推算业务年月。

### 1.3 沙箱不影响项（设计共识）

下列内容**始终按真实系统时间运行**，不被 sandbox 切换影响：

- 写入时间戳（`created_at` / `updated_at` / `finished_at`）
- 调度元数据（`scheduled_tasks.next_run_at` / `last_run_at`）
- AI tokens 计费、限流、熔断窗口
- AI 调用真实记录（`biz.ai_run_logs.created_at`）
- 缓存 TTL（`biz.ai_cache.expires_at`）
- 全局触发器调度（`biz.trigger_jobs` 无 `site_id` 列，多门店共用）
- AIDashboard 运维监控指标（"今日调用 / 7 天趋势"按真实日期）

---

## 2. 关键交付物

| # | 交付物 | 状态 | 路径 |
|---|---|---|---|
| 1 | 数据库迁移：`biz.site_runtime_context` 表 + 7 表加列 + 索引重构 | 已上 test 库 | `db/zqyy_app/migrations/20260501__runtime_context_sandbox.sql` |
| 2 | ETL 库迁移：`app.business_date_now()` + 39 个 RLS 视图业务日上界 | 已上 test 库 | `db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql` |
| 3 | 后端服务层 `RuntimeContext` + helper | 已合并 | `apps/backend/app/services/runtime_context.py` |
| 4 | 后端管理 API（4 个端点） | 已合并 | `apps/backend/app/routers/admin_runtime_context.py` |
| 5 | 后端小程序 API（1 个端点） | 已合并 | `apps/backend/app/routers/xcx_runtime_clock.py` |
| 6 | admin-web 切换页面 | 已合并 | `apps/admin-web/src/pages/RuntimeContext.tsx` |
| 7 | 小程序业务时钟工具 | 已合并 | `apps/miniprogram/miniprogram/utils/runtime-clock.ts` |
| 8 | RLS 迁移生成器 | 已合并 | `scripts/ops/gen_rls_business_date_migration.py` |
| 9 | 端到端验证脚本 | 已合并 | `tools/db/verify_sandbox_end_to_end.py`、`tools/db/verify_admin_web_sandbox.py` |
| 10 | BD_Manual + 一系列变更说明 | 已合并 | `docs/database/BD_Manual_runtime_context_sandbox.md`、`docs/database/changes/2026-05-01__*` 与 `2026-05-02__*` |
| 11 | 生产库执行 | **待执行** | 待运维窗口 |
| 12 | 跨模块完整测试与收口 | **待执行** | 见 `docs/_overview/04a-feedback/P0-7-runtime-context-todos.md` |

---

## 3. 数据模型

### 3.1 `biz.site_runtime_context`（核心状态表）

| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `site_id` | bigint | PK，FK → `biz.sites(site_id)` | 门店 ID |
| `mode` | varchar(20) | NOT NULL，DEFAULT `'live'`，CHECK `IN ('live','sandbox')` | 运行模式 |
| `sandbox_date` | date | 可空 | sandbox 模式下系统假设的业务日期 |
| `sandbox_instance_id` | varchar(64) | 可空 | sandbox 写入隔离实例 ID（格式 `sbx_<24hex>`） |
| `ai_mode` | varchar(20) | NOT NULL，DEFAULT `'live'`，CHECK `IN ('live')` | AI 调用模式（当前仅 live） |
| `status` | varchar(20) | NOT NULL，DEFAULT `'active'` | 上下文状态 |
| `reason` | text | 可空 | 切换原因（运维/演示备注） |
| `updated_by` | bigint | 可空 | 最近一次切换的 admin user_id |
| `created_at` | timestamptz | NOT NULL，DEFAULT now() | 创建时间 |
| `updated_at` | timestamptz | NOT NULL，DEFAULT now() | 更新时间（API 切换时显式 NOW()） |

复合 CHECK：`site_runtime_context_sandbox_check`

- `mode='live'` 时 `sandbox_date IS NULL` 且 `sandbox_instance_id IS NULL`
- `mode='sandbox'` 时 `sandbox_date IS NOT NULL` 且 `sandbox_instance_id IS NOT NULL`

### 3.2 7 张表新增 runtime 维度列

下列表均新增两列，DEFAULT 与 NOT NULL 一致，便于唯一索引覆盖两种模式：

| Schema.Table | 主要用途 |
|---|---|
| `biz.coach_tasks` | 助教任务（召回/回访/关系建设） |
| `biz.coach_task_transfer_log` | 任务转移日志 |
| `biz.coach_task_history` | 任务历史归档 |
| `biz.recall_events` | 消费引发的召回事件 |
| `biz.ai_cache` | AI 应用缓存 |
| `biz.ai_run_logs` | AI 调用明细 |
| `biz.ai_trigger_jobs` | AI 调度记录 |

| 列 | 类型 | DEFAULT | NULL | 说明 |
|---|---|---|---|---|
| `runtime_mode` | varchar(20) | `'live'` | NOT NULL | 写入时所处模式（`live` / `sandbox`） |
| `sandbox_instance_id` | varchar(64) | `'live'` | NOT NULL | sandbox 实例 ID；live 模式占位 `'live'` |

### 3.3 索引变更

旧（DROP）：

| Index | 原唯一性 |
|---|---|
| `biz.idx_coach_tasks_site_assistant_member_type` | UNIQUE |
| `biz.idx_recall_events_site_assistant_member_day` | UNIQUE |

新（CREATE）：

| Index | 类型 | 说明 |
|---|---|---|
| `idx_coach_tasks_runtime_unique_active` | UNIQUE，部分 `WHERE status='active'` | 唯一键加入 `runtime_mode + sandbox_instance_id`，允许 live 与 sandbox 同时存在同一 (site/assistant/member/task_type) 活跃任务 |
| `idx_recall_events_runtime_site_assistant_member_day` | UNIQUE | 召回事件按当日去重，加入 runtime 维度 |
| `idx_coach_tasks_runtime_assistant_status` | INDEX | 任务列表查询 |
| `idx_ai_cache_runtime_lookup` | INDEX | AI cache 按 `cache_type + site + runtime + target` 查询 |
| `idx_ai_trigger_jobs_runtime_site` | INDEX | AI 触发记录按 `site + runtime + event_type + status` 查询 |

### 3.4 ETL 库会话级 GUC + helper 函数

| 名称 | 类型 | 说明 |
|---|---|---|
| `app.current_business_date` | session GUC（字符串） | 业务日 ISO 字符串；未设置时回退 `CURRENT_DATE` |
| `app.current_runtime_mode` | session GUC（字符串） | 当前运行模式 `live` / `sandbox` |
| `app.business_date_now()` | STABLE SQL 函数 | 返回 `app.current_business_date` 解析的 date，或 `CURRENT_DATE` |

### 3.5 RLS 视图业务日上界（39 个视图）

模式：`<日期列> <= app.business_date_now()`（部分用 `COALESCE(... ::date, '0001-01-01'::date)` 兜底）。

涉及视图分组（详细列表见 `scripts/ops/gen_rls_business_date_migration.py` 的 `VIEWS_WITH_BD`）：

- 财务事实 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 配置 4 个：`v_cfg_assistant_level_price / performance_tier / bonus_rules / index_parameters`（`effective_from` 与 `effective_to` 双向夹住）
- 其他 DWS 16 个：`v_dws_assistant_customer_stats / order_contribution / project_tag / recharge_commission` 等

跳过项（明确不加上界）：

- `v_dim_assistant / v_dim_member / v_dim_member_card_account / v_dim_staff / v_dim_staff_ex / v_dim_table` —— SCD2 维度保留 `scd2_is_current=1` 当前快照语义。
- `v_assistant / v_member / v_site / v_cfg_area_category` —— 无业务日列。

---

## 4. 接口契约

### 4.1 管理面（admin-web 调用）

#### 4.1.1 `GET /api/admin/runtime-context/sites`

| 项 | 内容 |
|---|---|
| 权限 | `super_admin` |
| 说明 | 列出所有门店及其当前运行上下文（即使未配置也返回 site 行，运行字段为 NULL） |
| 响应 | `RuntimeSiteItem[]` |

`RuntimeSiteItem`：`{ site_id, site_name, site_code, is_active, mode, sandbox_date, sandbox_instance_id, ai_mode, status, updated_at }`

#### 4.1.2 `GET /api/admin/runtime-context?site_id=<id>`

| 项 | 内容 |
|---|---|
| 权限 | `super_admin` |
| 说明 | 按 site_id 查询当前运行上下文（含 `business_date` / `business_now` 计算结果） |
| 响应 | `RuntimeContextResponse` |

#### 4.1.3 `PATCH /api/admin/runtime-context`

| 项 | 内容 |
|---|---|
| 权限 | `super_admin` |
| 说明 | 切换 live / sandbox。**先 stop runtime activity，再 upsert 上下文** |
| 请求 | `RuntimeSwitchRequest = { site_id, mode, sandbox_date?, reset_sandbox?, reason? }` |
| 响应 | `RuntimeSwitchResponse = { context, steps[] }` |

校验：

- `mode='sandbox'` 且 `sandbox_date IS NULL` → 422
- `mode='live'` 且 `sandbox_date IS NOT NULL` → 422
- `sandbox_date > date.today()` → 422（沙箱只允许回放历史，禁止"未来"）

切换前置动作（写入 `steps[]`）：

1. `cancel_etl_processes` — 终止当前进程内 ETL 执行（`task_executor.cancel`）
2. `cancel_task_queue` — 取消当前门店 pending/running 的 `task_queue` 行（`status='cancelled'`）
3. `cancel_ai_runtime` — 取消当前进程内属于该门店的 AI 异步调用链（`get_dispatcher().cancel_running`）
4. `cancel_ai_jobs` — 标记该门店 pending/running 的 `biz.ai_trigger_jobs` 为 `cancelled`
5. `biz_triggers_unchanged` — `biz.trigger_jobs` 是全局调度表，不暂停（设计有意保留）
6. `apply_context` — UPSERT `biz.site_runtime_context`

`sandbox_instance_id` 处理：

- 进入 sandbox 且 `reset_sandbox=true`（默认）→ 调用 `new_sandbox_instance_id()` 生成 `sbx_<24hex>`
- 进入 sandbox 且 `reset_sandbox=false` → 沿用原 `sandbox_instance_id`
- 切回 live → 显式置 NULL

### 4.2 通用面

#### 4.2.1 `GET /api/config/runtime-context`

| 项 | 内容 |
|---|---|
| 权限 | 任意已登录用户（不要求 super_admin） |
| 说明 | 返回当前用户门店的 RuntimeContext |
| 响应 | `RuntimeContextResponse` |

### 4.3 小程序面

#### 4.3.1 `GET /api/xcx/runtime/clock`

| 项 | 内容 |
|---|---|
| 权限 | `require_approved`（已审核通过的小程序用户） |
| 说明 | 返回当前用户门店的业务时钟。小程序所有"今天""本月"判断必须走此接口 |
| 响应 | `{ mode, business_date, business_year, business_month, business_year_month, business_now, is_sandbox, sandbox_date, sandbox_instance_id }` |

`RuntimeContextResponse`（`apps/backend/app/schemas/runtime_context.py`）：

```python
{
  "site_id": int,
  "mode": "live" | "sandbox",
  "business_day_start_hour": int,
  "business_date": "YYYY-MM-DD",
  "business_now": "ISO 8601",
  "sandbox_date": "YYYY-MM-DD" | null,
  "sandbox_instance_id": "sbx_..." | null,
  "ai_mode": "live",
  "status": "active",
  "is_sandbox": bool
}
```

---

## 5. 各端读取约定

### 5.1 后端服务层

`apps/backend/app/services/runtime_context.py` 提供如下 helper（**所有业务窗口语义必须走 helper，禁止 `date.today()` / `datetime.now()` / `CURRENT_DATE` / `NOW()` 直出**）：

| Helper | 返回 | 用途 |
|---|---|---|
| `get_runtime_context(site_id, conn=None)` | `RuntimeContext` 实例 | 取上下文（异常时降级 live） |
| `as_runtime_now_param(site_id, conn=None)` | `datetime` | SQL 中的"业务当前时间"参数 |
| `as_runtime_today_param(site_id, conn=None)` | `date` | SQL 中的"业务今天"参数 |
| `as_runtime_year_month_param(site_id, conn=None)` | `'YYYY-MM'` | 月度报表 |
| `as_runtime_business_now_str(site_id, fmt='%Y-%m-%d %H:%M:%S')` | `str` | AI prompt `current_time` 字段 |
| `task_runtime_filter(site_id, alias='')` | `(sql_clause, params)` | 业务表查询过滤（live 用 COALESCE 兜历史 NULL） |
| `runtime_insert_columns(site_id, conn=None)` | `(cols, placeholders, values)` | 业务表 INSERT 片段 |
| `runtime_update_assignments(site_id, conn=None)` | `(set_clause, params)` | 业务表 UPDATE 片段 |
| `namespace_ai_target_id(site_id, target_id)` | `str` | sandbox 模式下给 `ai_cache.target_id` 加 `sbx_*:` 前缀 |
| `business_date_upper_bound_sql(site_id, column, alias='', cast=None)` | `(sql_clause, params)` | 给查询补 `column <= business_date` 上界（live 返回空） |
| `apply_runtime_session_vars(conn, ctx=None, site_id=None)` | None | 在已有连接上 `set_config('app.current_business_date', ...)` + `app.current_runtime_mode` |

#### 已接入 helper 的服务（截至 2026-05-02）

- 任务引擎：`task_manager.py` / `task_generator.py` / `task_expiry.py` / `recall_detector.py`
- 看板：`board_service.py` / `coach_service.py` / `customer_service.py` / `chat_service.py` / `performance_service.py`
- ETL FDW：`fdw_queries.py`（`_fdw_context` 在 `SET LOCAL app.current_site_id` 之后下发 `app.current_business_date` + `app.current_runtime_mode`）
- AI 数据：`ai/data_fetchers/member_data.py` / `assistant_data.py` / `page_context.py`
- AI 缓存：`ai/cache_service.py`（命名空间隔离）
- AI 日志：`ai/run_log_service.py`（写入时带 `runtime_mode + sandbox_instance_id`）
- AI prompts：`app2_finance_prompt.py` / `app2a_finance_area_prompt.py` / `app3-7` `current_time` 字段
- 路由：`tenant_users.py`（SCD2 配置查询用业务日）

### 5.2 ETL 视图层（C 方案）

后端在每次直连 ETL 库的事务里执行：

```sql
SET LOCAL app.current_site_id = '<site_id>';
SET LOCAL app.current_business_date = '<YYYY-MM-DD>';
SET LOCAL app.current_runtime_mode = 'live' | 'sandbox';
```

39 个 RLS 视图通过 `app.business_date_now()` 自动裁剪 `<日期列> <= business_date`。live 模式不下发 GUC 时函数回退 `CURRENT_DATE`，行为完全等同于改造前。

### 5.3 小程序

工具：`apps/miniprogram/miniprogram/utils/runtime-clock.ts`

- `getBusinessClock(force=false)`：60 秒 in-memory 缓存 + 失败降级本地时间
- `getBusinessYearMonth()` / `getBusinessDate()` 便捷方法
- `clearBusinessClockCache()` 主动失效

已接入页面（5 个，**详见 §10 跨模块覆盖矩阵**）：

| 页面 | 替换点 |
|---|---|
| `pages/performance/performance.ts` | G2 当月预估判断 |
| `pages/performance-records/performance-records.ts` | onLoad / loadData / switchMonth / canGoNext |
| `pages/task-list/task-list.ts` | `isCurrentMonth` 月度判断 |
| `pages/customer-records/customer-records.ts` | onLoad |
| `pages/customer-service-records/customer-service-records.ts` | onLoad |

### 5.4 AI 提示词

App2 / App2a：`当前时间` 字段走 `runtime_ctx.business_now`，日期范围（`_calc_date_range`）的 `ref_date` 必须由调用方显式传 `runtime_ctx.business_date`。

App3 / 4 / 5 / 6 / 7：prompt JSON 里 `current_time` 字段走 `as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M")`。

### 5.5 admin-web

切换页面 `apps/admin-web/src/pages/RuntimeContext.tsx` + API 封装 `apps/admin-web/src/api/runtimeContext.ts`：

- 路由：`/settings/runtime-context`（仅 super_admin 可见）
- 表格列：门店 / 运行模式 / 业务日期 / 沙箱实例 / AI 模式 / 更新时间 / 操作
- 切换弹窗：目标模式 disabled、沙箱业务日期 DatePicker（`disabledDate: d.isAfter(today, 'day')`）、重置沙箱实例 Switch、操作原因 TextArea
- 提交后弹 Steps 弹窗，逐步显示 6 个 transition step 的 success / skipped / warning

---

## 6. 与 ETL 影子跑数的衔接

`biz.trigger_jobs` 是全局调度（无 `site_id` 列），单门店切沙箱**不暂停**。多门店隔离完全在数据写入层用 `runtime_mode + sandbox_instance_id` 实现。

ETL `task_engine` 与 `flow_runner` 并不直接读 `biz.site_runtime_context`：ETL 跑批写 ODS/DWD/DWS 仍按真实数据；`task_generator` 等业务任务通过后端服务调用，间接读取 RuntimeContext。

**已知设计权衡**：sandbox 模式下若 ETL 在演示中跑了一轮真实数据（如把昨天的真实订单跑入 DWS），由于 RLS 视图按 `app.business_date_now() = sandbox_date` 截断，演示侧仍看不到。但 DWS 物理数据已变化，切回 live 后立刻可见。**这是有意设计**：沙箱只裁可见性，不阻断真实跑数。

---

## 7. 安全 / 权限模型

| 操作 | 角色 | 备注 |
|---|---|---|
| 列出门店 + 查上下文 | `super_admin` | `/api/admin/runtime-context/sites` 与 `?site_id=` |
| 切换 live / sandbox | `super_admin` | `PATCH /api/admin/runtime-context` |
| 读取自门店上下文 | 任意已登录用户 | `/api/config/runtime-context` |
| 读取业务时钟 | 已审核通过的小程序用户 | `/api/xcx/runtime/clock` |

边界：

- 多门店并行 sandbox 已经支持（每个 site 独立一行，互不影响）
- super_admin 当前可切任意 site，不限于自己绑定的门店
- `sandbox_date > date.today()` 服务端 422 拒绝，admin-web DatePicker `disabledDate` 兜底

---

## 8. 验收标准（AC）

| # | 验收点 | 验证方式 |
|---|---|---|
| AC1 | live → sandbox 切换后，小程序看到的"今天"变为 `sandbox_date` | 自动化：`tools/db/verify_sandbox_end_to_end.py`；手工：登录小程序看 performance / task-list 当月判断 |
| AC2 | sandbox 模式下，业务表写入带 `runtime_mode='sandbox' + sandbox_instance_id='sbx_*'`，与 live 行共存 | SQL：`SELECT runtime_mode, sandbox_instance_id, COUNT(*) FROM biz.coach_tasks GROUP BY 1,2` |
| AC3 | sandbox 模式下，39 个 RLS 视图 `max(<日期列>) <= sandbox_date` | 自动化：`verify_sandbox_end_to_end.py` § 1（已 31/31 PASS） |
| AC4 | 切回 live 后 `sandbox_date / sandbox_instance_id` 恢复 NULL，写入恢复 `('live', 'live')` | SQL：`SELECT mode, sandbox_date, sandbox_instance_id FROM biz.site_runtime_context` |
| AC5 | AI prompts（App2/2a 的"当前时间" + App3-7 的 `current_time`）显示 `sandbox_date HH:MM` | admin-web `/logs/ai-run-logs` Drawer 抽屉 |
| AC6 | `biz.trigger_jobs` 切沙箱**不被暂停**（无 `paused_by_sandbox` 状态） | SQL：`SELECT status, COUNT(*) FROM biz.trigger_jobs GROUP BY status` |
| AC7 | AIDashboard "今日" 与"7 天趋势"按真实日期，不被沙箱拉到 `sandbox_date` | admin-web `/ai/dashboard` 视觉验证 |
| AC8 | AI cache `target_id` 在 sandbox 模式下加 `sbx_*:` 前缀 | SQL：`SELECT target_id FROM biz.ai_cache WHERE target_id LIKE 'sbx_%'` |
| AC9 | `sandbox_date > date.today()` 切换返回 422 | curl PATCH 测试 |
| AC10 | admin-web 非 super_admin 不可见菜单 | 普通账号登录视觉验证 |
| AC11 | 多门店并行 sandbox：site A 切 sandbox 不影响 site B 的 live 行为 | **未验证**（详见 todos） |
| AC12 | 跨页时间漂移：小程序在 sandbox 下连续打开多页，business_date 一致（60s 缓存语义） | **未验证**（详见 todos） |
| AC13 | 沙箱写入数据可清理：按 `runtime_mode='sandbox' AND sandbox_instance_id=...` 一键清理 | **未实施**（详见 todos） |

---

## 9. 依赖

- **P3** 用户认证（super_admin 角色 + JWT aud=`admin`；小程序 `require_approved`）
- **P11** 部署上线（迁移与 RLS 视图必须在生产库执行；建议灰度先跑测试库）
- **P17** 助教归属任务引擎（任务表加 runtime 维度索引依赖 P17 的 `(site_id, assistant_id, member_id, task_type, status)` 唯一约束）
- **P18** Admin Task Engine Dashboard（沙箱状态在 admin-web 可视化）

---

## 10. 跨模块覆盖矩阵

> 来源：审计文档 + 代码 grep。**X = 已接入**；**?** = 不确定 / 未验证；**—** = 不需要。

### 10.1 后端服务层

| 模块 | 读 RuntimeContext | task_runtime_filter | runtime_insert_columns | 业务日上界 SQL |
|---|---|---|---|---|
| `task_manager.py` | X | X | X | X |
| `task_generator.py` | X | X | X | ? |
| `task_expiry.py` | X | X | — | — |
| `recall_detector.py` | X | X | X | X |
| `board_service.py` | X | X | — | X |
| `coach_service.py` | X | ? | — | X |
| `customer_service.py` | X | — | — | X |
| `chat_service.py` | X | — | — | X |
| `performance_service.py` | X | — | — | X |
| `fdw_queries.py` | X（`_fdw_context` GUC） | — | — | C 方案视图层 |
| `ai/cache_service.py` | X | — | X | — |
| `ai/run_log_service.py` | X | — | X | — |
| `ai/dispatcher.py` | ? | — | — | — |
| `ai/admin_service.py` | ? | — | — | — |

### 10.2 AI 提示词

| Prompt | `current_time` / 当前时间 | 数据窗口 ref_date |
|---|---|---|
| `app2_finance_prompt.py` | X | X |
| `app2a_finance_area_prompt.py` | X | X |
| `app3_clue_prompt.py` | X | — |
| `app4_analysis_prompt.py` | X | — |
| `app5_tactics_prompt.py` | X | — |
| `app6_note_prompt.py` | X | — |
| `app7_customer_prompt.py` | X | — |
| `app8_consolidate` | ? | ? |

### 10.3 小程序页面

| 页面 | 引入 `runtime-clock` | 备注 |
|---|---|---|
| `performance/performance.ts` | X | G2 月度判断 |
| `performance-records/performance-records.ts` | X | 4 处 |
| `task-list/task-list.ts` | X | 月度判断 |
| `customer-records/customer-records.ts` | X | onLoad |
| `customer-service-records/customer-service-records.ts` | X | onLoad |
| `board-finance/board-finance.ts` | ? | 设计文档要求改 `isCurrentMonthFilter`，未在 grep 结果中确认 |
| `board-customer/board-customer.ts` | ? | 未确认 |
| `board-coach/board-coach.ts` | ? | 未确认 |
| `customer-detail/customer-detail.ts` | — | 操作时间戳保留真实时钟（设计共识） |
| `chat/chat.ts` | — | 同上 |
| `utils/time.ts` | — | 显示文案，保留真实时钟（设计共识） |

### 10.4 ETL 视图

39 个 RLS 视图业务日上界（详见 `scripts/ops/gen_rls_business_date_migration.py` 的 `VIEWS_WITH_BD`），跳过 6 个 SCD2 dim 与 4 个无日期列视图。

---

## 11. 已知遗漏 / 未覆盖

### 11.1 设计共识保留（不视为 bug）

- `created_at` / `updated_at` / `finished_at` / `next_run_at` 持久化时间戳保留真实系统时间（审计需要）
- AI tokens 计费、限流、熔断、缓存 TTL 按真实时钟运行
- 调度元数据（`scheduled_tasks`, `biz.trigger_jobs`）按真实时钟
- AIDashboard / AIRunLogs 列表按真实写入时间排序
- 小程序 `chat / customer-detail / utils/time.ts` 操作时间戳保留 `new Date()`
- DIM SCD2 维度（`v_dim_*`）保留 `scd2_is_current=1` 当前快照

### 11.2 已知 hack

- `coach_service._build_task_groups` 是否带 `site_id` + runtime filter 未在审计中明确验证
- `task_generator` 部分 SQL 是否有业务日上界仅文档提及，未单测覆盖
- `page_context.py` 7 处直连 ETL 查询依赖 GUC（C 方案）兜底，未单独传 `ref_date`

### 11.3 完整待办指向

跨模块完整收口、深入测试用例、Wave 1 走查必测场景、清理脚本设计 → **`docs/_overview/04a-feedback/P0-7-runtime-context-todos.md`**。

---

## 12. 任务清单

- [x] T1：`biz.site_runtime_context` 表与 7 表加列迁移（test 库）
- [x] T2：后端 `RuntimeContext` 服务 + 4 + 1 个 API 端点
- [x] T3：admin-web 切换页面 + 权限校验
- [x] T4：AI prompts / data_fetchers 接入业务时钟
- [x] T5：fdw_queries `_fdw_context` 下发 GUC
- [x] T6：ETL 库 39 个 RLS 视图业务日上界（含 helper 函数）
- [x] T7：小程序 5 个页面引入 `getBusinessClock`
- [x] T8：`tools/db/verify_sandbox_end_to_end.py` 31/31 PASS（test 库 site=2790685415443269 / sandbox=2025-09-01）
- [x] T9：admin-web Playwright e2e 13/13 PASS
- [x] T10：BD_Manual + 6 份 changes 审计
- [ ] T11：生产库 `zqyy_app` + `etl_feiqiu` 迁移执行
- [ ] T12：跨模块完整测试 / Wave 1 走查（详见 todos）
- [ ] T13：sandbox 数据定期清理脚本（按 `sandbox_instance_id` 限定）
- [ ] T14：多门店并行 sandbox 验证脚本
- [ ] T15：admin-web 沙箱实例数据浏览页（可选，便于运维查看 `sbx_*` 写入了什么）

---

## 13. 已知冲突 / 历史文档与现状的偏差

| 项 | 冲突 | 当前权威 |
|---|---|---|
| `biz.trigger_jobs` 是否按 site 暂停 | `BD_Manual_runtime_context_sandbox.md` § 3.3 / § 4 仍写 "切沙箱时按 site_id 暂停 enabled 行为 paused_by_sandbox"；现状代码已经移除该逻辑 | **代码现状**：`biz.trigger_jobs` 全局共用，不按 site 暂停。BD_Manual 待修订 |
| Steps 弹窗步骤数量 | `2026-05-02__sandbox_admin_web_manual_checklist.md` 提到 `runtime_context_upserted` / `pending_jobs_cancelled` 等 key；代码实际是 `cancel_etl_processes` / `cancel_task_queue` / `cancel_ai_runtime` / `cancel_ai_jobs` / `biz_triggers_unchanged` / `apply_context` | **代码现状**：6 个 step，key 以 `cancel_*` / `biz_triggers_unchanged` / `apply_context` 为准 |
| 小程序覆盖范围 | `2026-05-02__sandbox_no_future_data_plan.md` § B2 计划改 `board-finance.ts` `isCurrentMonthFilter`；grep 结果未在 `board-finance.ts` 确认 `runtime-clock` import | **未验证**，归入 todos P1 |

---

## 14. 成果层走查（User-facing Acceptance）

### 14.1 验证哲学

工程层（代码 / 调用链 / 迁移 / lint / typecheck）是必要的根基,但**不是终点**。最终用户看到的页面 / 小程序数据是否符合设计目标,才是项目成功标准。

视角分两层:

| 层级 | 验证目标 | 工具 | 通过标准 |
|---|---|---|---|
| 工程层 | 代码可运行 / 调用链通 / 迁移落库 / lint 过 | tsc / pytest / SQL | 自动化全绿 |
| **成果层** | **页面渲染对 / 数据准确 / 交互流畅 / 角色权限对** | **Playwright + 微信开发者工具** | **逐条手工 + 截图归档** |

### 14.2 admin-web 走查清单（Playwright MCP,12 路由）

每条走查 = 设置 sandbox 时间（2026-03-01）→ 打开页面 → 截图 → 对照"期望展示"。

| # | 路由 | 期望展示 | 走查重点 |
|---|---|---|---|
| 14.2.1 | `/dashboard` | 顶部 sandbox 模式条带高亮 + 数据为 sandbox_date 当时数据 | 切 sandbox 后是否立刻显示"沙箱模式" + 数据是否切到虚拟日 |
| 14.2.2 | `/etl-tasks` | 5 Tab 切换 + 队列 / 历史 / 任务配置 / 触发器 / 状态 | sandbox 模式下 ETL 调度按真实时钟（设计共识）|
| 14.2.3 | `/triggers` (含 ?tab=biz / ?tab=ai) | 触发器列表 / status / cron 编辑 | 沙箱模式下显示"触发器仍按真实时钟运行"提示 |
| 14.2.4 | `/ai/dashboard` | 7 天趋势按真实日期（沙箱不影响）、今日 / 累计 token 按真实 | AC7 已要求,补实地走查截图 |
| 14.2.5 | `/ai/operations` | 8 个 APP + app2a 区域财务派生 列出 / 手动触发 → 沙箱模式下日期参数对 | 触发 1 次 app2_finance,看 AI 输入是否带 sandbox_date |
| 14.2.6 | `/ai/prewarm` | 分组展示 area=all 8 组合 + 8 area 64 组合 = 72 / 沙箱不影响预热范围 | 走查 prewarm 在 sandbox 下是否仍按真实 area 跑 |
| 14.2.7 | `/ai/trigger-jobs` | AI 调度历史 / 分页 / 筛选 | sandbox 影响触发的标记 |
| 14.2.8 | `/tenant-admins` | 租户管理员列表 / 创建 / 重置密码 | sandbox 不应改 auth 状态 |
| 14.2.9 | `/settings/env-config` | 环境变量列表 / 编辑 | 与 sandbox 无关 |
| 14.2.10 | `/settings/runtime-context` | 切 sandbox / 历史日期选择 / 切回 live | 完整切换流程,验证 AC1-AC4 |
| 14.2.11 | `/logs/dev-trace` | (已计划 Drop,可跳过) | — |
| 14.2.12 | `/logs/ai-run-logs` | 历史日志按真实写入时间排序 / Drawer 显示 sandbox_date | AC5 验证 |
| 14.2.13 | `/logs/db-viewer` | SELECT 查询正常 / DDL 拒绝（P0-8 修复后） | 验证 P0-8 白名单 |

### 14.3 小程序走查清单（微信开发者工具 MCP,10 页）

前置:打开微信开发者工具 + 启用 9420 自动化端口 + 以教练身份登录。

| # | 页面 | 期望展示 | 走查重点 |
|---|---|---|---|
| 14.3.1 | `task-list` | 任务列表按 sandbox_date 当月生成 | AC1 |
| 14.3.2 | `performance` | 月度统计反映 sandbox_date | AC1 |
| 14.3.3 | `performance-records` | 4 处时间过滤反映 sandbox_date | T7 |
| 14.3.4 | `customer-records` | onLoad 拿 sandbox_date 拉历史 | T7 |
| 14.3.5 | `customer-service-records` | onLoad 拿 sandbox_date 拉历史 | T7 |
| **14.3.6** | **`board-finance`** | **area 5 区域切换 / AI 洞察 12 项指标 / 折线图横轴为虚拟近 7 日** | **P0-3 主体,Wave 1 必修** |
| **14.3.7** | **`board-customer`** | **客户分层基于 sandbox_date 截止状态** | **P0-3** |
| **14.3.8** | **`board-coach`** | **助教绩效反映 sandbox_date 当月累计** | **P0-3** |
| 14.3.9 | `customer-detail` | 操作时间戳保留真实时钟（设计共识） | §11.1 |
| 14.3.10 | `chat` | AI 对话 prompt 含 sandbox_date / 操作时间戳保留真实时钟 | AC5 |

### 14.4 跨页时间漂移走查（AC12 实地化）

走查脚本:连续打开 10 个页面,各拉一次 `business-clock`,验证返回值在 60s 缓存窗口内一致。

### 14.5 多角色身份走查（看板收口后由主线主动提醒 Neo）

**前置**:看板沙箱接入完成（14.3.6 / 14.3.7 / 14.3.8 全部通过）。

**触发提醒**:在 §14.3.6/7/8 全部 PASS 时,在审计中标"提醒 Neo:可以切换用户身份做下一轮走查"。

走查矩阵:

| 身份 | 必走页面 |
|---|---|
| 教练 (coach) | task-list / performance / performance-records / 看板 3 页 |
| 顾问 (consultant) | task-list（无看板权限） / customer-records / chat |
| 散客模式（memberId=0） | coach-service-records 中"散客无详情"提示 |
| site_admin (admin-web) | /settings/runtime-context |
| tenant_admin (tenant-admin) | tenant-admin 主面板 |

每身份完整走完 + 截图归档。

### 14.6 走查产物归档

每次走查产出一份 `docs/audit/changes/2026-XX-XX__sandbox_acceptance_<wave>.md`,内容:

- 截图清单（按 §14.2 / §14.3 编号）
- 失败项明细（现状 / 期望 / 复现步骤）
- 通过率 / 总耗时

---

## 15. 变更记录

| 日期 | 操作 | 执行人 |
|---|---|---|
| 2026-05-01 | 主迁移产出 + 后端 / admin-web 接入 | Codex / Claude |
| 2026-05-02 | C 方案（GUC + 39 视图）+ 小程序接入 + Playwright e2e + 端到端验证 | Cursor + Neo |
| 2026-05-04 | 本 SPEC 草案产出（追溯型） | Claude（Neo 反馈触发） |
| 2026-05-04 | §14 成果层走查 patch 落入（Neo P0-7 反馈,选项 A） | Claude |
| 待定 | 生产库执行 + 跨模块走查 | Neo + 运维 |
