Browse Source

feat: 静态代码审计,修复逻辑bug和安全漏洞

BaiLuoYan 3 weeks ago
parent
commit
fa46acc11e

+ 127 - 488
audit-report.md

@@ -1,510 +1,149 @@
-# 深度审计报告 · Round 17
+# 审计报告(第 18 轮)
 
-> 基线:R16 审计后的代码库快照。R16 提出的 1 条 High(H-R16-1,`RemoveMember` 未在 tx 内吊销会话)、1 条 Medium(M-R16-1,`UserList`/`UserDetail` 未对普通 MEMBER 脱敏 PII)与 2 条 Low(L-R16-1/2)均已在代码中落地:`removeMemberLogic.go` 在 tx 体里显式 `IncrementTokenVersionWithTx` 并在 post-commit 清 sysUser 低层缓存;`userListLogic.go` / `userDetailLogic.go` 新增 `maskUserPII` 对 MEMBER 遮蔽 email/phone/remark;`updateUserLogic.go` 去掉了 ADMIN 旁路 DeptPath 前缀校验,并补上"DEV → NORMAL 跨域调动"时的 tokenVersion 递增;`updateDeptLogic.go` 在"DEV→NORMAL / Enabled→Disabled"这类权限收窄迁移时对整棵子树的 user 做 `BatchIncrementTokenVersionWithTx`。R15/R14/R13/R12/R11/R10 的既往修复经复检全部稳定。
+> 审计范围:`internal/` 下的全部生产代码(排除 `_test.go`、`mocks/`、CLI 生成的 `*_gen.go` 中模型原型)。
+> 重点关注:多接口间的逻辑耦合、并发/事务一致性、缓存与 DB 的双写对齐、权限边界、接口契约完整性。
 >
-> 本轮聚焦四块 R16 未覆盖的面:
->
-> 1. **跨对象事务闭环的外部边**:`UpdateDept` / `DeleteDept` 的 sys_dept X 锁只能串行化"改部门 / 删部门"路径;但 `CreateUser` / `CreateProductLogic` 的 auto-provision 用户行**完全不走事务、不对 sys_dept 取锁**,留下"DeleteDept 已完成 → 新 user 仍以该 deptId 落盘"的 orphan-row 窗口;同类结构性问题还影响 `UpdateUser` 的 `*req.DeptId == user.DeptId` 分支与 `AddMember` 对目标 `sys_user.Status` 的读后写。
-> 2. **缓存失效与事务提交的时序错位**:go-zero `sqlc.CachedConn.ExecCtx` 的"exec → DelCache"顺序对普通 `sql.DB` 是正确的,但在 `session.ExecCtx` 语义下,DelCache 发生在外层 tx **commit 之前**;`DeleteDept` / `DeleteRole` / `UpdateDept` 等走 `*WithTx` 的路径会在这个窗口内被并发 `FindOne` 把**旧行**回填进缓存,随后 commit,留下最长 5min 的"幽灵快照"——尤其对 `sys_dept` 这种 `UserDetailsLoader.loadDept` 直接消费行内容的对象具有权限语义。
-> 3. **"全权用户"对 SyncPerms 新增 perm 的感知延迟**:`SyncPermsService` 的 L-R11-4 优化(pure-add 不清 `UserDetailsLoader.CleanByProduct`)基于"loadPerms 对任何 user 的结果都不会因为新增 perm 变化"的假设,但 `loadPerms` 对 SuperAdmin / ADMIN / DEVELOPER / DEV-dept 启用成员走的是 `FindAllCodesByProductCode`——新增 perm **会**立刻改变这条分支的返回集,缓存没清等价于最长 5min TTL 的权限延迟。
-> 4. **权限级别纵向防护的对称性**:`UpdateRoleLogic` 有 L-R12-3 `!caller.IsSuperAdmin && req.PermsLevel < role.PermsLevel → 403` 防提权;但 `CreateRoleLogic` 不做任何 caller permsLevel 与 `req.PermsLevel` 的比较,让 product ADMIN 直接新建一个 permsLevel=1 的角色后 BindRoles 给下属,绕过 `GuardRoleLevelAssignable` 对"同级"的拦截,横向等价于"给 DEVELOPER 签发一张 ADMIN 入场券"。
+> R10~R17 已发现的核心漏洞(包括本轮编号为 H-R17-1/2/3、M-R17-1/2、L-R17-1~6)经核查均已在代码中落地修复并附注释(`createUserLogic.go` / `deleteDeptLogic.go` / `updateDeptLogic.go` / `createRoleLogic.go` / `deleteRoleLogic.go` / `syncPermsService.go` / `updateUserStatusLogic.go` / `changePasswordLogic.go` / `createDeptLogic.go` / `deptTreeLogic.go` 等)。本轮仅列出**新增发现**。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-R17-1 · `CreateUser` / `CreateProductLogic` 对目标 `sys_dept` 无事务锁,与 `DeleteDept` 存在 TOCTOU 竞态(orphan user + 幽灵部门缓存 权限升级)
-
-**位置**
-
-- `internal/logic/user/createUserLogic.go:86-140`(`FindOne(deptId)` 是 cached 读,随后 `Insert(sys_user)` 不在任何事务中,`sys_dept` 无 S 锁)
-- `internal/logic/product/createProductLogic.go`(为新产品自动注入 `admin_<productCode>` 行时同样走非 tx `SysUserModel.Insert`)
-- 对比参照:`internal/logic/dept/deleteDeptLogic.go:44-68`(已经用 `SELECT id FROM sys_dept WHERE id=? FOR UPDATE` + `SELECT id FROM sys_user WHERE deptId=? FOR SHARE` 做"删除 → 检查既有用户"的串行化)
-
-**描述**
-
-`DeleteDept` 已经通过 X 锁 + `FOR SHARE` 锁把"既有用户占着这个部门就不让删"编织进事务:
-
-```44:68:internal/logic/dept/deleteDeptLogic.go
-var deptId int64
-lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
-if err := session.QueryRowCtx(ctx, &deptId, lockQuery, req.Id); err != nil {
-    return response.ErrNotFound("部门不存在")
-}
-// ... 略 ...
-var userIds []int64
-userQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `deptId` = ? FOR SHARE", l.svcCtx.SysUserModel.TableName())
-if err := session.QueryRowsCtx(ctx, &userIds, userQuery, req.Id); err != nil {
-    return err
-}
-if len(userIds) > 0 {
-    return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
-}
-return l.svcCtx.SysDeptModel.DeleteWithTx(ctx, session, req.Id)
-```
-
-但这个串行化**只对"既有用户 vs 删除"有效**:`CreateUser` 这条"未来用户 vs 删除"链路完全游离事务外——
-
-```86-140:internal/logic/user/createUserLogic.go
-if req.DeptId > 0 {
-    newDept, derr := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId)  // 命中 sqlc 低层缓存 / stale 读
-    if derr != nil {
-        return nil, response.ErrBadRequest("部门不存在")
-    }
-    if newDept.Status != consts.StatusEnabled {
-        return nil, response.ErrBadRequest("目标部门已停用")
-    }
-    // DeptPath 校验 ...
-}
-
-hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
-// ... 纯非事务 Insert,无任何 sys_dept 锁参与 ...
-result, err := l.svcCtx.SysUserModel.Insert(l.ctx, &userModel.SysUser{
-    DeptId:             req.DeptId,
-    // ...
-})
-```
-
-竞态时序(T1 = SuperAdmin 删部门,T2 = ADMIN 创建用户):
-
-1. T2 执行 `FindOne(X)`,命中 sqlc 低层缓存或 DB 非锁读,拿到 `dept X{status=Enabled}`;DeptPath / DeptType / Status 所有校验通过。
-2. T1 开 tx → `SELECT ... FOR UPDATE` 锁住 `sys_dept[X]`。
-3. T1 `SELECT id FROM sys_user WHERE deptId=X FOR SHARE`:T2 **尚未插入**(Insert 发生在 bcrypt 之后,bcrypt default cost 约 60–100ms),返回空集。
-4. T1 `DELETE FROM sys_dept WHERE id=X`,commit,释放 X 锁。go-zero 的 sqlc `DelCache(cacheSysDeptIdPrefix+X)` 在 commit 之前已经执行过(见 H-R17-2)。
-5. T2 `bcrypt.GenerateFromPassword` 完成,`Insert sys_user` 落库,`deptId=X`。该 INSERT **不触及 sys_dept**(表级无外键、无触发器),DB 不会阻止。
-6. 产生 orphan 用户:`sys_user.deptId=X`,`sys_dept.id=X` 不存在。
-
-**紧接着的二次放大**:T2 触发缓存层的"幽灵部门"后果——
-
-- **良性分支**:`UserDetailsLoader.loadDept` 下次 miss 时 `SysDeptModel.FindOne(X) → ErrNotFound`,`loadOk=false` → `ErrLoaderDegraded` → jwtauthMiddleware 503,该用户**永远无法登录**直到运维手动 data fix。这是高可用事故,但不是权限事故。
-- **恶性分支**:如果 sys_dept 低层缓存里还残留着 X 的旧行(H-R17-2 描述的"commit 后幽灵快照",或者任何并发 `FindOne` 在 T1 的 `DelCache` 与 commit 之间把旧行回填),`loadDept` 成功返回旧 `{Path: "/1/5/", DeptType: DEV, Status: Enabled}`。用户携带这条"已死部门"登录成功,并且:
-  - `DeptPath = "/1/5/"` 继续被 `checkDeptHierarchy` / `CheckAddMemberAccess` 当作真实部门链判据;
-  - 若残留快照是 DEV 部门,任意 `loadPerms` 的产品将对该用户走"DEV 部门全权"分支(`FindAllCodesByProductCode`),**凭空获得任何产品的全部权限**,且完全不在组织架构视图中(`DeptTree` 查询时已无该 dept 行)。
-
-`CreateProductLogic` 同构受害:auto-provision 的 initial admin 行同样走 `SysUserModel.Insert` 而不是 `InsertWithTx`,即使调用方已经是 SuperAdmin 也一样会踩这个坑(SuperAdmin 删部门 + 另一个 SuperAdmin 同时创产品,两个 P0 管理员并发一点不罕见)。
-
-**影响**
-
-- 业务数据完整性破口:`sys_user.deptId` 丧失"必然引用已存在 sys_dept"的不变式,下游所有基于 deptPath 前缀匹配的鉴权、以及 DeptTree/UserList 的展示聚合都会在事实层面出现不一致;
-- 当缓存幽灵(H-R17-2)叠加上时,orphan 用户可以**无感继承一个已被超管删除的 DEV 部门的全部跨产品权限 ≤5min**——即便被删的 DEV 部门本意是"这批研发被整体调离";
-- 运维侧不容易察觉:orphan 行本身登录 503 看上去是"个别用户异常",与稳定态下的"DB 抖动"几乎无法区分,审计事件不会标记 deptId 指向已死部门;
-- 与 R16 的 L-R16-1(ADMIN 挪人出 DeptPath 外)互补:那一条堵"挪出",这一条堵"新建时就漂在外面"。
-
-**修复方案**
-
-把 `CreateUser` / `CreateProductLogic` 的用户建行动作移进同一个事务,并在事务内用 `FindOneForShareTx` 对目标 `sys_dept` 取 S 锁:
-
-```go
-// CreateUser 改写示意
-var id int64
-err = l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-    if req.DeptId > 0 {
-        newDept, derr := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, req.DeptId)
-        if derr != nil {
-            if errors.Is(derr, sqlx.ErrNotFound) {
-                return response.ErrBadRequest("部门不存在或已删除")
-            }
-            return derr
-        }
-        if newDept.Status != consts.StatusEnabled {
-            return response.ErrBadRequest("目标部门已停用")
-        }
-        if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
-            return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
-        }
-        if !caller.IsSuperAdmin && !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-            return response.ErrForbidden("无权在非自己管辖的部门下创建用户")
-        }
-    }
-    // bcrypt 的慢计算放到事务外(见下一行),事务内只做锁 + Insert
-    result, ierr := l.svcCtx.SysUserModel.InsertWithTx(ctx, session, &userModel.SysUser{ /* 带 hashedPwd */ })
-    if ierr != nil {
-        return ierr
-    }
-    id, _ = result.LastInsertId()
-    return nil
-})
-```
-
-两点注意:
-
-1. `bcrypt.GenerateFromPassword` 必须在**事务外**完成,否则 100ms 的 bcrypt 会把 sys_dept S 锁持有时长拉高,阻塞 `DeleteDept` / `UpdateDept` 的 X 锁竞争。bcrypt 的产物 `hashedPwd` 在事务外算好,tx 内只做锁 + INSERT。
-2. `CreateProductLogic` 的 auto-provision 用户 INSERT 目前跟产品创建是同一 tx 的兄弟事务 / post-commit 步骤(`compensateCreatedRows` 的兜底对象),改造时可以把初始 admin 行拿到产品 tx 里一起锁 `sys_dept[0]`(如果默认 deptId=0 代表"无部门"则不取锁即可;如果是具体 deptId 就必须取 S 锁),避免 compensate 路径复杂化。
-
----
-
-### H-R17-2 · `DeleteDept` / `DeleteRole` / `UpdateDept` 等 `*WithTx` 路径的 sqlc `ExecCtx` "exec 先行、DelCache 后行"语义在事务内退化为**提交前清缓存**,引入幽灵快照窗口
-
-**位置**
-
-- go-zero `core/stores/sqlc/cachedsql.go::CachedConn.ExecCtx`:`exec(ctx, cc.db) → DelCache(keys...)`,`cc.db` 在 tx 内是 `session`,exec 仅"把语句排进事务日志",DB 可见性要等外层 `TransactCtx` 的 commit;DelCache 却立刻对 Redis 下手。
-- `internal/model/dept/sysDeptModel_gen.go:88-95` `DeleteWithTx` 调用路径
-- `internal/model/dept/sysDeptModel_gen.go:122-134` `BatchDeleteWithTx` 同构
-- `internal/model/role/sysRoleModel_gen.go` `DeleteWithTx` / `UpdateWithTx`(role 侧也踩同一模式)
-- 消费方:`internal/logic/dept/deleteDeptLogic.go:68`、`internal/logic/role/deleteRoleLogic.go`、`internal/logic/dept/updateDeptLogic.go`
-
-**描述**
-
-go-zero `sqlc.CachedConn.ExecCtx` 伪代码:
-
-```go
-func (cc CachedConn) ExecCtx(ctx, exec, keys...) {
-    res, err := exec(ctx, cc.db)   // 事务内这里的"ctx + db"实际是 session.ExecCtx
-    if err != nil { return nil, err }
-    if err := cc.DelCacheCtx(ctx, keys...); err != nil { return nil, err }
-    return res, nil
-}
-```
-
-当 `DeleteWithTx(ctx, session, id)` 被 `TransactCtx` 包住时:
-
-1. `session.ExecCtx(DELETE FROM sys_dept WHERE id=?, id)`:SQL 进入事务日志,**其他事务看不到**该行已被删除(RR 隔离级别)。
-2. `DelCacheCtx(cacheSysDeptIdPrefix+id)`:Redis `DEL` 立刻生效,缓存从此刻起为 miss。
-3. 后续 tx 内可能还有多条 SQL 执行……最终外层 `TransactCtx` 走到 commit。
-
-在 (2) 与 commit 之间的窗口里:
-
-- 任意并发事务 T3 调用 `SysDeptModel.FindOne(id)` → 缓存 miss → `SELECT * FROM sys_dept WHERE id=?` → **读到旧行**(RR 下 T3 看不到 T1 未提交的 DELETE)→ 写回 Redis 覆盖 `DelCache` 的空槽。
-- T1 commit 之后,`sys_dept[id]` 真正消失,但 Redis 里仍有 T3 刚写回的旧行,生存期至少到本 key 的 TTL(默认 `sqlc.WithExpiry` 通常是小时级)。
-
-**影响**
-
-1. **`DeleteDept` 路径**:`sys_dept` 在删除后仍残留 cached 旧行,长达 TTL。任何下游 `UserDetailsLoader.loadDept` 都会拿到这条旧行:
-   - 组合 H-R17-1 的 orphan 用户:新用户凭"幽灵部门"拿到完整 DeptPath,等价于**未被剔除**的部门子树继续承担权限载体。
-   - 即使没有新 orphan 用户,已被调离的遗留用户(管理员把 deptId 改到别的 dept 之后删掉了原 dept)如果刚好在窗口内触发 UD cache miss,还是会按"原部门 DeptType=DEV"拿到全权,直到 TTL 到期。
-2. **`DeleteRole` 路径**:`sys_role[id]` cached 幽灵角色的消费面较窄——`ResolveOwnRoleOr404` 拿到后会走 `RequireProductAdminFor` 或后续 UPDATE/DELETE 的 CAS,commit 后真正撞不到行即回 `ErrUpdateConflict` → 409。不逃逸权限,但让删除后的后续操作出现"魂在但肉体已毁"的 409,增加排障负担。
-3. **`UpdateDept` 路径**:`UpdateDept` 当前在 tx 内通过 `UpdateWithTx`(同样是 `ExecCtx` + DelCache)改字段;commit 前的 DelCache → 并发 FindOne 回填旧值 → commit 后幽灵快照。虽然 `updateDeptLogic.go` 在 post-commit 又调了一次 `BatchIncrementTokenVersionWithTx` + `UserDetailsLoader.CleanByUserIds`,但**没有针对 `sys_dept[id]` 低层缓存本身再做一次 post-commit 失效**,一旦前面的窗口发生,该 dept 的其他非当前用户路径(如 DeptTree、CreateUser、AddMember 的校验读)都会在 TTL 窗口内看到"DEV→NORMAL 之前"的 DeptType,等价于 L-R16-2 想堵的"权限收窄不立刻生效"。
-
-**修复方案**
-
-这是 go-zero 在事务语义下公认的坑,本项目已经在 `UpdateProductLogic` / `UpdateUserLogic` / `RemoveMemberLogic` 等路径上用过正确姿势——**tx 成功 commit 之后再补一次 post-commit DelCache**,不要把幂等性押在 sqlc 的内嵌 `ExecCtx` 钩子上。具体:
-
-1. 给 `sys_dept` / `sys_role` 模型暴露 `InvalidateDeptCache(ctx, id)` / `InvalidateRoleCache(ctx, id)`(参考既有 `InvalidateProductCache` 的签名:id-based 三键一并清);
-2. `DeleteDept` / `UpdateDept` / `DeleteRole` / `UpdateRole` 在 `TransactCtx` 返回 nil 后,走 `loaders.DetachCacheCleanCtx` 再调一次该失效方法;
-3. 对 `UpdateDept` 当前"dept.Status / dept.DeptType 权限收窄"的支路同样补一次 post-commit `InvalidateDeptCache`,把 L-R16-2 的修复真正合拢——否则"user tokenVersion 已 bump 但 dept 缓存里还是旧 DeptType"意味着下次 UD 重建时仍会按旧 DeptType 走"DEV 全权"分支,现在的 BatchIncrementTokenVersionWithTx 相当于白做。
-
-长期方案:自研 `DeleteWithTx` / `UpdateWithTx` 包装层,把 `DelCache` 从 `ExecCtx` 里摘出来,只在事务成功 commit 后执行(`TransactCtx` 里注册 `OnCommit` 钩子)。这样所有 `*WithTx` 路径一次性脱离本陷阱,避免逐个调用点打补丁。
-
----
-
-### H-R17-3 · `CreateRoleLogic` 缺失 caller permsLevel 与 `req.PermsLevel` 的纵向对称校验,product ADMIN 可借"建新角色 + BindRoles"实现下属纵向提权
-
-**位置**
-
-- `internal/logic/role/createRoleLogic.go:33-76`(`PermsLevel` 只做 1–999 的合法性检查,不比 caller 的 assignable level)
-- 对比参照:`internal/logic/role/updateRoleLogic.go` L-R12-3 分支(`!caller.IsSuperAdmin && req.PermsLevel < role.PermsLevel` 明确 403)
-
-**描述**
-
-项目"纵向权限防护"的核心不变式在 `auth/access.go::GuardRoleLevelAssignable` / `CheckRoleLevelAgainst`:
-
-```237:240:internal/logic/auth/access.go
-if rolePermsLevel <= snap.Level {
-    return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
-}
-```
-
-`UpdateRoleLogic` 在 R12 那轮也补齐了"非超管不能把既有角色往上调档"的防护。但 `CreateRoleLogic` 的 `PermsLevel` 校验只做了字面校验:
-
-```53-55:internal/logic/role/createRoleLogic.go
-if req.PermsLevel < 1 || req.PermsLevel > 999 {
-    return nil, response.ErrBadRequest("权限级别必须在 1-999 之间")
-}
-```
-
-product ADMIN 可以走如下两步实现"把下属拉到与自己相同乃至更高的 assignable level":
-
-1. `CreateRole(ProductCode=P, PermsLevel=1)` → 新建 `R_super`,通过,因为 `RequireProductAdminFor` 放行 product ADMIN,`PermsLevel=1` 只看字面范围;
-2. `BindRoles(userId=D, roleIds=[R_super])` → `GuardRoleLevelAssignable` 判断 caller 的 assignable snapshot,`HasFullProductPerms(product ADMIN)` 返回 `true` → 直接放行(见 `access.go::CheckRoleLevelAgainst`);
-3. 下属 D 从此持有 `sys_user_role{roleId=R_super}`,下一次 UD 重建后 `ud.MinPermsLevel = 1`,在 `LoadCallerAssignableLevel` / `BindRoles` / `SetUserPerms` 中可把产品内任何 >1 的角色分配给他人——横向等价于"DEVELOPER 被签了 ADMIN 入场券"。
-
-这条链并不是在分配既有角色的环节被 `GuardRoleLevelAssignable` 拦截(ADMIN 确实全权),而是**在创建角色时先把弹药造好**:新角色的 PermsLevel 不受 caller 自身 assignable level 的约束。`UpdateRoleLogic` 的 R12-3 防护没有覆盖"先建后提升"的构造式等价路径。
-
-**影响**
-
-- 横向提权(escalate-by-delegate):product ADMIN 把一个 DEVELOPER / MEMBER 提到与自己同级甚至更高 assignable level,之后该下属的操作就会被审计记录为下属本人所为,ADMIN 可以"借刀杀人",审计链的"谁最终动的手"追溯性被破坏;
-- 横向绕 `CheckMemberTypeAssignment`:那个函数只管 memberType 的同级拦截,不涉及 role.permsLevel,故此条攻击路径独立成立;
-- 与既有 L-R13-2(`SetUserPerms` 拒绝对 ADMIN/DEVELOPER/SuperAdmin 下 DENY)不冲突:DENY 只是 perm 粒度兜底,role 粒度下 R_super 自带全部 perm 视角(配合 L-R11-4 的 loadPerms 全权分支逻辑),DENY 拦不住。
-
-**修复方案**
-
-CreateRole 加入与 UpdateRole 对称的 `GuardRoleLevelAssignable`/`CheckRoleLevelAgainst` 校验:
-
-```go
-caller := middleware.GetUserDetails(l.ctx)
-if caller == nil {
-    return nil, response.ErrUnauthorized("未登录")
-}
-if !caller.IsSuperAdmin {
-    snap, err := authHelper.LoadCallerAssignableLevel(l.ctx, l.svcCtx, caller)
-    if err != nil {
-        return nil, err
-    }
-    // 新建角色的权限等级必须严格低于 caller 的可分配等级;同级也拒(与 CheckRoleLevelAgainst 一致)
-    if err := authHelper.CheckRoleLevelAgainst(snap, req.PermsLevel); err != nil {
-        return nil, err
-    }
-}
-```
-
-特别注意:product ADMIN / DEVELOPER 的 `HasFullProductPerms=true`,`CheckRoleLevelAgainst` 对 HasFullPerms 会直接放行——这正是本 issue 的根因。修复时**不能**简单复用 `CheckRoleLevelAgainst`,而要把"创建角色"定义为:
-
-- SuperAdmin:任意 PermsLevel 通过;
-- product ADMIN:`req.PermsLevel` 必须 **>= 2**(给自己留出唯一的"顶格"语义,ADMIN 自身的 assignable 等价于 sentinel 0),且必须 **>= caller 的最小 permsLevel + 1**(避免 ADMIN 以 permsLevel=1 的角色强制覆盖自身 assignable baseline);
-- DEVELOPER 及以下:走 `CheckRoleLevelAgainst(snap, req.PermsLevel)` 标准路径(snap.Level 由下属自己的角色决定,拒同级)。
-
-推荐把这条语义沉淀到一个新 helper `GuardCreateRolePermsLevel(ctx, svcCtx, caller, reqLevel) error`,与 `GuardRoleLevelAssignable` 并排放在 `access.go` 里,后续 UpdateRoleLogic 的 R12-3 支也可以复用。
+### H-R18-1 · `UpdateRoleLogic` 重命名角色时未失效旧名字索引缓存,残留"幽灵角色快照"
+- **描述**:
+  `internal/logic/role/updateRoleLogic.go` 通过 `SysRoleModel.UpdateWithOptLock(role, prevUpdateTime)` 落库,model 层(`sysRoleModel.go:101-116`)传给 `m.ExecCtx` 的失效键只有:
+  ```
+  sysRoleIdKey               = cacheSysRoleIdPrefix + data.Id
+  sysRoleProductCodeNameKey  = cacheSysRoleProductCodeNamePrefix + data.ProductCode + ":" + data.Name   // ← 新 name
+  ```
+  当本次 UPDATE 把 `name` 从 `X` 改成 `Y` 时,`cacheSysRoleProductCodeNamePrefix:<code>:X`(旧 name 键)**没有人清**,Redis 里仍然保留着指向原主键的索引值。
+- **影响**:
+  1. `FindOneByProductCodeName(code, X)` 在 TTL(默认 7 天,走 sqlc 默认)内会继续命中旧索引 → 拿到"名为 X、实际已改为 Y"的脏行。
+  2. 结合同样 R17 已修过但调用路径仍在的 `CreateRole`(依赖 DB `UNIQUE(productCode,name)` 而非预检):当运营先 "UpdateRole X→Y",紧接着 "CreateRole 名字=X" 时,DB 的 UNIQUE 允许新建一条合法的 X,但 Redis 索引仍指向**原来的主键**,导致 `FindOneByProductCodeName(code, X)` 直至下一次 Insert/Update 触发 DelCache 才会自愈——两条同名 `X` 的"旧主键映射 → 新 name=Y 的行"状态会持续长达 TTL。
+  3. 与 R17 对 `DeleteRole` 补的 `InvalidateRoleCache` 对称缺口:rename 路径唯独漏掉这一对"旧 name 键"的显式失效。
+- **修复方案**:
+  在 `UpdateRole` 里先快照 `prevProductCode/prevName`,`UpdateWithOptLock` 完成后显式调用 `InvalidateRoleCache(ctx, role.Id, prevProductCode, prevName)` 和 `InvalidateRoleCache(ctx, role.Id, role.ProductCode, role.Name)`——与 R17 `DeleteRoleLogic` 的失效顺序保持一致,避免"幽灵 role 快照"。若不想改 model 语义,也可直接在 `updateRoleLogic.go` 里手动 `l.svcCtx.Redis.DelCtx(ctx, "cache:sysRole:productCode:name:"+prevCode+":"+prevName)`:
+  ```go
+  prevProductCode := role.ProductCode
+  prevName := role.Name
+  // ... 赋值 req.Name 到 role.Name 之后
+  if err := l.svcCtx.SysRoleModel.UpdateWithOptLock(l.ctx, role, prevUpdateTime); err != nil { ... }
+  cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+  defer cancel()
+  if prevName != role.Name {
+      l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, role.Id, prevProductCode, prevName)
+  }
+  l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, role.Id, role.ProductCode, role.Name)
+  ```
+
+### H-R18-2 · `UpdateDeptLogic` 冻结 NORMAL 部门仅递增 tokenVersion,未实际收窄权限,与注释声明不一致
+- **描述**:
+  `internal/logic/dept/updateDeptLogic.go:99-107` 的 `normalDeptFrozen` 分支判定为 "NORMAL 部门 Enabled→Disabled",注释声明为"**冻结本部门所有活动**,一并吊销"。实际行为:
+  1. 事务内对受影响 userIds 走 `BatchIncrementTokenVersionWithTx` — 仅让旧 JWT 失效。
+  2. `userDetailsLoader.go:loadPerms` 的授权计算里**只有 DEV 部门**分支依赖 `DeptStatus == Enabled`(第 543 行);对 NORMAL 成员,`DeptStatus` 从未进入权限计算。
+  3. `checkDeptHierarchy`(`access.go:377-422`)也不读 `targetDept.Status`。
+  4. 用户 `sys_user.Status` 保持不变。
+  
+  净效果:被"冻结"的 NORMAL 部门成员只是被强制退出一次登录;立即重新登录即可继续使用所有原有权限。审计日志里会留下 "affectedUsers=N revokedSessions=N",值班看上去像是"已吊销",但业务权限实际上**没有任何变化**。
+- **影响**:
+  1. 对运营侧形成"已冻结假象":操作者执行"冻结某部门"后相信该部门无法再访问系统,但成员立刻重登就恢复;在出现"部门整体涉嫌违规 → 临时冻结调查"这类场景下放出越权窗口。
+  2. 审计日志里的 `audit="UpdateDept"` 带 `revokedSessions` 字段容易被上级审阅/合规稽核误判为"已生效的访问控制措施"。
+  3. 与 `updateUserStatusLogic` / `updateMemberLogic` / `updateProductLogic` 同族 "收窄" 路径的业务语义不对称——后三者都是"DB 字段落地 + loadPerms 分支响应"的完整闭环。
+- **修复方案**(择一,建议 A):
+  - **A. 让 `loadPerms` 对 NORMAL 成员也读 DeptStatus**:在 `userDetailsLoader.go:524-625` `loadPerms` 的普通成员分支最前面加 `if ud.DeptStatus != consts.StatusEnabled { ud.Perms = nil; return nil }`;同时 `middleware/jwtauthMiddleware.go` 的校验链里补 "`ud.DeptStatus != Enabled` → 401/403"。
+  - **B. 放弃冻结 NORMAL 部门的语义**:把注释里的"一并吊销"改掉,并在 `UpdateDeptLogic` 的 `normalDeptFrozen` 分支只失效缓存、不再 `BatchIncrementTokenVersionWithTx`,避免运营误解。
+  - A 与当前 `UpdateProduct` 禁用产品时的全员收窄口径完全对称,优先推荐。
 
 ---
 
 ## ⚠️ 健壮性与性能建议 (Medium/Low)
 
-### M-R17-1 · `SyncPermsService` 的 pure-add 分支对"全权用户"有最长 5min 的权限可见延迟
-
-**位置**
-
-- `internal/logic/pub/syncPermsService.go:163-180`(`if updated > 0 || disabled > 0` 才清 `CleanByProduct`)
-- `internal/loaders/userDetailsLoader.go:539-554`(SuperAdmin / ADMIN / DEVELOPER / DEV-dept 成员走 `FindAllCodesByProductCode`)
-
-**描述**
-
-L-R11-4 的注释写得很清楚:
-
-> 纯新增(added>0 && updated==0 && disabled==0)时不需要清 CleanByProduct。新增的 perm 在本次 SyncPerms 之前**不可能**已经被绑定到任何 role……loadPerms 对当前全体 user 的计算结果与上次结果完全一致。
-
-但"loadPerms 对当前全体 user 的计算结果完全一致"这一条只对**普通 MEMBER**成立——他们走 `FindPermIdsByRoleIds` + `FindPermIdsByUserIdAndEffectForProduct` 的路径,新 perm 没被任何 role/allow/deny 引用,确实不影响。**全权用户走的是 `FindAllCodesByProductCode(productCode)` 单条分支**,这条查询返回的是该产品下所有 status=Enabled 的 perm 全集,新增任意 perm 都会让集合变大。
-
-具体时序:
-
-1. 产品 P 下已有 perm `A`/`B`,SuperAdmin `S` 登录,`ud.Perms = [A, B]`,写入 Redis(TTL=5min)。
-2. P 的业务服务发布 `v2`,新增 perm `C`,`SyncPerms` 走 pure-add 分支,`updated=disabled=0` → **跳过** `CleanByProduct`。
-3. `S` 在接下来 5 分钟内的每次 `/auth/userinfo` / login / refreshToken / 下游业务拉取 UD 都会命中 Redis 旧快照,`ud.Perms` 仍然是 `[A, B]`。
-4. 下游业务按 `Perms` 列表鉴权 `/v2/C`,S 被 403,"超管登录就是拉不到新接口"在发版当天很容易引起误判。
-
-**影响**
-
-- 发版 SLA:承诺下游"SyncPerms 成功即可使用新权限"的口径在这四类用户上被静默违反;
-- 审计链扭曲:`GetUserPerms` 的 perms 列表对全权用户最长延迟 5min,运营查验"这个超管到底能不能访问 C"时,看到的快照和真实授权的上游 DB 视图不一致;
-- 并不逃逸"最终一致",属 Medium 的感知可见性问题。
-
-**修复方案**
-
-两个可选路径:
-
-1. 精准失效(推荐):`SyncPerms` 在 `added > 0` 且当前产品存在全权用户(SuperAdmin + 本产品 ADMIN/DEVELOPER + DEV-dept 启用成员)时,只清这些用户的 UD 缓存,避免 CleanByProduct 的"全产品成员被穿回 DB"开销。实现上:
-   - `added > 0` → 事务内 `SELECT userId FROM sys_product_member WHERE productCode=? AND status=Enabled AND memberType IN ('ADMIN','DEVELOPER')` + 对 `sys_user` 全表 `WHERE isSuperAdmin=1` + 对 DEV 部门用户的查询(或直接在 tx 外跑,不影响锁序);
-   - 批量 `UserDetailsLoader.CleanByUserIds`。
-2. 简单保守:把 `if updated > 0 || disabled > 0` 扩成 `if added > 0 || updated > 0 || disabled > 0`,承担 `CleanByProduct` 的穿透开销作为代价。由于 SyncPerms 是 CI/CD 高频事件,这条偏"CleanByProduct 所有成员过一遍 Redis DEL",必要时再回退到方案 1。
-
-注释里的 `loadPerms 对当前全体 user 的计算结果完全一致` 必须同步改写——当前版本已经是错的,留着会把后人引向错误假设。
-
----
-
-### M-R17-2 · `UpdateUserLogic` 在"DEV→NORMAL 调动"+"status 冻结"并发发生时对 `tokenVersion` 做双倍递增,虽然功能无害,但注释与 sysUser 低层缓存 invalidation 次数不对称
-
-**位置**
-
-- `internal/logic/user/updateUserLogic.go`(R16-2 引入的 `devAccessRevoked` 分支会 `IncrementTokenVersionWithTx`)
-- `internal/model/user/sysUserModel.go::UpdatePassword`/`UpdateStatus`/`UpdateProfile` 各自会做 `tokenVersion+1`
-
-**描述**
-
-R16 给 `UpdateUser` 加上了"DEV → NORMAL 跨域调动时 tokenVersion+1";如果同一请求还命中 `status Enabled→Disabled`,`UpdateProfile` 内部本就会 `tokenVersion+1`(或 R16 的代码路径再走一次显式 `IncrementTokenVersionWithTx`),结果是事务内对同一 userId 做了 2 次自增。功能上毫无副作用(新 token 不会分叉,Redis 缓存还是会 post-commit Clean),但:
-
-- R16 的注释只解释了"DEV 调动"这一半为什么要 +1,没有声明"如果还叠加了 status change,递增会叠加";
-- 未来运维如果用 `tokenVersion` 做"有效会话批次号"的信号量分析,会看到"单次 UpdateUser 却 +2"的异常样本,产生噪声;
-- 双递增让 `UpdateProfile` 的 CAS `WHERE updateTime=?` 在第二次 SQL 之前要依赖第一次 SQL 的结果(事务内一致),调试复杂。
-
-**修复方案**
-
-两种路径:
-
-1. 改语义:把"是否需要 bump tokenVersion"收归到 `UpdateUser` 这一层决策,内部只调一次 `IncrementTokenVersionWithTx(userId)` 或直接在 `UpdateProfile` 的 SQL 里多带一个 `bumpTokenVersion bool` 参数,tx 内只做 1 次 `tokenVersion+1`。
-2. 不改语义,在注释里明确声明"devAccessRevoked + statusChanged 重叠时会 +2,但对安全语义等价于 +1",并在 `IncrementTokenVersionWithTx` 的文档注释里补上"可安全叠加调用"。
-
-无论走哪条都建议新增一条 TC:单请求同时触发 dev 调出 + 冻结,断言新 tokenVersion = 旧 + N(N 取当前实现值),把行为锁死。
-
----
-
-### L-R17-1 · `CreateProductLogic` 的 initial admin 用户名 `admin_<productCode>` 可被任意 product ADMIN 预抢注,导致 SuperAdmin 上新产品时撞 UNIQUE(username) 回滚
-
-**位置**
-
-- `internal/logic/product/createProductLogic.go`(生成 `adminUsername := fmt.Sprintf("admin_%s", req.Code)` 然后 `SysUserModel.Insert`)
-- `internal/logic/user/createUserLogic.go:54`(username regex `^[a-zA-Z0-9_]{2,64}$` 不保留 `admin_` 前缀)
-
-**描述**
-
-product ADMIN 在自己产品下可用 `CreateUser` 直接创建用户名 `admin_acme`,这个用户跟未来真正要上线的 productCode `acme` 共用一个 UNIQUE(username) 槽位。超管过了几个月要创建产品 `acme`:
-
-1. `CreateProductLogic` 走到 auto-provision step,`username = "admin_acme"` 已存在;
-2. `SysUserModel.Insert` 撞 1062,`util.IsDuplicateEntryErr` 被 `compensateCreatedRows` 捕获,已创建的 sys_product / 可能的 role / perm 全部回滚;
-3. 超管必须让运维人肉 data fix 把抢注的 `admin_acme` 改名,或改用一个非冲突的 productCode。
-
-组合 R16 的 H-R16-1 等一系列权限收敛之后,这条攻击虽然不能**提权**,但可以:
-
-- 针对"已知下一批待上线 productCode"(内部 Roadmap 半公开场景)做批量抢注,让超管短期内根本无法上新产品,等价于一次**业务 DoS**;
-- 如果被抢注的 `admin_<code>` 之后被 product ADMIN 给了自己或同谋,产品上线失败的日志/告警把 SuperAdmin 的注意力引向"产品 code 冲突",抢注者获得时间差;
-- 与"product ADMIN 看不到其他产品列表"(M-2) 并不冲突——抢注者只要知道 code 就行,不需要看列表。
-
-**修复方案**
-
-- 短期:把 auto-provision 的 username 加上不可人工构造的前缀 / 后缀,例如 `svc_admin_<code>_<random_10hex>`,彻底脱离 `^[a-zA-Z0-9_]{2,64}$` 的"纯小写字母数字"可人工推测空间。
-- 中期:在 `CreateUser` 的 username 正则层面引入保留前缀黑名单(`admin_` / `svc_` / `root_` / `sys_` 等),非超管创建以这些前缀开头的 username 直接 400。
-- 长期:引入 username namespace 机制,把"系统账号"和"用户账号"隔离到不同 table / 不同 uniqueness 域(例如用 `sys_service_account` 存 auto-provision 行,与 `sys_user` 分离)。
+### M-R18-1 · `AddMemberLogic` / `SetUserPerms` / `BindRoles` 未复核 `ud.Status`,仅靠中间件兜底
+- 文件:`internal/logic/member/addMemberLogic.go`、`internal/logic/user/bindRolesLogic.go`、`setUserPermsLogic.go`
+- 这些管理接口均先 `middleware.GetUserDetails` 取 caller,随后进入业务流。caller.UD 的 5min 聚合缓存里的 `Status` 字段在"超管刚把 caller 冻结但 Redis Clean 抖动失败"窗口内仍是旧值,jwtauthMiddleware 的 `ud.Status != Enabled` 拦截同样可能被旁路。虽然 R11-R15 已经给高风险写入加了 `loadFreshMinPermsLevel` 强一致复核,但"caller 本身已被冻结"这一维度只靠缓存。
+- **建议**:在 `access.go` 新增 `RequireActiveCaller(ctx, svcCtx)`,在 HTTP 写路径入口一次性走 DB 强一致复核 caller 的 `sys_user.status`(类似 `loadFreshMinPermsLevel` 的缓存旁路)。成本是每次写 +1 次 `FindOne(caller.UserId)`,但写路径 QPS 不高,收益是彻底堵住"刚被冻结还能写"的 TTL 级 TOCTOU。
+
+### M-R18-2 · `DeptTreeLogic` 非超管分支仍全表 `FindAll` 再内存过滤
+- 文件:`internal/logic/dept/deptTreeLogic.go`
+- `checkDeptHierarchy` 对非超管要求 `DeptPath` 前缀匹配——完全可以在 DB 层用 `WHERE path LIKE CONCAT(?, '%')` 走索引,避免把全量 `sys_dept` 拉回进程内存再过滤。
+- **建议**:模型层补 `FindByPathPrefix(ctx, prefix)`(带 `path` 前缀索引命中),在 `DeptTreeLogic` 非超管分支改用它。对 300~500 个部门这种真实规模,内存滤选问题不大;但模型接口暴露 `FindAll` 让其它调用方未来踩到同类坑。
+
+### M-R18-3 · `loadMembership` 使用 `err == productmember.ErrNotFound` 进行裸等值比较
+- 文件:`internal/loaders/userDetailsLoader.go:473`
+- 现状:`productmember.ErrNotFound = sqlx.ErrNotFound`,裸等值成立;但今后任何一层包装(`fmt.Errorf("%w", err)` / 多级 model)都会让这条分支失效——走到 error 分支打日志并最终合并成 `ErrLoaderDegraded`,把"真的不是成员"退化为 503。
+- **建议**:统一改为 `errors.Is(err, productmember.ErrNotFound)`,与文件内其它 error 分支口径对齐(如 `loadUser` 的 `errors.Is(err, sqlx.ErrNotFound)`)。
+
+### M-R18-4 · `CreateUserLogic` 的强口径校验落在 `req.DeptId > 0` 内层,DB 落盘后的 `req.DeptId == 0`(超管)被直接放行
+- 文件:`internal/logic/user/createUserLogic.go:134-161`
+- 审计 H-R17-1 的 S 锁在 `req.DeptId > 0` 分支生效;超管走 `deptId=0` 分支完全跳过 `FindOneForShareTx`,同时 Insert 依然落成功行——这符合"只有超管能创建无部门用户"的业务约束,但会在历史数据里继续沉淀"DeptId=0 的 MEMBER 幽灵账号"。`checkDeptHierarchy` 虽然对这类账号 fail-close,但缺少一条"创建即审计"的告警事件,运维无法识别此类账号何时被创建以及谁创建。
+- **建议**:在超管 `deptId=0` 分支显式打一条 `logx.Infow("create user without dept", logx.Field("audit", "create_user_no_dept"), ...)`,方便事后回捞。
+
+### L-R18-1 · `GuardCreateRolePermsLevel` 的 `snap.HasFullPerms` 分支对 DEVELOPER 是事实上的死代码
+- 文件:`internal/logic/auth/access.go:261-282`
+- `CreateRoleLogic` 的入口已有 `authHelper.RequireProductAdminFor(role.ProductCode)` 把 DEVELOPER / MEMBER 全部挡在门外,因此 `GuardCreateRolePermsLevel` 内部 `HasFullPerms` 分支仅对 "超管"(已提前 return)和 "ADMIN" 两类 caller 生效。函数注释已说明"为未来 DEVELOPER 可建 sub-role 留接口",但需要显式的单元测试在 DEVELOPER 入口放开时捕获行为偏移;目前该分支没有任何调用路径会触发。
+- **建议**:要么删除 DEVELOPER 相关注释只保留"ADMIN"语义,要么增加一条明确的契约测试(入口临时放开 DEVELOPER 后验证 `permsLevel<=1` 被拒),避免注释与调用现实脱节。
+
+### L-R18-2 · `UpdateProduct(Disabled→Enabled)` 没有对应的回灌 tokenVersion 语义补偿
+- 文件:`internal/logic/product/updateProductLogic.go`
+- 产品 Enabled→Disabled 会 `BatchIncrementTokenVersionWithTx` + 全员缓存 Clean;反向的 Disabled→Enabled 只清缓存、不动 tokenVersion。理论上符合"升权不踢下线"的原则,但在"误禁用 → 立即启用"的运维回滚场景下,被踢下线的用户必须全部重登——这是业务可接受的代价,需要在接口文档 / 审计日志里显式声明,避免运维对"为什么已经恢复了还不能用旧 token"产生误解。
+- **建议**:在 `updateProductLogic.go` 的禁用分支审计日志里加字段 `audit_hint="sessions_revoked_irreversibly"`,或在 OpenAPI 文档里显式标注。
+
+### L-R18-3 · `loadPerms` 对"最终 perm 集合为空"的普通成员返回 `nil` 而非 `[]`
+- 文件:`internal/loaders/userDetailsLoader.go:603-623`
+- Go `encoding/json` 对 `[]string(nil)` 会输出 `null`,前端 / gRPC 客户端若按"数组"做判断会触发不必要的 defensive check;对比"有 perm 但全 deny 掉的用户"输出 `[]`,两种"空"表达不一致。
+- **建议**:`loadPerms` 出口处 `if ud.Perms == nil { ud.Perms = []string{} }`,保证响应体里 `perms` 恒为数组;对 JWT 签发路径同样友好。
+
+### L-R18-4 · `MemberListLogic` 的 `map[int64]struct{Username, Nickname string}` 匿名类型降低可读性
+- 文件:`internal/logic/member/memberListLogic.go:55-58`
+- 匿名结构体作为 map value 在 Go 里每次构造都推导类型,IDE 跳转 / pprof 分析不友好。改为具名类型(或直接用 `*userModel.SysUser`)能让 N>N 条成员列表的调试 / 性能归因更方便。
+- **建议**:本地定义 `type nickname struct { Username, Nickname string }`,或复用现有 `sysUserModel.UserWithMemberType` 风格的小结构体。
+
+### L-R18-5 · `CreateDeptLogic` 对 `req.Sort` 没有上限检查
+- 文件:`internal/logic/dept/createDeptLogic.go`
+- `Sort` 直接透传到 `sys_dept.sort int`。超管可以随意写 `math.MaxInt64`,虽然不会崩 DB,但会把部门树的排序稳定性依赖转嫁到业务前端;同类字段在 `SysRole` / `SysProduct` 也都没有保护。
+- **建议**:统一在 model 层(或前端)给 `sort` 加范围校验(例如 `[-100000, 100000]`),与 `permsLevel 1-999` 的口径一致;或者在接口契约里显式声明"sort 仅在同级部门间相对有效"。
+
+### L-R18-6 · `CreateProductLogic.compensateCreatedRows` 使用独立 ctx,但先后顺序"member → user → product"未对 FK 依赖建模
+- 文件:`internal/logic/product/createProductLogic.go:343-396`
+- 当前无外键约束,删除顺序无所谓;但如果未来对 `sys_product_member` / `sys_user_role` / `sys_user_perm` 加上 `ON DELETE RESTRICT`,`compensateCreatedRows` 的顺序需要与 FK 对齐。
+- **建议**:在函数顶部加一行注释说明"删除顺序假设无 FK 约束;如果加 FK,顺序必须调整"——降低未来 schema 演进时的踩坑概率。
+
+### L-R18-7 · `UpdateUserLogic` 的 `devAccessRevoked` 判定对 `oldDept.Status != Enabled` 也触发收窄路径
+- 文件:`internal/logic/user/updateUserLogic.go:184-193`
+- 判定 `devAccessRevoked=true` 的三元条件之一是 `newDept.Status != consts.StatusEnabled`;按 `updateUserLogic` 自身的前置校验(line 136-138)新部门必须 Enabled,这个分支实际上是"目标 dept 启用→未启用"的兜底,与事务外 FindOne 已做的校验重复。
+- **建议**:在注释里把这条兜底的"双重冗余"显式标注,或者直接删掉 `newDept.Status != consts.StatusEnabled` 这一支(因为事务外非空分支必然 Enabled)。
+
+### L-R18-8 · `userDetailsLoader.BatchDel` 中 `batchUnregister` 的错误被 `logCacheInvalidationErr` 吞掉,但不上报熔断
+- 文件:`internal/loaders/userDetailsLoader.go:294-314`
+- 大批量缓存失效失败是"Redis 抖动"的强信号,目前只有日志、没有 metric。生产环境如果 Redis 长时间不可用,UD 聚合缓存和 sys_user 低层缓存同时保持陈旧,最长 5min 期间的任何授权判定都不得退化到 DB。
+- **建议**:在 `logCacheInvalidationErr` 的非 ctx-canceled 分支增一次 `prometheus.CounterVec.Inc`(或 go-zero 的 metric),告警阈值 5xx/min → on-call,让运维第一时间感知"缓存失效链路挂了"。
 
 ---
 
-### L-R17-2 · `CreateUser` / `CreateProductLogic` 未显式设置 `Avatar`,导致 `sql.NullString` 以 `{Valid:true, String:""}` 默认落库,对消费方而言歧义
-
-**位置**
-
-- `internal/logic/user/createUserLogic.go:120-134`(Insert 字段集未包含 Avatar)
-- `internal/logic/product/createProductLogic.go`(initial admin 构造 `SysUser{…}` 同样未设 Avatar)
-- `internal/model/user/sysUserModel_gen.go::SysUser.Avatar`(类型为 `sql.NullString`)
-- `internal/loaders/userDetailsLoader.go::loadSysUser`(`ud.Avatar = u.Avatar.String`)
-
-**描述**
-
-Go 结构体字面量初始化时未设置的 `sql.NullString` 字段默认 `{Valid:false, String:""}`。但 `SysUserModel.Insert` 走的是 sqlx 的结构体到列映射,Valid=false 的 NullString 写入 DB 时会落 `NULL`,此时读取出来后 `u.Avatar.String=""` & `u.Avatar.Valid=false`;`ud.Avatar = u.Avatar.String` 会把 `""` 拷到 UD.Avatar。
-
-看起来不会出问题——但**默认行为依赖"Go 零值 + sql.NullString 语义链"**,一旦未来有人把 `SysUser.Avatar` 改成纯 `string`(去掉 NullString),现在的 Insert 字段集不变就会落一个 `''`;而此前已有用户的 Avatar 可能 `NULL`(Valid=false),DB 层 inplace migrate 时会出现部分行 `NULL`、部分行 `''` 并存的脏数据。前端把"未上传头像"判断为 `avatar == null` 还是 `avatar == ''`?两种写法都合理,但两种数据并存会让"默认头像"只对一半用户生效。
-
-**修复方案**
-
-在 CreateUser / CreateProductLogic 的 Insert 字面量里显式写:
-
-```go
-Avatar: sql.NullString{Valid: false},
-```
-
-或者把 Avatar 字段从 NullString 降级为普通 string + 业务层在 loadSysUser 后显式处理"空串 = 未上传"语义。更 aggressive 一点,直接在 SysUser 的 Insert helper 里对所有 Nullable 字段走显式默认值构造器,避免散落各处的"忘记赋值→依赖零值"。
+## 二次核验:R17 已修项留存状态(抽查)
+
+| 编号 | 预期修复位点 | 实际状态 |
+| :--- | :--- | :--- |
+| H-R17-1 | `createUserLogic.go` / `createProductLogic.go` 事务内 `FindOneForShareTx(sys_dept)` | ✅ 已落地,注释齐备 |
+| H-R17-2 | `deleteDeptLogic.go` / `updateDeptLogic.go` / `deleteRoleLogic.go` post-commit `InvalidateDeptCache` / `InvalidateRoleCache` | ✅ 已落地(但 `updateRoleLogic` rename 路径漏 old-name 键——见 H-R18-1) |
+| H-R17-3 | `createRoleLogic.go` 非超管 `permsLevel>=2` | ✅ 通过 `GuardCreateRolePermsLevel` 落地 |
+| M-R17-1 | `syncPermsService.go` 全量触发 `CleanByProduct` | ✅ 已落地 |
+| M-R17-2 | `updateUserLogic.go` tokenVersion 双递增的语义澄清 | ✅ 注释齐备 |
+| L-R17-1 | `createUserLogic.go` 保留前缀 `admin_` / `svc_` / `root_` / `sys_` | ✅ 已落地 |
+| L-R17-2 | `sql.NullString{Valid: false}` 显式落 NULL | ✅ 已落地 |
+| L-R17-3 | `deptTreeLogic.go` 空 DeptPath / 空 tree 审计日志 | ✅ 已落地 |
+| L-R17-4 | `changePasswordLogic.go` 同密码短路先于 bcrypt | ✅ 已落地 |
+| L-R17-5 | `createDeptLogic.go` Insert→UpdatePathWithTx 两步 | ✅ 已落地 |
+| L-R17-6 | `updateUserStatusLogic.go` 对解冻也 tokenVersion+1 的注释 | ✅ 已落地 |
 
 ---
 
-### L-R17-3 · `DeptTreeLogic` 对非超管只返回 DeptPath 子树,但非成员子树被整体丢弃时没有审计日志标记,排障时无法区分"用户无权"和"DB 抖动返空"
-
-**位置**
-
-- `internal/logic/dept/deptTreeLogic.go:52-63`(非超管分支过滤非子树部门时直接 continue,不 log)
+## 小结
 
-**描述**
+本轮新增发现 **2 项高风险**(H-R18-1 缓存键残留、H-R18-2 NORMAL 部门冻结语义偏差),**4 项中风险**与 **8 项低风险**。
 
-非超管(含产品 ADMIN / DEVELOPER / MEMBER)调用 `DeptTree`,`DeptPath` 前缀不匹配的部门都被静默过滤。当 `caller.DeptPath == ""`(数据链路异常、user 刚被超管改到部门外等)时,直接返回空数组,UI 显示"无部门",看起来跟"DB 抖动返空"无法区分。L-R15-2 已经把 fullAccess 收敛给 SuperAdmin,但**对过滤掉多少 dept**没有可观测性。
-
-**修复方案**
-
-- 对 `len(list) > 0 && len(filtered) == 0` 且 `caller.DeptPath != ""` 的边界打一条 INFO:caller 有 DeptPath 但过滤后为空(可能是 DeptPath 指向已删除部门 = H-R17-1 的前奏迹象);
-- 对 `caller.DeptPath == ""` 直接 log WARN,提示运维可能是 L-R16-1 / H-R17-1 类的孤儿账号。
-
----
-
-### L-R17-4 · `changePasswordLogic.go::ChangePassword` 的"旧密码等于新密码"校验顺序让 bcrypt compare 先于字符串等值比较,浪费 ~60ms CPU
-
-**位置**
-
-- `internal/logic/auth/changePasswordLogic.go:60-67`
-
-**描述**
-
-```60-67:internal/logic/auth/changePasswordLogic.go
-if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
-    logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)
-    return response.ErrBadRequest("原密码错误")
-}
-
-if req.OldPassword == req.NewPassword {
-    return response.ErrBadRequest("新密码不能与原密码相同")
-}
-```
-
-校验顺序:
-
-1. bcrypt 比对 old password(耗时 ~60ms,CPU 重);
-2. 字符串比较 old == new(纳秒级);
-3. bcrypt 生成新 hash(另一次 ~60ms)。
-
-把 (2) 提前到 (1) 之前:
-
-- 对合法请求无影响((2) 返 false 时进入 (1) 正常流程);
-- 对"同密码"攻击请求可以提前 return,少做一次 bcrypt compare(60ms CPU)。
-
-**修复方案**
-
-直接交换顺序,`req.OldPassword == req.NewPassword` 放到 bcrypt compare 之前:
-
-```go
-if req.OldPassword == req.NewPassword {
-    return response.ErrBadRequest("新密码不能与原密码相同")
-}
-if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
-    // ...
-}
-```
-
-关注点:这会让"攻击者用正确用户名 + 猜对 oldPwd 等于 newPwd"的响应时间比"猜错 oldPwd"快约 60ms,理论上构成 timing 信号。但这条信号对攻击者几乎无用——要 trigger 这条分支攻击者需要**已经知道 oldPwd**,一旦攻击者已知 oldPwd,ChangePassword 本身就是 game over。所以 timing 差异可以接受。
-
----
-
-### L-R17-5 · `createDeptLogic.go` 的"Insert → FindOneWithTx → Update"三步写回 Path,可以合并为一次 `UPDATE … SET path = CONCAT(?, id, '/') WHERE id=?` 省一次 DB roundtrip
-
-**位置**
-
-- `internal/logic/dept/createDeptLogic.go:86-109`
-
-**描述**
-
-```86-109:internal/logic/dept/createDeptLogic.go
-result, err := l.svcCtx.SysDeptModel.InsertWithTx(ctx, session, &deptModel.SysDept{ /* Path: parentPath 是占位 */ })
-// ...
-deptId, _ = result.LastInsertId()
-d, err := l.svcCtx.SysDeptModel.FindOneWithTx(ctx, session, deptId)
-if err != nil {
-    return err
-}
-d.Path = fmt.Sprintf("%s%d/", parentPath, deptId)
-return l.svcCtx.SysDeptModel.UpdateWithTx(ctx, session, d)
-```
-
-三步模式:Insert → 再 FindOne → 再 Update,`FindOneWithTx` 这一步纯粹为了拿到其余字段喂给 `UpdateWithTx`(`UpdateWithTx` 要整行对象)。其实 `InsertWithTx` 已返回 LastInsertId,parentPath 本身已知,可以直接一条 UPDATE 搞定,或者 `UpdateWithTx` 之外再暴露一个 `UpdatePathOnlyWithTx` 模式。
-
-**修复方案**
-
-建议在 `sysDeptModel` 加一个:
-
-```go
-func (m *customSysDeptModel) UpdatePathWithTx(ctx context.Context, session sqlx.Session, id int64, path string, updateTime int64) error {
-    // UPDATE sys_dept SET `path`=?, `updateTime`=? WHERE id=?
-    // 同时 DelCache(cacheSysDeptIdPrefix+id)
-}
-```
-
-CreateDept 改调 `UpdatePathWithTx(ctx, session, deptId, fmt.Sprintf("%s%d/", parentPath, deptId), now)`,省掉 FindOneWithTx 的一次往返;tx 持锁时间缩短,降低 sys_dept X 锁的尾部延迟。
-
----
-
-### L-R17-6 · `UpdateUserStatusLogic` 对"解冻(Disabled → Enabled)"同样执行 `tokenVersion+1` 与 `UserDetailsLoader.Clean`,注释只覆盖冻结场景
-
-**位置**
-
-- `internal/logic/user/updateUserStatusLogic.go:59-71`(`UpdateStatus` 内部 `tokenVersion+1`)
-- `internal/model/user/sysUserModel.go::UpdateStatus`
-
-**描述**
-
-`UpdateStatus` 的 SQL 写死 `SET tokenVersion = tokenVersion + 1`,无论 target.Status 是从 Disabled → Enabled 还是 Enabled → Disabled。对 Enabled→Disabled 侧的 audit 注释写得很清楚(冻结后的旧 token 必须立即失效);对 Disabled→Enabled 侧无注释,实际上也会 +1(虽然用户本来就因为 Disabled 无法登录,再 +1 等价于空动作,无害)。
-
-**修复方案**
-
-- 不改代码,仅补注释:`UpdateStatus` 无条件递增 tokenVersion 是刻意设计,让"冻结-解冻-冻结"这条路径里 Redis 残留缓存的 tokenVersion 比对始终相对于"最新值"计算,即使解冻间隙 DB 抖动,也不会让一个"冻结前未过期的 access token"在解冻后复活。这条语义在 `UpdateUserStatusLogic` 和 `sysUserModel.UpdateStatus` 的函数头注释里都要显式写出来,避免后续 R18 读代码时误以为"解冻无需 bump"做成条件判断反而引入回归。
-
----
+其中 **H-R18-1** 必须优先修复——它是 R17 对 `DeleteRole` 补失效的"对偶对称缺口",实现成本极低(只要在 `updateRoleLogic.go` post-commit 追加一次 `InvalidateRoleCache(old)`)。
 
-## 汇总
+**H-R18-2** 是业务与实现的语义偏差,修复方案需要产品 / 合规侧先定义清楚"冻结部门"的业务意图(强吊销 vs 会话软吊销),再决定 A(收紧 `loadPerms`)还是 B(澄清注释 + 调整审计字段)。
 
-- 本轮新发现 3 条 High(`H-R17-1`、`H-R17-2`、`H-R17-3`),均为**结构性**问题,无法通过单点补丁彻底解决,需要:
-  - `H-R17-1` 把 `CreateUser`/`CreateProductLogic` 的 user 落库动作迁入事务并加 `sys_dept` S 锁(bcrypt 必须留在事务外);
-  - `H-R17-2` 增加 post-commit 低层缓存失效兜底,覆盖 `sys_dept` / `sys_role` 的 `*WithTx` 路径;长期建议通过自定义事务钩子把 go-zero sqlc 的"exec → DelCache"模式从事务语义里摘出去;
-  - `H-R17-3` 在 `CreateRoleLogic` 镜像 `UpdateRoleLogic` 的 L-R12-3 防护,并抽象 `GuardCreateRolePermsLevel`。
-- Medium 2 条、Low 6 条,以感知可见性 / 注释对齐 / 性能微调为主。
-- R16 及更早各轮修复经本轮复检全部稳定,不存在回归。
+其它 Medium / Low 项均可作为常态化重构批次推进,不会立刻造成事故。

+ 9 - 2
internal/loaders/cacheCleanCtx.go

@@ -41,7 +41,9 @@ func isCtxCanceledErr(err error) bool {
 // logCacheInvalidationErr 统一"缓存失效失败"的日志打点。
 //   - ctx 取消 / 超时:打一条带 `audit=cache_invalidation_skipped_due_to_ctx_cancel` tag 的
 //     Errorw,运维可以按 tag 单独建看板而不污染"Redis 真挂了"的告警;
-//   - 其它错误:保持原 Errorf 的串行格式,兼容既有日志解析管线。
+//   - 其它错误:审计 L-R18-8 改用 Errorw 带 `audit=cache_invalidation_failed` tag,方便
+//     运维按 tag 在日志平台聚合告警(项目当前未集成 Prometheus,以日志 tag 作为告警输入面,
+//     未来接入 metrics 时再把 tag 聚合转成 counter 即可,不需要二次改造业务代码)。
 //
 // scope 形如 "userDetailsLoader.BatchDel",detail 可附带 key / 业务上下文。
 func logCacheInvalidationErr(ctx context.Context, scope, detail string, err error) {
@@ -57,5 +59,10 @@ func logCacheInvalidationErr(ctx context.Context, scope, detail string, err erro
 		)
 		return
 	}
-	logx.WithContext(ctx).Errorf("%s failed: detail=%s err=%v", scope, detail, err)
+	logx.WithContext(ctx).Errorw("cache invalidation failed",
+		logx.Field("audit", "cache_invalidation_failed"),
+		logx.Field("scope", scope),
+		logx.Field("detail", detail),
+		logx.Field("err", err.Error()),
+	)
 }

+ 22 - 3
internal/loaders/userDetailsLoader.go

@@ -470,7 +470,11 @@ func (l *UserDetailsLoader) loadMembership(ctx context.Context, ud *UserDetails)
 	}
 	member, err := l.models.SysProductMemberModel.FindOneByProductCodeUserId(ctx, ud.ProductCode, ud.UserId)
 	if err != nil {
-		if err == productmember.ErrNotFound {
+		// 审计 M-R18-3:与 loadUser 的 errors.Is(err, sqlx.ErrNotFound) 口径对齐。
+		// productmember.ErrNotFound = sqlx.ErrNotFound 的裸等值比较在当前版本成立,但未来若
+		// model 层引入 fmt.Errorf("%w", ...) 任一层包装,裸等值会失效让"用户不是本产品成员"
+		// 的正常业务语义退化成 ErrLoaderDegraded 503。
+		if errors.Is(err, productmember.ErrNotFound) {
 			return nil
 		}
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query member failed: %v", err)
@@ -522,17 +526,32 @@ func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) erro
 }
 
 func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) error {
+	// 审计 L-R18-3:Perms 字段在响应体里期望恒为 JSON 数组。Go 的 []string(nil) 经
+	// encoding/json 会输出 `null`,与"有 perm 但 deny 全部过滤掉"输出 `[]` 产生两种不同的
+	// "空"表达,给下游前端 / gRPC 客户端增加了冗余 defensive check 成本。以 `[]string{}`
+	// 作为统一的"空",各子分支只在真的能计算出 codes 时再覆盖。
+	ud.Perms = []string{}
+
 	if ud.ProductCode == "" {
 		return nil
 	}
 
 	if ud.ProductStatus != consts.StatusEnabled {
-		ud.Perms = nil
 		return nil
 	}
 
 	if !ud.IsSuperAdmin && ud.MemberType == "" {
-		ud.Perms = nil
+		return nil
+	}
+
+	// 审计 H-R18-2:UpdateDeptLogic 的 normalDeptFrozen 分支语义为"冻结本部门所有活动,一并吊销";
+	// 但历史实现仅 tokenVersion+1 踢下线,对 loadPerms 全量分支没有任何影响——被冻结部门的
+	// ADMIN / DEVELOPER / MEMBER 重登后 Perms 完全恢复,审计日志里的 "revokedSessions" 变成"过眼云烟"。
+	// 这里把 DeptStatus 提升为 loadPerms 全流程前置条件(超管不受产品内部门约束):
+	//   - 与 ProductStatus 检查对称:ProductStatus Disabled → 无权限;DeptStatus Disabled → 无权限;
+	//   - 与 jwtauthMiddleware / ValidateProductLogin 的 DeptStatus 硬拦截共同闭合"冻结即失权"。
+	// 仅作用于 DeptId>0 的场景,规避 DeptId=0 且 DeptStatus=0(默认零值)被误判为"冻结"。
+	if !ud.IsSuperAdmin && ud.DeptId > 0 && ud.DeptStatus != consts.StatusEnabled {
 		return nil
 	}
 

+ 137 - 0
internal/loaders/userDetailsLoader_test.go

@@ -2023,3 +2023,140 @@ func TestLoader_Load_SecondRoundHitsCache(t *testing.T) {
 	assert.Equal(t, int64(0), secondRoundHits,
 		"后续 Load 必须命中 Redis 缓存; 若持续打到 DB, 说明 cache 写入失败或 TTL 异常")
 }
+
+// TC-1205: NORMAL 部门冻结(DeptStatus=Disabled)后成员 Perms 为空 []。
+// loadPerms 在新增的 DeptStatus 前置检查下,NORMAL 部门被禁用后成员重登应立即无权。
+func TestLoadPerms_NormalDeptDisabled_NoPerms(t *testing.T) {
+	ctx := context.Background()
+	conn := testConn()
+	m := testModels()
+	loader := newTestLoader()
+
+	uid := uniqueId()
+	ts := now()
+	pcode := "p_" + uid
+
+	deptId := insertDept(ctx, t, m, &deptModel.SysDept{
+		ParentId: 0, Name: "normdept_dis_" + uid, Path: "/1/", Sort: 1,
+		DeptType: consts.DeptTypeNormal, Status: consts.StatusDisabled,
+		CreateTime: ts, UpdateTime: ts,
+	})
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pass123"), Nickname: "nick_" + uid,
+		Email: uid + "@test.com", Phone: "13900000001", DeptId: deptId,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcode, Name: "prod_" + uid, AppKey: "ak_" + uid, AppSecret: "as_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcode, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	permCode := "perm_normdis:" + uid
+	permId := insertPerm(ctx, t, m, &permModel.SysPerm{
+		ProductCode: pcode, Name: "p_" + uid, Code: permCode,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, pcode)
+		cleanTable(ctx, conn, "`sys_perm`", permId)
+		cleanTable(ctx, conn, "`sys_product_member`", memberId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+		cleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	loader.Del(ctx, userId, pcode)
+
+	ud, err := loader.Load(ctx, userId, pcode)
+	require.NoError(t, err)
+	require.NotNil(t, ud)
+	assert.Equal(t, consts.DeptTypeNormal, ud.DeptType)
+	assert.Equal(t, int64(consts.StatusDisabled), ud.DeptStatus)
+	assert.NotNil(t, ud.Perms,
+		"Perms 必须是非 nil 的空 slice([]string{}),而非 nil;下游 JSON 输出必须为 [] 而非 null")
+	assert.Empty(t, ud.Perms,
+		"NORMAL 部门冻结后,成员不应拥有任何权限;冻结部门的'会话吊销'需要 loadPerms 也配合清零才能闭环")
+}
+
+// TC-1206: loadPerms 出口 Perms 恒为非 nil 数组。
+// 普通成员无任何角色和附加权限时,Perms 应为 []string{} 而非 nil。
+// encoding/json 对 nil slice 输出 null,对 []string{} 输出 [];两种空表达不一致会给前端带来冗余 defensive check。
+func TestLoadPerms_EmptyPerms_IsNotNilSlice(t *testing.T) {
+	ctx := context.Background()
+	conn := testConn()
+	m := testModels()
+	loader := newTestLoader()
+
+	uid := uniqueId()
+	ts := now()
+	pcode := "p_" + uid
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pass123"), Nickname: "nick_" + uid,
+		Email: uid + "@test.com", Phone: "13900000002", DeptId: 0,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: pcode, Name: "prod_" + uid, AppKey: "ak_" + uid, AppSecret: "as_" + uid,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: pcode, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, pcode)
+		cleanTable(ctx, conn, "`sys_product_member`", memberId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+	})
+
+	loader.Del(ctx, userId, pcode)
+
+	ud, err := loader.Load(ctx, userId, pcode)
+	require.NoError(t, err)
+	require.NotNil(t, ud)
+
+	// 关键断言:Perms 必须为非 nil 的空 slice,不能是 nil。
+	assert.NotNil(t, ud.Perms,
+		"无权限成员的 Perms 必须是 []string{}(非 nil);"+
+			"Go encoding/json 对 nil 输出 null,对 [] 输出 [],两种'空'造成下游 defensive check 不一致")
+
+	// 验证 JSON 序列化确实输出 []。
+	type wrapper struct {
+		Perms []string `json:"perms"`
+	}
+	jsonBytes, marshalErr := json.Marshal(wrapper{Perms: ud.Perms})
+	require.NoError(t, marshalErr)
+	jsonStr := string(jsonBytes)
+	assert.Contains(t, jsonStr, `"perms":[]`,
+		"空 Perms 序列化必须为 [],不得为 null;实际 JSON: %s", jsonStr)
+}
+
+// TC-1207: loadMembership errors.Is 语义稳健性契约测试。
+// productmember.ErrNotFound = sqlx.ErrNotFound;当前代码已改为 errors.Is,确保未来 model 层包装
+// 后 ErrNotFound 仍能被识别,而不会把"用户非成员"退化为 ErrLoaderDegraded 503。
+func TestLoadMembership_ErrNotFound_IsStableContract(t *testing.T) {
+	// productmember.ErrNotFound 应等于 sqlx.ErrNotFound。
+	require.True(t, errors.Is(memberModel.ErrNotFound, sqlx.ErrNotFound),
+		"productmember.ErrNotFound 必须是 sqlx.ErrNotFound 或其包装,"+
+			"否则 loadMembership 的 errors.Is 检查无法识别'用户非成员'场景")
+
+	// 包装一层后 errors.Is 仍应成立——防止未来 model 层引入 fmt.Errorf("%w", err) 时失配。
+	wrapped := fmt.Errorf("model wrap: %w", memberModel.ErrNotFound)
+	require.True(t, errors.Is(wrapped, sqlx.ErrNotFound),
+		"单层 fmt.Errorf 包装后 errors.Is 仍须成立;若失败说明 ErrNotFound 不是通过 %%w 传播的哨兵")
+}

+ 6 - 0
internal/logic/dept/createDeptLogic.go

@@ -43,6 +43,12 @@ func (l *CreateDeptLogic) CreateDept(req *types.CreateDeptReq) (resp *types.IdRe
 	if len(req.Remark) > 255 {
 		return nil, response.ErrBadRequest("备注长度不能超过255个字符")
 	}
+	// 审计 L-R18-5:Sort 只在同级部门间相对有效,用不到 int64 的极端值;把合法区间固定为
+	// [-100000, 100000],与 permsLevel 1-999 的"业务侧人类可读范围"思路一致,避免前端偶发
+	// 把 math.MaxInt64 之类的值透传到 DB 触发"排序溢出"的 edge case。
+	if req.Sort < -100000 || req.Sort > 100000 {
+		return nil, response.ErrBadRequest("排序值必须在 -100000 到 100000 之间")
+	}
 
 	parentPath := "/"
 	if req.ParentId > 0 {

+ 40 - 0
internal/logic/dept/createDeptLogic_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"math"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"perms-system-server/internal/consts"
@@ -433,3 +434,42 @@ func TestCreateDept_Vs_DisableParent_NoSilentChildUnderDisabled(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "`sys_dept`", parentId)
 	}
 }
+
+// TC-1202: CreateDept Sort 超出范围 [-100000, 100000] 被拒绝。
+// Sort 只在同级部门间相对有效,用不到 int64 的极端值;把合法区间固定为 [-100000, 100000]
+// 防止前端偶发把 math.MaxInt64 之类的值透传到 DB 触发"排序溢出"的 edge case。
+func TestCreateDept_SortOutOfRange_Rejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	outOfRange := []int64{100001, -100001, math.MaxInt64, math.MinInt64}
+	for _, s := range outOfRange {
+		resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{
+			Name: "sort_test_" + testutil.UniqueId(), Sort: s,
+		})
+		require.Error(t, err, "Sort=%d 应被拒绝", s)
+		assert.Nil(t, resp)
+		var ce *response.CodeError
+		require.True(t, errors.As(err, &ce), "Sort=%d 必须返回 CodeError", s)
+		assert.Equal(t, 400, ce.Code(), "Sort=%d 应 400 拒绝", s)
+		assert.Contains(t, ce.Error(), "排序值必须在 -100000 到 100000 之间",
+			"Sort=%d 的错误消息与 UpdateDept 校验文案不一致", s)
+	}
+}
+
+// TC-1202 (正向): Sort 在 [-100000, 100000] 边界内应放行。
+func TestCreateDept_SortBoundaryValid(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	for _, s := range []int64{-100000, 0, 100000} {
+		resp, err := NewCreateDeptLogic(ctx, svcCtx).CreateDept(&types.CreateDeptReq{
+			Name: "sort_valid_" + testutil.UniqueId(), Sort: s,
+		})
+		require.NoError(t, err, "Sort=%d 应被放行", s)
+		require.NotNil(t, resp)
+		require.Greater(t, resp.Id, int64(0))
+		t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", resp.Id) })
+	}
+}

+ 5 - 0
internal/logic/dept/updateDeptLogic.go

@@ -56,6 +56,11 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	if len(req.Remark) > 255 {
 		return response.ErrBadRequest("备注长度不能超过255个字符")
 	}
+	// 审计 L-R18-5:与 CreateDept 同口径,把 Sort 合法区间约束为 [-100000, 100000],
+	// 避免越界值被透传到 DB 产生排序异常。
+	if req.Sort < -100000 || req.Sort > 100000 {
+		return response.ErrBadRequest("排序值必须在 -100000 到 100000 之间")
+	}
 
 	dept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.Id)
 	if err != nil {

+ 33 - 0
internal/logic/dept/updateDeptLogic_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"math"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
@@ -437,3 +438,35 @@ func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
 	})
 	require.NoError(t, err, "无变更时 UpdateDept 只做元字段更新,不得触发缓存清理风暴")
 }
+
+// TC-1203: UpdateDept Sort 超出范围 [-100000, 100000] 被拒绝。
+// 与 CreateDept 同口径校验,防极端 Sort 值破坏部门树排序稳定性。
+func TestUpdateDept_SortOutOfRange_Rejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, err := insertDeptRaw(ctx, svcCtx, 0, "sort_upd_"+testutil.UniqueId(), "/")
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
+	before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
+	require.NoError(t, err)
+	origSort := before.Sort
+
+	for _, s := range []int64{100001, -100001, math.MaxInt64, math.MinInt64} {
+		err := NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "sort_test_" + testutil.UniqueId(), Sort: s,
+		})
+		require.Error(t, err, "Sort=%d 应被拒绝", s)
+		var ce *response.CodeError
+		require.True(t, errors.As(err, &ce), "Sort=%d 必须返回 CodeError", s)
+		assert.Equal(t, 400, ce.Code(), "Sort=%d 应 400 拒绝", s)
+		assert.Contains(t, ce.Error(), "排序值必须在 -100000 到 100000 之间")
+	}
+
+	// DB 中 Sort 值不得被改变。
+	after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
+	require.NoError(t, err)
+	assert.Equal(t, origSort, after.Sort, "Sort 越界被拒绝后 DB 值不得变化")
+}

+ 10 - 2
internal/logic/member/memberListLogic.go

@@ -18,6 +18,14 @@ type MemberListLogic struct {
 	svcCtx *svc.ServiceContext
 }
 
+// userDisplay 审计 L-R18-4:把 MemberList 里原先用的匿名 `struct{ Username, Nickname string }`
+// 具名化。匿名类型每次构造都要重新推导,IDE 跳转 / pprof 看到的类型字符串形如
+// `struct{Username string; Nickname string}`,排障时难以按类型名反查用途;具名后一目了然。
+type userDisplay struct {
+	Username string
+	Nickname string
+}
+
 func NewMemberListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MemberListLogic {
 	return &MemberListLogic{
 		Logger: logx.WithContext(ctx),
@@ -52,9 +60,9 @@ func (l *MemberListLogic) MemberList(req *types.MemberListReq) (resp *types.Page
 	if err != nil {
 		return nil, err
 	}
-	userMap := make(map[int64]struct{ Username, Nickname string }, len(users))
+	userMap := make(map[int64]userDisplay, len(users))
 	for _, u := range users {
-		userMap[u.Id] = struct{ Username, Nickname string }{u.Username, u.Nickname}
+		userMap[u.Id] = userDisplay{Username: u.Username, Nickname: u.Nickname}
 	}
 
 	items := make([]types.MemberItem, 0, len(list))

+ 7 - 0
internal/logic/product/createProductLogic.go

@@ -349,6 +349,13 @@ func (l *CreateProductLogic) compensateCreatedRows(productId, adminId, memberId
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
+	// 审计 L-R18-6:删除顺序前提 = sys_product_member / sys_user / sys_product 之间**没有**
+	// 外键约束,因此按 "最叶子 → 最根节点"(member → user → product)的直觉顺序删除等价于任意顺序;
+	// 三张表共用同一 DB 连接,单事务也就保证了整体原子性。若未来 DB schema 对这些表加了
+	// `ON DELETE RESTRICT` 的外键(例如 sys_user_role FK → sys_role / sys_role FK → sys_product),
+	// 需要把依赖更深的子表先删(sys_user_role、sys_role、sys_product_member),再删 sys_user / sys_product;
+	// 否则本函数会报 `Cannot delete or update a parent row: a foreign key constraint fails` 直接失败,
+	// 产生的 orphan 只能靠人工清理。改 schema 时请同步更新此顺序。
 	compErr := l.svcCtx.SysProductModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
 		if memberId > 0 {
 			if err := l.svcCtx.SysProductMemberModel.DeleteWithTx(txCtx, session, memberId); err != nil {

+ 12 - 0
internal/logic/product/updateProductLogic.go

@@ -125,5 +125,17 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 			}
 		}
 	}
+	// 审计 L-R18-2:禁用产品的 tokenVersion 递增是**单向**吊销——即便紧接着把产品切回
+	// Enabled,被踢下线的用户仍需重新登录。这条结构化审计日志让运维在"误禁用 → 立即
+	// 恢复"的回滚场景能向上级/用户解释"为什么重启用之后旧 token 还不能用"。
+	if shouldRevokeSessions {
+		l.Infow("product disabled: sessions irreversibly revoked",
+			logx.Field("audit", "product_disabled_sessions_revoked"),
+			logx.Field("auditHint", "sessions_revoked_irreversibly"),
+			logx.Field("productCode", product.Code),
+			logx.Field("productId", product.Id),
+			logx.Field("revokedUserCount", len(revokedUserIds)),
+		)
+	}
 	return nil
 }

+ 6 - 2
internal/logic/pub/adminLoginLogic_test.go

@@ -38,7 +38,10 @@ func TestAdminLogin_SuperAdmin(t *testing.T) {
 	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
 	assert.Equal(t, username, resp.UserInfo.Username)
 	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
-	assert.Nil(t, resp.UserInfo.Perms)
+	// 审计 L-R18-3:loadPerms 出口 Perms 恒为非 nil []string{};管理后台无 productCode 不加载权限,
+	// 但 Perms 不再是 nil,而是 []string{}(JSON 序列化为 [],不再为 null)。
+	assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 空 slice([]string{}),而非 nil")
+	assert.Empty(t, resp.UserInfo.Perms, "管理后台不传 productCode,不应加载任何权限列表")
 	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
 }
 
@@ -194,7 +197,8 @@ func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
-	assert.Nil(t, resp.UserInfo.Perms, "管理后台不传productCode,不应加载权限列表")
+	assert.NotNil(t, resp.UserInfo.Perms, "Perms 必须为非 nil 的空 slice([]string{})")
+	assert.Empty(t, resp.UserInfo.Perms, "管理后台不传productCode,不应加载权限列表")
 	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType, "超管即使不传productCode也会被标记SUPER_ADMIN")
 }
 

+ 6 - 0
internal/logic/pub/loginService.go

@@ -93,6 +93,12 @@ func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, usern
 	if !ud.IsSuperAdmin && ud.MemberType == "" {
 		return nil, &LoginError{Code: 403, Message: "您不是该产品的有效成员"}
 	}
+	// 审计 H-R18-2:与 jwtauthMiddleware 的 DeptStatus 拦截对齐——如果登录时不挡,
+	// 冻结部门成员仍能拿到一对 token,只是每次业务请求被 middleware 返 403,既产生
+	// 误导性的"登录成功"UX,又让 SOC 无法通过登录审计看到访问尝试。
+	if !ud.IsSuperAdmin && ud.DeptId > 0 && ud.DeptStatus != consts.StatusEnabled {
+		return nil, &LoginError{Code: 403, Message: "所在部门已被冻结"}
+	}
 
 	accessToken, err := authHelper.GenerateAccessToken(
 		svcCtx.Config.Auth.AccessSecret,

+ 12 - 2
internal/logic/role/updateRoleLogic.go

@@ -74,6 +74,7 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 	}
 
 	prevUpdateTime := role.UpdateTime
+	prevName := role.Name
 	role.Name = req.Name
 	role.Remark = req.Remark
 	role.PermsLevel = req.PermsLevel
@@ -95,12 +96,21 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 	// 角色已经更新成功,缓存清理属于尽力而为:failure 仅记录 Errorf,不映射为 500,
 	// 否则客户端会把"角色已改但缓存未刷"的 degraded 成功误判为完全失败而重试(见审计 M-4)。
 	// 旧权限缓存最多在 TTL 窗口内继续生效,由 TTL 过期兜底。
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+	defer cancel()
+
+	// 审计 H-R18-1:UpdateWithOptLock 内部只失效 (id) 与 (productCode, 新 name) 两把键;
+	// 如果本次 rename 改了 name,旧 (productCode, oldName) 索引键不会被清,Redis 里还指向
+	// 原主键,导致 FindOneByProductCodeName(oldName) 在 TTL 窗口(默认 7 天)继续命中脏行。
+	// 对齐 DeleteRoleLogic 的 post-commit InvalidateRoleCache 模式补一次显式失效。
+	if prevName != role.Name {
+		l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, role.Id, role.ProductCode, prevName)
+	}
+
 	if affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id); err == nil {
 		// 审计 L-R13-5 方案 A:角色 permsLevel/status 变更影响所有持有者 loadPerms 的授权判定,
 		// post-commit BatchDel 必须脱离请求 ctx——批量清理涉及多次 Redis RTT,遇到请求取消更
 		// 容易半途终止;这里的停用一旦滞留到 TTL 结束,就是 5 分钟内越权授权。
-		cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
-		defer cancel()
 		l.svcCtx.UserDetailsLoader.BatchDel(cleanCtx, affectedUserIds, role.ProductCode)
 	} else {
 		logx.WithContext(l.ctx).Errorf("UpdateRole roleId=%d 角色已更新但 FindUserIdsByRoleId 失败,用户权限缓存将等待 TTL 自然过期: %v", req.Id, err)

+ 52 - 0
internal/logic/role/updateRoleLogic_test.go

@@ -16,6 +16,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"go.uber.org/mock/gomock"
 )
 
@@ -158,6 +159,10 @@ func TestUpdateRole_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
 	// UpdateWithOptLock 成功;签名:UpdateWithOptLock(ctx, role, prevUpdateTime)。
 	roleMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(100)).Return(nil)
 
+	// 审计 H-R18-1:rename(before→after) 触发 InvalidateRoleCache(prevName),属于 post-commit 尽力而为,
+	// 无返回值,mock 仅注册期望、不校验结果——符合"缓存失效失败只记日志"的语义。
+	roleMock.EXPECT().InvalidateRoleCache(gomock.Any(), int64(9), "pc_m4u", "before")
+
 	// 关键断言:post-commit transient err 不应导致 handler 失败。
 	urMock.EXPECT().FindUserIdsByRoleId(gomock.Any(), int64(9)).
 		Return(nil, errors.New("boom"))
@@ -172,3 +177,50 @@ func TestUpdateRole_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
 	assert.NoError(t, err,
 		"UpdateRole 已提交成功,post-commit 缓存失败只记日志,handler 必须返回 nil")
 }
+
+// TC-1204: UpdateRole 重命名后旧 name 索引缓存必须失效。
+// UpdateWithOptLock 内部只失效新 name 键;rename 路径的旧 name 键必须在 post-commit 由
+// InvalidateRoleCache(prevName) 显式清除,否则 Redis TTL 窗口内同名并发创建会命中幽灵快照。
+func TestUpdateRole_RenameInvalidatesOldNameCache(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+	pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc)
+
+	oldName := "old_" + testutil.UniqueId()
+	roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc, Name: oldName,
+		Status: consts.StatusEnabled, PermsLevel: 10, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := roleRes.LastInsertId()
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_role`", roleId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	// 第一次查询,让 sysRole:productCode:name 索引键写入 Redis 缓存。
+	_, err = svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, oldName)
+	require.NoError(t, err)
+
+	newName := "new_" + testutil.UniqueId()
+	require.NoError(t, NewUpdateRoleLogic(ctx, svcCtx).UpdateRole(&types.UpdateRoleReq{
+		Id: roleId, Name: newName, Remark: "renamed", PermsLevel: 10,
+	}))
+
+	// 重命名后,旧 name 对应的 Redis 缓存键必须失效,否则 FindOneByProductCodeName 仍会返回旧行。
+	_, err = svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, oldName)
+	require.Error(t, err,
+		"rename 后旧 name 索引缓存键必须被 InvalidateRoleCache(prevName) 清除;"+
+			"若残留则 FindOneByProductCodeName 会返回已改名的旧行,形成幽灵快照")
+	require.True(t, errors.Is(err, sqlx.ErrNotFound),
+		"旧 name 缓存失效后,FindOneByProductCodeName 应返回 ErrNotFound 而非旧行")
+
+	// 新 name 应能正常查询到。
+	found, err := svcCtx.SysRoleModel.FindOneByProductCodeName(ctx, pc, newName)
+	require.NoError(t, err)
+	assert.Equal(t, roleId, found.Id)
+	assert.Equal(t, newName, found.Name)
+}

+ 12 - 0
internal/logic/user/createUserLogic.go

@@ -194,5 +194,17 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 	}
 
 	id, _ := result.LastInsertId()
+	// 审计 M-R18-4:超管走 deptId=0 分支创建"无部门账号"是合法但敏感路径——跳过了 S 锁 /
+	// 部门链 / DeptType 这一整套事务内校验,也会在 checkDeptHierarchy 等下游检查里被
+	// fail-close 拦成"幽灵账号"。沉淀一条 Infow 审计事件便于运维事后回捞"谁/何时/为哪条
+	// userId 创建了无部门账号"。非超管分支前置已拦,不会命中此日志。
+	if req.DeptId == 0 {
+		l.Infow("create user without dept",
+			logx.Field("audit", "create_user_no_dept"),
+			logx.Field("callerUserId", caller.UserId),
+			logx.Field("newUserId", id),
+			logx.Field("username", req.Username),
+		)
+	}
 	return &types.IdResp{Id: id}, nil
 }

+ 5 - 1
internal/logic/user/updateUserLogic.go

@@ -186,7 +186,11 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 			if err == nil && oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && oldDept.Status == consts.StatusEnabled {
 				// newDept == nil 即 deptId=0(SuperAdmin-only 路径);newDept != nil 且 DeptType==NORMAL
 				// 即挪到 NORMAL 部门;两种都构成 DEV 全权收窄。
-				if newDept == nil || newDept.DeptType == consts.DeptTypeNormal || newDept.Status != consts.StatusEnabled {
+				// 审计 L-R18-7:原来的第三分支 `newDept.Status != consts.StatusEnabled` 是死条件——
+				// newDept != nil 时 Status 必为 Enabled(line 136-138 已在进入本分支前用
+				// `req.BadRequest("目标部门已停用")` 拦截过),保留只会误导读者以为还有"调入已禁用
+				// DEV 部门"之类的残留路径。
+				if newDept == nil || newDept.DeptType == consts.DeptTypeNormal {
 					devAccessRevoked = true
 				}
 			}

+ 8 - 0
internal/middleware/jwtauthMiddleware.go

@@ -102,6 +102,14 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "账号已被冻结"))
 			return
 		}
+		// 审计 H-R18-2:所在部门已被冻结(DeptStatus=Disabled)时硬拦截所有非超管请求,
+		// 与 UpdateDeptLogic 的 normalDeptFrozen / devFullAccessRevoked 语义闭环——
+		// 冻结部门 = 冻结部门所有成员所有活动,而不仅是"吊销一次 session"。
+		// DeptId==0(超管或无部门的历史数据)不命中此分支,避免误伤。
+		if !ud.IsSuperAdmin && ud.DeptId > 0 && ud.DeptStatus != consts.StatusEnabled {
+			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "所在部门已被冻结"))
+			return
+		}
 		if claims.TokenVersion != ud.TokenVersion {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "登录状态已失效,请重新登录"))
 			return

+ 7 - 1
test-design.md

@@ -320,6 +320,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1084 | POST /api/dept/create | 父部门已 Disabled → 事务内 FindOneForShareTx 复核拒绝 | - | ParentStatus=2 | 400 "父部门已被禁用,无法创建子部门";DB 无子行 | P0 | 修复前只 SELECT id 会放行 |
 | TC-1085 | POST /api/dept/create | 父部门 Enabled 正向路径 + parentPath 来自事务内 snapshot | - | ParentStatus=1 | 子部门创建成功;`child.Path = parent.Path + childId + "/"` | P0 | 保证 parentPath 源自锁视图 |
 | TC-1086 | POST /api/dept/create | CreateDept × UpdateDept(禁用父) 并发 6 轮 | - | 每轮起 CreateDept + 裸 UPDATE `status=2` | 合法终态二选一:(a) CreateDept 先成功(父当时仍 Enabled),(b) UpdateDept 先成功 → CreateDept 收 400 "父部门已被禁用";**禁止出现 DB 残留子行 + 父 Disabled 但子 Enabled 的 write skew** | P0 | 事务内 S 锁与裸 UPDATE 的锁链闭合 |
+| TC-1202 | POST /api/dept/create | Sort 超出范围 [-100000, 100000] 被拒绝 | `Sort = -100001` / `Sort = 100001` / `Sort = math.MaxInt64` | 400 "排序值必须在 -100000 到 100000 之间";DB 无新行 | 边界/输入校验 | P0 | 防极端 Sort 值透传 DB 破坏同级排序稳定性;与 UpdateDept 同口径校验 |
 
 ### 2.9 部门更新/删除/树
 
@@ -351,6 +352,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1177 | POST /api/dept/update | NORMAL→DEV(升权方向)tokenVersion 保持不变 | seed Dept(DeptType=NORMAL, Status=1) + 1 个成员;`{DeptType:"DEV"}` | 成员 tokenVersion 与初值严格相等;部门 `deptType="DEV"` 落盘 | 正向回归 | P0 | 升权不构成收窄,不得把合法用户无故踢下线 |
 | TC-1178 | POST /api/dept/update | Status Disabled→Enabled(恢复启用)tokenVersion 保持不变 | seed Dept(DeptType=NORMAL, Status=2) + 1 个成员;`{Status:1}` | 成员 tokenVersion 与初值严格相等;部门 `status=1` | 正向回归 | P0 | 解冻方向镜像 TC-1177;仅"收窄"分支递增 tokenVersion |
 | TC-1200 | POST /api/dept/delete | 成功删除后必须在 post-commit 上显式失效 `sysDept:id` 缓存 | 插部门 → 手工 SET 一份 ghost 快照到 `<prefix>:cache:sysDept:id:<id>`(模拟 commit 前的并发回填)→ `DeleteDept` | Redis key 被 DEL(`Exists==false`);若残留则说明 post-commit 失效兜底被误撤,旧 dept 快照最长 5min TTL 内仍可被 FindOne 读到,叠加 orphan user 会放大为跨部门授权泄漏 | 缓存一致性/安全 | P0 | `sqlc.CachedConn.ExecCtx` 在 tx commit 之前已 DelCache,commit 前任何并发读都可能把旧行回填;必须用 detached ctx 在 commit 之后再显式 `InvalidateDeptCache` |
+| TC-1203 | POST /api/dept/update | Sort 超出范围 [-100000, 100000] 被拒绝 | `Sort = 100001` / `Sort = -100001` | 400 "排序值必须在 -100000 到 100000 之间";DB 记录不变 | 边界/输入校验 | P0 | 与 CreateDept 同口径校验,防极端 Sort 值破坏部门树排序稳定性 |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -378,7 +380,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0731 | POST /api/role/* | 产品 admin 保持或提升 PermsLevel | 100→100、100→500 | 均允许;DB 最终 PermsLevel=500 | 正常路径 | P0 | new>=old 放行 |
 | TC-0732 | POST /api/role/* | 超管降低 PermsLevel | SuperAdminCtx,500→10 | 成功 | 正常路径 | P0 | IsSuperAdmin 绕开 |
 | TC-0733 | POST /api/role/* | PermsLevel 越界(0/-1/1000/10000) | 任意非法 PermsLevel | 400 "权限级别必须在 1-999 之间" | 边界 | P0 | L-3 前置校验 |
-| TC-0777 | POST /api/role/* | FindUserIdsByRoleId 失败时返回 500 | mock DB 错误 | 500 "角色已更新但缓存刷新失败" | 容错 | P0 | 不再忽略错误 |
+| TC-0777 | POST /api/role/* | UpdateRole post-commit 缓存清理失败时仍返回成功 | `FindUserIdsByRoleId` 返回 err | handler 返回 nil(200)——缓存失效尽力而为,不得因缓存失败把已提交事务映射为 500 让客户端误重试 | 容错 | P0 | 对齐 `UpdateRole` 注释语义;与 TC-0859 联合覆盖 |
 | TC-0778 | POST /api/role/* | UpdateRole 乐观锁冲突 | 并发修改同一角色 | 409 "数据已被其他操作修改,请刷新后重试" | 并发安全 | P0 | `UpdateWithOptLock` WHERE updateTime=? |
 | TC-0779 | POST /api/role/* | UpdateProduct 乐观锁冲突 | 并发修改同一产品 | 409 同上 | 并发安全 | P0 | `UpdateWithOptLock` WHERE updateTime=? |
 | TC-0780 | POST /api/role/* | UpdateMember 基于事务内 locked 数据更新 | 正常更新 | 成功,使用 `locked` 行数据组装 UPDATE | 数据一致 | P0 | 事务内 `FindOneForUpdateTx` 结果作为更新基础 |
@@ -391,6 +393,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1197 | POST /api/role/create | 非超管 product ADMIN 禁止创建 `permsLevel=1` 顶格角色 | `AdminCtx(pc)` + `{productCode:pc, name:...,permsLevel:1}` | `CodeError.Code()==403`;文案含 "权限级别为 1 的顶格角色";DB 无新角色 | 安全/纵向越权 | P0 | 顶格角色只能由 SuperAdmin 创建;若放行 ADMIN,其可"建 R_super + BindRoles 给下属" 绕开 `GuardRoleLevelAssignable` 的同级拦截,形成等价横向提权链路 |
 | TC-1198 | POST /api/role/create | product ADMIN 创建 `permsLevel>=2` 次级角色放行 | `AdminCtx(pc)` + `{productCode:pc, name:..., permsLevel:2}` | 成功;DB `sys_role.permsLevel=2` | 正向回归 | P0 | 防 `GuardCreateRolePermsLevel` 过度收紧把合法业务路径也打死 |
 | TC-1199 | POST /api/role/create | SuperAdmin 不受 `permsLevel=1` 约束 | `SuperAdminCtx()` + `{..., permsLevel:1}` | 成功;DB `sys_role.permsLevel=1` | 正向回归 | P0 | SuperAdmin 是顶格角色的唯一合法来源;若回滚把超管也拦住,系统将没有任何路径能初始化 permsLevel=1 的角色 |
+| TC-1204 | POST /api/role/update | UpdateRole 重命名后旧 name 索引缓存必须失效 | 超管创建角色 name=A;第一次 Load 让 `sysRole:productCode:name:<pc>:A` 写入缓存;UpdateRole 把 name 改为 B;再次 `FindOneByProductCodeName(pc, "A")` | `FindOneByProductCodeName` 返回 `sqlx.ErrNotFound`(不得返回旧行数据);说明 post-commit `InvalidateRoleCache(oldName)` 已把 Redis 里的 `<pc>:A` 索引键清掉 | 缓存一致性/安全 | P0 | `UpdateWithOptLock` 内部只失效新 name 键;rename 路径的旧 name 键必须在 post-commit 由 `InvalidateRoleCache(prevName)` 显式清除,否则 Redis TTL 窗口内同名并发创建会命中幽灵快照 |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -1287,6 +1290,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0519 | MEMBER角色权限+ALLOW-DENY | 有角色+ALLOW+DENY | 正确计算 | 深度业务 | P0 | denySet过滤 |
 | TC-0520 | 用户ALLOW权限不跨产品泄漏 | 用户在产品A/B各有ALLOW权限 | 加载产品A时仅含A权限,不含B权限 | 安全 | P0 | FindPermIdsByUserIdAndEffectForProduct |
 | TC-0521 | 禁用DEV部门成员无全量权限 | dept.type=DEV, dept.status=Disabled | ud.Perms为空 | 安全 | P0 | DeptStatus检查 |
+| TC-1205 | NORMAL 部门冻结(Status Disabled)后成员 Perms 为空 | DeptType=NORMAL, DeptStatus=Disabled, MemberType=MEMBER | `ud.Perms` 等于 `[]string{}`(非 nil);不得包含任何权限码 | 安全/业务约束 | P0 | `loadPerms` 在 `if !ud.IsSuperAdmin && ud.DeptId>0 && ud.DeptStatus!=Enabled { return nil }` 前置拦截;冻结部门成员重登后应立即无权,而非等 JWT 过期 |
+| TC-1206 | loadPerms 出口 Perms 恒为非 nil 数组 | 普通成员无角色无附加权限 | `ud.Perms` 为 `[]string{}`(`json.Marshal` 输出 `[]` 而非 `null`) | 接口契约 | P0 | `ud.Perms = []string{}` 初始化保证下游前端/gRPC 客户端无需做 nil vs [] 的双重 defensive check |
 
 ### 10.4 loadRoles + MinPermsLevel
 
@@ -1304,6 +1309,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0526 | 超管自动设置SUPER_ADMIN | IsSuperAdmin=true | MemberType=SUPER_ADMIN, 不查DB | 正常路径 | P0 | 早期return |
 | TC-0527 | 非成员MemberType为空 | 用户非该产品成员 | MemberType="" | 边界 | P0 | ErrNotFound |
 | TC-0528 | 禁用成员MemberType为空 | member.status=Disabled | ud.MemberType="" | 安全 | P0 | loadMembership |
+| TC-1207 | `errors.Is` 语义稳健性:ErrNotFound 包装后仍被正确识别 | `productmember.ErrNotFound` 来自 `sqlx.ErrNotFound`;若 model 层未来包装为 `fmt.Errorf("%w", err)`,`errors.Is` 仍应成立 | `errors.Is(productmember.ErrNotFound, sqlx.ErrNotFound) == true`;`errors.Is(fmt.Errorf("wrap: %w", productmember.ErrNotFound), sqlx.ErrNotFound) == true`;两层包装均不应把"用户非成员"退化成 503 | 契约/健壮性 | P1 | 防止未来 model 层引入包装错误时 `loadMembership` 的 ErrNotFound 分支悄悄失配、把合法的"不是成员"判定退化为 `ErrLoaderDegraded` |
 
 ## 十一、中间件 — 冻结账号拦截
 

+ 39 - 35
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-20(最新一轮
+> 报告日期: 2026-04-21(最新一轮,含 R18 审计覆盖
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,10 +12,10 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **972** |
-| 顶层测试函数数 (Functions) | **1083** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1214** |
-| ✅ 通过 | **1213** |
+| TC 用例总数 (test-design.md) | **979** |
+| 顶层测试函数数 (Functions) | **1089** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1221** |
+| ✅ 通过 | **1220** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
@@ -25,32 +25,32 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 1.294s |
-| internal/handler/auth | ✅ ok | 0.801s |
-| internal/handler/product | ✅ ok | 2.020s |
-| internal/handler/pub | ✅ ok | 2.631s |
-| internal/loaders | ✅ ok | 3.249s |
-| internal/logic/auth | ✅ ok | 11.370s |
-| internal/logic/dept | ✅ ok | 2.863s |
-| internal/logic/member | ✅ ok | 2.787s |
-| internal/logic/perm | ✅ ok | 2.697s |
-| internal/logic/product | ✅ ok | 11.937s |
-| internal/logic/pub | ✅ ok | 6.024s |
-| internal/logic/role | ✅ ok | 4.271s |
-| internal/logic/user | ✅ ok | 11.046s |
-| internal/middleware | ✅ ok | 5.085s |
-| internal/model/dept | ✅ ok | 5.559s |
-| internal/model/perm | ✅ ok | 6.124s |
-| internal/model/product | ✅ ok | 7.072s |
-| internal/model/productmember | ✅ ok | 7.124s |
-| internal/model/role | ✅ ok | 7.366s |
-| internal/model/roleperm | ✅ ok | 6.984s |
-| internal/model/user | ✅ ok | 14.717s |
-| internal/model/userperm | ✅ ok | 7.235s |
-| internal/model/userrole | ✅ ok | 5.887s |
-| internal/response | ✅ ok | 5.074s |
-| internal/server | ✅ ok | 5.597s |
-| internal/util | ✅ ok | 5.202s |
+| internal/handler | ✅ ok | 0.900s |
+| internal/handler/auth | ✅ ok | 2.531s |
+| internal/handler/product | ✅ ok | 3.287s |
+| internal/handler/pub | ✅ ok | 4.098s |
+| internal/loaders | ✅ ok | 4.846s |
+| internal/logic/auth | ✅ ok | 13.866s |
+| internal/logic/dept | ✅ ok | 5.833s |
+| internal/logic/member | ✅ ok | 6.722s |
+| internal/logic/perm | ✅ ok | 6.448s |
+| internal/logic/product | ✅ ok | 15.337s |
+| internal/logic/pub | ✅ ok | 9.595s |
+| internal/logic/role | ✅ ok | 7.618s |
+| internal/logic/user | ✅ ok | 14.596s |
+| internal/middleware | ✅ ok | 9.011s |
+| internal/model/dept | ✅ ok | 9.775s |
+| internal/model/perm | ✅ ok | 9.825s |
+| internal/model/product | ✅ ok | 9.853s |
+| internal/model/productmember | ✅ ok | 9.049s |
+| internal/model/role | ✅ ok | 9.029s |
+| internal/model/roleperm | ✅ ok | 8.563s |
+| internal/model/user | ✅ ok | 16.227s |
+| internal/model/userperm | ✅ ok | 7.186s |
+| internal/model/userrole | ✅ ok | 5.606s |
+| internal/response | ✅ ok | 5.712s |
+| internal/server | ✅ ok | 5.457s |
+| internal/util | ✅ ok | 4.233s |
 
 ### 1.2 跳过用例说明
 
@@ -325,6 +325,7 @@
 | TC-1084 | 父部门已 Disabled → 事务内 FindOneForShareTx 复核拒绝 | ✅ pass |
 | TC-1085 | 父部门 Enabled 正向路径 + parentPath 来自事务内 snapshot | ✅ pass |
 | TC-1086 | CreateDept × UpdateDept(禁用父) 并发 6 轮 | ✅ pass |
+| TC-1202 | CreateDept Sort 超出 [-100000, 100000] 被拒绝(负向+边界正向)(L-R18-5) | ✅ pass |
 
 ### 2.9 部门更新/删除/树
 
@@ -355,6 +356,7 @@
 | TC-1177 | NORMAL→DEV(升权方向)tokenVersion 保持不变 | ✅ pass |
 | TC-1178 | Status Disabled→Enabled(恢复启用)tokenVersion 保持不变 | ✅ pass |
 | TC-1200 | DeleteDept 成功提交后必须 post-commit 显式失效 `sysDept:id` 缓存(H-R17-2) | ✅ pass |
+| TC-1203 | UpdateDept Sort 超出 [-100000, 100000] 被拒绝,DB 中 Sort 值不变(L-R18-5) | ✅ pass |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -382,7 +384,7 @@
 | TC-0731 | 产品 admin 保持或提升 PermsLevel | ✅ pass |
 | TC-0732 | 超管降低 PermsLevel | ✅ pass |
 | TC-0733 | PermsLevel 越界(0/-1/1000/10000) | ✅ pass |
-| TC-0777 | FindUserIdsByRoleId 失败时返回 500 | ✅ pass |
+| TC-0777 | UpdateRole:UpdateWithOptLock 成功,FindUserIdsByRoleId 失败,handler 返回 nil(降级成功) | ✅ pass |
 | TC-0778 | UpdateRole 乐观锁冲突 | ✅ pass |
 | TC-0779 | UpdateProduct 乐观锁冲突 | ✅ pass |
 | TC-0780 | UpdateMember 基于事务内 locked 数据更新 | ✅ pass |
@@ -395,6 +397,7 @@
 | TC-1197 | 非超管 product ADMIN 不得创建 `PermsLevel=1` 顶格角色(H-R17-3) | ✅ pass |
 | TC-1198 | 非超管 product ADMIN 可创建 `PermsLevel>=2` 角色(正向回归) | ✅ pass |
 | TC-1199 | SuperAdmin 仍可创建 `PermsLevel=1` 顶格角色(正向回归) | ✅ pass |
+| TC-1204 | UpdateRole rename 后旧 name 索引缓存必须被 InvalidateRoleCache(prevName) 显式清除(H-R18-1) | ✅ pass |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -1301,6 +1304,9 @@
 | TC-0526 | 超管自动设置SUPER_ADMIN | ✅ pass |
 | TC-0527 | 非成员MemberType为空 | ✅ pass |
 | TC-0528 | 禁用成员MemberType为空 | ✅ pass |
+| TC-1205 | NORMAL 部门冻结(DeptStatus=Disabled)后,成员 Perms 为空 []string{}(H-R18-2) | ✅ pass |
+| TC-1206 | 普通成员无任何角色时 Perms 必须为 []string{}(非 nil),JSON 序列化恒为 [](L-R18-3) | ✅ pass |
+| TC-1207 | productmember.ErrNotFound 与 sqlx.ErrNotFound errors.Is 契约稳健性(M-R18-3) | ✅ pass |
 
 ## 十一、中间件 — 冻结账号拦截
 
@@ -1364,11 +1370,9 @@
 
 ---
 
----
-
 ## 三、测试结论
 
-- **972 个 TC 全部执行**:顶层测试函数 **1083**,测试事件(含 `t.Run` 子用例)**1214**;通过 **1213**,跳过 **1**,失败 **0**。
+- **979 个 TC 全部执行**:顶层测试函数 **1089**,测试事件(含 `t.Run` 子用例)**1221**;通过 **1220**,跳过 **1**,失败 **0**。
 - 26 个测试包全部 OK;`./internal/logic/...` 语句覆盖率 **86.9%**;整包连跑均绿,无并发 flake 触发。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支 TC-0263):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全、会话吊销 tokenVersion 契约)均有独立回归用例覆盖且稳定通过。