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:
293
docs/database/changes/2026-05-01__runtime_context_sandbox.md
Normal file
293
docs/database/changes/2026-05-01__runtime_context_sandbox.md
Normal 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 |
|
||||
@@ -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=sandbox,sandbox_date=2025-09-01,sandbox_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`) → 预估 → 确认执行。等待 5s(AI 实际调用约 2–10s)。
|
||||
- **路径 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`)。
|
||||
**手工**:按本清单 1–8 节逐项核对,预估 10–15 分钟。
|
||||
@@ -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` 登录后跑完手工清单 1–8 节。
|
||||
|
||||
- 测试时间:2026-05-02 16:50–16: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=1171,Drawer 渲染含 App 类型 / 触发方式 / Tokens / 延迟 / 状态 / 创建/完成时间 / 错误信息 / **Request Prompt** 完整 JSON | ✅ PASS | ai-run-logs-drawer.png |
|
||||
| 12 | Request Prompt 内 `当前时间` | 该条是 2026-05-01 live 调用,显示 `"当前时间": "2026-05-01 01:53"` 与当时真实时间一致;**证明 prompt 内时间锚走 RuntimeContext,sandbox 切换会改写为 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「切换运行上下文 — 朗朗桌球」
|
||||
- 目标模式 = sandbox(disabled)
|
||||
- 沙箱业务日期 = 必填
|
||||
- 重置沙箱实例 = 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 渲染 PASS;prompt 内 `当前时间` 字段存在并按 RuntimeContext 取值(间接证据) |
|
||||
| 3. AIOperations 缓存隔离 | ⚪ 未在 UI 触发(避免烧 token);自动化 verify_admin_web_sandbox.py 已 PASS |
|
||||
| 4. TaskManager 任务隔离 | ⚪ 未在 UI 触发(同上);自动化已 PASS(task_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 MCP(Playwright)实地走一遍验证。
|
||||
未在 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 内 `当前时间` 走 RuntimeContext(live 时显示真实时间,sandbox 时变 sandbox_date)
|
||||
- ✅ 沙箱写入隔离生效(sandbox_instance_id = `sbx_xxxx` 前缀)
|
||||
- ✅ 切回 live 后状态完全恢复
|
||||
@@ -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 下行为符合预期。**
|
||||
136
docs/database/changes/2026-05-02__sandbox_complete_refactor.md
Normal file
136
docs/database/changes/2026-05-02__sandbox_complete_refactor.md
Normal 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_now,SQL 补上界 | ✅ |
|
||||
| **C 数据层** | etl_feiqiu app schema RLS 视图 | 引入 GUC ``app.current_business_date`` + ``app.business_date_now()`` 函数 + 关键视图 WHERE 上界 | ✅ |
|
||||
|
||||
## 关键改动
|
||||
|
||||
### A 层
|
||||
|
||||
- `apps/admin-web/src/pages/RuntimeContext.tsx` — 顶部 Alert 增加"读取层修复进行中"+ plan 链接。
|
||||
- `docs/database/BD_Manual_runtime_context_sandbox.md` — 第 7 节新增"读取层不看未来路线"。
|
||||
|
||||
### B 层 (后端)
|
||||
|
||||
- `apps/backend/app/services/runtime_context.py` 新增 helpers:
|
||||
- `as_runtime_year_month_param(site_id) -> 'YYYY-MM'`
|
||||
- `as_runtime_business_now_str(site_id, fmt) -> str`
|
||||
- `business_date_upper_bound_sql(site_id, column, alias, cast)` 返回 SQL 片段
|
||||
- `apply_runtime_session_vars(conn, ctx | site_id)` 设置 GUC(C 层基础)
|
||||
- AI prompts:app3/4/5/6/7 的 `current_time` 改用 `as_runtime_business_now_str`,不再 `datetime.now()`。
|
||||
- AI data_fetchers:
|
||||
- `member_data._query_consumption_records` / `_query_visit_info` 接受 `ref_date`,所有窗口加业务日上界。
|
||||
- `assistant_data._fetch_assistant_info_sync` / `_fetch_service_history_sync` 用业务日。
|
||||
- `page_context._text_board_finance/customer/coach/customer_service_records` 全部上界化。
|
||||
- 所有直连 ETL 库的 cursor 在 `SET LOCAL app.current_site_id` 之后再下发 `app.current_business_date`,供 RLS 视图 GUC 读取。
|
||||
- service:
|
||||
- `board_service._batch_coach_details` 接受 ref_date,60 天消费窗口按业务日截。
|
||||
- `chat_service._get_consumption_30d` / `_get_visit_count_30d` 业务日 30 天窗口。
|
||||
- `coach_service.get_coach_detail` / `_build_history_months` 用业务日年月。
|
||||
- `customer_service` 60 天助教统计上界化。
|
||||
- `task_generator` 转移子流程的 `now` 改用 business_now。
|
||||
- `task_manager.batch_query_for_task_list` / `build_performance_summary` / 任务详情 60 天窗口全部业务日。
|
||||
- `tenant_users.py` SCD2 配置(cfg_assistant_level_price)用业务日。
|
||||
- **fdw_queries**(关键修复):
|
||||
- `_fdw_context` 进入事务后下发 `app.current_business_date` + `app.current_runtime_mode` GUC。
|
||||
- **客户看板「最近到店」bug 修复**:`get_last_visit_days` / `batch_query_for_task_list`(last_visit 计算)改为 ETL `last_consume_date` + `business_date - last_consume_date` 实时计算,不再依赖 ETL 预计算的 `days_since_last`,沙箱场景与 ETL 跑批延迟下都能正确显示"距上次到店 N 天"。
|
||||
- `get_customer_board_recent` / `get_customer_board_recharge` / `get_customer_board_freq60` / `get_customer_board_recall` / `_get_weekly_visits_batch` / `get_coach_60d_stats` / `batch_query_for_task_list` 60 天窗口 / SCD2 配置等全部用业务日。
|
||||
|
||||
### B 层 (小程序)
|
||||
|
||||
- `apps/backend/app/routers/xcx_runtime_clock.py` 新增端点 `GET /api/xcx/runtime/clock`,返回 mode/business_date/business_year/business_month/business_year_month/business_now/is_sandbox/sandbox_date。
|
||||
- `apps/miniprogram/miniprogram/services/api.ts` 增加 `fetchRuntimeClock`。
|
||||
- `apps/miniprogram/miniprogram/utils/runtime-clock.ts`(新增)—— 60s 缓存 + 失败降级到本地时间。
|
||||
- 关键页面切换为业务时钟:
|
||||
- `pages/performance/performance.ts` —— G2 当月预估判断
|
||||
- `pages/performance-records/performance-records.ts` —— onLoad / loadData / switchMonth
|
||||
- `pages/task-list/task-list.ts` —— 月度判断
|
||||
- `pages/customer-records/customer-records.ts` —— onLoad
|
||||
- `pages/customer-service-records/customer-service-records.ts` —— onLoad
|
||||
|
||||
### C 层 (RLS 视图)
|
||||
|
||||
- 新增迁移 `db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql`:
|
||||
- 注册 STABLE SQL 函数 `app.business_date_now()`:从 GUC `app.current_business_date` 读取业务日,未设置时回退 `CURRENT_DATE`。
|
||||
- **21 个视图**重写 WHERE,加 `<日期列> <= app.business_date_now()`:
|
||||
- 财务事实 6 个:`v_dws_finance_area_daily / daily_summary / discount_detail / expense_summary / income_structure / recharge_summary`
|
||||
- 助教汇总 5 个:`v_assistant_daily / v_dws_assistant_daily_detail / monthly_summary / salary_calc / finance_analysis`
|
||||
- 客户事实 3 个:`v_dws_member_consumption_summary / visit_detail / winback_index`
|
||||
- DWD 事实 5 个:`v_dwd_settlement_head / assistant_service_log / recharge_order / store_goods_sale / table_fee_log`
|
||||
- SCD2 配置 2 个:`v_cfg_assistant_level_price / performance_tier`
|
||||
- 列签名通过 `pg_get_viewdef` 实时从测试库读取,确保 `CREATE OR REPLACE VIEW` 不会因列签名漂移而失败。
|
||||
- 生成脚本:`scripts/ops/gen_rls_business_date_migration.py`(可重复执行)。
|
||||
- DDL 同步:`docs/database/ddl/etl_feiqiu__app.sql`、`db/etl_feiqiu/schemas/app.sql` 已同步。
|
||||
|
||||
## 验证
|
||||
|
||||
### 测试库迁移结果
|
||||
|
||||
```
|
||||
test site_id = 2790685415443269
|
||||
live: max(stat_date)=2026-04-27, count=2439
|
||||
sandbox(=2025-09-01):
|
||||
max(stat_date) finance_area_daily = 2025-09-01, count=432
|
||||
max(visit_date) member_visit = 2025-09-01
|
||||
max(create_time::date) settlement = 2025-09-01
|
||||
RESULT: PASS
|
||||
```
|
||||
|
||||
live 模式行为不变;sandbox 模式下所有事实视图严格不返回 sandbox_date 之后的数据。
|
||||
|
||||
### 静态检查
|
||||
|
||||
- 后端 99 个改动文件 AST 解析全部通过。
|
||||
- 前端 admin-web、小程序关键页面 lint 无新增错误。
|
||||
|
||||
## 兼容性 / 回滚
|
||||
|
||||
- live 模式下 GUC 不设置 → `app.business_date_now()` 回退 `CURRENT_DATE`,行为完全等同于改造前。
|
||||
- 回滚:`DROP FUNCTION app.business_date_now() CASCADE;`(视图会一并被 DROP),然后重新执行 `db/etl_feiqiu/schemas/app.sql` 即可恢复 live 行为。
|
||||
- B 层 / 小程序的时间锚替换全部走 RuntimeContext(fail-soft 降级 live),不影响生产链路。
|
||||
|
||||
## 已知未覆盖
|
||||
|
||||
- **page_context.py** 中 7 处直连 ETL 的查询,已加 SQL 上界(B 层),但部分位置依赖 GUC(C 层)即可,未单独传 ref_date。
|
||||
- 写入时间戳(`created_at`、`updated_at`、`finished_at`、调度 `last_run_at`、ai_run_logs 写入)保持系统真实时间,**不应**被沙箱影响(这是审计/运行时元数据),保留现状。
|
||||
- 小程序 chat / customer-detail 页面用于"展示当前操作时间"的 `new Date()` 保留(与会话/操作记录关联)。
|
||||
- AI 调度的预算计算、限流仍按真实系统时间。
|
||||
- DIM SCD2 维度(v_dim_assistant / v_dim_member / v_dim_member_card_account / v_dim_staff / v_dim_staff_ex / v_dim_table)保留 ``scd2_is_current=1`` 当前快照语义,未按 sandbox_date 重建历史维度行;如需"sandbox 当时维度状态"另行评估。
|
||||
|
||||
## 2026-05-02 后续追加
|
||||
|
||||
### B-2 / C 层补强
|
||||
- 18 个非关键视图补业务日上界(详见 `gen_rls_business_date_migration.py` 的 `VIEWS_WITH_BD`):覆盖 `v_cfg_bonus_rules` / `v_cfg_index_parameters` 两个配置维度,及 16 个 DWS 业务事实/汇总(如 `v_dws_assistant_customer_stats`、`v_dws_member_assistant_intimacy`、`v_dws_finance_board_cache`、`v_finance_daily` 等)。**总计 39 个 RLS 视图带业务日上界**。
|
||||
- 端到端验证:`tools/db/verify_sandbox_end_to_end.py` 一键跑 live + sandbox(2025-09-01) 对比,输出 `2026-05-02__sandbox_e2e_verify_report.md`。本轮结果 31/31 PASS。
|
||||
- 注意:脚本里测的 `get_customer_board_recent / recharge / freq60 / recall` 是 `fdw_queries` 函数,**实际服务的是小程序 `board-customer`**,不是 admin-web。验证脚本同时覆盖 RLS 视图层(21+18=39 个视图),与端无关。
|
||||
|
||||
### log 警告止血(独立于沙箱)
|
||||
- `apps/etl/connectors/feiqiu/tasks/dws/finance_area_daily.py`: 拓宽 `_is_all_only_area`,把 `补时长N`/`虚拟台N` 编号变体、`area_name=None & table_id 不空` 都归入 INFO(不再 WARNING),消除噪音。
|
||||
- `apps/etl/connectors/feiqiu/tasks/dws/task_engine.py`: ETL → backend HTTP `_TIMEOUT` 由 `(5, 30)` 改 `(10, 600)`,与 `flow_runner` 对齐,止血 30s 读超时。**根因(同步长任务+30s timeout)已记录,长期方案是 `/api/internal/run-job` 改异步入队,待后续 PR。**
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
- 主迁移:`db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql`
|
||||
- 生成器:`scripts/ops/gen_rls_business_date_migration.py`
|
||||
- 端到端验证:`tools/db/verify_sandbox_end_to_end.py`
|
||||
- 验证报告:`docs/database/changes/2026-05-02__sandbox_e2e_verify_report.md`
|
||||
- 文档:本文件 + `docs/database/BD_Manual_runtime_context_sandbox.md`
|
||||
@@ -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 之前。**
|
||||
246
docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md
Normal file
246
docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md
Normal 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 个 PR:A、B-后端、B-小程序、C 财务视图、C 其他视图。
|
||||
|
||||
---
|
||||
|
||||
## 四、风险与开发约束
|
||||
|
||||
1. **live 行为不能变**:所有 SQL 上界用 `COALESCE(... , '9999-12-31')` 形式,live 不设变量时等同无限制。
|
||||
2. **双 schema 规则**:`db/etl_feiqiu/schemas/app.sql` 与 `dws.v_*` 必须同时更改。
|
||||
3. **DDL 副本同步**:每批 C 改完跑 `python tools/db/gen_consolidated_ddl.py`,把 `docs/database/ddl/etl_feiqiu__app.sql` 等同步进 git。
|
||||
4. **真实时钟字段保留**:`updated_at = NOW()`、`finished_at = NOW(timezone.utc)`、缓存 TTL `expires_at`、调度 `next_run_at`、AI run_logs 写入时间——这些**保留真实时钟**。
|
||||
5. **去重键**:dispatcher / cache 的「当日去重」key 需带 `runtime_mode + business_date`,避免 sandbox 与 live 互相污染。
|
||||
6. **配置表沙箱语义**:`v_cfg_*` 一般取「最新有效」;如需历史回放,需要单独评估 `scd2_effective_to` 上界。
|
||||
7. **测试**:每批改完都要在 `test_zqyy_app` + `test_etl_feiqiu` 上运行至少一次端到端 sandbox 切换 + 看板抽查 + AI 触发。
|
||||
|
||||
---
|
||||
|
||||
## 五、未覆盖项(需用户确认或单独立项)
|
||||
|
||||
- **生产数据库执行**:本修复在 test 库通过后才能上生产;窗口需运维约定。
|
||||
- **写入沙箱数据归零**:长期使用沙箱后会积累 sandbox 行(任务、AI cache、run logs、recall events),应有按 `runtime_mode='sandbox' AND sandbox_instance_id=...` 的清理脚本。
|
||||
- **小程序 utils/time.ts**:当前不改;如需把「相对时间」也按沙箱算(如沙箱日下「3 天前」按沙箱日推算),属于另一议题。
|
||||
- **跨多门店 sandbox**:当前未限制同时多门店进入 sandbox;若并行需求,需要约定每个 site 独立 RuntimeContext + 各自上界(架构已支持)。
|
||||
- **Admin-Web AI 调用统计 / 监控页**:是否按真实时间口径,需产品决定。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施建议
|
||||
|
||||
1. **本文档已落地**(`docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md`),作为后续多 PR 的统一目录。
|
||||
2. **不立即动业务代码**;等用户确认范围后开始。
|
||||
3. **优先级建议**:A → B-后端关键路径(task_generator、board_service、prompts ref_date 漏传)→ B-AI prompts current_time → B-小程序 performance → B-fdw_queries → C-财务视图 → 其他 C。
|
||||
4. **每个 PR 自带单测**:sandbox 模式下 SQL 不返回 sandbox_date 之后的数据。
|
||||
Reference in New Issue
Block a user