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