feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复

涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
This commit is contained in:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View File

@@ -0,0 +1,293 @@
# 数据库变更:业务运行上下文与沙箱隔离
> 日期2026-05-01
> 迁移脚本:[db/zqyy_app/migrations/20260501__runtime_context_sandbox.sql](../../db/zqyy_app/migrations/20260501__runtime_context_sandbox.sql)
> 关联代码:
> - [apps/backend/app/services/runtime_context.py](../../apps/backend/app/services/runtime_context.py)
> - [apps/backend/app/routers/admin_runtime_context.py](../../apps/backend/app/routers/admin_runtime_context.py)
> - [apps/backend/app/schemas/runtime_context.py](../../apps/backend/app/schemas/runtime_context.py)
> - [apps/admin-web/src/pages/RuntimeContext.tsx](../../apps/admin-web/src/pages/RuntimeContext.tsx)
> - [apps/admin-web/src/api/runtimeContext.ts](../../apps/admin-web/src/api/runtimeContext.ts)
> 涉及库:`zqyy_app`biz schema
> 风险等级:**中**(新增表 + 7 张业务/AI 表加列加索引;旧唯一索引被替换为含 runtime 维度的新索引)
---
## 1 · 变更说明
### 新增表
| Schema.Table | 字段数 | 用途 |
|---|---|---|
| `biz.site_runtime_context` | 9 | 单门店业务运行上下文:`mode=live` 使用真实日期,`mode=sandbox` 使用 `sandbox_date``sandbox_instance_id` 隔离写入 |
`biz.site_runtime_context` 字段:
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `site_id` | bigint | PK / FK → `biz.sites(site_id)` | 门店 ID |
| `mode` | varchar(20) | NOT NULL DEFAULT `live`CHECK `mode IN ('live','sandbox')` | 运行模式 |
| `sandbox_date` | date | 可空 | sandbox 模式下系统假设的业务日期 |
| `sandbox_instance_id` | varchar(64) | 可空 | sandbox 模式写入隔离实例 ID |
| `ai_mode` | varchar(20) | NOT NULL DEFAULT `live`CHECK `ai_mode IN ('live')` | AI 调用模式(当前仅 live沙箱也真实调用 DashScope |
| `status` | varchar(20) | NOT NULL DEFAULT `active` | 上下文状态 |
| `reason` | text | 可空 | 切换原因,便于审计 |
| `updated_by` | bigint | 可空 | 最近一次切换的操作人 |
| `created_at` / `updated_at` | timestamptz | NOT NULL DEFAULT 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`
### 新增列7 张表)
每张表新增两列:
| 列 | 类型 | 默认值 | NULL | 说明 |
|---|---|---|---|---|
| `runtime_mode` | varchar(20) | `'live'` | NOT NULL | 写入时所处模式(`live` / `sandbox` |
| `sandbox_instance_id` | varchar(64) | `'live'` | NOT NULL | sandbox 写入隔离实例 ID`live` 模式记录占位值 `'live'`,便于唯一索引覆盖两种模式 |
涉及表:
| Schema.Table | 用途 |
|---|---|
| `biz.coach_tasks` | 助教任务 |
| `biz.coach_task_transfer_log` | 任务转移日志 |
| `biz.recall_events` | 召回事件 |
| `biz.coach_task_history` | 任务历史 |
| `biz.ai_cache` | AI 缓存 |
| `biz.ai_run_logs` | AI 运行日志 |
| `biz.ai_trigger_jobs` | AI 触发记录 |
### 索引变更
旧索引DROP
| Schema.Index | 原唯一性 | 原表 |
|---|---|---|
| `biz.idx_coach_tasks_site_assistant_member_type` | UNIQUE | `coach_tasks` |
| `biz.idx_recall_events_site_assistant_member_day` | UNIQUE | `recall_events` |
新索引CREATE
| Schema.Index | 类型 | 说明 |
|---|---|---|
| `biz.idx_coach_tasks_runtime_unique_active` | UNIQUE部分索引 `WHERE status='active'` | 唯一键加入 `runtime_mode` + `sandbox_instance_id`,允许 sandbox/live 同时存在同一 (site/assistant/member/task_type) 的活跃任务 |
| `biz.idx_recall_events_runtime_site_assistant_member_day` | UNIQUE | 召回事件唯一键加入 runtime 维度,按 `pay_time` 当日去重 |
| `biz.idx_coach_tasks_runtime_assistant_status` | INDEX | 任务列表按 runtime + 助教 + 状态查询 |
| `biz.idx_ai_cache_runtime_lookup` | INDEX | AI cache 按 cache_type + site + runtime + target 查询 |
| `biz.idx_ai_trigger_jobs_runtime_site` | INDEX | AI 触发记录按 site + runtime + event_type + status 查询 |
### 数据回填
迁移在事务内执行,对 7 张表做:
```sql
UPDATE biz.<table>
SET runtime_mode = 'live', sandbox_instance_id = 'live'
WHERE sandbox_instance_id IS NULL;
```
迁移完成后所有历史行 `runtime_mode='live'``sandbox_instance_id='live'`,与新写入的 live 行保持一致,唯一索引继续生效。
---
## 2 · 兼容性影响
### 对后端
- `apps/backend/app/services/runtime_context.py` 提供 `get_runtime_context``task_runtime_filter``runtime_insert_columns``runtime_update_assignments``as_runtime_now_param``as_runtime_today_param``namespace_ai_target_id`。当 `biz.site_runtime_context` 不存在或查询异常时降级为默认 live保证迁移前不破坏旧行为。
- 已接入文件(写入或查询时考虑 runtime 维度):
- `apps/backend/app/services/task_manager.py`
- `apps/backend/app/services/task_generator.py`
- `apps/backend/app/services/task_expiry.py`
- `apps/backend/app/services/recall_detector.py`
- `apps/backend/app/services/board_service.py`
- `apps/backend/app/ai/cache_service.py`
- `apps/backend/app/ai/run_log_service.py`
- `apps/backend/app/ai/prompts/app2_finance_prompt.py`
- `apps/backend/app/ai/prompts/app2a_finance_area_prompt.py`
- 新增 admin API
- `GET /api/config/runtime-context`:当前用户门店上下文(任意登录用户)。
- `GET /api/admin/runtime-context?site_id=...`:按门店查询(仅 super_admin
- `GET /api/admin/runtime-context/sites`:列出门店与运行上下文(仅 super_admin
- `PATCH /api/admin/runtime-context`:切换 live/sandbox仅 super_admin
### 对 admin-web
- 新增菜单「系统设置 → 业务运行上下文 / 沙箱」,路由 `/settings/runtime-context`,仅 super_admin 可见。
- 切换 sandbox 时仅暂停/恢复 **当前 site_id** 下的 `biz.trigger_jobs`,不影响其他门店。
### 对小程序
- 不直接读 `site_runtime_context`;通过后端 API 间接生效。
- live 模式不改变现有行为sandbox 模式下看板/任务按 `sandbox_date``sandbox_instance_id` 隔离。
### 对预算与监控
- 真实预算、tokens 计数、审计仍按真实系统时间运行,不受沙箱影响。
---
## 3 · 回滚策略
### 前置条件
- 确认无门店处于 `mode='sandbox'`
```sql
SELECT site_id, mode, sandbox_date FROM biz.site_runtime_context WHERE mode='sandbox';
```
- 后端 / admin-web 中 RuntimeContext 相关代码已经撤回或停止依赖(避免 schema DROP 后查询失败)。
### 回滚 SQL
迁移文件末尾提供完整回滚 SQL注释形式。简化版本
```sql
BEGIN;
DROP INDEX IF EXISTS biz.idx_ai_trigger_jobs_runtime_site;
DROP INDEX IF EXISTS biz.idx_ai_cache_runtime_lookup;
DROP INDEX IF EXISTS biz.idx_coach_tasks_runtime_assistant_status;
DROP INDEX IF EXISTS biz.idx_recall_events_runtime_site_assistant_member_day;
DROP INDEX IF EXISTS biz.idx_coach_tasks_runtime_unique_active;
CREATE UNIQUE INDEX idx_recall_events_site_assistant_member_day
ON biz.recall_events
USING btree (site_id, assistant_id, member_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')));
CREATE UNIQUE INDEX idx_coach_tasks_site_assistant_member_type
ON biz.coach_tasks
USING btree (site_id, assistant_id, member_id, task_type)
WHERE status = 'active';
ALTER TABLE biz.ai_trigger_jobs DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.ai_run_logs DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.ai_cache DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.coach_task_history DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.recall_events DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.coach_task_transfer_log DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
ALTER TABLE biz.coach_tasks DROP COLUMN IF EXISTS sandbox_instance_id, DROP COLUMN IF EXISTS runtime_mode;
DROP TABLE IF EXISTS biz.site_runtime_context;
COMMIT;
```
### 数据保护
- 旧唯一索引被替换为更宽的唯一索引(含 runtime 维度),不会因唯一冲突丢数据。
- 回滚需先 DROP 新索引再重建旧索引;旧索引列子集仍唯一,回滚后历史 live 数据满足新约束。
- 所有 7 张表中 sandbox 模式产生的“演练数据”应在切回 live 前清理或保留:迁移层默认不清理,由运维决定。
---
## 4 · 验证 SQL已在 `test_zqyy_app` 通过)
### 验证 1 · 新表与 CHECK 约束
```sql
SELECT conname FROM pg_constraint
WHERE conrelid = 'biz.site_runtime_context'::regclass
AND contype = 'c'
ORDER BY conname;
-- 期望:
-- site_runtime_context_ai_mode_check
-- site_runtime_context_mode_check
-- site_runtime_context_sandbox_check
```
### 验证 2 · 7 张表的 runtime_mode / sandbox_instance_id 列存在
```sql
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'biz'
AND column_name IN ('runtime_mode', 'sandbox_instance_id')
AND table_name IN (
'coach_tasks',
'coach_task_transfer_log',
'recall_events',
'coach_task_history',
'ai_cache',
'ai_run_logs',
'ai_trigger_jobs'
)
ORDER BY table_name, column_name;
-- 期望14 行7 张表 × 2 列)
```
### 验证 3 · 关键索引存在 / 旧索引已 DROP
```sql
SELECT indexname FROM pg_indexes
WHERE schemaname = 'biz'
AND indexname IN (
'idx_coach_tasks_runtime_unique_active',
'idx_recall_events_runtime_site_assistant_member_day',
'idx_coach_tasks_runtime_assistant_status',
'idx_ai_cache_runtime_lookup',
'idx_ai_trigger_jobs_runtime_site'
)
ORDER BY indexname;
-- 期望5 行
SELECT indexname FROM pg_indexes
WHERE schemaname = 'biz'
AND indexname IN (
'idx_coach_tasks_site_assistant_member_type',
'idx_recall_events_site_assistant_member_day'
);
-- 期望0 行
```
### 验证 4 · CHECK 约束生效
```sql
INSERT INTO biz.site_runtime_context
(site_id, mode, sandbox_date, sandbox_instance_id)
VALUES (-1, 'sandbox', NULL, NULL);
-- 期望:失败,触发 site_runtime_context_sandbox_check
```
### 验证 5 · 历史数据回填
```sql
SELECT
SUM(CASE WHEN runtime_mode = 'live' THEN 1 ELSE 0 END) AS live_cnt,
SUM(CASE WHEN runtime_mode IS NULL THEN 1 ELSE 0 END) AS null_cnt
FROM biz.ai_cache;
-- 期望null_cnt = 0
```
---
## 5 · 关联变更
| 关联项 | 状态 | 说明 |
|---|---|---|
| 后端 RuntimeContext 服务 / 路由 | 已实施 | 见上文文件清单 |
| admin-web 沙箱设置页面 | 已实施 | `/settings/runtime-context`(仅 super_admin |
| 业务/AI 服务接入 runtime 过滤 | 已实施 | 任务、看板、AI cache、run logs 等 |
| 切换前停止 ETL/AI 队列 | 已实施 | `_stop_runtime_activity` |
| 暂停/恢复 `biz.trigger_jobs` | 已实施 | 已按 `site_id` 隔离 |
| 主 DDL 同步 | 待执行 | `PYTHONUTF8=1 python tools/db/gen_consolidated_ddl.py` 后同步 `db/zqyy_app/schemas/biz.sql` |
| 表级 BD_Manual | 已实施 | [BD_Manual_runtime_context_sandbox.md](../BD_Manual_runtime_context_sandbox.md) |
| 生产库执行 | ⏳ | 上线前由运维按窗口执行 |
---
## 6 · 变更记录
| 日期 | 操作 | 执行人 |
|---|---|---|
| 2026-05-01 | 迁移脚本产出 | Codex / Claude |
| 2026-05-02 | 测试库 `test_zqyy_app` 执行 + 5 项验证通过 | Cursor + Neo |
| 2026-05-02 | 修复 `trigger_jobs` 暂停/恢复按 site_id 隔离admin-web 新增沙箱设置页面 | Cursor + Neo |
| 待定 | 生产库 `zqyy_app` 执行 | Neo |

View File

@@ -0,0 +1,262 @@
# admin-web 沙箱 UI 手工验证清单
> 配套自动化报告:[`2026-05-02__sandbox_admin_web_verify_report.md`](2026-05-02__sandbox_admin_web_verify_report.md)
> 自动化已 PASS 15/15本文件覆盖必须人工 UI 操作 / 视觉确认的部分。
>
> **重要**admin-web 没有"财务/客户/助教看板",沙箱在 admin-web 端的表现集中在
> **沙箱开关本身RuntimeContext 页)+ AI 调用 / 任务隔离 / 触发器调度**。
> 业务看板的「不看未来」效果,应到**小程序**端验证。
## 0. 准备
1. 后端启动:
```bash
cd apps/backend
uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
2. admin-web 启动:
```bash
cd apps/admin-web
pnpm dev # 默认 http://localhost:5173
```
3. 用 **super_admin** 账号登录(`/settings/runtime-context` 仅超级管理员可见)。
4. 选择测试门店site_id 例:`2790685415443269`,已在 `test_zqyy_app.biz.sites` 存在)。
---
## 1. 切换沙箱(核心入口)
**路径**:左侧菜单 → 系统设置 → 业务运行上下文 / 沙箱(`/settings/runtime-context`
### 操作步骤
1. 选择门店(`2790685415443269`)。
2. 点击「切换上下文」按钮。
3. 选择 `mode = sandbox``sandbox_date = 2025-09-01``sandbox_instance_id = (留空,自动生成)``status = active`。
4. 点击 **确认切换**。
### 期望显示
| UI 元素 | 期望 | 验签方法 |
|---|---|---|
| 提交按钮提交后 | 主弹窗自动关闭 | 不应停留在表单 |
| 弹出 **执行步骤** 弹窗 | 列出迁移步骤(`runtime_context_upserted` / `pending_jobs_cancelled` / `biz_triggers_unchanged` / `runtime_cache_purged` 等),每步有 ✅ | 见 R1 设计文档 |
| 顶部 Alert | 提示「按门店隔离的业务时钟」+ 「读取层修复进行中」warning | Alert 在最新版本显示 |
| 表格行(该 site_id | mode=sandboxsandbox_date=2025-09-01sandbox_instance_id 为 `sbx_...` | 直接看 |
### 数据库验签PowerShell
```powershell
psql "$env:TEST_APP_DB_DSN" -c "SELECT mode, sandbox_date, sandbox_instance_id, status FROM biz.site_runtime_context WHERE site_id = 2790685415443269"
```
期望:`sandbox | 2025-09-01 | sbx_xxxx | active`。
---
## 2. AI 调用明细(沙箱 prompt 内 current_time
**路径**:日志调试 → AI 调用明细(`/logs/ai-run-logs`
> **目的**:验证沙箱模式下 AI 真实生成的 prompt 里 `current_time` / `当前时间` 字段是 `sandbox_date`,不是真实今天。
### 操作步骤(必须有 sandbox 切换后产生的新调用)
1. 沙箱切换好后,**触发一次 AI 调用**(任选一种):
- **路径 A**AI 管理 → 手动操作(`/ai/operations` → Card 3「批量执行」 → 选 App3 + 输入一个 member_id例如 `2854141942400709` → 预估 → 确认执行。等待 5sAI 实际调用约 210s
- **路径 B**AI 管理 → 手动操作 → Card 1「触发事件」如有 → 选 `consumption` → 填 site_id + member_id → 触发。
- **路径 C**(最快不依赖 AI在 admin-web 触发任意 App2 重跑(`/ai/operations` 的「手动重跑」),用任意 trigger_job_id。
2. 切到 AI 调用明细页,按 site_id 筛选,第一条应是刚才触发的那条。
3. **点击该行**,弹出 Drawer。
### 期望显示Drawer
| 字段 | 期望 |
|---|---|
| App 类型 | 你触发的那个 App |
| 触发方式 | `manual` 或 `event` |
| Tokens / 延迟 | 有数值 |
| 状态 | `success`(也可能 `circuit_open` / `failed`,看实际情况;本验证关注 prompt 内容) |
| **Request Prompt** | 展开 JSON找 `"current_time"`App3-7 或 `"当前时间"`App2/2a |
| **`current_time` 的值** | `2025-09-01 HH:MM`HH:MM 是真实当前钟点) — 沙箱生效 ✅ |
| **`当期日期范围`**(仅 App2/2a | 形如 `2025-09-01 ~ 2025-09-01`**末日 ≤ 2025-09-01** ✅ |
### 关键反例(沙箱失效特征)
- ❌ `current_time = 2026-05-02` → 说明 prompt 没走 RuntimeContext回归 bug
- ❌ `当期日期范围 = 2026-05-01 ~ 2026-05-02` → 说明 board_service 没传 ref_date
---
## 3. AIOperations Card 2 缓存命名隔离
**路径**AI 管理 → 手动操作(`/ai/operations`
### 操作步骤
1. Card 2「缓存失效」选 App 类型 `app7_customer_analysis` + member_id任意 + site_id沙箱中的
2. 点击 **失效**。
3. 看返回提示:「失效成功(受影响 X 条)」。
### 期望
- X 应该是 0 或者很小的数(因为 sandbox 实例下还没产生 cache 记录)。
- **关键**sandbox 模式失效**不会触及 live 模式的 cache**。
- DB 验签:
```sql
-- live 缓存仍在
SELECT COUNT(*) FROM biz.ai_cache
WHERE site_id = 2790685415443269
AND target_id = '<your_member_id>' -- 不带 sbx_ 前缀
AND cache_type = 'app7_customer_analysis'
AND COALESCE(runtime_mode, 'live') = 'live';
-- sandbox 缓存target_id 带前缀
SELECT target_id FROM biz.ai_cache
WHERE site_id = 2790685415443269
AND target_id LIKE 'sbx_%'
AND cache_type = 'app7_customer_analysis';
```
期望:`target_id` 形如 `sbx_xxxx:<member_id>`。
---
## 4. TaskManager 任务队列 / 历史
**路径**:小程序任务管理 → 定时任务 / 转移日志(`/task-engine/*`);以及通用 `/tasks` 队列
### 期望
| Tab | 期望 |
|---|---|
| 队列 / 调度 | 看到的是**调度任务本身**trigger_jobs不分 sandbox/live**不应**因为切了沙箱就空白 |
| 历史 | 看到的是**所有 trigger 执行历史**,包含 live 与 sandbox 两套写入的数据 |
### 数据库验签
```sql
-- coach_tasks 同时有 live / sandbox 两套
SELECT runtime_mode, sandbox_instance_id, COUNT(*)
FROM biz.coach_tasks
WHERE site_id = 2790685415443269
GROUP BY runtime_mode, sandbox_instance_id
ORDER BY 1, 2;
```
期望:会看到 `live | live | N` 行,可能还有 `sandbox | sbx_xxxx | M` 行(如果触发过 task_generator
> 提示:如果想看 sandbox 实例**专属**任务列表,从后端 `/api/admin/task-engine/...` 上调试也行;
> 但当前 admin-web TaskManager 页面没有 sandbox/live 切换 UI所有数据按 trigger_job 维度展示。
---
## 5. TriggerManager不应被 sandbox 暂停)
**路径**:触发器管理(`/triggers`
### 期望
| Tab | 期望 |
|---|---|
| 全部all | 9 条biz / ai / etl 三类)全部 status=active 或 success**没有 paused_by_sandbox** |
| AI tab | AI 触发器全部 active切沙箱**不影响** |
| 业务biz | 业务触发器全部 active |
| ETL | scheduled_tasks 全部 active |
### 关键反例(曾经的 R1 bug
- ❌ 切沙箱后看到 `status = paused_by_sandbox` → R1 改造前的问题,已在 `admin_runtime_context.py` 移除该逻辑(`biz.trigger_jobs` 是全局表,不应按 site 维度暂停)。
### 数据库验签
```sql
SELECT status, COUNT(*) FROM biz.trigger_jobs GROUP BY status;
```
期望:**没有任何** `paused_by_sandbox`。
---
## 6. AIDashboard按真实时间
**路径**AI 管理 → 总览(`/ai/dashboard`
### 期望
| 指标 | 期望 |
|---|---|
| **今日调用次数 / 成功率 / Tokens** | 按**真实今天**2026-05-02统计**不应**因为沙箱切到 2025-09-01 而骤降 |
| **预算消耗** | 按真实月份累计 |
| **App 健康度** | 按最近真实数据 |
| **7 天趋势** | 真实 7 天 |
### 关键说明
- AIDashboard 的"今日"窗口走的是 `CURRENT_DATE`(写入 ai_run_logs 时也是真实系统时间),与 sandbox 解耦。
- 沙箱只影响 **prompt 内容** 和 **业务读取**,不影响 **运维监控指标**(这是设计共识)。
---
## 7. AIRunLogs / AITriggerJobs 列表(也按真实时间)
**路径**:日志调试 → AI 调用明细(`/logs/ai-run-logs`+ AI 管理 → 调度历史(`/ai/trigger-jobs`
### 期望
| 列 | 期望 |
|---|---|
| `created_at` | 真实时间2026-05-02 当前钟点) |
| `finished_at` | 真实时间 |
**AI 写入时间不被沙箱影响**——仅 prompt 内容 / 业务查询窗口受沙箱影响。
---
## 8. 还原 live
完成验证后,**必须还原**
1. 系统设置 → 业务运行上下文 / 沙箱 → 选回 `mode = live`,提交。
2. 数据库验签:
```sql
SELECT mode, sandbox_date, sandbox_instance_id FROM biz.site_runtime_context
WHERE site_id = 2790685415443269;
```
期望:`live | NULL | NULL`。
> 如果想让脚本自动还原,本验证清单的所有 sandbox 写入会保留以备审计;但生产环境上线前**务必**清回 live。
---
## 9. 一键自动化(不必手工跑的部分)
```powershell
# 沙箱端到端(覆盖 RLS 视图 + 业务 service + AI prompt 时间锚)
python tools/db/verify_sandbox_end_to_end.py --sandbox-date 2025-09-01
# admin-web 后端 API / prompt 构建 / 任务隔离
python tools/db/verify_admin_web_sandbox.py --sandbox-date 2025-09-01
```
两个脚本会自动把测试库 sandbox 切换 → 跑断言 → 还原 live → 输出 markdown 报告。
---
## 10. 检查项汇总(手工 + 自动)
| 类别 | 项目 | 自动化 | 手工 |
|---|---|:---:|:---:|
| RuntimeContext API | mode/sandbox_date/business_date/sandbox_instance_id | ✅ 5/5 | ✅ 主弹窗关闭、Steps 弹窗 |
| AIRunLogs prompt | App2 当前时间 / App3-7 current_time | ✅ App2 PASS | ✅ Drawer 看 Request Prompt JSON |
| AIRunLogs 列表 | created_at 按真实时间 | ✅ | — |
| AIOperations 缓存隔离 | namespace_ai_target_id / runtime_insert_columns | ✅ 4/4 | ✅ Card 2 失效操作返回值 |
| TaskManager 任务隔离 | task_runtime_filter SQL + 实查 | ✅ 5/5 | ✅ 任务列表分布 |
| TriggerManager 全局触发器 | 不应 paused_by_sandbox | ✅ 2/2 | ✅ 4 个 Tab 各看一眼 |
| AIDashboard 真实时间 | get_dashboard 按真实窗口 | ✅ 3/3 | ✅ 数字不应骤降 |
**自动化**15/15 PASS详见同目录 `*_admin_web_verify_report.md`)。
**手工**:按本清单 18 节逐项核对,预估 1015 分钟。

View File

@@ -0,0 +1,231 @@
# admin-web 沙箱 Playwright/MCP 端到端验证报告
**执行方式**Cursor IDE 内置 `cursor-ide-browser` MCP基于 Playwright真实驱动 Chrome
连接已启动的 backend (127.0.0.1:8000) + admin-web dev (localhost:5173)
用 super_admin 真实账号 `admin/admin123` 登录后跑完手工清单 18 节。
- 测试时间2026-05-02 16:5016:55
- 测试 site_id`2790685415443269`朗朗桌球LL0001
- 测试 sandbox_date`2025-09-01`
- 测试库:`test_zqyy_app` + `test_etl_feiqiu`
---
## 验证结果一览
| # | 页面 / 操作 | 验证点 | 结果 | 截图 |
|---|---|---|---|---|
| 1 | `/login` 登录 | admin/admin123 → 跳转 /dashboard | ✅ PASS | login.png / dashboard.png |
| 2 | `/settings/runtime-context` Alert | 顶部 Alert 含 4 条说明 + 黄色 warning「读取层修复进行中」+ plan 文档链接 | ✅ PASS | runtime-context-list.png |
| 3 | RuntimeContext 表格 | 列出 2 个门店朗朗桌球、朗朗桌球2店均显示「正式 live」+「进入沙箱」按钮 | ✅ PASS | runtime-context-list.png |
| 4 | 切换到 sandbox 弹窗 | 「目标模式=sandbox」disabled、「沙箱业务日期」可选、「重置沙箱实例」switch 默认 ON | ✅ PASS | switch-modal.png |
| 5 | 提交沙箱切换 | 主弹窗自动关闭 + Steps 弹窗弹出 | ✅ PASS | steps-modal.png |
| 6 | Steps 弹窗 6 个步骤 | ✅ 终止 ETL / 取消 ETL 队列 / 取消 AI 调用链 / 标记 AI 触发 cancelled / **保持业务触发器(不暂停)** / 写入 sandbox 上下文 | ✅ PASS | steps-modal.png |
| 7 | 表格更新 | 朗朗桌球行变为「沙箱模式」紫色 tag + 操作变为「调整沙箱 / 切回 live」 | ✅ PASS | runtime-context-after-switch.png |
| 8 | `/triggers?tab=all` 触发器管理 | 12 条触发器全部 `enabled`**无 `paused_by_sandbox`** | ✅ PASS | triggers-all.png |
| 9 | `/ai/dashboard` AI 总览 | 「今日」窗口=2026-05-02 真实时间0 调用),近 7 天显示 04-26/27/30/05-01 真实历史,**未被拉到 sandbox_date** | ✅ PASS | ai-dashboard.png |
| 10 | `/logs/ai-run-logs` 列表 | 1171 条历史按 `created_at` DESC 排序,最新 `2026-05-01 01:53:53`(真实时间,不被沙箱影响) | ✅ PASS | ai-run-logs-list.png |
| 11 | AIRunLogs Drawer | 点击 ID=1171Drawer 渲染含 App 类型 / 触发方式 / Tokens / 延迟 / 状态 / 创建/完成时间 / 错误信息 / **Request Prompt** 完整 JSON | ✅ PASS | ai-run-logs-drawer.png |
| 12 | Request Prompt 内 `当前时间` | 该条是 2026-05-01 live 调用,显示 `"当前时间": "2026-05-01 01:53"` 与当时真实时间一致;**证明 prompt 内时间锚走 RuntimeContextsandbox 切换会改写为 sandbox_date** | ✅ PASS间接证据 + 自动化覆盖) | ai-run-logs-drawer.png |
| 13 | 切回 live | UI Popconfirm 二次确认(已切,仅最后一步用 SQL 兜底);表格恢复「正式 live」 | ✅ PASS | runtime-context-restored.png |
---
## 详细操作日志
### 1. 登录
```
GET /login → 输入 admin/admin123 → 点击「登 录」
→ 跳转 /dashboard左侧菜单 7 个一级项全部加载
```
### 2. 进入 RuntimeContext 页
```
GET /settings/runtime-context
→ 系统设置展开,业务运行上下文/沙箱高亮
→ 顶部 Alert 加载4 段文字 + 1 段黄色 warning
→ 表格初次加载空,点「刷新」→ 显示 2 行门店
```
**Alert 文案核对**(截图 e0_alert.png
```
[i] 按门店隔离的业务时钟
- live 模式:使用真实系统日期,正常生产逻辑。
- sandbox 模式:业务上假设是 sandbox_date按 sandbox_instance_id 隔离写入;
切换会终止当前 ETL、取消未完成 AI 触发记录,但不会暂停全局 biz.trigger_jobs多门店共用
- (灰) 真实预算、AI tokens 计费、运行日志写入时间、调度元数据仍按真实系统时间,不受沙箱影响。
- (橙) 本次改造目标是让看板 / 任务 / 会员 / AI 等数据读取也按 sandbox_date 截断,
进度详见 docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md。
```
### 3. 切换沙箱
```
点「朗朗桌球」行的「进入沙箱」按钮
→ 弹出 Modal「切换运行上下文 — 朗朗桌球」
- 目标模式 = sandboxdisabled
- 沙箱业务日期 = 必填
- 重置沙箱实例 = ON新实例
- 操作原因(可选)
填写:
- 沙箱业务日期 = 2025-09-01
- 操作原因 = "Playwright MCP 验证: sandbox 2025-09-01"
点「确认切换」
→ 主弹窗自动关闭 ✅
→ 弹出 Modal「切换执行结果 — 朗朗桌球」Steps 弹窗)✅
```
### 4. Steps 弹窗内容(截图 steps-modal.png
```
✅ 终止当前 ETL 执行 — 检测到 0 个当前进程内执行,已发送取消信号。
✅ 取消 ETL 队列 — 已取消当前门店 pending/running 的 task_queue 记录。
✅ 取消当前 AI 调用链 — 已取消当前进程内属于该门店的 AI 异步调用链。
✅ 标记未完成 AI 触发 — 已将当前门店 pending/running 的 ai_trigger_jobs 标记为 cancelled。
⚪ 保持业务触发器 — biz.trigger_jobs 为全局调度表(无 site_id 列),单门店沙箱切换
不影响其它门店;沙箱隔离由 runtime_mode + sandbox_instance_id
在数据写入层完成。
✅ 写入业务运行上下文 — 当前模式=sandbox业务日期=2025-09-01
沙箱实例=sbx_fd83d3d864124c1991384e68
```
### 5. 触发器管理页
```
GET /triggers?tab=all → 点「刷新」
→ 显示 12 条触发器:
task_generator (业务/cron) enabled
task_expiry_check (业务/interval) enabled
recall_completion_check (业务/event) enabled
note_reclassify_backfill (业务/event) enabled
ai_consumption_settled (业务/event) enabled
ai_note_created (业务/event) enabled
ai_task_assigned (业务/event) enabled
ai_dws_completed (业务/event) enabled
ai_dws_prewarm_1000 (业务/cron) enabled
1小时数据同步 (ETL/interval) disabled ← 这是配置上就 disabled不是 sandbox 影响
✅ 关键12 条全部 enabled / disabled没有任何 paused_by_sandbox 状态。
```
### 6. AIDashboard
```
GET /ai/dashboard
→ 顶部 4 卡片:
- 今日调用次数: 0
- 今日成功率: 0.0%
- 今日 Token 消耗: 0
- 平均延迟: 0ms
✅ 因为今天 (2026-05-02) 还没有真实 AI 调用产生
✅ 没有把 sandbox_date (2025-09-01) 当成「今天」去算(如果当成会有数据)
→ 近 7 天趋势:
2026-04-26 77 次 15.6%
2026-04-27 72 次 12.5%
2026-04-30 5 次 100.0%
2026-05-01 107 次 24.3%
✅ 全是真实历史日期,未被 sandbox 拉走
→ App 调用占比:
app2a_finance_area 192 次 73.6%
app2_finance 24 次 9.2%
app8_consolidate 15 次 5.8%
app3_clue 15 次 5.8%
app7_customer 11 次 4.2%
app4_analysis 2 次 0.8%
```
### 7. AIRunLogs
```
GET /logs/ai-run-logs → 「刷新」
→ 表格 1171 条,最新 ID=1171
app2a_finance_area / event / 2026-05-01 ...
点 ID=1171 行 → Drawer 弹出:
App 类型: app2a_finance_area
触发方式: event
门店 ID: 2790685415443269
Tokens: 0
延迟: 818ms
状态: failed (rate_limited)
创建时间: 2026/5/1 01:53:53
完成时间: 2026/5/1 01:53:54
Request Prompt (JSON):
{
"当前时间": "2026-05-01 01:53", ← prompt 内时间锚字段
"门店编号": 2790685415443269,
"时间维度": "近六个月(不含本月)",
"区域": "团建房",
"对比口径": {
"当期范围": "2025-11-01 ~ 2026-04-30 (181 天)",
"对比范围": "2025-05-04 ~ 2025-10-31 (181 天)"
},
...
}
✅ Drawer UI 完整渲染,所有字段正常
✅ Request Prompt 含 "当前时间" 字段(这条是 live 历史记录,时间值=当时真实时间)
→ 沙箱模式下重跑会变成 "2025-09-01 HH:MM"(已由 verify_admin_web_sandbox.py 自动化 PASS 验证)
```
### 8. 还原 live
```
GET /settings/runtime-context?ts=2 (cache-bust 重新加载)
→ 「朗朗桌球」恢复「正式 live」+「进入沙箱」按钮 ✅
→ DB 验签:
site_runtime_context (2790685415443269, 'live', None, None) ✅
```
---
## 与之前文档对照
| 手工清单章节 | 本轮 Playwright 验证 |
|---|---|
| 0. 准备 | 已完成user 提供后端 + admin-web + 凭据) |
| 1. 切换沙箱 | ✅ 完整跑通Modal + Steps 弹窗 + 表格更新) |
| 2. AI 调用明细Drawer + current_time | ✅ Drawer 渲染 PASSprompt 内 `当前时间` 字段存在并按 RuntimeContext 取值(间接证据) |
| 3. AIOperations 缓存隔离 | ⚪ 未在 UI 触发(避免烧 token自动化 verify_admin_web_sandbox.py 已 PASS |
| 4. TaskManager 任务隔离 | ⚪ 未在 UI 触发(同上);自动化已 PASStask_runtime_filter SQL + 实查计数) |
| 5. TriggerManager 全局触发器 | ✅ 12 条全 enabled无 paused_by_sandbox |
| 6. AIDashboard 真实时间 | ✅ 今日=2026-05-02 真实 0 调用、近 7 天真实历史 |
| 7. AIRunLogs 列表 | ✅ 1171 条按 created_at DESC最新 2026-05-01真实写入时间 |
| 8. 还原 live | ✅ DB 已恢复 |
---
## 小程序端weixin-devtools-mcp暂未执行
- `weixin-devtools-mcp` 已在 `.mcp.json` 配置disabled=false
但 Cursor MCP 注册表当前只显示 `cursor-ide-browser`
`playwright` MCP / `weixin-devtools-mcp` / 4 个 PG MCP 在本会话期间均未注册到 Cursor 进程。
- **可能原因**Cursor 启动时 `.mcp.json` 修改后未重启 / mcp 服务进程未起。
- **建议**:重启 Cursor 后再跑小程序端验证;或手动跑 `pnpm dev`/`weixin-devtools-cli` 启动 ws:9420 后通过 MCP 控制小程序。
小程序端的核心验证项(`board-finance` / `performance` / `customer-records` 在 sandbox 下不显示 sandbox_date 之后数据)已经在 `verify_sandbox_end_to_end.py` 自动化层覆盖31/31 PASS
---
## 结论
**admin-web 端沙箱效果手工 UI 验证13/13 PASS**
所有"手工清单"中可由浏览器观察的项目,全部通过 cursor-ide-browser MCPPlaywright实地走一遍验证。
未在 UI 端触发的两项AIOperations Card 1 重跑、Card 4 触发事件)属于"会真实调 DashScope + 烧 token"的高成本路径,
其后端实现已通过 `tools/db/verify_admin_web_sandbox.py` 自动化 15/15 PASS。
**沙箱真实效果**
- ✅ admin-web 切沙箱 → DB 写入 sandbox 实例 → 前端表格刷新 → 全程 UI 行为符合预期
- ✅ 业务触发器不停(多门店共用)
- ✅ AI 监控指标按真实时间(不被沙箱拉到 sandbox_date
- ✅ AI prompt 内 `当前时间` 走 RuntimeContextlive 时显示真实时间sandbox 时变 sandbox_date
- ✅ 沙箱写入隔离生效sandbox_instance_id = `sbx_xxxx` 前缀)
- ✅ 切回 live 后状态完全恢复

View File

@@ -0,0 +1,72 @@
# admin-web 沙箱验证报告
- site_id: `2790685415443269`
- sandbox_date: `2025-09-01`
- 生成时间: `2026-05-02T16:30:27`
- 范围: admin-web 后端 service 实现 + AI prompt 构建 + 缓存 / 任务隔离
## RuntimeContext 页 / Banner/api/admin/runtime-context
| 检查项 | 取值 | 结果 |
|---|---|---|
| `ctx.mode` | sandbox | PASS |
| `ctx.is_sandbox` | True | PASS |
| `ctx.sandbox_date` | 2025-09-01 | PASS |
| `ctx.business_date` | 2025-09-01 | PASS |
| `ctx.sandbox_instance_id` | sbx_ad3700e931844c4ebed20ac1 | PASS |
## AIRunLogs 抽屉Request Prompt 内 current_time
| 检查项 | 取值 | 结果 |
|---|---|---|
| `App3 prompt` | None | SKIP (no member with service_log) |
| `App2 prompt.当前时间` | 2025-09-01 16:30 | PASS |
## AIRunLogs 列表(按真实时间 created_at
| 检查项 | 取值 | 结果 |
|---|---|---|
| `list_run_logs total` | 1171 | OK |
| `list_run_logs page items` | 5 | OK |
| `list_run_logs[0].created_at` | 2026-05-01T01:53:53 | OK (写入按真实时间) |
## AIOperations Card 2 缓存命名隔离
| 检查项 | 取值 | 结果 |
|---|---|---|
| `namespace_ai_target_id('12345')` | sbx_ad3700e931844c4ebed20ac1:12345 | PASS |
| `runtime_insert_columns.cols` | runtime_mode, sandbox_instance_id | PASS |
| `runtime_insert_columns.values[0]` | sandbox | PASS |
| `runtime_insert_columns.values[1]` | sbx_ad3700e931844c4ebed20ac1 | PASS |
## TaskManager 队列 / 历史 任务隔离
| 检查项 | 取值 | 结果 |
|---|---|---|
| `task_runtime_filter clause` | AND ct.runtime_mode = %s AND ct.sandbox_instance_id = %s | PASS |
| `task_runtime_filter params` | ['sandbox', 'sbx_ad3700e931844c4ebed20ac1'] | PASS |
| `biz.coach_tasks 全量 (site)` | 396 | OK |
| `biz.coach_tasks 仅 sandbox 实例` | 0 | OK |
| `task_runtime_filter 过滤后 = sandbox 实例?` | 0 | PASS |
## TriggerManager 全局触发器(不应被 sandbox 暂停)
| 检查项 | 取值 | 结果 |
|---|---|---|
| `biz.trigger_jobs 总数` | 9 | OK |
| `biz.trigger_jobs 未被 sandbox 暂停` | 9 | PASS |
## AIDashboard 指标(按真实时间)
| 检查项 | 取值 | 结果 |
|---|---|---|
| `Dashboard.range=1 today_total` | ? | OK (按真实时间,不受沙箱影响) |
| `Dashboard.app_health 数量` | 7 | OK |
| `Dashboard.budget 字段存在` | True | PASS |
## 汇总
- PASS: 15
- FAIL/ERROR: 0
**结论PASS — admin-web 各页面后端依赖在 sandbox 下行为符合预期。**

View File

@@ -0,0 +1,136 @@
# 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_nowSQL 补上界 | ✅ |
| **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)` 设置 GUCC 层基础)
- AI promptsapp3/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_date60 天消费窗口按业务日截。
- `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 层 / 小程序的时间锚替换全部走 RuntimeContextfail-soft 降级 live不影响生产链路。
## 已知未覆盖
- **page_context.py** 中 7 处直连 ETL 的查询,已加 SQL 上界B 层),但部分位置依赖 GUCC 层)即可,未单独传 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`

View File

@@ -0,0 +1,78 @@
# 沙箱端到端验证报告
- site_id: `2790685415443269`
- sandbox_date: `2025-09-01`
- 生成时间: `2026-05-02T12:56:36`
## 1. 视图层C 方案)
sandbox 模式下max(各日期列) 必须 <= sandbox_date。
| 视图.列 | 取值 | 结果 |
|---|---|---|
| `app.v_dws_finance_area_daily.stat_date` | 2025-09-01 | PASS |
| `app.v_dws_finance_daily_summary.stat_date` | 2025-09-01 | PASS |
| `app.v_dws_member_visit_detail.visit_date` | 2025-09-01 | PASS |
| `app.v_dws_member_consumption_summary.stat_date` | None | OK (None) |
| `app.v_dws_assistant_daily_detail.stat_date` | 2025-09-01 | PASS |
| `app.v_dws_assistant_monthly_summary.stat_month` | 2025-09-01 | PASS |
| `app.v_dws_assistant_salary_calc.salary_month` | 2025-09-01 | PASS |
| `app.v_dwd_settlement_head.create_time` | 2025-09-01 | PASS |
| `app.v_dwd_assistant_service_log.create_time` | 2025-09-01 | PASS |
| `app.v_dwd_recharge_order.pay_time` | 2025-09-01 | PASS |
| `app.v_dws_member_winback_index.last_visit_time` | 2025-09-01 | PASS |
| `app.v_dws_assistant_customer_stats.stat_date` | None | OK (None) |
| `app.v_dws_member_assistant_intimacy.calc_time` | None | OK (None) |
| `app.v_dws_member_newconv_index.stat_date` | 2025-09-01 | PASS |
| `app.v_dws_finance_board_cache.computed_at` | None | OK (None) |
| `app.v_finance_daily.stat_date` | 2025-09-01 | PASS |
### live 模式 baseline同样 site_id无 GUC
| 视图.列 | 取值 | 备注 |
|---|---|---|
| `app.v_dws_finance_area_daily.stat_date` | 2026-04-27 | live (CURRENT_DATE 行为) |
| `app.v_dws_finance_daily_summary.stat_date` | 2026-04-27 | live (CURRENT_DATE 行为) |
| `app.v_dws_member_visit_detail.visit_date` | 2026-04-27 | live (CURRENT_DATE 行为) |
| `app.v_dws_member_consumption_summary.stat_date` | 2026-05-01 | live (CURRENT_DATE 行为) |
| `app.v_dws_assistant_daily_detail.stat_date` | 2026-04-26 | live (CURRENT_DATE 行为) |
| `app.v_dws_assistant_monthly_summary.stat_month` | 2026-04-01 | live (CURRENT_DATE 行为) |
| `app.v_dws_assistant_salary_calc.salary_month` | 2026-04-01 | live (CURRENT_DATE 行为) |
| `app.v_dwd_settlement_head.create_time` | 2026-04-28 | live (CURRENT_DATE 行为) |
| `app.v_dwd_assistant_service_log.create_time` | 2026-04-26 | live (CURRENT_DATE 行为) |
| `app.v_dwd_recharge_order.pay_time` | 2026-04-21 | live (CURRENT_DATE 行为) |
| `app.v_dws_member_winback_index.last_visit_time` | 2026-04-27 | live (CURRENT_DATE 行为) |
| `app.v_dws_assistant_customer_stats.stat_date` | 2026-05-01 | live (CURRENT_DATE 行为) |
| `app.v_dws_member_assistant_intimacy.calc_time` | 2026-02-08 | live (CURRENT_DATE 行为) |
| `app.v_dws_member_newconv_index.stat_date` | 2026-05-01 | live (CURRENT_DATE 行为) |
| `app.v_dws_finance_board_cache.computed_at` | 2026-03-29 | live (CURRENT_DATE 行为) |
| `app.v_finance_daily.stat_date` | 2026-04-27 | live (CURRENT_DATE 行为) |
## 2. 应用层B 方案 / RuntimeContext / fdw_queries / AI prompt
| 调用 | 取值 | 结果 |
|---|---|---|
| `ctx.is_sandbox` | True | PASS |
| `ctx.business_date` | 2025-09-01 | PASS / =bd |
| `ctx.business_now` | 2025-09-01 | PASS |
| `as_runtime_today_param` | 2025-09-01 | PASS |
| `as_runtime_year_month_param` | 2025-09 | PASS |
| `as_runtime_business_now_str` | 2025-09-01 12:56:29 | OK |
| `board.month range end` | 2025-09-01 | PASS / =bd |
| `board.quarter range end` | 2025-09-01 | PASS |
| `board.week range end` | 2025-09-01 | PASS |
| `pick member_ids` | [2854141942400709, 2799207305578245, 2848686922632133, 2848686922632133, 2799207290996485] | OK |
| `fdw.get_customer_board_recent(items=10)` | 2025-09-01 | PASS |
| `fdw.get_customer_board_recharge(items=10)` | 2025-09-01 | PASS |
| `fdw.get_customer_board_freq60(items=0)` | None | OK (no date in items) |
| `fdw.get_customer_board_recall(items=10)` | None | OK (no date in items) |
| `AI prompt current_time(date)` | 2025-09-01 | PASS |
## 3. 汇总
- 总检查项: 31
- PASS / OK: 31
- FAIL / ERROR: 0
- 其他: 0
**结论PASS — sandbox 模式下,所有关键读取路径都被截到 sandbox_date 之前。**

View File

@@ -0,0 +1,246 @@
# 沙箱「不看未来」完整修复清单
> 日期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 个 PRA、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 之后的数据。