Files
xiaotutongxue/scripts/dev-clean-e2e.py
T
lovife 4a5790d900 chore(clean): 测试数据三本账清完(库 / 磁盘 / 七牛桶)+ 100 条问题全部核销
一、dev-clean-e2e.py:加「在用组织保护」后执行
  · 先算「仍被存活任务引用的组织」并保留 —— 当前演示态就挂在其中一个
    「E2E 24数控1班」上,删了它演示态就坏(保留 3 个,演示态完好:
    任务 1449 / 名册 3 人 / 组织 43 都在)
  · 已执行 --apply --demo-tasks --orphan-files:
    49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,
    共 3714 行 / 27 个文件(业务表按 information_schema 动态枚举,不写死清单)

二、dev-clean-qiniu.py(新增):清真实桶里本项目前缀的测试对象
  · 只动 xiaotutongxue/ 前缀;默认 dry-run;删前核实库中零引用、
    数量异常(>5000)直接停
  · 已执行:2036 个对象 / 116 MB 全部删除,删后复验 0 个(F-93「删库不删桶」)
  · 签名按官方文档逐字核对 —— 踩了两个 401 才对:旧 QBox 格式(一个换行 +
    "QBox " 前缀)不对,是「两个换行 + Qiniu 前缀」;batch 的 Body 必须进
    签名串(列举无 Body 能过、批删全 401 —— 同一签名函数一好一坏,线索在 Body)
  · 列举在 rsf.qiniuapi.com,不在 rs(打错 host 报「Bad method: list」)

三、xt-minio 容器已删(F-2)—— 代码早已不用 S3,deploy 里也没有它

四、文档核销与记录
  · docs/08:F-3/4/7/10(小节)与 F-14/26/29/30/31/38/51/57/64/65/71/72/93
    (表格行)全部标「已闭环」;F-5 补执行记录;F-28 备案维持两级(→D-11);
    变更记录补两行 —— **F-1~F-100 至此无未决项**,剩余全部等外部输入
  · docs/01:新增 E4「国标职业库数据来源待客户确认」(F-34,字典页如实显示 0 项)
  · 交接摘要:新增 §一·三十一(17 项决策执行记录)
  · AGENTS.md:清理一节写清三个工具与「在用组织保护」

验证:python scripts/verify-all.py --with-e2e →
      通过 8 / 失败 0 / 跳过 0(37 个 E2E 脚本 / 1577 条断言 / 0 失败,291s;
      prodguard 9/9;test profile 能起)
2026-09-30 12:38:55 +08:00

308 lines
14 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.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
开发库与本地磁盘的**测试残留**清理。
E2E 每跑一次会:
1. 新建一个租户(编码 e2e######)并给它建角色与账号;
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里堆满
"E2E 示范学校 586923" 这种名字。
跑几十轮之后真正的问题不是"列表里有测试名字",而是两笔越滚越大的账:
· **租户垃圾**:`sys_tenant` 里 48 个 `e2e*`(每个还带着用户、角色、组织);
· **磁盘垃圾**:`backend/data/uploads` 里几百 MB 的文件 ——
**删库不删盘**。压测与 E2E 会删掉自己的库行,但文件留在磁盘上,
而"哪些文件还有主"只有库知道(`collect_file.std_path/raw_path`、
`collect_export_file.file_path`)。实测:库里 16 行文件、磁盘上 281 个文件。
用法
----
python scripts/dev-clean-e2e.py # 只看会删什么(默认 dry-run)
python scripts/dev-clean-e2e.py --apply # 真删:e2e 租户 + 测试组织 + 它们的业务行
python scripts/dev-clean-e2e.py --apply --demo-tasks # 连【演示·旧】任务一起删
python scripts/dev-clean-e2e.py --apply --orphan-files # 连"没有库行指向"的磁盘文件一起删
⚠️ 只在开发库上跑,不要对生产执行。
⚠️ `--orphan-files` 删的是**磁盘文件**,不可恢复;它靠"库里没有任何一行指向它"来判定,
所以只要库是完整的,它就不会删到在用的文件。默认 dry-run,先看一眼再 `--apply`。
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UPLOADS = os.path.join(ROOT, "backend", "data", "uploads")
# 子表在前、主表在后(无外键约束,顺序只为可读性与排查方便)
TENANT_CHILD_TABLES = [
("角色授权", "sys_role_permission"),
("用户角色", "sys_user_role"),
("登录日志", "sys_login_log"),
("组织", "sys_org"),
("角色", "sys_role"),
("用户", "sys_user"),
]
TENANT_FILTER = "tenant_code LIKE 'e2e%'"
SUB_FILTER = f"tenant_id IN (SELECT id FROM sys_tenant WHERE {TENANT_FILTER})"
# demo 租户下由 E2E 建的组织:编码都是 ROOT/DEPT/CLS + 6 位时间戳
DEMO_ORG_FILTER = (
"org_code ~ '^(ROOT|DEPT|CLS|BAD)[0-9]{6}$' "
"OR org_name LIKE 'E2E %' OR org_name LIKE '重复编码%' OR org_name LIKE '层级颠倒%'"
)
# 演示残留:给客户演示时反复摆过的任务,为了不污染演示态,已发布的那批被**改名挪开**
# 而不是删掉(F-14:已发布的任务删不掉)。它们属于"当时的演示数据",不是需求数据。
#
# ⚠️ 只匹配带「旧」的那一批:`【演示】审核台` 是**当前**演示态在用的任务,
# 删了它 = 把演示态弄坏(而且它的班级就是下面那批 "E2E 24数控1班",同理不能删)。
DEMO_TASK_FILTER = "task_name LIKE '【演示·旧】%'"
class Db:
def __init__(self, container: str, user: str, name: str):
self.container = container
self.user = user
self.name = name
def rows(self, sql: str) -> list[str]:
cmd = ["docker", "exec", self.container, "psql", "-U", self.user, "-d", self.name,
"-v", "ON_ERROR_STOP=1", "-t", "-A", "-F", "|", "-c", sql]
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
if r.returncode != 0:
print(r.stderr.strip(), file=sys.stderr)
sys.exit(1)
return [x for x in r.stdout.split("\n") if x.strip()]
def tables_with(db: Db, column: str) -> list[str]:
"""
所有带这一列的 `collect_*` 表。
⚠️ **动态枚举,不写死清单**:写死的话,以后新增一张业务表就会漏清,
而"漏清"的表现是下一次跑 E2E 时某个统计数字莫名偏大 —— 排查方向完全错。
"""
sql = ("SELECT c.table_name FROM information_schema.columns c "
"JOIN information_schema.tables t "
" ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE' "
f" AND c.table_name LIKE 'collect\\_%' AND c.column_name = '{column}' "
"ORDER BY c.table_name")
return [r.split("|")[0] for r in db.rows(sql)]
def db_file_paths(db: Db) -> set[str]:
"""库里所有被引用的文件路径(相对 uploads 目录)。"""
known: set[str] = set()
for table, cols in (("collect_file", ("raw_path", "std_path")),
("collect_export_file", ("file_path",))):
for col in cols:
for r in db.rows(f"SELECT {col} FROM {table} WHERE {col} IS NOT NULL"):
p = r.split("|")[0].strip().replace("\\", "/")
if not p:
continue
known.add(p)
if os.path.isabs(p):
known.add(os.path.relpath(p, UPLOADS).replace("\\", "/"))
return known
def orgs_referenced_by_live_tasks(db: Db) -> set[int]:
"""
仍被**存活任务**引用的组织 id —— 这些组织绝不能删。
⚠️ 为什么必须有它:当前演示态就挂在其中一个 "E2E 24数控1班" 上
(历史原因:演示种子当时用了 E2E 建的班)。删了组织 = 演示态的审核范围、
导出目录全部失去归属,而**界面上一句话都不会提示**。
引用判断用任务表的 deleted(collect_task_org 没有 deleted 列,不能用它判活)。
"""
keep: set[int] = set()
for r in db.rows(
"SELECT DISTINCT cto.org_id FROM collect_task_org cto "
"JOIN collect_task t ON t.id = cto.task_id AND t.deleted = 0 "
"WHERE cto.org_id IS NOT NULL"):
try:
keep.add(int(r.split("|")[0]))
except ValueError:
pass
return keep
def scan_orphans(db: Db) -> tuple[list[str], int]:
"""磁盘上有、而库里没有任何一行指向的文件。"""
known = db_file_paths(db)
orphans, size = [], 0
for dirpath, _dirs, files in os.walk(UPLOADS):
for fn in files:
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, UPLOADS).replace("\\", "/")
if rel not in known:
orphans.append(rel)
try:
size += os.path.getsize(full)
except OSError:
pass
return sorted(orphans), size
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="真正执行删除(默认只预览)")
ap.add_argument("--demo-tasks", action="store_true",
help="连【演示·旧】任务(及其名册/材料/填报单/文件)一起删")
ap.add_argument("--orphan-files", action="store_true",
help="连磁盘上「没有库行指向」的文件一起删(不可恢复)")
ap.add_argument("--only-orphan-files", action="store_true",
help="**只**清磁盘孤儿文件,不碰租户/组织/任务(最安全的一档)")
ap.add_argument("--container", default="xt-pg")
ap.add_argument("--user", default="fill")
ap.add_argument("--db", default="filldb")
args = ap.parse_args()
if args.only_orphan_files:
args.orphan_files = True
db = Db(args.container, args.user, args.db)
tenants = [] if args.only_orphan_files else db.rows(
f"SELECT id || '|' || tenant_code || '|' || tenant_name "
f"FROM sys_tenant WHERE {TENANT_FILTER} ORDER BY id")
# 在用组织保护:先算好(任何删除发生之前),删除阶段只动"没被引用的"
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
demo_orgs = [] if args.only_orphan_files else [
r for r in db.rows(
f"SELECT id || '|' || org_name FROM sys_org "
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
if int(r.split("|")[0]) not in keep_orgs
]
kept_note = ("(另有 %d 个测试组织被存活任务引用,**保留**:其中就有当前演示态挂着的班)"
% len(keep_orgs)) if keep_orgs else ""
demo_tasks = (db.rows(f"SELECT id || '|' || task_name FROM collect_task "
f"WHERE deleted = 0 AND {DEMO_TASK_FILTER} ORDER BY id")
if (args.demo_tasks and not args.only_orphan_files) else [])
orphans, orphan_size = scan_orphans(db) if args.orphan_files else ([], 0)
if not any((tenants, demo_orgs, demo_tasks, orphans)):
print("没有需要清理的测试数据")
return 0
if tenants:
print(f"① e2e 测试租户 {len(tenants)} 个:")
for r in tenants[:15]:
p = r.split("|")
print(" id=%-5s code=%-14s %s" % (p[0], p[1], p[2] if len(p) > 2 else ""))
if len(tenants) > 15:
print(f" ... 另有 {len(tenants) - 15} 个")
if demo_orgs:
print(f"\n② 其它租户下的 e2e 测试组织 {len(demo_orgs)} 个{kept_note}:")
for r in demo_orgs[:15]:
p = r.split("|")
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
if len(demo_orgs) > 15:
print(f" ... 另有 {len(demo_orgs) - 15} 个")
if demo_tasks:
print(f"\n③ 演示残留任务 {len(demo_tasks)} 个(--demo-tasks):")
for r in demo_tasks[:10]:
p = r.split("|")
print(" id=%-5s %s" % (p[0], p[1] if len(p) > 1 else ""))
if len(demo_tasks) > 10:
print(f" ... 另有 {len(demo_tasks) - 10} 个")
if args.orphan_files:
print(f"\n④ 磁盘上没有库行指向的文件 {len(orphans)} 个 / {orphan_size / 1048576.0:.1f} MB"
f"(--orphan-files)")
by_dir: dict[str, int] = {}
for rel in orphans:
by_dir[rel.split("/")[0]] = by_dir.get(rel.split("/")[0], 0) + 1
for k, v in sorted(by_dir.items(), key=lambda x: -x[1]):
print(" %-10s %d 个" % (k, v))
for rel in orphans[:5]:
print(" 例:%s" % rel)
if not args.apply:
print("\n这是预览。加 --apply 才会真正删除。")
return 0
print("\n开始清理:")
total = 0
if not args.only_orphan_files:
for label, table in TENANT_CHILD_TABLES:
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % (label, table, n))
# e2e 租户的**业务行**:不删的话,租户没了、任务还在,
# 于是"每个请求多一次租户解析"的旧账会一直留着(F-5)
for table in tables_with(db, "tenant_id"):
n = len(db.rows(f"DELETE FROM {table} WHERE tenant_id IN "
f"(SELECT id FROM sys_tenant WHERE {TENANT_FILTER}) RETURNING id"))
if n:
total += n
print(" %-10s %-22s 删除 %d 行" % ("业务行", table, n))
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
# 测试组织:按 id 删(keep_orgs 已在预览阶段剔除;这里再校验一次,
# 因为上面可能刚删过任务,引用集合应该只减不增 —— 双保险,宁可少删)
keep_orgs = orgs_referenced_by_live_tasks(db) if not args.only_orphan_files else set()
org_ids = [int(r.split("|")[0]) for r in db.rows(
f"SELECT id FROM sys_org WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
if int(r.split("|")[0]) not in keep_orgs]
if org_ids:
# 一次 IN 里塞太多 id 会撑爆语句,分批(144 个一批也行,但分批无成本)
for i in range(0, len(org_ids), 200):
batch = org_ids[i:i + 200]
n = len(db.rows("DELETE FROM sys_org WHERE id IN (%s) RETURNING id"
% ",".join(map(str, batch))))
total += n
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
else:
print(" %-10s %-22s 没有可删的(都在用或已清)" % ("测试组织", "sys_org"))
if args.demo_tasks:
# 先按任务 id 删子表(同样动态枚举"带 task_id 的表")
for table in tables_with(db, "task_id"):
n = len(db.rows(f"DELETE FROM {table} WHERE task_id IN "
f"(SELECT id FROM collect_task WHERE {DEMO_TASK_FILTER}) RETURNING id"))
if n:
total += n
print(" %-10s %-22s 删除 %d 行" % ("演示子表", table, n))
n = len(db.rows(f"DELETE FROM collect_task WHERE {DEMO_TASK_FILTER} RETURNING id"))
total += n
print(" %-10s %-22s 删除 %d 行" % ("演示任务", "collect_task", n))
removed = 0
if args.orphan_files:
# ⚠️ 删文件前**重算一次**:上面刚删过库行,此时"还有主"的文件集合变小了,
# 但那些刚被删掉主的文件属于"任务数据",本该跟着一起清 —— 重算正好覆盖它们
orphans, orphan_size = scan_orphans(db)
for rel in orphans:
try:
os.remove(os.path.join(UPLOADS, rel))
removed += 1
except OSError:
pass
print(" %-10s %-22s 删除 %d 个文件 / %.1f MB"
% ("磁盘文件", "backend/data/uploads", removed, orphan_size / 1048576.0))
print(f"\n完成,共删除 {total} 行" + (f"、{removed} 个文件。" if args.orphan_files else "。"))
return 0
if __name__ == "__main__":
raise SystemExit(main())