# 数据库变更：业务运行上下文与沙箱隔离

> 日期：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 |
