微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
267
scripts/ops/_migrate_p4_biz_tables.py
Normal file
267
scripts/ops/_migrate_p4_biz_tables.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
迁移脚本执行与验证: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()
|
||||
Reference in New Issue
Block a user