# P0-1 沙箱与参数快照机制设计调研

> 日期:2026-05-04
> 触发:Neo 在 P0-1 反馈中提出"沙箱模式下 SPI/全部参数是否每天都该有快照"
> 调研者:沙箱-参数快照专项子代理
> 状态:**调研 + Patch 建议草案**(不直接修改 P20 SPEC,不动代码 / 数据库)
> 调研边界:仅读测试库与仓库代码,产出文字结论

---

## 一、问题定义

沙箱回放历史日期(例如 `sandbox_date = 2026-03-01`)时,SPI 算法、绩效档位、奖金规则、助教等级定价等"门店级配置参数"应当使用**哪一版**?

- **A 方案(取最新)**:始终用今天的参数。简单,但 2026-03-01 当时跑出的真实分数无法在沙箱中复现 —— 沙箱失去"还原历史现场"的意义。
- **B 方案(取历史生效版)**:用 2026-03-01 当时生效的参数。准确,要求所有 cfg_* 表都有可按日期切片的版本机制,并且**所有读取入口都按业务日过滤**。

Neo 直觉:既然沙箱机制存在,SPI 参数和全部参数都应该每天都有快照吧?

**调研结论先行**:Neo 的直觉方向正确,但"每天快照"是次优方案;最优方案是**SCD2 区间(已有)+ 让所有读取入口都按业务日过滤(未做)**。当前 P20 已经在视图层为 4 个 cfg_* 表加了业务日上界,但 ETL 层的 SPI/绩效/工资任务直接读 `dws.cfg_*` 裸表绕过了视图,这是 P20 的隐藏裂缝。

---

## 二、当前实现状态

### 2.1 cfg_index_parameters 表结构(权威 DDL `db/etl_feiqiu/schemas/dws.sql:93-103`)

```sql
CREATE TABLE dws.cfg_index_parameters (
    param_id        integer PK,
    index_type      varchar(50) NOT NULL,      -- WBI/NCI/RS/OS/MS/ML/SPI
    param_name      varchar(100) NOT NULL,
    param_value     numeric(14,6) NOT NULL,
    description     text,
    effective_from  date DEFAULT CURRENT_DATE NOT NULL,
    effective_to    date,                      -- 可空 = 至今仍生效
    created_at      timestamptz NOT NULL,
    updated_at      timestamptz NOT NULL
);
ALTER TABLE ... UNIQUE (index_type, param_name, effective_from);
CREATE INDEX idx_cfg_index_params_effective ON ... (effective_from, effective_to);
```

**结论**:已经具备 SCD2 区间能力。同名 `(index_type, param_name)` 可以有多行,通过 `effective_from / effective_to` 区间区分。无需新增 snapshot_date 列。

### 2.2 其他 cfg_* 表盘点

| 表 | 库.Schema | 区间字段 | UNIQUE 约束 | RLS 视图(app schema) | 视图业务日上界 |
|---|---|---|---|---|---|
| `cfg_index_parameters` | etl_feiqiu.dws | `effective_from / effective_to` | `(index_type, param_name, effective_from)` | `app.v_cfg_index_parameters` | 已加(20260502 迁移) |
| `cfg_assistant_level_price` | etl_feiqiu.dws | `effective_from / effective_to` | `(level_code, effective_from)` | `app.v_cfg_assistant_level_price` | 已加 |
| `cfg_performance_tier` | etl_feiqiu.dws | `effective_from / effective_to` | `(tier_code, effective_from)` | `app.v_cfg_performance_tier` | 已加 |
| `cfg_bonus_rules` | etl_feiqiu.dws | `effective_from / effective_to` | `(rule_type, rule_code, effective_from)` | `app.v_cfg_bonus_rules` | 已加 |
| `cfg_skill_type` | etl_feiqiu.dws | 无 | `(skill_id)` | 未单独建视图(`is_active=true`) | — |
| `cfg_area_category` | etl_feiqiu.dws | 无 | `(source_area_name)` | 未单独建视图 | — |
| `biz.cfg_task_generator_params` | zqyy_app.biz | 无(只有 `updated_at + updated_by`) | `(site_id, param_key)` | 无 RLS,后端直读 | — |

**结论**:7 张配置表中,5 张算法/财务相关的(SPI/RS/OS 参数、定价、档位、奖金)**已经有 SCD2 区间机制**。`cfg_skill_type / cfg_area_category` 是"枚举映射型"配置(技能 ID → 课程类型,区域名 → 分类),变更频率极低且无版本概念;`biz.cfg_task_generator_params` 是任务引擎运行时参数,只有最新值。

### 2.3 SPI 任务读参数的实际方式(关键裂缝)

**`apps/etl/connectors/feiqiu/tasks/dws/index/base_index_task.py:303-360`**:

```python
def load_index_parameters(self, index_type=None, force_reload=False):
    ...
    sql = """
        SELECT param_name, param_value
        FROM dws.cfg_index_parameters       -- 直接读裸表,不走 app.v_*
        WHERE index_type = %s
          AND effective_from <= CURRENT_DATE          -- 用 CURRENT_DATE,不用 as_of
          AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
        ORDER BY effective_from DESC
    """
    rows = self.db.query(sql, (index_type,))
    ...
```

**`spending_power_index_task.py:178-186`** 同时调用:

```python
as_of = (context.as_of_date if context and context.as_of_date else None) or _dt.now(self.tz)
db_params = self.load_index_parameters('SPI')                 # 不传 as_of
features = self._extract_spending_features(site_id, params, as_of=as_of)   # 传 as_of
```

**问题**:
1. 数据特征(消费/充值/EWMA)的 SQL 查询用 `as_of` 参数化,具备回测能力;
2. **参数加载用 `CURRENT_DATE` 硬编码**,沙箱模式下拿到的是"今天生效的参数",不是 sandbox_date 当时生效的参数。
3. 视图层 `app.v_cfg_index_parameters` 已经按 `app.business_date_now()` 加上界,但 ETL 任务**直接读 `dws.cfg_index_parameters` 裸表绕过了视图**,GUC 不生效。

同样问题存在于 `base_dws_task.py:540-581` 的 `_load_perf_tiers` / `_load_level_prices` / `_load_bonus_rules`(绩效档位/工资任务用),这三个方法甚至连 `effective_from` WHERE 都没有,把所有历史区间的行全取出来,再让 Python 代码自己挑——一旦表里有重复 `tier_code`/`level_code` 的多区间数据,行为依赖 ORDER BY 和 Python 侧逻辑。

### 2.4 后端读参数的方式(已对接 P20)

后端通过 `app.v_cfg_*` 视图读取(如 `tenant_users.py` 走 v_cfg_assistant_level_price),`fdw_queries._fdw_context` 在事务内 `SET LOCAL app.current_business_date = sandbox_date`,视图自动按 `business_date_now()` 切片。**后端侧已经是 B 方案**。

---

## 三、参数与沙箱关联性矩阵

| 参数表 | 是否影响沙箱回放 | 当前是否有快照能力 | 后端读路径 | ETL 读路径 | 是否需要快照 |
|---|---|---|---|---|---|
| `cfg_index_parameters` (SPI/RS/OS/MS/ML/WBI/NCI 算法权重) | 是(分数会因参数变化而不同) | 有 SCD2 | 走 v_* 已切片 | **裸表绕过** | 必须 |
| `cfg_assistant_level_price` (助教等级定价) | 是(月薪计算结果不同) | 有 SCD2 | 走 v_* 已切片 | **裸表绕过** | 必须 |
| `cfg_performance_tier` (绩效档位区间) | 是(档位归属不同) | 有 SCD2 | 走 v_* 已切片 | **裸表绕过** | 必须 |
| `cfg_bonus_rules` (奖金规则) | 是(奖金金额不同) | 有 SCD2 | 走 v_* 已切片 | **裸表绕过** | 必须 |
| `cfg_skill_type` (技能 → 课程类型映射) | 否(枚举映射,业务定义不会因日期变) | 无 | 直读 `is_active=true` | 直读 | 不必 |
| `cfg_area_category` (区域名 → 分类) | 否(同上) | 无 | 直读 | 直读 | 不必 |
| `biz.cfg_task_generator_params` (任务引擎冷启动天数等) | 弱影响(沙箱演示新功能时一致即可) | 无 | 后端直读 | — | 可选(优先级 P3) |

**额外参数源**:
- AI prompt 模板版本(后端代码内常量,不在 DB)→ 沙箱演示新 prompt 时跨切换会有差异,但属于"prompt 工程版本"不属于业务参数。**优先级 P3**,通过 `prompt_version` 字段记录即可,无需快照。
- runtime_context 自身配置(`biz.site_runtime_context`)→ 是状态本身,不需要快照。
- `biz.scheduled_tasks` cron(全局调度)→ 设计共识保留真实时钟,不需要。

---

## 四、四个核心问题的答案

### Q1:A 方案 vs B 方案

**答案:B 方案**(用 2026-03-01 当时生效的参数)。

理由:
1. P20 § 1.2 沙箱设计目标是"假装今天是某个历史日期",参数是业务规则的一部分,用今天参数 = 历史现场不完整。
2. 后端走 v_* 视图已经隐式选了 B,如果 ETL 用 A,会出现"小程序看到的分数(后端取展示分)" 与 "重跑 ETL 后实际写入的分数"不一致。
3. SCD2 区间已经存在,迁移成本极低,只需改读取 SQL 的 WHERE 子句。

### Q2:B 方案的实现路径

**推荐 B-1 + 视图归一**(见 § 五):利用现有 SCD2 区间,把所有 cfg_* 读取入口统一到 `app.v_cfg_*` 视图(由 `app.business_date_now()` 自动切片),禁止 ETL 任务直读 `dws.cfg_*` 裸表。

为什么不选 B-2(每日快照表):
- 写入成本高(每天每门店复制全量参数行)。
- 实际参数变更频率极低(SPI 27 个参数自上线以来基本未变;BD 手册显示 cfg_assistant_level_price 历史只有 1-2 次区间切换),99% 快照都是冗余。
- SCD2 区间在数学上等价于"按需快照",且空间复杂度 O(变更次数) 而非 O(天数 × 门店数)。

为什么不选 B-3(变更日志重建):
- 重建逻辑复杂,需要在每个调用点应用变更日志,容易遗漏。
- SCD2 已经做了同样的事,无需再发明。

### Q3:哪些参数需要"快照"(SCD2 切片),哪些不需要

需要切片(已具备能力,只是读取入口未对齐):
- `cfg_index_parameters`(7 类指数算法参数)
- `cfg_assistant_level_price`(助教等级定价)
- `cfg_performance_tier`(绩效档位区间)
- `cfg_bonus_rules`(奖金规则)

不需要切片:
- `cfg_skill_type`(技能 → 课程类型),纯枚举映射,变更等同重命名。
- `cfg_area_category`(区域 → 分类),纯枚举映射。
- `biz.cfg_task_generator_params`(冷启动天数等),沙箱场景对其敏感度极低;若未来需要可补 SCD2,优先级 P3。
- AI prompt 版本(代码内常量),通过 `prompt_version` 标识即可。
- 调度 cron / runtime_context 自身 / 时间戳列(P20 § 1.3 已有设计共识)。

### Q4:是否已存在类似机制

存在,且**比 Neo 直觉的"每日快照"更优**:

1. SCD2 区间 `effective_from / effective_to`:5 张关键 cfg 表已有(2.2 矩阵)。
2. P20 视图层已经在 `app.v_cfg_*` 用 `app.business_date_now()` 切片(`db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql:710-782`)。
3. 业务事实表(如 `dws_member_spending_power_index`)按 `business_date / calc_time` 已经天然形成"每日产出快照",这是事实层快照,不是参数快照。

**漏的不是机制,是读取入口的统一**。

---

## 五、推荐设计方案

### 方案对比

| 方案 | 参数侧改造 | 读取入口改造 | 写入侧改造 | 工作量 | 推荐度 |
|---|---|---|---|---|---|
| **方案 1:统一视图入口** | 无(SCD2 已有) | ETL 全部 cfg 读取改走 `app.v_cfg_*` 视图 | 无 | 0.5 天(改 2-3 处 SQL) | **首选** |
| 方案 2:函数显式 as_of | 无 | 给 `load_index_parameters / _load_*` 传 `as_of` 参数 | 无 | 1 天(签名改造 + 调用方 7+ 处) | 备选 |
| 方案 3:每日快照表 | 新增 `dws.cfg_*_snapshot(snapshot_date, ...)` 6 张 | 改读 snapshot 表 | 新增日度生成 job | 3-5 天 + 持续维护 | 不推荐 |

### 推荐方案 1:统一视图入口(B-1 + 视图归一)

**核心思想**:cfg_* 表的"按业务日切片"语义封装在 `app.v_cfg_*` 视图里,所有读取入口(后端 + ETL + 后端 FDW)统一走视图。GUC `app.current_business_date` 由 ETL Loader / 任务引擎在事务起始处下发,沙箱模式下下发 sandbox_date,live 模式下不下发(回退 CURRENT_DATE)。

**实施步骤**:

1. **DB 侧**:确认 5 个 `app.v_cfg_*` 视图当前定义已经按 `effective_from <= app.business_date_now() AND effective_to >= app.business_date_now()` 切片(20260502 迁移已完成)。无需新增迁移。

2. **ETL Loader / 任务引擎入口**:在 `task_engine.py` / `flow_runner` 跑 dws 任务前,先按当前门店读 `biz.site_runtime_context.mode + sandbox_date`,在事务内执行:
   ```sql
   SET LOCAL app.current_site_id = '<site_id>';
   SET LOCAL app.current_business_date = '<sandbox_date or CURRENT_DATE>';
   SET LOCAL app.current_runtime_mode = 'live' | 'sandbox';
   ```
   live 模式下也建议显式 `SET LOCAL app.current_business_date = CURRENT_DATE::text`,使语义对称(无侧效应,函数已 STABLE)。

3. **代码侧改造**(3 处):
   - `apps/etl/connectors/feiqiu/tasks/dws/index/base_index_task.py:335-342`: `FROM dws.cfg_index_parameters` → `FROM app.v_cfg_index_parameters`,移除 `effective_from <= CURRENT_DATE AND effective_to ...` WHERE(视图已切片)。
   - `apps/etl/connectors/feiqiu/tasks/dws/base_dws_task.py:543-581`: `_load_perf_tiers / _load_level_prices / _load_bonus_rules` 改为 `FROM app.v_cfg_*`,删除不必要的 ORDER BY effective_from DESC(视图已只返回当前生效行)。
   - `apps/etl/connectors/feiqiu/tasks/utility/seed_dws_config_task.py`(种子任务):写入仍然走裸表(写入永远是真实操作),无需改。

4. **测试**:
   - 在测试库 `test_etl_feiqiu` 切沙箱到 `sandbox_date = 2026-03-01`,跑 SPI,验证读到的参数版本与裸表 `WHERE effective_from <= '2026-03-01' AND effective_to >= '2026-03-01'` 结果一致。
   - 切回 live,跑 SPI,验证读到的参数 = `CURRENT_DATE` 切片结果。
   - 同时跑两次(live → sandbox → live),三次结果中两次 live 必须一致。

5. **审计**:`docs/audit/changes/2026-MM-DD__sandbox_param_view_unify.md` + 更新 BD_Manual。

**工作量预估**:0.5 天(代码 ≤ 3 文件 / ≤ 30 行 + 1 篇审计 + 测试)。

**风险**:
- 视图层 SQL 复杂度上升微乎其微(简单 WHERE 子句)。
- 历史数据兼容性:cfg_* 表中 `effective_to` 应为 `'9999-12-31'` 表示"至今仍生效",当前 `cfg_index_parameters` 默认 `effective_to` 是 NULL,而 v_cfg_index_parameters WHERE 写的是 `effective_to >= app.business_date_now()`——**NULL 会被过滤掉**!这是一个真实存在的视图 bug,需要在执行方案 1 前先修视图(改为 `(effective_to IS NULL OR effective_to >= app.business_date_now())`)。

### 视图 NULL 兼容性补丁(前置必做)

`db/etl_feiqiu/migrations/20260502__rls_views_business_date_upper_bound.sql:782` 当前:

```sql
WHERE effective_from <= app.business_date_now()
  AND effective_to >= app.business_date_now();   -- NULL 行被过滤
```

应改为:

```sql
WHERE effective_from <= app.business_date_now()
  AND (effective_to IS NULL OR effective_to >= app.business_date_now());
```

这条修复独立于沙箱方案,任何"effective_to 留空" 的行都需要它(`cfg_index_parameters.effective_to` 是 nullable)。建议作为方案 1 的前置 P0 修复。

---

## 六、P20 SPEC Patch 建议(不实际修改,仅列出"应在 § X 加入以下内容")

### Patch P20-A:§ 1.3 沙箱不影响项 → 调整描述

当前 § 1.3 列出 7 项不被沙箱影响。建议**新增一段说明**澄清 cfg_* 配置:

```
### 1.4 沙箱影响项(配置参数版本)

下列配置表参与沙箱"业务日切片"语义,沙箱模式下读取 sandbox_date 当时生效的版本:

- cfg_index_parameters(指数算法权重 SPI/RS/OS/MS/ML/WBI/NCI)
- cfg_assistant_level_price(助教等级定价)
- cfg_performance_tier(绩效档位区间)
- cfg_bonus_rules(奖金规则)

下列不受沙箱影响:

- cfg_skill_type / cfg_area_category(纯枚举映射)
- biz.cfg_task_generator_params(任务引擎运行时参数)
- AI prompt 版本(代码常量)
```

### Patch P20-B:§ 3.5 RLS 视图业务日上界 → 修复 NULL 兼容性

§ 3.5 当前列 `v_cfg_*` 4 个视图"effective_from 与 effective_to 双向夹住"。应补充注释:

```
注:effective_to IS NULL 表示"至今仍生效",视图 WHERE 必须写为
   `(effective_to IS NULL OR effective_to >= app.business_date_now())`,
   不能写成 `effective_to >= app.business_date_now()`(NULL 会被过滤)。
   当前 20260502 迁移生成的 4 个 v_cfg_* 视图存在此 bug,需在 P0 修复。
```

### Patch P20-C:新增 § 5.6 ETL 库读取约定

P20 § 5 当前覆盖了"后端服务层 / ETL 视图层 / 小程序 / AI 提示词 / admin-web",**没有覆盖 ETL 任务自身**。建议新增:

```
### 5.6 ETL 任务读取约定

ETL 任务(task_engine 调度的 DWS 计算任务)对 cfg_* 配置表的读取必须遵守:

1. 入口必须是 `app.v_cfg_*` 视图,禁止直读 `dws.cfg_*` 裸表(参数加载场景)。
   - 例外:种子写入任务(seed_dws_config_task)写入裸表是必需的。
2. 任务事务开启后,`base_dws_task` 应在 SQL 执行前下发:
   - `SET LOCAL app.current_site_id`
   - `SET LOCAL app.current_business_date`(live 用 CURRENT_DATE,sandbox 用 sandbox_date)
   - `SET LOCAL app.current_runtime_mode`
3. 任务上下文(TaskContext)必须能从 `biz.site_runtime_context` 读到当前模式;
   live 模式下回退 live 默认行为。

当前已知未对齐(2026-05-04):
- `base_index_task.load_index_parameters` 直读 dws.cfg_index_parameters,需改走 v_*
- `base_dws_task._load_perf_tiers / _load_level_prices / _load_bonus_rules` 直读裸表,需改走 v_*
```

### Patch P20-D:§ 8 验收标准 → 新增 AC14 / AC15

```
| AC14 | sandbox 模式跑 SPI,读到的 cfg_index_parameters 版本 = sandbox_date 当时生效版本 | SQL: 对比 ETL 任务日志中 params dump 与 SELECT * FROM v_cfg_index_parameters 直查结果 |
| AC15 | sandbox 模式跑工资任务,读到的 cfg_performance_tier / cfg_bonus_rules / cfg_assistant_level_price 版本 = sandbox_date 当时生效版本 | 同上 |
```

### Patch P20-E:§ 11.2 已知 hack → 新增条目

```
- ETL DWS 任务直读 dws.cfg_* 裸表绕过 v_* 视图,沙箱模式下参数版本不切片 — 详见 P0-1-sandbox-snapshot-design.md
- v_cfg_index_parameters 视图 effective_to NULL 兼容性 bug — 详见 P0-1-sandbox-snapshot-design.md § 五
```

### Patch P20-F:§ 12 任务清单 → 新增 T16 / T17

```
- [ ] T16:修复 4 个 v_cfg_* 视图 effective_to NULL 兼容性(P0,前置)
- [ ] T17:ETL DWS 任务参数读取统一走 v_cfg_* 视图(P1,沙箱方案 1)
```

---

## 七、给 Neo 的决策清单

请就以下 6 项给出 GO / NO-GO / 修订意见:

1. **B 方案确认**:沙箱模式下,SPI/绩效/工资任务应使用 sandbox_date 当时生效的参数版本(而非今天的最新参数)。 → GO / NO-GO

2. **方案 1 vs 方案 3**:推荐方案 1(SCD2 + 视图归一,0.5 天),不推荐方案 3(每日快照表,3-5 天)。 → 同意 / 切方案 / 其他

3. **NULL 兼容性补丁前置**:`v_cfg_index_parameters` 视图 NULL 行被过滤是真实 bug,建议作为 P0 前置修复(独立审计)。 → GO / 合并到 T17 一起处理

4. **biz.cfg_task_generator_params 是否要 SCD2**:当前无版本机制,沙箱影响弱。建议优先级 P3,本轮不做。 → 同意 / 现在就要

5. **AI prompt 版本**:沙箱演示中切换 prompt 工程版本时,跨切换的 ai_run_logs 不可比。建议增加 `prompt_version` 字段标识(后续单独 SPEC),不快照 prompt 全文。 → 同意 / 其他

6. **是否直接产出 Patch SPEC**:本调研只列出 P20 应改章节,不实际修改。如果决策清单 1-5 项通过,是否授权按 § 六 patch 直接修改 `docs/prd/specs/P20-runtime-context-sandbox.md`,并产出 0.5 天的代码+测试改造? → GO / 先讨论

---

## 八、关键文件路径(便于 Neo 复核)

- `c:\Project\NeoZQYY\db\etl_feiqiu\schemas\dws.sql:93-103` — cfg_index_parameters 表定义
- `c:\Project\NeoZQYY\db\etl_feiqiu\migrations\20260502__rls_views_business_date_upper_bound.sql:710-782` — 4 个 v_cfg_* 视图(含 NULL bug)
- `c:\Project\NeoZQYY\apps\etl\connectors\feiqiu\tasks\dws\index\base_index_task.py:303-360` — load_index_parameters 直读裸表
- `c:\Project\NeoZQYY\apps\etl\connectors\feiqiu\tasks\dws\base_dws_task.py:540-581` — 工资任务三个 _load_* 直读裸表
- `c:\Project\NeoZQYY\apps\etl\connectors\feiqiu\tasks\dws\index\spending_power_index_task.py:178-186` — as_of 传入数据查询但参数加载未传
- `c:\Project\NeoZQYY\docs\prd\specs\P20-runtime-context-sandbox.md` — P20 SPEC 主体
- `c:\Project\NeoZQYY\docs\_overview\04a-feedback\P0-1-SPI-research.md` — P0-1 step1 调研(SPI 27 参数清单)
- `c:\Project\NeoZQYY\docs\_overview\04a-feedback\P0-7-runtime-context-todos.md` — 跨模块沙箱 todos

---

> 本调研产出由 Claude Opus 4.7(沙箱-参数快照专项子代理)生成于 2026-05-04,
> 不修改 P20 SPEC 主文,不动代码与数据库,仅产出决策建议。
