涵盖(每条对应已存的审计记录): - 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 逐一处理
17 KiB
沙箱「不看未来」完整修复清单
日期:2026-05-02 关联:changes/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 加两个工具:
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 模式
-- 时间事实表(含 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_* |
一般取「最新有效」,沙箱可保留真实最新(配置不该回放) |
需要按视图逐个判断时间上界字段。建议分批:
- C-1:财务相关
v_dws_finance_*(5 视图)。 - C-2:助教/任务相关
v_dws_assistant_*、v_dws_member_assistant_*(约 12 视图)。 - C-3:DWD 事实表
v_dwd_*(6 视图)。 - C-4:维度表 SCD2
v_dim_*(10 视图,需配合 SCD2 字段)。 - C-5:配置表
v_cfg_*(一般保留真实最新,但确认是否需要按effective_to)。
每批按 RLS 双 schema 规则同时改 dws.v_* 和 app.v_*。
C 方案后端改造
# 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 个 PR:A、B-后端、B-小程序、C 财务视图、C 其他视图。
四、风险与开发约束
- live 行为不能变:所有 SQL 上界用
COALESCE(... , '9999-12-31')形式,live 不设变量时等同无限制。 - 双 schema 规则:
db/etl_feiqiu/schemas/app.sql与dws.v_*必须同时更改。 - DDL 副本同步:每批 C 改完跑
python tools/db/gen_consolidated_ddl.py,把docs/database/ddl/etl_feiqiu__app.sql等同步进 git。 - 真实时钟字段保留:
updated_at = NOW()、finished_at = NOW(timezone.utc)、缓存 TTLexpires_at、调度next_run_at、AI run_logs 写入时间——这些保留真实时钟。 - 去重键:dispatcher / cache 的「当日去重」key 需带
runtime_mode + business_date,避免 sandbox 与 live 互相污染。 - 配置表沙箱语义:
v_cfg_*一般取「最新有效」;如需历史回放,需要单独评估scd2_effective_to上界。 - 测试:每批改完都要在
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 调用统计 / 监控页:是否按真实时间口径,需产品决定。
六、实施建议
- 本文档已落地(
docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md),作为后续多 PR 的统一目录。 - 不立即动业务代码;等用户确认范围后开始。
- 优先级建议:A → B-后端关键路径(task_generator、board_service、prompts ref_date 漏传)→ B-AI prompts current_time → B-小程序 performance → B-fdw_queries → C-财务视图 → 其他 C。
- 每个 PR 自带单测:sandbox 模式下 SQL 不返回 sandbox_date 之后的数据。