# 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 = ''; SET LOCAL app.current_business_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 主文,不动代码与数据库,仅产出决策建议。