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,475 @@
# -*- coding: utf-8 -*-
"""admin-web 端沙箱验证脚本。
不依赖前端 / 浏览器,直接调后端的 service 实现,校验 admin-web 各页面所依赖的核心数据
在 sandbox 模式下是否符合"按 sandbox_date 截断 / 隔离"的预期。
覆盖页面:
- RuntimeContext → /api/admin/runtime-context, /api/config/runtime-context
- AIRunLogs → list_run_logs / get_run_log看 prompt 中 current_time
- AIOperations → namespace_ai_target_id / runtime_insert_columns缓存命名隔离
- TaskManager → biz.coach_tasks 按 (runtime_mode, sandbox_instance_id) 隔离
- TriggerManager → biz.trigger_jobs 是全局共享(不应被 site 隔离)
- AIDashboard → 调用次数等指标按真实时间统计(不受沙箱影响)
不覆盖(需手工 UI 验证):
- 浏览器层 RuntimeContext 切换 Modal 关闭、Steps 弹窗
- AIRunLogs Drawer 渲染
- AIOperations 4 个 Card 交互
运行:
python tools/db/verify_admin_web_sandbox.py [--sandbox-date 2025-09-01] [--site-id N]
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
BACKEND = ROOT / "apps" / "backend"
sys.path.insert(0, str(BACKEND))
import psycopg2 # noqa: E402
import psycopg2.extensions # noqa: E402
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env", override=False)
os.environ["APP_DB_DSN"] = os.environ.get("TEST_APP_DB_DSN") or os.environ["APP_DB_DSN"]
os.environ["PG_DSN"] = os.environ.get("TEST_PG_DSN") or os.environ["PG_DSN"]
_etl = psycopg2.extensions.parse_dsn(os.environ["PG_DSN"])
_app = psycopg2.extensions.parse_dsn(os.environ["APP_DB_DSN"])
os.environ.setdefault("DB_HOST", _app.get("host", "localhost"))
os.environ.setdefault("DB_PORT", str(_app.get("port", "5432")))
os.environ.setdefault("DB_USER", _app.get("user", ""))
os.environ.setdefault("DB_PASSWORD", _app.get("password", ""))
os.environ.setdefault("ETL_DB_HOST", _etl.get("host", "localhost"))
os.environ.setdefault("ETL_DB_PORT", str(_etl.get("port", "5432")))
os.environ.setdefault("ETL_DB_USER", _etl.get("user", ""))
os.environ.setdefault("ETL_DB_PASSWORD", _etl.get("password", ""))
os.environ.setdefault("ETL_DB_NAME", _etl.get("dbname", "test_etl_feiqiu"))
# ---------------------------------------------------------------------------
# 公共工具(与 verify_sandbox_end_to_end.py 复用)
# ---------------------------------------------------------------------------
def switch_runtime(site_id: int, mode: str, sandbox_date: date | None = None) -> None:
from app.database import get_connection
from app.services.runtime_context import new_sandbox_instance_id
instance_id = None if mode == "live" else new_sandbox_instance_id()
sb_date = None if mode == "live" else sandbox_date
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO biz.site_runtime_context
(site_id, mode, sandbox_date, sandbox_instance_id, ai_mode, status,
updated_at, updated_by)
VALUES (%s, %s, %s, %s, 'live', 'active', NOW(), NULL)
ON CONFLICT (site_id) DO UPDATE SET
mode = EXCLUDED.mode,
sandbox_date = EXCLUDED.sandbox_date,
sandbox_instance_id = EXCLUDED.sandbox_instance_id,
updated_at = NOW(),
updated_by = NULL
""",
(site_id, mode, sb_date, instance_id),
)
conn.commit()
finally:
conn.close()
def pick_test_site() -> int:
pg_dsn = os.environ["PG_DSN"]
conn = psycopg2.connect(pg_dsn)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT site_id FROM dws.dws_finance_area_daily "
"GROUP BY site_id ORDER BY COUNT(*) DESC LIMIT 1"
)
row = cur.fetchone()
if not row:
raise SystemExit("etl 测试库无 finance_area_daily 数据")
return int(row[0])
finally:
conn.close()
# ---------------------------------------------------------------------------
# 各 admin-web 页面专项检查
# ---------------------------------------------------------------------------
def check_runtime_context_api(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""RuntimeContext 页 / Banner 用:核对 API 返回结构。"""
from app.routers.admin_runtime_context import _context_response
from app.services.runtime_context import get_runtime_context
ctx = get_runtime_context(site_id)
resp = _context_response(ctx).model_dump()
rows: list[tuple[str, Any, str]] = []
rows.append(("ctx.mode", resp["mode"], "PASS" if resp["mode"] == "sandbox" else f"FAIL (got {resp['mode']})"))
rows.append(("ctx.is_sandbox", resp["is_sandbox"], "PASS" if resp["is_sandbox"] else "FAIL"))
# sandbox_date / business_date 在 model_dump 里是 date 对象,不是字符串
sb_d = resp["sandbox_date"]
bd_d = resp["business_date"]
sb_d_iso = sb_d.isoformat() if hasattr(sb_d, "isoformat") else str(sb_d)
bd_d_iso = bd_d.isoformat() if hasattr(bd_d, "isoformat") else str(bd_d)
rows.append(("ctx.sandbox_date", sb_d_iso, "PASS" if sb_d_iso == bd.isoformat() else f"FAIL (got {sb_d_iso})"))
rows.append(("ctx.business_date", bd_d_iso, "PASS" if bd_d_iso == bd.isoformat() else f"FAIL (got {bd_d_iso})"))
rows.append(("ctx.sandbox_instance_id", resp["sandbox_instance_id"], "PASS" if resp["sandbox_instance_id"] and resp["sandbox_instance_id"].startswith("sbx_") else "FAIL"))
return rows
async def check_ai_run_logs_have_sandbox_prompt(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""AIRunLogs 页:本轮触发的 App3 / App2 prompt 中 current_time 应等于 sandbox_date。
不实际调 AI避免烧 token但**直接构建 prompt 字符串**,校验 prompt JSON 内 current_time。
这就是 admin-web 用户点 AIRunLogs Drawer 看到的 'Request Prompt' 字段内容。
"""
rows: list[tuple[str, Any, str]] = []
# === App3 prompt ===
try:
from app.ai.prompts import app3_clue_prompt
# 取一个有消费记录的 member_id直接从 dwd 表拿,避开视图业务日上界)
pg_dsn = os.environ["PG_DSN"]
member_id = None
conn_etl = psycopg2.connect(pg_dsn)
try:
with conn_etl.cursor() as cur:
cur.execute(
"SELECT tenant_member_id FROM dwd.dwd_assistant_service_log "
"WHERE is_delete = 0 AND site_id = %s AND tenant_member_id IS NOT NULL "
"AND create_time::date <= %s "
"LIMIT 1",
(site_id, bd),
)
r = cur.fetchone()
if r:
member_id = r[0]
finally:
conn_etl.close()
if member_id:
prompt_str = await app3_clue_prompt.build_prompt({"site_id": site_id, "member_id": member_id})
payload = json.loads(prompt_str)
current_time = payload.get("current_time")
d_in_prompt = datetime.strptime(current_time, "%Y-%m-%d %H:%M").date()
rows.append((
"App3 prompt.current_time",
current_time,
"PASS" if d_in_prompt == bd else f"FAIL (expected {bd})",
))
rows.append((
"App3 prompt.member_id",
payload.get("member_id"),
"OK" if payload.get("member_id") == member_id else "FAIL",
))
else:
rows.append(("App3 prompt", None, "SKIP (no member with service_log)"))
except Exception as exc:
rows.append(("App3 prompt", None, f"ERROR: {type(exc).__name__}: {str(exc)[:120]}"))
# === App2 prompt ===
try:
from app.ai.prompts import app2_finance_prompt
# 注意App2 的 time_dimension 是业务侧枚举this_month/last_month/...
# 不是 board_service 的内部枚举month/quarter/...
ctx = {"site_id": site_id, "time_dimension": "this_month"}
prompt_str = await app2_finance_prompt.build_prompt(ctx)
payload = json.loads(prompt_str)
current_time = payload.get("当前时间", "")
d_in_prompt = datetime.strptime(current_time, "%Y-%m-%d %H:%M").date() if current_time else None
rows.append((
"App2 prompt.当前时间",
current_time,
"PASS" if d_in_prompt == bd else f"FAIL (expected {bd})",
))
# 财务窗口结束日应 <= sandbox_date
period = payload.get("当期日期范围", "")
if period and "~" in period:
end_str = period.split("~")[-1].strip()
try:
end_d = datetime.strptime(end_str, "%Y-%m-%d").date()
rows.append((
"App2 prompt.当期日期范围(end)",
end_str,
"PASS" if end_d <= bd else f"FAIL (>{bd})",
))
except Exception:
rows.append(("App2 prompt.当期日期范围(end)", end_str, "OK (无法解析)"))
except Exception as exc:
rows.append(("App2 prompt", None, f"ERROR: {type(exc).__name__}: {str(exc)[:120]}"))
return rows
def check_ai_cache_namespace_isolation(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""AIOperations 页 Card 2 缓存失效sandbox 模式下 target_id 应被加 sandbox_instance_id 前缀。"""
from app.services.runtime_context import (
get_runtime_context,
namespace_ai_target_id,
runtime_insert_columns,
)
ctx = get_runtime_context(site_id)
rows: list[tuple[str, Any, str]] = []
# 1. namespace_ai_target_id
raw = "12345"
namespaced = namespace_ai_target_id(site_id, raw)
expected_prefix = ctx.sandbox_instance_id + ":"
rows.append((
"namespace_ai_target_id('12345')",
namespaced,
"PASS" if namespaced.startswith(expected_prefix) and namespaced.endswith(":12345") else "FAIL",
))
# 2. runtime_insert_columns 应该返回 ('runtime_mode, sandbox_instance_id', '%s, %s', ['sandbox', sbx_id])
cols, placeholders, values = runtime_insert_columns(site_id)
rows.append((
"runtime_insert_columns.cols",
cols,
"PASS" if cols == "runtime_mode, sandbox_instance_id" else "FAIL",
))
rows.append((
"runtime_insert_columns.values[0]",
values[0],
"PASS" if values[0] == "sandbox" else f"FAIL (got {values[0]})",
))
rows.append((
"runtime_insert_columns.values[1]",
values[1],
"PASS" if values[1] and values[1].startswith("sbx_") else f"FAIL (got {values[1]})",
))
return rows
def check_task_manager_isolation(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""TaskManager 页sandbox 模式下查 coach_tasks 应只返回 sandbox 实例的记录。"""
from app.database import get_connection
from app.services.runtime_context import get_runtime_context, task_runtime_filter
ctx = get_runtime_context(site_id)
rows: list[tuple[str, Any, str]] = []
runtime_clause, runtime_params = task_runtime_filter(site_id, alias="ct")
# 1. 校验 task_runtime_filter SQL 片段含 runtime_mode/sandbox_instance_id
rows.append((
"task_runtime_filter clause",
runtime_clause.strip(),
"PASS" if "runtime_mode" in runtime_clause and "sandbox_instance_id" in runtime_clause else "FAIL",
))
rows.append((
"task_runtime_filter params",
runtime_params,
"PASS" if runtime_params and runtime_params[0] == "sandbox" else f"FAIL (got {runtime_params})",
))
# 2. 实查 biz.coach_tasks 全量 vs sandbox 过滤后的差
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*), COUNT(*) FILTER (WHERE runtime_mode = 'sandbox' AND sandbox_instance_id = %s) "
"FROM biz.coach_tasks ct WHERE ct.site_id = %s",
(ctx.sandbox_instance_id, site_id),
)
total_all, total_sbx = cur.fetchone()
sql = (
"SELECT COUNT(*) FROM biz.coach_tasks ct "
"WHERE ct.site_id = %s" + runtime_clause
)
cur.execute(sql, [site_id, *runtime_params])
filtered = cur.fetchone()[0]
conn.commit()
finally:
conn.close()
rows.append((
"biz.coach_tasks 全量 (site)",
total_all,
"OK",
))
rows.append((
"biz.coach_tasks 仅 sandbox 实例",
total_sbx,
"OK",
))
rows.append((
"task_runtime_filter 过滤后 = sandbox 实例?",
filtered,
"PASS" if filtered == total_sbx else f"FAIL (filtered={filtered}, expected={total_sbx})",
))
return rows
def check_trigger_manager_global(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""TriggerManager 页biz.trigger_jobs 是**全局共享表**sandbox 切换不应停掉它。"""
from app.database import get_connection
rows: list[tuple[str, Any, str]] = []
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*), COUNT(*) FILTER (WHERE status NOT IN ('paused_by_sandbox')) FROM biz.trigger_jobs")
total, active_like = cur.fetchone()
conn.commit()
finally:
conn.close()
rows.append(("biz.trigger_jobs 总数", total, "OK"))
rows.append((
"biz.trigger_jobs 未被 sandbox 暂停",
active_like,
"PASS" if active_like == total else f"FAIL (有 {total - active_like} 条被 sandbox 错误暂停)",
))
return rows
async def check_dashboard_uses_real_time(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""AIDashboard 页:'今日'调用次数应用真实日期窗口CURRENT_DATE不被沙箱拉到 sandbox_date。"""
from app.services.ai.admin_service import AdminAIService
svc = AdminAIService()
rows: list[tuple[str, Any, str]] = []
try:
data = await svc.get_dashboard(site_id=site_id, range_days=1)
# data 含 today_total 等字段,时间窗口由 CURRENT_DATE 决定
rows.append((
"Dashboard.range=1 today_total",
data.get("today_total", "?"),
"OK (按真实时间,不受沙箱影响)",
))
rows.append((
"Dashboard.app_health 数量",
len(data.get("app_health", [])),
"OK",
))
rows.append((
"Dashboard.budget 字段存在",
"budget" in data,
"PASS" if "budget" in data else "FAIL",
))
except Exception as exc:
rows.append(("AIDashboard.get_dashboard", None, f"ERROR: {type(exc).__name__}: {str(exc)[:120]}"))
return rows
async def check_ai_run_logs_query(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""AIRunLogs 列表 API 仍按真实 created_at 排序(写入时间是真实系统时间,不受沙箱影响)。"""
from app.services.ai.admin_service import AdminAIService
svc = AdminAIService()
rows: list[tuple[str, Any, str]] = []
try:
resp = await svc.list_run_logs({"site_id": site_id}, page=1, page_size=5)
items = resp.get("items", [])
rows.append(("list_run_logs total", resp.get("total"), "OK"))
rows.append(("list_run_logs page items", len(items), "OK"))
if items:
latest = items[0]
rows.append(("list_run_logs[0].created_at", str(latest.get("created_at"))[:19], "OK (写入按真实时间)"))
except Exception as exc:
rows.append(("list_run_logs", None, f"ERROR: {type(exc).__name__}: {str(exc)[:120]}"))
return rows
# ---------------------------------------------------------------------------
# 报告渲染
# ---------------------------------------------------------------------------
def render_report(site_id: int, bd: date, sections: list[tuple[str, list[tuple[str, Any, str]]]]) -> str:
out: list[str] = []
out.append(f"# admin-web 沙箱验证报告\n")
out.append(f"- site_id: `{site_id}`")
out.append(f"- sandbox_date: `{bd.isoformat()}`")
out.append(f"- 生成时间: `{datetime.now().isoformat(timespec='seconds')}`")
out.append(f"- 范围: admin-web 后端 service 实现 + AI prompt 构建 + 缓存 / 任务隔离\n")
pass_n = 0
fail_n = 0
for title, rows in sections:
out.append(f"## {title}\n")
out.append("| 检查项 | 取值 | 结果 |")
out.append("|---|---|---|")
for label, val, status in rows:
out.append(f"| `{label}` | {val} | {status} |")
if status.startswith("PASS"):
pass_n += 1
elif status.startswith("FAIL") or status.startswith("ERROR"):
fail_n += 1
out.append("")
out.append(f"## 汇总\n")
out.append(f"- PASS: {pass_n}")
out.append(f"- FAIL/ERROR: {fail_n}\n")
if fail_n == 0:
out.append("**结论PASS — admin-web 各页面后端依赖在 sandbox 下行为符合预期。**")
else:
out.append("**结论FAIL — 见上表。**")
return "\n".join(out)
async def main_async(args: argparse.Namespace) -> None:
bd = datetime.strptime(args.sandbox_date, "%Y-%m-%d").date()
site_id = args.site_id or pick_test_site()
print(f"[admin-verify] site_id={site_id}, sandbox_date={bd}")
print(f"[admin-verify] 切换到 sandbox({bd})")
switch_runtime(site_id, "sandbox", bd)
sections: list[tuple[str, list[tuple[str, Any, str]]]] = []
try:
sections.append(("RuntimeContext 页 / Banner/api/admin/runtime-context", check_runtime_context_api(site_id, bd)))
sections.append(("AIRunLogs 抽屉Request Prompt 内 current_time", await check_ai_run_logs_have_sandbox_prompt(site_id, bd)))
sections.append(("AIRunLogs 列表(按真实时间 created_at", await check_ai_run_logs_query(site_id, bd)))
sections.append(("AIOperations Card 2 缓存命名隔离", check_ai_cache_namespace_isolation(site_id, bd)))
sections.append(("TaskManager 队列 / 历史 任务隔离", check_task_manager_isolation(site_id, bd)))
sections.append(("TriggerManager 全局触发器(不应被 sandbox 暂停)", check_trigger_manager_global(site_id, bd)))
sections.append(("AIDashboard 指标(按真实时间)", await check_dashboard_uses_real_time(site_id, bd)))
finally:
if not args.keep_sandbox:
print(f"[admin-verify] 还原到 live")
switch_runtime(site_id, "live")
md = render_report(site_id, bd, sections)
out_path = ROOT / args.out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md, encoding="utf-8")
print(f"[admin-verify] 报告写入 {out_path.relative_to(ROOT)}")
print()
if "## 汇总" in md:
print(md.split("## 汇总")[1])
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sandbox-date", default="2025-09-01")
parser.add_argument("--site-id", type=int, default=None)
parser.add_argument("--keep-sandbox", action="store_true")
parser.add_argument("--out", default="docs/database/changes/2026-05-02__sandbox_admin_web_verify_report.md")
args = parser.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,442 @@
# -*- coding: utf-8 -*-
"""沙箱端到端验证:站在小程序 / admin-web 端视角,
切到 sandbox 后调用后端关键 service / fdw_queries 函数,
断言返回的最大日期 / 业务月份不超过 sandbox_date。
运行方式(在仓库根 / 已有 .env::
python tools/db/verify_sandbox_end_to_end.py [--sandbox-date 2025-09-01]
[--site-id <id>] [--keep-sandbox]
输出 markdown 格式报告 + 控制台 PASS/FAIL 摘要。
重要:本脚本会**临时**修改 ``biz.site_runtime_context``
脚本结束时自动还原(除非 --keep-sandbox
请只在 ``test_zqyy_app`` / ``test_etl_feiqiu`` 这种测试库使用。
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
# 让 backend 包可被 import
ROOT = Path(__file__).resolve().parents[2]
BACKEND = ROOT / "apps" / "backend"
sys.path.insert(0, str(BACKEND))
import psycopg2 # noqa: E402
from dotenv import load_dotenv # noqa: E402
load_dotenv(ROOT / ".env", override=False)
# 强制走测试库
os.environ["APP_DB_DSN"] = os.environ.get("TEST_APP_DB_DSN") or os.environ["APP_DB_DSN"]
os.environ["PG_DSN"] = os.environ.get("TEST_PG_DSN") or os.environ["PG_DSN"]
# backend.config 从 ETL_DB_HOST/PORT/... 读取,缺省走 DB_HOST=localhost
# 这里从 PG_DSNETL和 APP_DB_DSN业务解析出真实地址确保 backend 内部连接也走测试库
import psycopg2.extensions # noqa: E402
_etl = psycopg2.extensions.parse_dsn(os.environ["PG_DSN"])
_app = psycopg2.extensions.parse_dsn(os.environ["APP_DB_DSN"])
os.environ.setdefault("DB_HOST", _app.get("host", "localhost"))
os.environ.setdefault("DB_PORT", str(_app.get("port", "5432")))
os.environ.setdefault("DB_USER", _app.get("user", ""))
os.environ.setdefault("DB_PASSWORD", _app.get("password", ""))
os.environ.setdefault("ETL_DB_HOST", _etl.get("host", "localhost"))
os.environ.setdefault("ETL_DB_PORT", str(_etl.get("port", "5432")))
os.environ.setdefault("ETL_DB_USER", _etl.get("user", ""))
os.environ.setdefault("ETL_DB_PASSWORD", _etl.get("password", ""))
os.environ.setdefault("ETL_DB_NAME", _etl.get("dbname", "test_etl_feiqiu"))
def _date_of(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return datetime.fromisoformat(str(value)).date()
except Exception:
return None
def _le(actual: date | None, bound: date) -> str:
if actual is None:
return "OK (None)"
return "PASS" if actual <= bound else f"FAIL (>{bound})"
# ---------------------------------------------------------------------------
# RuntimeContext 切换
# ---------------------------------------------------------------------------
def switch_runtime(site_id: int, mode: str, sandbox_date: date | None = None) -> None:
"""切换业务库 runtime_contexttest_zqyy_app"""
from app.database import get_connection
from app.services.runtime_context import new_sandbox_instance_id
# live 模式下 site_runtime_context 强制 sandbox_instance_id IS NULLCHECK 约束);
# 'live' 字符串只是后端 task_runtime_filter 对未配置时的兜底,不能直接写表
instance_id = None if mode == "live" else new_sandbox_instance_id()
sandbox_date = None if mode == "live" else sandbox_date
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO biz.site_runtime_context
(site_id, mode, sandbox_date, sandbox_instance_id, ai_mode, status, updated_at, updated_by)
VALUES (%s, %s, %s, %s, 'live', 'active', NOW(), NULL)
ON CONFLICT (site_id) DO UPDATE SET
mode = EXCLUDED.mode,
sandbox_date = EXCLUDED.sandbox_date,
sandbox_instance_id = EXCLUDED.sandbox_instance_id,
updated_at = NOW(),
updated_by = NULL
""",
(site_id, mode, sandbox_date, instance_id),
)
conn.commit()
finally:
conn.close()
def pick_test_site(min_settle_count: int = 100) -> int:
"""找一个 etl_feiqiu 数据量较多的测试 site_id。"""
from app.database import get_etl_readonly_connection
# etl_feiqiu 直查
candidate_sites: list[tuple[int, int]] = []
pg_dsn = os.environ["PG_DSN"]
conn = psycopg2.connect(pg_dsn)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT site_id, COUNT(*) AS cnt
FROM dws.dws_finance_area_daily
GROUP BY site_id
ORDER BY cnt DESC
LIMIT 5
"""
)
candidate_sites = list(cur.fetchall())
finally:
conn.close()
if not candidate_sites:
raise SystemExit("etl_feiqiu 测试库无 dws_finance_area_daily 数据")
site_id = candidate_sites[0][0]
# 确保该 site 在 zqyy_app 也有 biz.sites 行
from app.database import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM biz.sites WHERE site_id = %s", (site_id,))
if not cur.fetchone():
# 兜底插入一个 biz.sites 行site_runtime_context 主键)
cur.execute(
"INSERT INTO biz.sites (site_id, site_name, is_active) VALUES (%s, %s, true) ON CONFLICT DO NOTHING",
(site_id, f"测试门店#{site_id}"),
)
conn.commit()
finally:
conn.close()
return int(site_id)
# ---------------------------------------------------------------------------
# 验证项
# ---------------------------------------------------------------------------
def run_view_layer_checks(site_id: int, bd: date, conn_etl) -> list[tuple[str, date | None, str]]:
"""直查 RLS 视图层C 方案最底层)。"""
rows: list[tuple[str, date | None, str]] = []
targets = [
# (label, sql)
("v_dws_finance_area_daily.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_finance_area_daily"),
("v_dws_finance_daily_summary.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_finance_daily_summary"),
("v_dws_member_visit_detail.visit_date", "SELECT MAX(visit_date) FROM app.v_dws_member_visit_detail"),
("v_dws_member_consumption_summary.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_member_consumption_summary"),
("v_dws_assistant_daily_detail.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_assistant_daily_detail"),
("v_dws_assistant_monthly_summary.stat_month", "SELECT MAX(stat_month) FROM app.v_dws_assistant_monthly_summary"),
("v_dws_assistant_salary_calc.salary_month", "SELECT MAX(salary_month) FROM app.v_dws_assistant_salary_calc"),
("v_dwd_settlement_head.create_time", "SELECT MAX(create_time)::date FROM app.v_dwd_settlement_head"),
("v_dwd_assistant_service_log.create_time", "SELECT MAX(create_time)::date FROM app.v_dwd_assistant_service_log"),
("v_dwd_recharge_order.pay_time", "SELECT MAX(pay_time)::date FROM app.v_dwd_recharge_order"),
("v_dws_member_winback_index.last_visit_time", "SELECT MAX(last_visit_time)::date FROM app.v_dws_member_winback_index"),
# 新增 18 个视图样本
("v_dws_assistant_customer_stats.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_assistant_customer_stats"),
("v_dws_member_assistant_intimacy.calc_time", "SELECT MAX(calc_time)::date FROM app.v_dws_member_assistant_intimacy"),
("v_dws_member_newconv_index.stat_date", "SELECT MAX(stat_date) FROM app.v_dws_member_newconv_index"),
("v_dws_finance_board_cache.computed_at", "SELECT MAX(computed_at)::date FROM app.v_dws_finance_board_cache"),
("v_finance_daily.stat_date", "SELECT MAX(stat_date) FROM app.v_finance_daily"),
]
with conn_etl.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd.isoformat(),))
for label, sql in targets:
try:
cur.execute(sql)
value = cur.fetchone()[0]
d = _date_of(value)
rows.append((label, d, _le(d, bd)))
except Exception as exc:
rows.append((label, None, f"ERROR: {type(exc).__name__}"))
conn_etl.rollback()
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd.isoformat(),))
conn_etl.commit()
return rows
def run_service_layer_checks(site_id: int, bd: date) -> list[tuple[str, Any, str]]:
"""通过后端 service / fdw_queries 函数验证。"""
from app.database import get_connection
from app.services import board_service, fdw_queries
from app.services.runtime_context import (
as_runtime_business_now_str,
as_runtime_today_param,
as_runtime_year_month_param,
get_runtime_context,
)
rows: list[tuple[str, Any, str]] = []
conn = get_connection()
try:
# === 1. RuntimeContext helpers ===
ctx = get_runtime_context(site_id, conn=conn)
rows.append(("ctx.is_sandbox", ctx.is_sandbox, "PASS" if ctx.is_sandbox else "FAIL"))
rows.append(("ctx.business_date", ctx.business_date, _le(ctx.business_date, bd) + (" / =bd" if ctx.business_date == bd else "")))
rows.append(("ctx.business_now", ctx.business_now.date(), _le(ctx.business_now.date(), bd)))
rows.append(("as_runtime_today_param", as_runtime_today_param(site_id, conn=conn), "PASS" if as_runtime_today_param(site_id, conn=conn) == bd else "FAIL"))
rows.append(("as_runtime_year_month_param", as_runtime_year_month_param(site_id, conn=conn), "PASS" if as_runtime_year_month_param(site_id, conn=conn) == f"{bd.year:04d}-{bd.month:02d}" else "FAIL"))
rows.append(("as_runtime_business_now_str", as_runtime_business_now_str(site_id, conn=conn), "OK"))
# === 2. board_service.calc_date_range财务看板时间区间 ===
s, e = board_service._calc_date_range("month", ref_date=ctx.business_date)
rows.append(("board.month range end", e, _le(e, bd) + (" / =bd" if e == bd else "")))
s2, e2 = board_service._calc_date_range("quarter", ref_date=ctx.business_date)
rows.append(("board.quarter range end", e2, _le(e2, bd)))
s3, e3 = board_service._calc_date_range("week", ref_date=ctx.business_date)
rows.append(("board.week range end", e3, _le(e3, bd)))
# === 3. fdw_queries.get_last_visit_days客户看板"最近到店"修复点) ===
# 任取若干 member_id用 PG_DSN 直连 ETL 测试库)
member_ids: list[int] = []
try:
etl_conn = psycopg2.connect(os.environ["PG_DSN"])
with etl_conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd.isoformat(),))
cur.execute(
"SELECT member_id FROM app.v_dws_member_visit_detail "
"WHERE visit_date <= %s ORDER BY visit_date DESC LIMIT 5",
(bd,),
)
member_ids = [r[0] for r in cur.fetchall()]
etl_conn.commit()
etl_conn.close()
rows.append(("pick member_ids", member_ids, "OK"))
except Exception as exc:
rows.append(("pick member_ids", None, f"ERROR: {type(exc).__name__}: {str(exc)[:80]}"))
if member_ids:
try:
last_visit_map = fdw_queries.get_last_visit_days(conn, site_id, member_ids)
if last_visit_map:
days_list = [v for v in last_visit_map.values() if v is not None]
if days_list:
min_days = min(days_list)
# min_days 至少 0最近到店日期 <= bd ⇒ days >= 0
rows.append((
"fdw.get_last_visit_days(min)",
f"min={min_days} 天前",
"PASS" if min_days >= 0 else f"FAIL ({min_days})",
))
except Exception as exc:
rows.append(("fdw.get_last_visit_days", None, f"ERROR: {type(exc).__name__}: {str(exc)[:80]}"))
# === 4. fdw_queries.get_customer_board_recent / recharge / freq60 / recall ===
for fn_name in [
"get_customer_board_recent",
"get_customer_board_recharge",
"get_customer_board_freq60",
"get_customer_board_recall",
]:
try:
fn = getattr(fdw_queries, fn_name)
resp = fn(conn, site_id, project="ALL", page=1, page_size=10)
items = resp.get("items", [])
# 找最大日期字段
max_d = None
for it in items:
for key in ("last_visit_date", "last_recharge", "last_visit"):
v = it.get(key) if key in it else None
d2 = _date_of(v) if v else None
if d2 and (max_d is None or d2 > max_d):
max_d = d2
rows.append((
f"fdw.{fn_name}(items={len(items)})",
max_d,
_le(max_d, bd) if max_d else "OK (no date in items)",
))
except Exception as exc:
rows.append((f"fdw.{fn_name}", None, f"ERROR: {type(exc).__name__}: {str(exc)[:80]}"))
# === 5. fdw_queries.get_coach_60d_stats任意一对 ===
if member_ids:
try:
etl_conn = psycopg2.connect(os.environ["PG_DSN"])
with etl_conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd.isoformat(),))
cur.execute(
"SELECT site_assistant_id FROM app.v_dwd_assistant_service_log "
"WHERE tenant_member_id = %s LIMIT 1",
(member_ids[0],),
)
row = cur.fetchone()
etl_conn.commit()
etl_conn.close()
if row:
aid = row[0]
stats = fdw_queries.get_coach_60d_stats(conn, site_id, aid, member_ids[0])
rows.append((
f"fdw.get_coach_60d_stats(aid={aid},mid={member_ids[0]})",
stats,
"OK" if isinstance(stats, dict) else "FAIL",
))
except Exception as exc:
rows.append(("fdw.get_coach_60d_stats", None, f"ERROR: {type(exc).__name__}: {str(exc)[:80]}"))
# === 6. AI 提示词时间锚 ===
try:
now_str = as_runtime_business_now_str(site_id, conn=conn, fmt="%Y-%m-%d %H:%M")
d_ai = datetime.strptime(now_str, "%Y-%m-%d %H:%M").date()
rows.append(("AI prompt current_time(date)", d_ai, _le(d_ai, bd)))
except Exception as exc:
rows.append(("AI prompt current_time", None, f"ERROR: {type(exc).__name__}"))
finally:
conn.close()
return rows
# ---------------------------------------------------------------------------
# 报告
# ---------------------------------------------------------------------------
def render_report(
site_id: int,
bd: date,
sandbox_view: list[tuple[str, date | None, str]],
sandbox_svc: list[tuple[str, Any, str]],
live_view: list[tuple[str, date | None, str]],
) -> str:
out: list[str] = []
out.append(f"# 沙箱端到端验证报告\n")
out.append(f"- site_id: `{site_id}`")
out.append(f"- sandbox_date: `{bd.isoformat()}`")
out.append(f"- 生成时间: `{datetime.now().isoformat(timespec='seconds')}`\n")
out.append(f"## 1. 视图层C 方案)\n")
out.append("sandbox 模式下max(各日期列) 必须 <= sandbox_date。\n")
out.append("| 视图.列 | 取值 | 结果 |")
out.append("|---|---|---|")
for label, val, status in sandbox_view:
out.append(f"| `app.{label}` | {val} | {status} |")
out.append(f"\n### live 模式 baseline同样 site_id无 GUC\n")
out.append("| 视图.列 | 取值 | 备注 |")
out.append("|---|---|---|")
for label, val, _ in live_view:
out.append(f"| `app.{label}` | {val} | live (CURRENT_DATE 行为) |")
out.append(f"\n## 2. 应用层B 方案 / RuntimeContext / fdw_queries / AI prompt\n")
out.append("| 调用 | 取值 | 结果 |")
out.append("|---|---|---|")
for label, val, status in sandbox_svc:
out.append(f"| `{label}` | {val} | {status} |")
# 汇总
all_rows = sandbox_view + sandbox_svc
pass_n = sum(1 for _, _, s in all_rows if s.startswith("PASS") or s == "OK" or s.startswith("OK "))
fail_n = sum(1 for _, _, s in all_rows if s.startswith("FAIL") or s.startswith("ERROR"))
other_n = len(all_rows) - pass_n - fail_n
out.append(f"\n## 3. 汇总\n")
out.append(f"- 总检查项: {len(all_rows)}")
out.append(f"- PASS / OK: {pass_n}")
out.append(f"- FAIL / ERROR: {fail_n}")
out.append(f"- 其他: {other_n}\n")
if fail_n == 0:
out.append("**结论PASS — sandbox 模式下,所有关键读取路径都被截到 sandbox_date 之前。**")
else:
out.append("**结论FAIL — 仍有项目读到 sandbox_date 之后的数据,详见上表。**")
return "\n".join(out)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sandbox-date", default="2025-09-01")
parser.add_argument("--site-id", type=int, default=None)
parser.add_argument("--keep-sandbox", action="store_true", help="不还原为 live")
parser.add_argument("--out", default="docs/database/changes/2026-05-02__sandbox_e2e_verify_report.md")
args = parser.parse_args()
bd = datetime.strptime(args.sandbox_date, "%Y-%m-%d").date()
site_id = args.site_id or pick_test_site()
print(f"[verify] site_id={site_id}, sandbox_date={bd}")
# === live baseline ===
pg_dsn = os.environ["PG_DSN"]
etl_conn = psycopg2.connect(pg_dsn)
try:
live_view = run_view_layer_checks(site_id, date.today() + timedelta(days=10), etl_conn)
finally:
etl_conn.close()
# === sandbox ===
print(f"[verify] 切换 site_id={site_id} 到 sandbox({bd})")
switch_runtime(site_id, "sandbox", bd)
try:
etl_conn = psycopg2.connect(pg_dsn)
try:
sandbox_view = run_view_layer_checks(site_id, bd, etl_conn)
finally:
etl_conn.close()
sandbox_svc = run_service_layer_checks(site_id, bd)
finally:
if not args.keep_sandbox:
print(f"[verify] 还原 site_id={site_id} 到 live")
switch_runtime(site_id, "live")
md = render_report(site_id, bd, sandbox_view, sandbox_svc, live_view)
out_path = ROOT / args.out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md, encoding="utf-8")
print(f"[verify] 报告写入 {out_path.relative_to(ROOT)}")
print()
print(md.split("## 3. 汇总")[1] if "## 3. 汇总" in md else md[-500:])
if __name__ == "__main__":
main()