1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库 (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖 接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环 - 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis, 空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底 - V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里) 与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129 - 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外); 平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存) - RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 —— 修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红 - scripts/e2e-m10-role.py 77/77 需求收敛(交接摘要 §一·三十六,本节的数字为准): - 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、 加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列; 审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支 - V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有 别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集, 存量账号会"能登录、处处 40301",不抛异常不打日志 - 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、 小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条) - UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭 (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常) - 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、 存储自检探针内容;学生端 app 名与全局导航栏标题
174 lines
7.1 KiB
Python
174 lines
7.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""契约 ↔ 代码 接口实现度比对(可复现的盘点工具)。
|
||
|
||
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
|
||
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
|
||
|
||
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 119 个,全文权威清单)。
|
||
代码来源: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 里用)
|
||
python scripts/check-api-parity.py --extra # 反向:代码有、契约 §4 清单没有的(F-4 底稿)
|
||
"""
|
||
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
|
||
only_extra = "--extra" in sys.argv
|
||
doc = doc_overview()
|
||
code = code_endpoints()
|
||
code_keys = {(m, norm(p)) for _, m, p in code}
|
||
doc_keys = {(m, norm(p)) for eps in doc.values() for m, p in eps}
|
||
|
||
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)))
|
||
|
||
# ── 反向差集:代码注册了、而契约 §4 清单里没有 ──
|
||
# ⚠️ 这个清单**必须由工具生成**:它以前是人工做一次差集后手抄进文档的,
|
||
# 于是每加一个接口就悄悄过期一次(数字 83/62/21 与实际差了 3 倍)。
|
||
# 手抄件会过期,命令不会。
|
||
extra = [(f, m, p) for f, m, p in code if (m, norm(p)) not in doc_keys]
|
||
|
||
if only_extra:
|
||
print("=" * 78)
|
||
print("代码有、契约 §4 清单没有:%d 个" % len(extra))
|
||
print("(代码共注册 %d 个;契约声称 %d 个,其中 %d 个已在代码里)"
|
||
% (len(code), claimed, implemented))
|
||
print("=" * 78)
|
||
cur = None
|
||
for f, m, p in extra:
|
||
if f != cur:
|
||
print("\n【%s】" % f)
|
||
cur = f
|
||
print(" %-6s %s" % (m, p))
|
||
print()
|
||
return 0
|
||
|
||
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 只列缺口;不带参数时会先打全部已注册接口。")
|
||
|
||
# 契约必须恰好 119(需求收敛 2026-10 后的口径:删掉「历史整理器」5 个与
|
||
# 「加入申请」5 个,原 129 → 119;缺口只保留 /mp/ocr/idcard 一个),
|
||
# 不等就说明解析或文档出了问题
|
||
if claimed != 119:
|
||
print(" [FAIL] 契约接口数应为 119,实际解析出 %d —— 检查 §4 表格格式" % claimed)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|