117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""清理验证脚本留下的「软删垃圾」。
|
||
|
||
背景:三个验证脚本为了证明功能真的能用,必须往库里写真实数据。
|
||
它们跑完都会把数据删掉,但删的是**逻辑删除**(is_del=1)—— 这恰恰是
|
||
被测功能的正确行为,不该为了测试去改它。代价是每跑一轮,表里就多几行
|
||
名字带「自检 / 权限对账」的尸体。
|
||
|
||
这些尸体对业务没影响(所有查询都带 is_del=0),但演示库是要给人看的,
|
||
不该越跑越脏。所以单独开一个工具,把它们的**物理行**清掉。
|
||
|
||
默认干跑(只列不删),确认没问题再加 --yes。
|
||
|
||
用法:
|
||
python verify/purge_selftest.py # 干跑,看看会删什么
|
||
python verify/purge_selftest.py --yes # 真的删
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
|
||
if PROJECT_ROOT not in sys.path:
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
from sqlalchemy import or_, select, text # noqa: E402
|
||
|
||
from app.core.database import engine # noqa: E402
|
||
from app.model import Advisor, Clazz, Employment, Score, Student, Teacher # noqa: E402
|
||
|
||
# 只认验证脚本自己造的命名,宁可漏清也不能误伤真实数据
|
||
NAME_PATTERNS = ["自检%", "%权限对账%", "%只读越权%", "%权限探针%"]
|
||
|
||
|
||
def _like(col):
|
||
return or_(*[col.like(p) for p in NAME_PATTERNS])
|
||
|
||
|
||
def collect() -> dict[str, list[tuple[int, str]]]:
|
||
out: dict[str, list[tuple[int, str]]] = {}
|
||
with engine.connect() as conn:
|
||
for model in (Student, Clazz, Teacher, Advisor):
|
||
rows = conn.execute(select(model.id, model.name).where(_like(model.name))).all()
|
||
out[model.__tablename__] = [(r[0], r[1]) for r in rows]
|
||
return out
|
||
|
||
|
||
def purge(findings: dict[str, list[tuple[int, str]]]) -> dict[str, int]:
|
||
deleted: dict[str, int] = {}
|
||
with engine.begin() as conn:
|
||
stu_ids = [i for i, _ in findings.get("student", [])]
|
||
clazz_ids = [i for i, _ in findings.get("clazz", [])]
|
||
teacher_ids = [i for i, _ in findings.get("teacher", [])]
|
||
|
||
if stu_ids:
|
||
# 先清依附于学生的行,再删学生本身,避免外键悬空
|
||
deleted["score"] = _del_in(conn, "score", "stu_id", stu_ids)
|
||
deleted["employment"] = _del_in(conn, "employment", "stu_id", stu_ids)
|
||
if clazz_ids:
|
||
deleted["class_teachers(按班级)"] = _del_in(conn, "class_teachers", "class_id", clazz_ids)
|
||
if teacher_ids:
|
||
deleted["class_teachers(按老师)"] = _del_in(conn, "class_teachers", "teacher_id", teacher_ids)
|
||
|
||
for table in ("student", "clazz", "teacher", "advisor"):
|
||
ids = [i for i, _ in findings.get(table, [])]
|
||
if ids:
|
||
deleted[table] = _del_in(conn, table, "id", ids)
|
||
return deleted
|
||
|
||
|
||
def _del_in(conn, table: str, col: str, ids: list[int]) -> int:
|
||
"""按主键列表硬删。SQLAlchemy 的 IN 展开走 bindparam,避免手拼 SQL。"""
|
||
stmt = text(f"DELETE FROM {table} WHERE {col} IN :ids").bindparams(ids=tuple(ids))
|
||
return conn.execute(stmt).rowcount
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--yes", action="store_true", help="真的执行删除(默认只干跑)")
|
||
args = ap.parse_args()
|
||
|
||
findings = collect()
|
||
total = sum(len(v) for v in findings.values())
|
||
|
||
print("=" * 70)
|
||
print(f"扫描验证残留 @ {engine.url.render_as_string(hide_password=True)}")
|
||
print("=" * 70)
|
||
for table, rows in findings.items():
|
||
if not rows:
|
||
continue
|
||
print(f"\n{table}({len(rows)} 条)")
|
||
for i, name in rows:
|
||
print(f" id={i:<6} {name}")
|
||
|
||
if not total:
|
||
print("\n没有残留,库是干净的。")
|
||
return 0
|
||
|
||
print(f"\n合计 {total} 条待清理。")
|
||
if not args.yes:
|
||
print("这是干跑。确认无误后加 --yes 真删。")
|
||
return 0
|
||
|
||
deleted = purge(findings)
|
||
print("\n已物理删除:")
|
||
for table, n in deleted.items():
|
||
if n:
|
||
print(f" {table:<24} {n} 行")
|
||
print("\n清理完成。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|