Files
xiaotutongxue/scripts/check-api-parity.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

148 lines
5.8 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 -*-
"""契约 ↔ 代码 接口实现度比对(可复现的盘点工具)。
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。
代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。
★ 两个必须容忍的写法(踩过):
1. 全限定注解 `@org.springframework.web.bind.annotation.PostMapping("/x")`
—— FillController 就是这么写的;用 `@PostMapping` 短名匹配会漏掉 3 个接口;
2. `@RequestMapping(value="/x", method=RequestMethod.POST)` 这种显式写法。
用法:
python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单
python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用)
"""
from __future__ import annotations
import glob
import os
import re
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOC = os.path.join(ROOT, "docs", "03-接口设计.md")
JAVA = os.path.join(ROOT, "backend", "xt-server", "src", "main", "java", "cn", "tianbaotong", "fill")
# §4 里这两节是索引(交叉引用别处已计数的接口),不重复计入
INDEX_SECTIONS = ("4.9", "4.11")
def norm(path: str) -> str:
"""归一化:去掉 /api/v1 前缀、统一占位符(`{id}` 与 `{fileId}` 视为同一个)。"""
p = path.replace("/api/v1", "")
p = re.sub(r"\{[^}]+\}", "{}", p)
return p.rstrip("/") or "/"
def doc_overview() -> dict[str, list[tuple[str, str]]]:
"""解析 §4.1~§4.10 的清单表,返回 分类 -> [(METHOD, path)]。"""
text = open(DOC, encoding="utf-8").read()
sec = text[text.index("## §4"):text.index("## §5")]
out: dict[str, list[tuple[str, str]]] = {}
cur = None
for line in sec.splitlines():
h = re.match(r"^### (4\.\d+) (.+)$", line)
if h:
cur = f"{h.group(1)} {h.group(2)}"
out[cur] = []
continue
if cur is None or not line.startswith("|") or cur.startswith(INDEX_SECTIONS):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2 or set(cells[0]) <= set("-: "):
continue
method, path = cells[0].replace("⭐", "").strip(), cells[1].strip("` ")
if not path.startswith("/"):
continue
for m in re.findall(r"GET|POST|PUT|DELETE|PATCH", method.upper()) or ["?"]:
out[cur].append((m, path))
return out
def code_endpoints() -> list[tuple[str, str, str]]:
"""解析所有 controller,返回 [(文件, METHOD, 完整路径)]。"""
out = []
for f in glob.glob(os.path.join(JAVA, "module", "**", "controller", "*.java"), recursive=True):
src = open(f, encoding="utf-8").read()
mb = re.search(r'@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]+)"', src)
base = mb.group(1) if mb else ""
for mm in re.finditer(
r'@(?:[\w.]+\.)?(Get|Post|Put|Delete|Patch)Mapping(?:\(\s*(?:value\s*=\s*)?"([^"]*)")?',
src):
out.append((os.path.basename(f), mm.group(1).upper(),
(base + (mm.group(2) or "")) or "/"))
for mm in re.finditer(
r'@RequestMapping\(\s*value\s*=\s*"([^"]+)"\s*,\s*method\s*=\s*'
r'(?:RequestMethod\.)?(GET|POST|PUT|DELETE|PATCH)', src):
out.append((os.path.basename(f), mm.group(2), base + mm.group(1)))
return sorted(set(out), key=lambda x: (x[0], x[2]))
def main() -> int:
quiet = "--quiet" in sys.argv
only_missing = "--missing" in sys.argv
doc = doc_overview()
code = code_endpoints()
code_keys = {(m, norm(p)) for _, m, p in code}
claimed = implemented = 0
missing: list[tuple[str, str, str]] = []
rows = []
for cat, eps in doc.items():
uniq = sorted(set(eps))
done = [e for e in uniq if (e[0], norm(e[1])) in code_keys]
miss = [e for e in uniq if (e[0], norm(e[1])) not in code_keys]
claimed += len(uniq)
implemented += len(done)
missing += [(cat, m, p) for m, p in miss]
rows.append((cat, len(uniq), len(done), len(miss)))
if not quiet and not only_missing:
print("=" * 78)
print("代码注册接口:%d 个" % len(code))
print("=" * 78)
for f, m, p in code:
print(" %-30s %-6s %s" % (f, m, p))
print()
print("=" * 78)
print("契约 §4 总览逐分类")
print("=" * 78)
for cat, n, d, miss_n in rows:
print(" %-22s 声称 %2d 已实现 %2d 缺口 %2d" % (cat, n, d, miss_n))
print()
print("合计:契约 %d / 已实现 %d / 缺口 %d / 实现度 %.0f%%"
% (claimed, implemented, len(missing), 100.0 * implemented / claimed))
if not quiet:
print()
cur = None
for cat, m, p in missing:
if cat != cur:
print("\n【%s】" % cat)
cur = cat
print(" %-6s %s" % (m, p))
# `--missing` 就是"只看还差什么":上面那两段(代码清单 + 逐分类数字)
# 在"接着往下做"时反而是噪声,而这是最常被问的问题
if only_missing:
print()
print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。")
# 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题
if claimed != 121:
print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())