# -*- 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())