Files
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(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 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

174 lines
7.1 KiB
Python
Raw Permalink 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 接口总览(合计 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())