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:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View 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 个 PRA、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 之后的数据。