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
+28 -4
View File
@@ -351,6 +351,17 @@ def main() -> int:
# ── 6. 查询 ────────────────────────────────────────
print("\n[6] 查询与分页")
# ⚠️ 这条是 2026-10 需求收敛时补的防线回归:
# 删掉「副班主任」角色后,前端一度还在传 roleCode=DEPUTY_TEACHER,
# 而旧实现对**不认识的角色码是静默忽略过滤条件** → 返回全校账号(含学生),
# 且不报任何错。现在必须**失败关闭**(空列表)。
status, body = call("GET", f"{base}/api/v1/users?roleCode=DEPUTY_TEACHER", token)
ghost = (body.get("data") or {}).get("list")
check("⭐ 按已删除的角色码过滤 → 返回空(不是「忽略过滤条件」返回全校)",
body.get("code") == 0 and ghost == [],
f"code={body.get('code')} 条数={len(ghost) if isinstance(ghost, list) else ghost}")
status, body = call("GET", f"{base}/api/v1/users?pageNum=1&pageSize=5", token)
data = body.get("data") or {}
check("列表返回 {list,total,pageNum,pageSize}",
@@ -398,10 +409,13 @@ def main() -> int:
# ── 7. 修改 ────────────────────────────────────────
print("\n[7] 修改账号")
# ⚠️ 2026-10 需求收敛:删掉「副班主任」后,user_type=3 的内置角色只剩班主任一个,
# 所以这里改成单角色改名,并**另加一条**断言把"多角色必须同属一个用户类型"这条
# 规则本身验出来(以前拿「班主任 + 副班主任」验的是"两个同类角色都生效")。
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
"realName": teacher_name + "改",
"phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER", "DEPUTY_TEACHER"],
"roleCodes": ["HEAD_TEACHER"],
"userType": 3,
})
check("修改姓名与角色成功", body.get("code") == 0,
@@ -411,9 +425,19 @@ def main() -> int:
status, body = call("GET", f"{base}/api/v1/users?keyword={teacher_user}", token)
row = next((h for h in (body.get("data") or {}).get("list", []) if h["id"] == teacher_uid), None)
check("两个角色都生效", row is not None and sorted(row.get("roleCodes") or [])
== ["DEPUTY_TEACHER", "HEAD_TEACHER"],
check("改后的角色生效", row is not None and sorted(row.get("roleCodes") or [])
== ["HEAD_TEACHER"],
f"roleCodes={row.get('roleCodes') if row else 'N/A'}")
status, body = call("PUT", f"{base}/api/v1/users/{teacher_uid}", token, {
"realName": teacher_name + "改",
"phone": teacher_phone,
"roleCodes": ["HEAD_TEACHER", "SCHOOL_ADMIN"],
"userType": 3,
})
check("⭐ 多角色必须同属一个用户类型(班主任 + 学校管理员 → 40001)",
body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
check("登录名没有被改掉(username 不可改)",
row is not None and row.get("username") == teacher_user,
f"username={row.get('username') if row else 'N/A'}")
@@ -643,7 +667,7 @@ def build_import_xlsx(suffix: str, base: str, token: str) -> str | None:
# 3 条合法
ws.append([f"导入甲{suffix}", f"imp1{suffix}", "13900139001", org_name, "班主任"])
ws.append([f"导入乙{suffix}", f"imp2{suffix}", "13900139002", org_name, None])
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "副班主任"])
ws.append([f"导入丙{suffix}", f"imp3{suffix}", "13900139003", None, "学校管理员"])
# 4 条非法
ws.append(["手机号错", f"imp4{suffix}", "1390013", None, None])
ws.append(["账号重复", f"imp1{suffix}", "13900139005", None, None])