涵盖(每条对应已存的审计记录): - 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 逐一处理
476 lines
19 KiB
Python
476 lines
19 KiB
Python
# -*- 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()
|