test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检

新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+186
View File
@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""机械校验:实体继承的基类要求哪些列,迁移里到底建了没有(防 F-24 第四次复发)。
## 为什么需要这个脚本
`FillMetaObjectHandler` 会为**继承 `BaseEntity`/`TenantEntity` 的实体**自动填
`create_by` / `create_time` / `update_by` / `update_time` / `deleted`(以及 `TenantEntity` 的
`tenant_id`),并且 MyBatis-Plus 的 `@TableLogic` 会自动给查询补 `deleted = 0`。
**这一切都在编译期不报错**。少一列的表现是:
· 插入报 `column "create_by" ... does not exist`(运行时 500);
· 或者查询报 `column "deleted" ... does not exist`。
本项目已经因此踩过三次(`collect_task_org` / `collect_export_task` / `collect_message`),
第四次是 `collect_export_file`。每次的现象都长得像"SQL 写错了",而实际是建表漏列。
所以把它变成**机械检查**:从 Java 实体读出 `@TableName` 与继承的基类,
从迁移 SQL 里读出 `CREATE TABLE` 的列,然后比对该有的列在不在。
## 口径
| 基类 | 必须有的列 |
|---|---|
| `BaseEntity` | `create_by` `create_time` `update_by` `update_time` `deleted` |
| `TenantEntity` | 上面 5 个 + `tenant_id` |
| 普通 `Serializable` | 无要求(但注意:**不能**带 `@TableLogic`,否则查询会补 `deleted`) |
## 用法
python scripts/check-entity-columns.py # 有问题退出码 1
python scripts/check-entity-columns.py --quiet # 只打结论
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BACKEND = os.path.join(ROOT, "backend", "xt-server", "src", "main")
JAVA = os.path.join(BACKEND, "java")
MIG = os.path.join(BACKEND, "resources", "db", "migration")
SKIP_DIRS = ("test", "target")
BASE_COLS = ["create_by", "create_time", "update_by", "update_time", "deleted"]
TENANT_COLS = ["tenant_id"] + BASE_COLS
def strip_comments(src: str) -> str:
"""去掉注释再找注解。
⚠️ 必须先去注释:这四个"普通实体"的**类注释里都写着** `@TableLogic`
(说明它们为什么不继承基类),直接搜会把解释性文字当成真注解 ——
我第一版就是这么误报的(4 个假阳性)。
"""
src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL)
src = re.sub(r"//[^\n]*", "", src)
return src
def java_files() -> list[str]:
out = []
for path in glob.glob(os.path.join(JAVA, "**", "*.java"), recursive=True):
if any(s in path.replace(os.sep, "/") for s in SKIP_DIRS):
continue
out.append(path)
return out
def read(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.read()
def tables_from_migrations() -> dict[str, set[str]]:
"""从迁移 SQL 里抓 `CREATE TABLE x (...)` 的列名。
只做**足够用**的解析:本项目建表都是规规矩矩的
`CREATE TABLE [IF NOT EXISTS] name ( 列名 类型 …, )`。
碰到 `CREATE TABLE ... AS SELECT` 或视图就跳过(本项目没有)。
"""
tables: dict[str, set[str]] = {}
for path in sorted(glob.glob(os.path.join(MIG, "*.sql"))):
sql = read(path)
for m in re.finditer(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)\s*\((.*?)\n\)\s*;",
sql, re.IGNORECASE | re.DOTALL):
name = m.group(1).lower()
body = m.group(2)
cols: set[str] = set()
for line in body.split("\n"):
s = line.strip()
if not s or s.startswith("--"):
continue
# 约束行跳过
if re.match(r"^(CONSTRAINT|PRIMARY|UNIQUE|FOREIGN|CHECK|EXCLUDE)\b", s, re.I):
continue
cm = re.match(r"^([a-z_][a-z0-9_]*)\s+", s, re.I)
if cm:
cols.add(cm.group(1).lower())
tables.setdefault(name, set()).update(cols)
# ALTER TABLE ... ADD COLUMN 也算(V9 就是靠它补列的)
for m in re.finditer(
r"ALTER\s+TABLE\s+([a-z_][a-z0-9_]*)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)",
sql, re.IGNORECASE):
tables.setdefault(m.group(1).lower(), set()).add(m.group(2).lower())
return tables
def entities() -> list[tuple[str, str, str]]:
"""(文件, 表名, 基类) —— 只取带 @TableName 的实体。"""
out = []
for path in java_files():
src = read(path)
tm = re.search(r'@TableName\(\s*(?:value\s*=\s*)?"([a-z_][a-z0-9_]*)"', src)
if not tm:
continue
cm = re.search(r"public\s+class\s+\w+\s+extends\s+([\w.]+)", src)
base = cm.group(1).split(".")[-1] if cm else "Serializable"
out.append((path, tm.group(1), base))
return out
def main() -> int:
ap = argparse.ArgumentParser(description="实体基类所需列 vs 迁移建表列")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
tables = tables_from_migrations()
ents = entities()
problems: list[str] = []
if not args.quiet:
print("=" * 74)
print(" 实体所需列 vs 迁移建表列(防 F-24:少一列在运行期才炸)")
print("=" * 74)
for path, table, base in sorted(ents, key=lambda x: x[1]):
need = TENANT_COLS if base == "TenantEntity" else (
BASE_COLS if base == "BaseEntity" else [])
rel = os.path.relpath(path, ROOT)
src = read(path)
if not need:
# ⚠️ 普通实体带 @TableLogic 时,MyBatis-Plus 会给所有查询补 `deleted = 0`。
# 所以只有当**表里确实没有 deleted 列**时才是问题 ——
# (本项目这四张表都有 deleted 列,所以注解是正常的,
# 但"当时为什么去掉实体上的 deleted 字段"这个理由要说清楚)。
cols = tables.get(table, set())
if "@TableLogic" in strip_comments(src) and "deleted" not in cols:
problems.append(f"{rel}:普通实体带 @TableLogic,但表 `{table}` 没有 deleted 列 "
f"→ 所有查询都会报 column does not exist")
print(f" [可疑] {table:<28} ⭐ 带 @TableLogic 但表里没有 deleted")
elif not args.quiet:
note = "(有 deleted 列,注解有效)" if "@TableLogic" in src else ""
print(f" [跳过] {table:<28} 普通实体(无自动填充列要求){note}")
continue
if table not in tables:
problems.append(f"{rel}:表 `{table}` 在迁移里找不到 CREATE TABLE")
print(f" [缺失] {table:<28} ⭐ 迁移里没有这张表")
continue
missing = [c for c in need if c not in tables[table]]
if missing:
problems.append(f"{rel}:表 `{table}` 缺列 {', '.join(missing)}(基类 {base} 要求)")
print(f" [缺列] {table:<28} ⭐ 缺 {', '.join(missing)}")
elif not args.quiet:
print(f" [OK] {table:<28} {base} 要求的 {len(need)} 列都在")
print()
if problems:
print(f"✗ {len(problems)} 个实体与迁移不一致:")
for p in problems:
print(" ·", p)
print("\n修法:迁移里补列(**不要**改实体去掉字段 —— 去掉会让自动填充失效)。")
return 1
print(f"✓ {len(ents)} 个实体全部与迁移一致({len(tables)} 张表)")
return 0
if __name__ == "__main__":
raise SystemExit(main())