# 数据库变更:业务运行上下文与沙箱隔离 > 日期: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. 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 |