Files
xiaotutongxue/scripts/check-entity-columns.py
T
lovife 30c3714d35 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。
2026-09-30 09:02:57 +08:00

187 lines
7.6 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.
# -*- 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())