Files
Neo-ZQYY/docs/prd/specs/P20-runtime-context-sandbox.md
Neo c6453829a6 docs(prd): 新增 P20 Runtime Context 沙箱 SPEC + §14 成果层走查
补全 Runtime Context / 虚拟时间机制的独立 SPEC,基于现有代码 +
2026-05-01/02 审计记录追溯型起草(Neo P0-7 反馈触发)。

§1-§13 工程层规范:
- 数据模型 (biz.site_runtime_context + 7 表 runtime 维度列)
- 5 个 API 端点(管理面 + 通用面 + 小程序面)
- 各端读取约定(后端服务层 / ETL 视图层 / 小程序 / AI 提示词 / admin-web)
- 跨模块覆盖矩阵 + 13 条 AC + 任务清单 T1-T15
- 已知冲突清单 (BD_Manual vs 代码现状)

§14 成果层走查 (Neo P0-7 §15 patch 落入,选项 A):
- admin-web Playwright 12 路由走查清单
- 小程序微信开发者工具 10 页走查清单
- 跨页时间漂移 (AC12 实地化)
- 多角色身份走查 (看板收口后主线主动提醒切身份)
- 走查产物归档约定

参考: docs/_overview/04a-feedback/P0-7-spec-acceptance-layer-check.md
2026-05-04 07:38:01 +08:00

578 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P20Runtime 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 | 后端管理 API4 个端点) | 已合并 | `apps/backend/app/routers/admin_runtime_context.py` |
| 5 | 后端小程序 API1 个端点) | 已合并 | `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 | PKFK → `biz.sites(site_id)` | 门店 ID |
| `mode` | varchar(20) | NOT NULLDEFAULT `'live'`CHECK `IN ('live','sandbox')` | 运行模式 |
| `sandbox_date` | date | 可空 | sandbox 模式下系统假设的业务日期 |
| `sandbox_instance_id` | varchar(64) | 可空 | sandbox 写入隔离实例 ID格式 `sbx_<24hex>` |
| `ai_mode` | varchar(20) | NOT NULLDEFAULT `'live'`CHECK `IN ('live')` | AI 调用模式(当前仅 live |
| `status` | varchar(20) | NOT NULLDEFAULT `'active'` | 上下文状态 |
| `reason` | text | 可空 | 切换原因(运维/演示备注) |
| `updated_by` | bigint | 可空 | 最近一次切换的 admin user_id |
| `created_at` | timestamptz | NOT NULLDEFAULT now() | 创建时间 |
| `updated_at` | timestamptz | NOT NULLDEFAULT 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 实例 IDlive 模式占位 `'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 / 7prompt 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 promptsApp2/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 | 多门店并行 sandboxsite 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 查询依赖 GUCC 方案)兜底,未单独传 `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] T3admin-web 切换页面 + 权限校验
- [x] T4AI prompts / data_fetchers 接入业务时钟
- [x] T5fdw_queries `_fdw_context` 下发 GUC
- [x] T6ETL 库 39 个 RLS 视图业务日上界(含 helper 函数)
- [x] T7小程序 5 个页面引入 `getBusinessClock`
- [x] T8`tools/db/verify_sandbox_end_to_end.py` 31/31 PASStest 库 site=2790685415443269 / sandbox=2025-09-01
- [x] T9admin-web Playwright e2e 13/13 PASS
- [x] T10BD_Manual + 6 份 changes 审计
- [ ] T11生产库 `zqyy_app` + `etl_feiqiu` 迁移执行
- [ ] T12跨模块完整测试 / Wave 1 走查(详见 todos
- [ ] T13sandbox 数据定期清理脚本(按 `sandbox_instance_id` 限定)
- [ ] T14多门店并行 sandbox 验证脚本
- [ ] T15admin-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 个 stepkey 以 `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 草案产出(追溯型) | ClaudeNeo 反馈触发) |
| 2026-05-04 | §14 成果层走查 patch 落入Neo P0-7 反馈,选项 A | Claude |
| 待定 | 生产库执行 + 跨模块走查 | Neo + 运维 |