test(scripts): 补测"没测过的四项"+ 清理工具扩展(F-99 / F-5)
一、load-test.py:把 docs/09 §9.5 里四项"没测过"的变成"测过"
· --base:另起一个 --fill.storage.provider=local 的实例(18080)量上传,
不把测试文件灌进真实七牛桶;
· --fixture-only:量名册导入本身(造 Excel 与导入接口分开计时 ——
脚本自己的成本不能算到系统头上);
· --only export-big:造 N 份已通过的单(门禁绑定→草稿→上传→提交→
初审(班主任)→终审(管理员)→前置检查→创建→轮询),再逐卷解开验证 ZIP;
· --soak/--rps:长跑,每分钟采 RSS(从系统取,不是应用自报的堆)与
PG 连接数,比较前 1/3 与后 1/3 的均值 —— 判据是趋势,不是"跑完没崩";
· 上传夹具换成真 JPEG(原来的"JPEG 头+随机字节"在服务端解码即失败,
量到的根本不是压缩成本)。
数字(docs/09 §9.5 有完整复现命令):
上传 20 并发 40/40,串行 321ms(2850KB→1329KB,47%),并发 p50 620ms;
5000 行导入 20.2s(RSS 782MB);150 人 194.6MB 导出打包 13.2s,
分卷 4 卷每卷可单独解开;长跑 51 样本 15300 请求 0 失败,
RSS 603.9→582.9MB 平稳,PG 连接定时任务高峰后正常回落。
二、F-99:这一轮压测工具自己撒了 8 次谎(都是"度量/夹具出问题时
现象长得像产品缺陷"):前置未断言(--only upload 单跑全报"没有草稿")、
假 JPEG、猜列名(part_no/file_name/file_path)、拿 HTTP 码当业务码、
跨租户同名 admin(库里 50 多个,改从 token 的 sub 取 uid)、
夹具接口不查返回码(PUT orgs 被拒→任务没发布→学生端整齐报 40008)、
PowerShell -First 截管道把"被堵"变成"卡住"、清理只删库行不删文件。
修法:bind_users/call_ok(夹具每个调用都断言业务码)、make_img 真图、
列名按 DDL、cleanup 连磁盘文件一起删。
三、dev-clean-e2e.py(F-5 落地):
· --orphan-files:清"库里没有任何一行指向"的磁盘文件(判据来自
collect_file.std_path/raw_path + collect_export_file.file_path)——
实测 uploads 里 281 个文件/276MB 而库里只剩 16 行,已清 255 个/275.4MB;
· --demo-tasks:清【演示·旧】任务(只匹配带「旧」的 ——
【演示】审核台是当前演示态在用的,原来那句 LIKE 会把它一起删掉);
· e2e 租户的业务行按 information_schema 动态枚举(写死清单必然漏新表);
· --only-orphan-files:最安全的一档,只碰磁盘不碰任何库行。
⚠️ e2e 租户与"E2E 24数控1班"组织刻意未删:当前演示态正挂在其中一个班下面。
验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0 / 跳过 0;
docs/09 §9.1~§9.5 已同步(含机器规格:12 物理核 / 59.6GB)。
This commit is contained in:
+158
-19
@@ -1,31 +1,47 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
清理 e2e-m0.py 产生的测试数据。
|
||||
开发库与本地磁盘的**测试残留**清理。
|
||||
|
||||
E2E 每跑一次会:
|
||||
1. 新建一个租户(编码 e2e######)并给它建角色与账号;
|
||||
2. **在 demo 租户下**建三个组织(学校/院系/班级,编码带时间戳)。
|
||||
|
||||
第 2 点容易被忽略——这些组织挂在正常租户下,不清掉会让组织树里
|
||||
堆满 "E2E 示范学校 586923" 这种名字。
|
||||
第 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 # 真删
|
||||
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"),
|
||||
@@ -45,6 +61,13 @@ DEMO_ORG_FILTER = (
|
||||
"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):
|
||||
@@ -62,25 +85,86 @@ class Db:
|
||||
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 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 = db.rows(
|
||||
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")
|
||||
demo_orgs = db.rows(
|
||||
demo_orgs = [] if args.only_orphan_files else db.rows(
|
||||
f"SELECT id || '|' || org_name FROM sys_org "
|
||||
f"WHERE deleted = 0 AND ({DEMO_ORG_FILTER}) ORDER BY id")
|
||||
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 tenants and not demo_orgs:
|
||||
print("没有 e2e 测试数据,无需清理")
|
||||
if not any((tenants, demo_orgs, demo_tasks, orphans)):
|
||||
print("没有需要清理的测试数据")
|
||||
return 0
|
||||
|
||||
if tenants:
|
||||
@@ -99,6 +183,25 @@ def main() -> int:
|
||||
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
|
||||
@@ -106,20 +209,56 @@ def main() -> int:
|
||||
print("\n开始清理:")
|
||||
total = 0
|
||||
|
||||
for label, table in TENANT_CHILD_TABLES:
|
||||
n = len(db.rows(f"DELETE FROM {table} WHERE {SUB_FILTER} RETURNING id"))
|
||||
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 行" % (label, table, n))
|
||||
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
|
||||
|
||||
n = len(db.rows(f"DELETE FROM sys_tenant WHERE {TENANT_FILTER} RETURNING id"))
|
||||
total += n
|
||||
print(" %-10s %-22s 删除 %d 行" % ("租户", "sys_tenant", n))
|
||||
n = len(db.rows(f"DELETE FROM sys_org WHERE {DEMO_ORG_FILTER} RETURNING id"))
|
||||
total += n
|
||||
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
|
||||
|
||||
n = len(db.rows(f"DELETE FROM sys_org WHERE {DEMO_ORG_FILTER} RETURNING id"))
|
||||
total += n
|
||||
print(" %-10s %-22s 删除 %d 行" % ("测试组织", "sys_org", n))
|
||||
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))
|
||||
|
||||
print(f"\n完成,共删除 {total} 行。")
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user