""" 前后端接口覆盖率审计。 用途:比对后端 OpenAPI(scripts/openapi.json)的全部操作与前端 src/api/*.ts 里 真实发出的 request() 调用,得出「已接入 / 未接入」清单,按域分组统计。 匹配规则: - 路径归一化:`{jobId}` 与模板串 `${jobId}` 一律折成 `{}`,避免占位符命名差异误判。 - 方法:request 未显式传 method 时按 GET 计(http.ts 默认 GET)。 用法:python scripts/audit_coverage.py """ import json import re from collections import defaultdict # 权威端点源 = 后端给的 markdown 文档(scripts/openapi.json 是 9/28 的旧快照, # 比 md 少 28 个端点,含 /auth/identity/select,拿它当基准会把已接接口误判成幽灵) SPEC_MD = "docs/职来后端接口.md" API_FILES = ["src/api/api.ts", "src/api/auth.ts", "src/api/http.ts"] HTTP_METHODS = {"get", "post", "put", "patch", "delete"} def norm(path: str) -> str: """把 {id} / ${id} 统一折成 {}""" p = re.sub(r"\$\{[^}]*\}", "{}", path) p = re.sub(r"\{[^}]*\}", "{}", p) if not p.startswith("/"): p = "/" + p return p.rstrip("/") or "/" def load_spec(): """从后端 markdown 文档解析端点:`## GET /jobs/search(标题)` 行给出方法与路径""" # md 结构是「## GET 中文标题」+ 下一行「GET /real/path」,标题行里的中文不是路径, # 真正可请求的行就是行首 `METHOD /path` 那一行。 ops = {} entry_re = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/\S*)\s*$") with open(SPEC_MD, encoding="utf-8") as f: for ln in f: m = entry_re.match(ln) if not m: continue ops[(m.group(1).upper(), norm(m.group(2)))] = ln.strip() return ops def load_frontend(): """从 api.ts / auth.ts 抽取 request() 调用:返回 [(method, normpath, funcname, file, line)] 注意:泛型参数常跨多行书写(如 request<{ accessToken: string;\n ... }>('/auth/login')), 因此必须在「整文件文本」上匹配,逐行扫描会整片漏掉。 """ calls = [] call_re = re.compile( r"request\s*(?:<[^>]*>)?\s*\(\s*(?P['\"`])(?P[^'\"`]+)(?P=q)", re.DOTALL, ) func_re = re.compile( r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P\w+)\s*\(", re.MULTILINE ) for file in API_FILES: with open(file, encoding="utf-8") as f: text = f.read() # 函数名 → 起始偏移,用于把调用归属到所在函数 func_starts = [(m.start(), m.group("name")) for m in func_re.finditer(text)] for cm in call_re.finditer(text): path = norm(cm.group("path")) after = text[cm.end() : cm.end() + 200] mm = re.search(r"method:\s*['\"](GET|POST|PUT|PATCH|DELETE)['\"]", after) method = mm.group(1) if mm else "GET" name = "(module)" for start, fname in func_starts: if start <= cm.start(): name = fname else: break line = text.count("\n", 0, cm.start()) + 1 calls.append((method, path, name, file, line)) return calls def main(): ops = load_spec() calls = load_frontend() used = {(m, p) for m, p, *_ in calls} covered = sorted(k for k in ops if k in used) missing = sorted(k for k in ops if k not in used) phantom = sorted({(m, p, fn, f, ln) for m, p, fn, f, ln in calls if (m, p) not in ops}) print(f"后端端点总数: {len(ops)}") print(f"前端 request() 调用数: {len(calls)}(去重路径 {len(used)})") print(f"已接入: {len(covered)} 未接入: {len(missing)} 幽灵调用(后端无此端点): {len(phantom)}") print() print("=== 幽灵调用(前端发了但后端 spec 没有,必改)===") for m, p, fn, f, ln in phantom: print(f" {m:6} {p:52} {fn} ({f}:{ln})") print() by_domain = defaultdict(list) for m, p in missing: by_domain[p.strip("/").split("/")[0]].append((m, p)) print("=== 未接入端点(按域)===") for d in sorted(by_domain, key=lambda k: -len(by_domain[k])): print(f"\n[{d}] {len(by_domain[d])} 个") for m, p in sorted(by_domain[d]): print(f" {m:6} {p}") if __name__ == "__main__": main()