Files
Neo-ZQYY/tools/db/verify_admin_web_sandbox.py
Neo caf179a5da 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 逐一处理
2026-05-04 02:30:19 +08:00

476 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()