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
@@ -5,7 +5,7 @@ import lombok.Getter;
/**
* 错误码全集。
*
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>58 个</b>(含成功码 {@code 0})。
* <p>权威来源:《03-接口设计》§15「错误码全集」,共 <b>59 个</b>(含成功码 {@code 0})。
* <b>改这里之前先改文档,不要只改代码。</b>
*
* <p><b>为什么 code 是 int 而不是 String</b>:§1.3 约定响应形如
@@ -101,22 +101,14 @@ public enum ErrorCode {
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
/** 加入申请次数超限 */
JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"),
/** 该证件号已有待审申请 */
JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"),
/** 该证件号已在名册中 */
ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"),
/** 申请状态已变更(非待审) */
JOIN_STATUS_CHANGED(40040, "申请状态已变更"),
/** 名册中该证件号已被占用 */
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
/** 申请已通过,不可再驳回 */
JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
/** 角色还有账号在用,不可删除 */
ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
// ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */
@@ -146,7 +138,7 @@ public enum ErrorCode {
STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ──────────────────────────────────────
/** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
/** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
@@ -68,9 +68,24 @@ public record LoginUser(
return false;
}
/**
* 这个人是不是<b>租户级管理员</b>({@code user_type = 2},即学校 / 机构管理员)。
*
* <p>⚠️ 判 {@code user_type} 而<b>不判角色码</b>。上一版这里写的是
* {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")},有两个问题:
* <ol>
* <li>「审核员」({@code AUDITOR})角色已删除;</li>
* <li>租户可以自建角色,而"自定义角色 + {@code user_type=2}"同样是租户管理员 ——
* 按角色码判会让这批人退化成普通用户(表现是"什么都点不动",且不报任何错)。</li>
* </ol>
*/
public boolean isTenantAdmin() {
return userType != null && userType == 2;
}
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) {
if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
if (isPlatformAdmin() || isTenantAdmin()) {
return true;
}
return orgId != null && orgIds != null && orgIds.contains(orgId);