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 名与全局导航栏标题
This commit is contained in:
2026-10-10 16:48:54 +08:00
parent 5b877451eb
commit 1f3c485ceb
84 changed files with 2845 additions and 4904 deletions
+13 -15
View File
@@ -331,8 +331,8 @@ def main() -> int:
c1, c2 = classes[0]["id"], classes[1]["id"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [
{"orgId": c1, "headUserId": head_id, "deputyUserIds": [], "expectedCount": 28},
{"orgId": c2, "headUserId": None, "deputyUserIds": [head_id], "expectedCount": 31},
{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "headUserId": None, "expectedCount": 31},
],
})
check("保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
@@ -350,15 +350,14 @@ def main() -> int:
and f"({ht_user})" in (r1.get("headUserName") or ""),
f"headUserName={r1.get('headUserName')}")
check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}")
check("第一个班:副班主任为空数组(不是 null)",
r1.get("deputyUserIds") == [] and r1.get("deputyUserNames") == [],
f"{r1.get('deputyUserIds')} / {r1.get('deputyUserNames')}")
check("第一个班:响应里不再有「协办人」字段(V19 已删列)",
"deputyUserIds" not in r1 and "deputyUserNames" not in r1,
f"keys={sorted(r1.keys())}")
check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)",
r2.get("headUserId") is None and r2.get("headUserName") is None,
f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}")
check("第二个班:副班主任姓名已装配",
r2.get("deputyUserNames") and r2["deputyUserNames"][0].startswith(f"E2E班主任{suffix}"),
f"deputyUserNames={r2.get('deputyUserNames')}")
check("第二个班:应填人数保存正确", r2.get("expectedCount") == 31,
f"{r2.get('expectedCount')}")
check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows),
f"{[r.get('status') for r in rows]}")
@@ -396,11 +395,10 @@ def main() -> int:
"不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "deputyUserIds": [head_id]}],
})
check("主班主任与副班主任同一人被拒 40001", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
okk, out = sql("SELECT count(*) FROM information_schema.columns "
"WHERE table_name = 'collect_task_org' AND column_name = 'deputy_user_ids'")
check("⚠️ collect_task_org.deputy_user_ids 列已被 V19 删除(协办人机制下线)",
okk and out.strip() == "0", f"查询结果={out}")
if other_org_id:
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
@@ -437,7 +435,7 @@ def main() -> int:
okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
{"orgId": c2, "expectedCount": 31}],
})
check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
@@ -459,7 +457,7 @@ def main() -> int:
# 恢复 2 个班,然后造发布前置数据走到"收集中"
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
{"orgId": c2, "expectedCount": 31}],
})
sql(f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, {c1}, 'E2E学生A', 'seed', 'seed')")