30c3714d35
新增: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。
187 lines
7.6 KiB
Python
187 lines
7.6 KiB
Python
# -*- 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())
|