轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

114 lines
4.3 KiB

"""
前后端接口覆盖率审计。
用途:比对后端 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<q>['\"`])(?P<path>[^'\"`]+)(?P=q)",
re.DOTALL,
)
func_re = re.compile(
r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>\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()