""" 迁移脚本执行与验证:P4 业务表(coach_tasks / coach_task_history / notes / trigger_jobs) - 在 test_zqyy_app 中执行 DDL 建表脚本和种子数据脚本 - 验证幂等性:连续执行两次无错误 - 验证表结构、约束、索引正确 - 验证种子数据完整(4 条触发器配置) Requirements: 11.1-11.5, 12.1 """ import os import sys from pathlib import Path # 加载根 .env root = Path(__file__).resolve().parents[2] sys.path.insert(0, str(root)) from dotenv import load_dotenv load_dotenv(root / ".env") dsn = os.environ.get("TEST_APP_DB_DSN") or os.environ.get("APP_DB_DSN") if not dsn: raise RuntimeError("TEST_APP_DB_DSN / APP_DB_DSN 未配置") if "test_zqyy_app" not in dsn: raise RuntimeError(f"DSN 不是测试库: {dsn}") import psycopg2 # 迁移脚本路径 DDL_SCRIPT = root / "db" / "zqyy_app" / "migrations" / "2026-02-27__p4_create_biz_tables.sql" SEED_SCRIPT = root / "db" / "zqyy_app" / "migrations" / "2026-02-27__p4_seed_trigger_jobs.sql" PASS = "✅" FAIL = "❌" results: list[tuple[str, bool, str]] = [] def run_sql_file(conn, path: Path, label: str) -> None: """执行 SQL 文件(跳过注释行中的回滚语句)。""" sql = path.read_text(encoding="utf-8") with conn.cursor() as cur: cur.execute(sql) conn.commit() print(f" {PASS} {label} 执行成功") def check(name: str, ok: bool, detail: str = "") -> None: results.append((name, ok, detail)) icon = PASS if ok else FAIL msg = f" {icon} {name}" if detail: msg += f" — {detail}" print(msg) def main() -> None: conn = psycopg2.connect(dsn) try: # ================================================================== # 第一阶段:执行迁移脚本(第 1 次) # ================================================================== print("\n" + "=" * 60) print("阶段 1:首次执行迁移脚本") print("=" * 60) run_sql_file(conn, DDL_SCRIPT, "DDL 建表脚本(第 1 次)") run_sql_file(conn, SEED_SCRIPT, "种子数据脚本(第 1 次)") # ================================================================== # 第二阶段:幂等性验证(第 2 次执行) # ================================================================== print("\n" + "=" * 60) print("阶段 2:幂等性验证(第 2 次执行)") print("=" * 60) try: run_sql_file(conn, DDL_SCRIPT, "DDL 建表脚本(第 2 次 — 幂等)") check("DDL 幂等性", True) except Exception as e: check("DDL 幂等性", False, str(e)) conn.rollback() try: run_sql_file(conn, SEED_SCRIPT, "种子数据脚本(第 2 次 — 幂等)") check("种子数据幂等性", True) except Exception as e: check("种子数据幂等性", False, str(e)) conn.rollback() # ================================================================== # 第三阶段:表结构验证 # ================================================================== print("\n" + "=" * 60) print("阶段 3:表结构验证") print("=" * 60) with conn.cursor() as cur: # 3.1 验证 4 张表存在 expected_tables = ["coach_tasks", "coach_task_history", "notes", "trigger_jobs"] cur.execute(""" SELECT table_name FROM information_schema.tables WHERE table_schema = 'biz' AND table_name = ANY(%s) ORDER BY table_name """, (expected_tables,)) found_tables = [r[0] for r in cur.fetchall()] check( "4 张表存在", set(expected_tables) == set(found_tables), f"期望 {sorted(expected_tables)},实际 {sorted(found_tables)}", ) # 3.2 验证 coach_tasks 关键字段 cur.execute(""" SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'biz' AND table_name = 'coach_tasks' ORDER BY ordinal_position """) ct_cols = {r[0]: r for r in cur.fetchall()} required_cols = [ "id", "site_id", "assistant_id", "member_id", "task_type", "status", "priority_score", "expires_at", "is_pinned", "abandon_reason", "completed_at", "completed_task_type", "parent_task_id", "created_at", "updated_at", ] missing = [c for c in required_cols if c not in ct_cols] check("coach_tasks 字段完整", len(missing) == 0, f"缺失: {missing}" if missing else f"{len(required_cols)} 个字段") # 3.3 验证 coach_tasks.status 默认值 status_col = ct_cols.get("status") if status_col: check( "coach_tasks.status 默认 'active'", status_col[3] is not None and "active" in str(status_col[3]), f"default={status_col[3]}", ) # 3.4 验证 notes CHECK 约束 cur.execute(""" SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'biz.notes'::regclass AND contype = 'c' """) check_constraints = cur.fetchall() check( "notes CHECK 约束(评分 1-5)", len(check_constraints) >= 2, f"找到 {len(check_constraints)} 个 CHECK 约束: {[c[0] for c in check_constraints]}", ) # 3.5 验证外键 cur.execute(""" SELECT conname, conrelid::regclass, confrelid::regclass FROM pg_constraint WHERE contype = 'f' AND (conrelid::regclass::text LIKE 'biz.%') ORDER BY conname """) fks = cur.fetchall() fk_names = [r[0] for r in fks] check( "外键约束存在", len(fks) >= 3, f"找到 {len(fks)} 个外键: {fk_names}", ) # ================================================================== # 第四阶段:索引验证 # ================================================================== print("\n" + "=" * 60) print("阶段 4:索引验证") print("=" * 60) with conn.cursor() as cur: expected_indexes = [ "idx_coach_tasks_site_assistant_member_type", "idx_coach_tasks_assistant_status", "idx_notes_target", ] cur.execute(""" SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'biz' AND indexname = ANY(%s) """, (expected_indexes,)) found_idx = {r[0]: r[1] for r in cur.fetchall()} for idx_name in expected_indexes: check( f"索引 {idx_name}", idx_name in found_idx, found_idx.get(idx_name, "未找到")[:80] if idx_name in found_idx else "未找到", ) # 验证部分唯一索引包含 WHERE 子句 partial_idx = found_idx.get("idx_coach_tasks_site_assistant_member_type", "") check( "部分唯一索引含 WHERE status='active'", "active" in partial_idx.lower() and "unique" in partial_idx.lower(), partial_idx[:100] if partial_idx else "未找到", ) # 验证 trigger_jobs.job_name UNIQUE 约束 cur.execute(""" SELECT indexname FROM pg_indexes WHERE schemaname = 'biz' AND tablename = 'trigger_jobs' AND indexdef ILIKE '%unique%' """) unique_idx = cur.fetchall() check( "trigger_jobs.job_name UNIQUE", len(unique_idx) >= 1, f"找到 {len(unique_idx)} 个唯一索引", ) # ================================================================== # 第五阶段:种子数据验证 # ================================================================== print("\n" + "=" * 60) print("阶段 5:种子数据验证") print("=" * 60) with conn.cursor() as cur: cur.execute("SELECT job_name, trigger_condition, trigger_config, status FROM biz.trigger_jobs ORDER BY job_name") rows = cur.fetchall() check("trigger_jobs 记录数 = 4", len(rows) == 4, f"实际 {len(rows)} 条") expected_jobs = { "note_reclassify_backfill": ("event", "recall_completed"), "recall_completion_check": ("event", "etl_data_updated"), "task_expiry_check": ("interval", None), "task_generator": ("cron", None), } for job_name, trigger_condition, trigger_config, status in rows: exp = expected_jobs.get(job_name) if exp: cond_ok = trigger_condition == exp[0] if exp[0] == "event": event_ok = trigger_config.get("event_name") == exp[1] check(f" {job_name}", cond_ok and event_ok, f"condition={trigger_condition}, event={trigger_config.get('event_name')}, status={status}") elif exp[0] == "cron": cron_ok = "0 4 * * *" in str(trigger_config.get("cron_expression", "")) check(f" {job_name}", cond_ok and cron_ok, f"condition={trigger_condition}, cron={trigger_config.get('cron_expression')}, status={status}") elif exp[0] == "interval": interval_ok = trigger_config.get("interval_seconds") == 3600 check(f" {job_name}", cond_ok and interval_ok, f"condition={trigger_condition}, interval={trigger_config.get('interval_seconds')}s, status={status}") else: check(f" {job_name}", False, "未预期的 job") # ================================================================== # 汇总 # ================================================================== print("\n" + "=" * 60) total = len(results) passed = sum(1 for _, ok, _ in results if ok) failed = total - passed print(f"验证完成:{passed}/{total} 通过,{failed} 失败") if failed: print("\n失败项:") for name, ok, detail in results: if not ok: print(f" {FAIL} {name}: {detail}") sys.exit(1) else: print(f"{PASS} 全部验证通过!") print("=" * 60) finally: conn.close() if __name__ == "__main__": main()