Prechádzať zdrojové kódy

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

BaiLuoYan 3 týždňov pred
rodič
commit
2e80c836c1
31 zmenil súbory, kde vykonal 1444 pridanie a 495 odobranie
  1. 368 364
      audit-report.md
  2. 41 0
      internal/logic/auth/access.go
  3. 8 4
      internal/logic/auth/changePasswordLogic.go
  4. 50 0
      internal/logic/auth/changePasswordLogic_test.go
  5. 6 6
      internal/logic/dept/createDeptLogic.go
  6. 7 10
      internal/logic/dept/createDeptLogic_mock_test.go
  7. 20 2
      internal/logic/dept/deleteDeptLogic.go
  8. 40 0
      internal/logic/dept/deleteDeptLogic_test.go
  9. 23 0
      internal/logic/dept/deptTreeLogic.go
  10. 18 0
      internal/logic/product/createProductLogic.go
  11. 8 2
      internal/logic/product/createProductLogic_mock_test.go
  12. 90 1
      internal/logic/product/createProductLogic_test.go
  13. 18 11
      internal/logic/pub/syncPermsLogic_test.go
  14. 17 9
      internal/logic/pub/syncPermsService.go
  15. 14 0
      internal/logic/role/createRoleLogic.go
  16. 87 0
      internal/logic/role/createRoleLogic_test.go
  17. 6 0
      internal/logic/role/deleteRoleLogic.go
  18. 56 0
      internal/logic/role/deleteRoleLogic_test.go
  19. 93 39
      internal/logic/user/createUserLogic.go
  20. 10 2
      internal/logic/user/createUserLogic_mock_test.go
  21. 263 1
      internal/logic/user/createUserLogic_test.go
  22. 17 2
      internal/logic/user/updateUserLogic.go
  23. 15 0
      internal/logic/user/updateUserStatusLogic.go
  24. 17 2
      internal/logic/user/updateUserStatusLogic_test.go
  25. 19 0
      internal/model/dept/sysDeptModel.go
  26. 32 0
      internal/model/role/sysRoleModel.go
  27. 7 0
      internal/model/user/sysUserModel.go
  28. 14 0
      internal/testutil/mocks/mock_dept_model.go
  29. 12 0
      internal/testutil/mocks/mock_role_model.go
  30. 16 3
      test-design.md
  31. 52 37
      test-report.md

+ 368 - 364
audit-report.md

@@ -1,506 +1,510 @@
-# 深度审计报告 · Round 16
+# 深度审计报告 · Round 17
 
-> 基线:R15 审计后的代码库快照。R15 提出的 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1/2/3)均已在代码中落地(`updateMemberLogic.go` / `updateProductLogic.go` 在 tx 内补齐 `tokenVersion+1` 降权吊销;`updateUserLogic.go` 的 `deptId=0` 收敛给 SuperAdmin;`deptTreeLogic.go` 的 `fullAccess` 收敛给 SuperAdmin)。本轮聚焦两类 R15 未闭合的面:
+> 基线: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 的既往修复经复检全部稳定。
 >
-> 1. **降权吊销的覆盖对称性**:`UpdateMember` / `UpdateProduct` 已走"降权即 tokenVersion+1"的闭环,但与它们语义同构的**其它降权路径**(`RemoveMember`、`UpdateDept` 改 DeptType/Status、`UpdateUser` 改 deptId 跨越 DEV/NORMAL 边界)仍是"仅 post-commit `UserDetailsLoader.*` 尽力而为失效"的旧口径——Redis 抖动时 5min TTL 窗口内旧权限继续生效,构成与 M-R15-1 同等的 TOCTOU 面;
-> 2. **全局字段 `sys_user.deptId` 的跨产品结构性副作用**:R15 的 L-R15-1 只封了 `deptId=0` 这条极端路径,但**产品 ADMIN 把共享成员调入自己部门子树外的非 DEV 部门**同样会改写全局 `sys_user.deptId`,在其它产品的管理视角里制造"同产品 ADMIN 够不到、DeptTree 里显示错位"的异常状态;
-> 3. **PII 最小授权**:`UserList` / `UserDetail` 两接口只做"同产品成员"校验即返回目标的 `email` / `phone`,没有按 MemberType 收敛,普通 MEMBER 成员可枚举本产品全部成员的联系方式。
+> 本轮聚焦四块 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 入场券"。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-R16-1 · `RemoveMember` 降权路径未随 M-R15-1 同步吊销会话 —— 与 `UpdateMember` 对称缺口
+### H-R17-1 · `CreateUser` / `CreateProductLogic` 对目标 `sys_dept` 无事务锁,与 `DeleteDept` 存在 TOCTOU 竞态(orphan user + 幽灵部门缓存 权限升级)
 
 **位置**
 
-- `internal/logic/member/removeMemberLogic.go:42-74`(事务体只做 `DeleteByUserId* + Delete`,无 `IncrementTokenVersionWithTx`)
-- 对比参照:`internal/logic/member/updateMemberLogic.go:94-134`("降权即 `tokenVersion+1`"已落地)
+- `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` 做"删除 → 检查既有用户"的串行化)
 
 **描述**
 
-M-R15-1 / L-R15-3 落地后,"降权/禁用"这类"从'有效成员'向'无效成员'迁移"的路径都在 tx 内把目标的 `sys_user.tokenVersion` 做 `+1`,让旧 access token 在 `jwtauthMiddleware` 的 `claims.TokenVersion != ud.TokenVersion` 兜底下立刻 401,即使 Redis `Del`/`Clean` 失败也不会残留特权。但这条口径**漏掉了 `RemoveMember`**
+`DeleteDept` 已经通过 X 锁 + `FOR SHARE` 锁把"既有用户占着这个部门就不让删"编织进事务
 
-```42:74:internal/logic/member/removeMemberLogic.go
-if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-    locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
-    if err != nil {
-        return response.ErrNotFound("成员不存在")
-    }
-    if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
-        otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
-        if err != nil {
-            return err
-        }
-        if otherAdminCount == 0 {
-            return response.ErrBadRequest("不能移除该产品的最后一个管理员")
-        }
-    }
-    if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
-        return err
+```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 err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
-        return err
+    if newDept.Status != consts.StatusEnabled {
+        return nil, response.ErrBadRequest("目标部门已停用")
     }
-    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
-}); err != nil {
-    return err
+    // DeptPath 校验 ...
 }
 
-cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
-defer cancel()
-l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
-return nil
+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,
+    // ...
+})
 ```
 
-**`RemoveMember` 事实上是 `UpdateMember` 降权路径的极端版**——不是"ADMIN→MEMBER"而是"ADMIN→`MemberType=""`(非成员)",造成的授权语义跳变更剧烈
+竞态时序(T1 = SuperAdmin 删部门,T2 = ADMIN 创建用户)
 
-- `loadPerms` 对"非成员"会返回 `nil` → 原本的 ADMIN 全权直接清空;
-- `jwtauthMiddleware` 对 `ud.MemberType == ""` 非超管会 403 `"您已不是该产品的有效成员"`;
-- `CheckManageAccess` 的 ADMIN 分支直接跳过 `checkDeptHierarchy`,对成员的管理面彻底开放。
+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` 不存在。
 
-攻击场景(与 M-R15-1 的描述完全同构,只是触发点从 `UpdateMember` 换成 `RemoveMember`):
+**紧接着的二次放大**:T2 触发缓存层的"幽灵部门"后果——
 
-1. SuperAdmin 发起 `RemoveMember` 把 P1 的 ADMIN `A` 移出产品;tx 成功,`UserDetailsLoader.Del(A.UserId, P1)` 被调用;
-2. Redis 在这 3s 内(`DetachCacheCleanCtx` 的超时)出现网络抖动,`DelCtx` 返回 err(代码已通过 `logCacheInvalidationErr` 打 `cache_invalidation_skipped_*` 标签,但**不重试、不 block 请求**,继续返回 200);
-3. 缓存里仍是 `{MemberType: ADMIN, Perms: [...P1 全权], TokenVersion: 旧值}`,TTL 还剩最长 5 分钟;
-4. A 拿着手里的 access token 继续调:
-   - `jwtauthMiddleware` 命中 Redis 旧 UD → 放行;
-   - `UpdateMember` / `BindRoles` / `UpdateRole` / `DeleteRole` / `SetUserPerms` 等所有产品管理接口的 `RequireProductAdminFor` 都看 `caller.MemberType == ADMIN` 放行;
-   - `CheckManageAccess` 走 ADMIN 分支跳过部门链校验,A 可以对 P1 任意成员继续降级、改 PermsLevel、授高权限角色;
-5. 5 分钟 TTL 过期后缓存自然重建,A 才被系统真正视作"非成员"。
+- **良性分支**:`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 行)。
 
-更严重的是:**此时 A 的 `tokenVersion` 未被递增**,即便运维事后发现 Redis 抖动手动 `Del` 了缓存 key,只要 A 把 access token 保存好,下一次 Load 重建的 UD 仍然是"DB 视角下的非成员"——这是良性的(会 403)。但在 TTL 滞留窗口里 A 的 access token 本身是有效凭据(签名、类型、过期时间、`TokenVersion` 与 `ud.TokenVersion` 都匹配),`jwtauthMiddleware` 无法把这段残留权限踢下线
+`CreateProductLogic` 同构受害:auto-provision 的 initial admin 行同样走 `SysUserModel.Insert` 而不是 `InsertWithTx`,即使调用方已经是 SuperAdmin 也一样会踩这个坑(SuperAdmin 删部门 + 另一个 SuperAdmin 同时创产品,两个 P0 管理员并发一点不罕见)。
 
 **影响**
 
-- 与 M-R15-1 等级相同的权限升级 TOCTOU:被移除的 ADMIN / DEVELOPER 在 Redis 抖动时保留完整产品管理权 ≤5min;
-- 运维侧无任何手段**强制**下线(缓存 TTL 过期是唯一收敛机制);
-- 组合攻击面:如果 5min 内 A 利用残留的 ADMIN 权把自己以不同 userId(例如预埋的备用账号)重新 `AddMember` / `BindRoles`,账号回收动作形同虚设——这正是 M-R15-1 修 `UpdateMember` 时就要求的"必须在签发层吊销而不是在缓存层吊销"语义。
+- 业务数据完整性破口:`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 外)互补:那一条堵"挪出",这一条堵"新建时就漂在外面"。
 
 **修复方案**
 
-把 `RemoveMember` 的事务体补齐"降权即 `tokenVersion+1`"的闭环,与 `UpdateMember` 的 M-R15-1 口径完全对齐。`RemoveMember` 的语义比 `UpdateMember` 更清晰——只要走到事务体,就一定构成"从有效成员(或 ADMIN/DEVELOPER)→ 非成员"的降权,**无需再判定是否为"降权"**,无条件递增即可
+把 `CreateUser` / `CreateProductLogic` 的用户建行动作移进同一个事务,并在事务内用 `FindOneForShareTx` 对目标 `sys_dept` 取 S 锁
 
 ```go
-// internal/logic/member/removeMemberLogic.go
-if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-    locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
-    if err != nil {
-        return response.ErrNotFound("成员不存在")
-    }
-    if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
-        otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
-        if err != nil {
-            return err
+// 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 otherAdminCount == 0 {
-            return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+        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("无权在非自己管辖的部门下创建用户")
         }
     }
-    if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
-        return err
-    }
-    if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
-        return err
-    }
-    // 审计 H-R16-1:移除成员是"有效成员→非成员"的极端降权,签发层吊销与 M-R15-1 / L-R15-3 对齐:
-    //   - IncrementTokenVersionWithTx 放在 DeleteWithTx 之前,任一步失败整体回滚,不会出现
-    //     "已 +1 但成员行没删" 或 "成员行已删但 tokenVersion 没 +1" 的脏中间态。
-    //   - 不再把吊销绑定到 UserDetailsLoader.Del 的 Redis 可用性——即使 Del 失败,
-    //     jwtauthMiddleware 的 claims.TokenVersion != ud.TokenVersion 兜底仍然会 401。
-    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil {
-        return err
+    // bcrypt 的慢计算放到事务外(见下一行),事务内只做锁 + Insert
+    result, ierr := l.svcCtx.SysUserModel.InsertWithTx(ctx, session, &userModel.SysUser{ /* 带 hashedPwd */ })
+    if ierr != nil {
+        return ierr
     }
-    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
-}); err != nil {
-    return err
-}
+    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 后行"语义在事务内退化为**提交前清缓存**,引入幽灵快照窗口
 
-cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
-defer cancel()
-// 审计 H-R16-1:tokenVersion 已落库,post-commit 还要把 sysUser 的低层缓存
-// (cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一并失效——否则 UD loader 下次 miss 时
-// 会从 sysUser 低层缓存里拿到旧 tokenVersion,把刚递增过的值抹回去(与 UpdateMember 同)。
-if user, err := l.svcCtx.SysUserModel.FindOne(cleanCtx, member.UserId); err == nil && user != nil {
-    l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, user.Id, user.Username)
-} else if err != nil {
-    logx.WithContext(l.ctx).Errorf("RemoveMember post-commit FindOne(%d) failed for token-version cache invalidation: %v", member.UserId, err)
+**位置**
+
+- 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
 }
-l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
-return nil
 ```
 
-若不希望在事务外再 `FindOne`(Redis 抖动时可能慢 100ms 级别),也可以在事务体内把 `locked.UserId` 与 `username`(通过 tx 内的 `l.svcCtx.SysUserModel.FindOneForShareTx` 或在进入 tx 前的 `member` 查询里预取)通过闭包透传到 post-commit;由于 `RemoveMember` 没有超高 QPS 预期(行政操作),直接走 post-commit `FindOne`(该查询本身带 sqlc 缓存)也足够。
+当 `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。
 
-- tx 体内 `IncrementTokenVersionWithTx` 返回 `ErrUpdateConflict`(竞态:并发 `RemoveMember`/`Logout`/`ChangePassword`)时整体回滚,测试需断言成员行仍存在;
-- Redis 完全不可用场景下,断言被移除的 ADMIN 在下一次 HTTP 请求时被 `jwtauthMiddleware` 401;
-- 与 `UpdateMember` 的测试矩阵对称扩容:ADMIN/DEVELOPER/MEMBER 被移除后,旧 access token 均应被立即拒绝。
+在 (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 想堵的"权限收窄不立刻生效"。
 
-## ⚠️ 健壮性与性能建议 (Medium / Low)
+**修复方案**
+
+这是 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` 路径一次性脱离本陷阱,避免逐个调用点打补丁。
+
+---
 
-### M-R16-1 · `UserList` / `UserDetail` 对任意同产品成员暴露 `email` / `phone` —— PII 最小授权缺失
+### H-R17-3 · `CreateRoleLogic` 缺失 caller permsLevel 与 `req.PermsLevel` 的纵向对称校验,product ADMIN 可借"建新角色 + BindRoles"实现下属纵向提权
 
 **位置**
 
-- `internal/logic/user/userListLogic.go:38-90`(仅做"同产品"校验,无 MemberType 收敛,`email`/`phone` 直接回落到所有可见成员)
-- `internal/logic/user/userDetailLogic.go:34-76`(仅做"同产品成员"校验,`email`/`phone` 同样全量返回)
+- `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
 
 **描述**
 
-R14 / R15 已经把"同产品 ADMIN 可以管理同产品成员"的边界拉紧,但"同产品 MEMBER **读**其他成员信息"的边界还停留在"只要是同产品成员即可"
+项目"纵向权限防护"的核心不变式在 `auth/access.go::GuardRoleLevelAssignable` / `CheckRoleLevelAgainst`
 
-```38:45:internal/logic/user/userListLogic.go
-if !caller.IsSuperAdmin {
-    if req.ProductCode == "" {
-        return nil, response.ErrForbidden("非超管用户必须指定产品编码")
-    }
-    if caller.ProductCode != req.ProductCode {
-        return nil, response.ErrForbidden("无权访问该产品的数据")
-    }
+```237:240:internal/logic/auth/access.go
+if rolePermsLevel <= snap.Level {
+    return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
 }
 ```
 
-```34:41:internal/logic/user/userDetailLogic.go
-if !caller.IsSuperAdmin {
-    if caller.ProductCode == "" {
-        return nil, response.ErrForbidden("会话缺少产品上下文")
-    }
-    if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
-        return nil, 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 之间")
 }
 ```
 
-两处响应都**无差别地**把 `u.Email` / `u.Phone` / `u.Remark` / `u.DeptId` 填到响应:
-
-```77:90:internal/logic/user/userListLogic.go
-items = append(items, types.UserItem{
-    Id:         u.Id,
-    Username:   u.Username,
-    Nickname:   u.Nickname,
-    Avatar:     avatar,
-    Email:      u.Email,
-    Phone:      u.Phone,
-    Remark:     u.Remark,
-    DeptId:     u.DeptId,
-    Status:     u.Status,
-    MemberType: memberType,
-    CreateTime: u.CreateTime,
-})
-```
+product ADMIN 可以走如下两步实现"把下属拉到与自己相同乃至更高的 assignable level":
 
-对比 `MemberListLogic` 的返回(只含 `Username` / `Nickname` / `MemberType` / `Status`,**无** `email` / `phone`)可以看到:业务上 MEMBER 浏览成员列表的合理需要是"看到谁在这个产品里",不是"拿到所有同事的联系方式"。当前 `UserList` / `UserDetail` 无视 caller 的 MemberType,让 P1 的普通 MEMBER 能一键分页导出 P1 全体成员的 `email` / `phone`:
+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 入场券"。
 
-- **内部滥用面**:P1 的任一普通 MEMBER(包括产品接入方给终端客户开的 low-tier 账号)都能拉走全员通讯录,钓鱼 / 二次社工的投递名单直接就位;
-- **跨产品溯源弱化**:`email` / `phone` 是全局 `sys_user` 字段,一人在 P1+P2 同为 MEMBER 时,两产品任一方的 MEMBER 都可拉到同一份 PII,合规审计上分不出泄漏源;
-- **UI 真需 vs 接口默认输出不一致**:即使前端当前 MEMBER 视角的页面不渲染 `email` / `phone`,接口仍在响应体里把字段返回,绕过前端就能拿到——API 契约才是授权边界,不是 UI。
+这条链并不是在分配既有角色的环节被 `GuardRoleLevelAssignable` 拦截(ADMIN 确实全权),而是**在创建角色时先把弹药造好**:新角色的 PermsLevel 不受 caller 自身 assignable level 的约束。`UpdateRoleLogic` 的 R12-3 防护没有覆盖"先建后提升"的构造式等价路径。
 
 **影响**
 
-- **PII 过度暴露 → 合规红线**:GDPR/PIPL/内部数据分级都要求"联系方式"类字段按职责最小化返回。当前接口对同产品 MEMBER 无差别发放,容易被监管 / 安全评估点名
-- **社工攻击前置资源充足**:攻击者一旦拿到任意 P1 MEMBER 的凭据(撞库、钓鱼、误 commit token),就能把 P1 全员通讯录导出,为后续的二阶钓鱼 / SIM swap / 账号接管提供精准名单
-- **审计覆盖面与 `MemberList` 口径不一致**:`MemberListLogic` 已经收敛了响应字段(不泄露 PII),但 `UserListLogic`(同为"按产品分页列成员"用途)没有收敛,两者 API 语义重合但安全边界不同,易被误判
+- 横向提权(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 拦不住。
 
 **修复方案**
 
-按"自己可见全部字段、他人仅超管/ADMIN/DEVELOPER 可见 PII"的分层授权收窄响应体。不建议在 logic 层加复杂 if/else 后再 copy 字段——容易随字段增加漏脱敏。推荐在响应装配前统一做 PII 脱敏
+CreateRole 加入与 UpdateRole 对称的 `GuardRoleLevelAssignable`/`CheckRoleLevelAgainst` 校验:
 
 ```go
-// internal/logic/user/userListLogic.go
-
-// maskPII 统一决定:对于同产品内的他人,是否对 caller 隐藏 PII。
-// 规则:caller 是 SuperAdmin / ADMIN / DEVELOPER 返回原值;其他情况(含普通 MEMBER)置空。
-// caller 看自己不走这里——UserListLogic / UserDetailLogic 单独兜一下 `u.Id == caller.UserId` 的分支即可。
-func maskPII(caller *loaders.UserDetails, u *userModel.SysUser) (email, phone, remark string) {
-    if caller.IsSuperAdmin ||
-        caller.MemberType == consts.MemberTypeAdmin ||
-        caller.MemberType == consts.MemberTypeDeveloper ||
-        (caller.UserId == u.Id) {
-        return u.Email, u.Phone, u.Remark
-    }
-    return "", "", ""
+caller := middleware.GetUserDetails(l.ctx)
+if caller == nil {
+    return nil, response.ErrUnauthorized("未登录")
 }
-
-// 拼装 items 时:
-for _, u := range list {
-    email, phone, remark := maskPII(caller, u)
-    items = append(items, types.UserItem{
-        Id:         u.Id,
-        Username:   u.Username,
-        Nickname:   u.Nickname,
-        Avatar:     avatar,
-        Email:      email,
-        Phone:      phone,
-        Remark:     remark,
-        DeptId:     u.DeptId, // DeptId 本身不含 PII,可保留——前端可以凭此做部门筛选
-        Status:     u.Status,
-        MemberType: memberType,
-        CreateTime: u.CreateTime,
-    })
+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
+    }
 }
 ```
 
-`UserDetailLogic` 沿用相同 helper;同时在 `UserDetailLogic` 里额外加一层"caller == target 或 caller 有管理职权时才返回完整信息"的自检(目标是 `caller.UserId != req.Id` 且 `caller` 非 ADMIN/DEVELOPER 的场景,可以考虑直接 `return ErrForbidden`,而不是回一个"只有昵称没有 PII"的半成品——后者会让前端误以为目标真的没绑邮箱 / 手机号)。
+特别注意: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 由下属自己的角色决定,拒同级)。
 
-- MEMBER 身份调用 `UserList` / `UserDetail`:
-  - 看自己 → Email / Phone 原样返回;
-  - 看他人 → Email / Phone / Remark 为空字符串,其余字段保留;
-- ADMIN / DEVELOPER 调用上述接口:所有字段原样返回(与现网行为一致,避免破坏管理台体验);
-- 前端若强依赖"字段非空"作逻辑分支,需同步升级——建议增加响应 schema 的版本协商或在 item 上新增 `piiVisible bool` 提示,减少默默置空导致的 UI 侧 regression。
+推荐把这条语义沉淀到一个新 helper `GuardCreateRolePermsLevel(ctx, svcCtx, caller, reqLevel) error`,与 `GuardRoleLevelAssignable` 并排放在 `access.go` 里,后续 UpdateRoleLogic 的 R12-3 支也可以复用。
 
 ---
 
-### L-R16-1 · `UpdateUser` 的 ADMIN 分支短路 DeptPath 前缀校验 —— 非 `deptId=0` 方向的同构缺口
+## ⚠️ 健壮性与性能建议 (Medium/Low)
+
+### M-R17-1 · `SyncPermsService` 的 pure-add 分支对"全权用户"有最长 5min 的权限可见延迟
 
 **位置**
 
-- `internal/logic/user/updateUserLogic.go:140-157`(`newDept.DeptType != DEV` 且 `caller.MemberType == ADMIN` 时直接放行,不比 DeptPath 前缀)
-- 对比参照:
-  - `internal/logic/user/updateUserLogic.go:158-170`(`deptId=0` 已由 L-R15-1 收敛给 SuperAdmin)
-  - `internal/logic/user/createUserLogic.go:102-109`(`CreateUser` 对非超管**强制**执行 `strings.HasPrefix(newDept.Path, caller.DeptPath)`,无 ADMIN 豁免)
+- `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-R15-1 把 `UpdateUser.req.DeptId = 0` 这种"把目标移出全局部门树"的极端路径收敛给了 SuperAdmin,理由是:`sys_user.deptId` 是**全局**字段,P1 ADMIN 在 P1 的授权范围不应影响 P2 视角下的成员归属。但同一个逻辑在"`req.DeptId > 0` 且 `newDept.DeptType != DEV`"分支里仍然存在
+L-R11-4 的注释写得很清楚
 
-```140:157:internal/logic/user/updateUserLogic.go
-if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
-    return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
-}
-// 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV**
-// 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截。
-if !caller.IsSuperAdmin &&
-    caller.MemberType != consts.MemberTypeAdmin &&
-    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
-}
-```
+> 纯新增(added>0 && updated==0 && disabled==0)时不需要清 CleanByProduct。新增的 perm 在本次 SyncPerms 之前**不可能**已经被绑定到任何 role……loadPerms 对当前全体 user 的计算结果与上次结果完全一致。
 
-这一 `caller.MemberType != consts.MemberTypeAdmin` 短路让 P1 ADMIN 可以把"同时是 P2 成员"的 target `B` 从 P2 的部门子树下挪到 P1 的部门子树下(或任意既非 DEV 又处于 Enabled 的 NORMAL 部门,只要校验通过 `FindOneForShareTx`)。其直接副作用:
+但"loadPerms 对当前全体 user 的计算结果完全一致"这一条只对**普通 MEMBER**成立——他们走 `FindPermIdsByRoleIds` + `FindPermIdsByUserIdAndEffectForProduct` 的路径,新 perm 没被任何 role/allow/deny 引用,确实不影响。**全权用户走的是 `FindAllCodesByProductCode(productCode)` 单条分支**,这条查询返回的是该产品下所有 status=Enabled 的 perm 全集,新增任意 perm 都会让集合变大。
 
-1. **P2 视角下的部门链崩坏**:P2 的非 ADMIN 管理员(DEVELOPER、PermsLevel 接近 SuperAdmin 的 MEMBER)依赖 `CheckManageAccess → checkDeptHierarchy` 做"目标必须在 caller 子树下"的判定;B 的 DeptPath 被 P1 ADMIN 单方面改写为 P1 子树的前缀后,P2 的这些管理员对 B 的所有管理操作(降级、授权、改 Profile、冻结)都会落到 `response.ErrForbidden("无权管理其他部门的用户")`——B 在 P2 视角成为一个**结构性不可触达的账号**;
-2. **P2 的 DeptTree 里 B 也同时失踪**:`deptTreeLogic.go:52-62` 对非超管做 `strings.HasPrefix(d.Path, caller.DeptPath)` 的裁剪,B 的新 DeptPath 一旦不再以 P2 任何管理员的 `caller.DeptPath` 为前缀,B 在 P2 的部门树渲染里完全消失;
-3. **攻击链可达性**:P1 ADMIN 不需要超管权限,不需要绕过 H-R14-1 的 DEV 部门护栏,**仅靠合法的 "给 P1 成员换部门" 操作**就能在 P2 侧制造"隐形成员"——这与 L-R15-1 的 `deptId=0` 场景**完全同构**,只是构造方式换成"挪到 P1 子树"而不是"清空到 0"。
+具体时序:
 
-注释里声明的豁免理由("product ADMIN 对产品内既有成员有全面管理权")只在"不共享 target 的单产品场景"成立;一旦 target 同时归属多个产品(`sys_product_member` 允许多对多),ADMIN 改 `sys_user.deptId` 的动作已经穿透了"本产品 ADMIN 的权限天花板"。
+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,"超管登录就是拉不到新接口"在发版当天很容易引起误判。
 
 **影响**
 
-- 与 L-R15-1 同量级的跨产品结构性扰动,差别只是"orphan(deptId=0)"换成"挪到 P1 子树";运维可观测性更差——L-R15-1 的 orphan 至少可以通过 `WHERE deptId=0 AND isSuperAdmin=0` 直接捞出,而本条的 B 依然有正常 DeptPath,P2 运维必须跨产品比对才能发现"B 明明在 P2 有成员行,但 DeptPath 已经不在 P2 子树"的异常
-- P1 ADMIN 对 B 的"单方面去 P2 化"是一种隐蔽的服务降级工具——B 在 P2 里还能调接口(jwt 依然有效、MemberType 还在 P2),但 P2 的管理台侧完全管不了他
-- 与 `CreateUser` 的不对称性:`CreateUser` 对 ADMIN 也强制 DeptPath 前缀校验(`createUserLogic.go:102-109`),`UpdateUser` 对 ADMIN 却短路了这一校验——两者语义应该对齐(同样是"让一个用户落在某部门下"的动作)
+- 发版 SLA:承诺下游"SyncPerms 成功即可使用新权限"的口径在这四类用户上被静默违反
+- 审计链扭曲:`GetUserPerms` 的 perms 列表对全权用户最长延迟 5min,运营查验"这个超管到底能不能访问 C"时,看到的快照和真实授权的上游 DB 视图不一致
+- 并不逃逸"最终一致",属 Medium 的感知可见性问题
 
 **修复方案**
 
-删除 `UpdateUser` 的 `caller.MemberType != consts.MemberTypeAdmin` 短路,把 ADMIN 也纳入 DeptPath 前缀校验范围;同时与 `CreateUser` 的校验口径保持一致。这条修改不会破坏 ADMIN 的正当业务:ADMIN 作为产品管理员,其 `caller.DeptPath` 本来就是其部门子树前缀,调整同子树内的成员部门归属不会被拦;真正被拦住的是"跨子树、跨产品"的越权改写。
-
-```go
-// internal/logic/user/updateUserLogic.go
-if *req.DeptId > 0 {
-    newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
-    if err != nil {
-        return response.ErrBadRequest("部门不存在")
-    }
-    if newDept.Status != consts.StatusEnabled {
-        return response.ErrBadRequest("目标部门已停用")
-    }
-    if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
-        return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
-    }
-    // 审计 L-R16-1:与 CreateUser 口径对齐,删除 ADMIN 豁免。`sys_user.deptId` 是全局字段,
-    // ADMIN 把共享成员挪到自己部门子树外会让其它产品的部门链校验对该 target 失效——与 L-R15-1
-    // 的 deptId=0 场景完全同构,仅改"目标落点"而非"结构性副作用"。SuperAdmin 仍可跨子树调度。
-    if !caller.IsSuperAdmin {
-        if caller.DeptPath == "" {
-            return response.ErrForbidden("您未归属任何部门,无权调整用户部门")
-        }
-        if !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-            return response.ErrForbidden("无权将用户调入非自己管辖的部门")
-        }
-    }
-}
-```
+两个可选路径:
 
-**回归验证要点**
+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。
 
-- P1 ADMIN 在 P1 子树内挪动自己部门的成员:放行(与现网一致);
-- P1 ADMIN 挪动"只属于 P1"的成员到 P1 子树外的 NORMAL 部门:拦截为 403(**本轮新增**;若业务确有此需求,应改走 SuperAdmin 审批流);
-- P1 ADMIN 挪动"同时也是 P2 成员"的 target 到 P1 子树外的部门:同上 403,避免跨产品结构性扰动;
-- SuperAdmin 行为不变:任何 NORMAL 部门均可挪入;DEV 部门同样不受本条改动影响。
+注释里的 `loadPerms 对当前全体 user 的计算结果完全一致` 必须同步改写——当前版本已经是错的,留着会把后人引向错误假设。
 
 ---
 
-### L-R16-2 · `UpdateDept` DeptType/Status 收窄 + `UpdateUser` 跨 DEV/NORMAL 边界调 deptId 均未吊销 tokenVersion —— 与 M-R15-1 同构的缓存失效 TOCTOU
+### M-R17-2 · `UpdateUserLogic` 在"DEV→NORMAL 调动"+"status 冻结"并发发生时对 `tokenVersion` 做双倍递增,虽然功能无害,但注释与 sysUser 低层缓存 invalidation 次数不对称
 
 **位置**
 
-- `internal/logic/dept/updateDeptLogic.go:91-106`(`deptTypeChanged || statusChanged` 只走 `UserDetailsLoader.CleanByUserIds`,**无** `BatchIncrementTokenVersionWithTx`)
-- `internal/logic/user/updateUserLogic.go:195-250`(`UpdateProfile` / `UpdateProfileWithTx` 仅在 `statusChanged` 触发 `tokenVersion+1`;**deptId 跨越 DEV↔NORMAL 边界不递增**)
-- 对比参照:`internal/logic/product/updateProductLogic.go:77-104`(L-R15-3 已落地"禁用产品即批量吊销成员 session")、`internal/logic/member/updateMemberLogic.go:94-134`(M-R15-1 已落地)
+- `internal/logic/user/updateUserLogic.go`(R16-2 引入的 `devAccessRevoked` 分支会 `IncrementTokenVersionWithTx`)
+- `internal/model/user/sysUserModel.go::UpdatePassword`/`UpdateStatus`/`UpdateProfile` 各自会做 `tokenVersion+1`
 
 **描述**
 
-`loadPerms` 的"是否走全权分支"明确地受三个字段驱动:`IsSuperAdmin` / `MemberType` / `DeptType + DeptStatus`
+R16 给 `UpdateUser` 加上了"DEV → NORMAL 跨域调动时 tokenVersion+1";如果同一请求还命中 `status Enabled→Disabled`,`UpdateProfile` 内部本就会 `tokenVersion+1`(或 R16 的代码路径再走一次显式 `IncrementTokenVersionWithTx`),结果是事务内对同一 userId 做了 2 次自增。功能上毫无副作用(新 token 不会分叉,Redis 缓存还是会 post-commit Clean),但:
 
-```539:554:internal/loaders/userDetailsLoader.go
-// 超管 / ADMIN / DEVELOPER / 研发部门的有效成员 → 全量权限
-if ud.IsSuperAdmin ||
-    ud.MemberType == consts.MemberTypeAdmin ||
-    ud.MemberType == consts.MemberTypeDeveloper ||
-    (ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
-    codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
-    ...
-}
-```
+- R16 的注释只解释了"DEV 调动"这一半为什么要 +1,没有声明"如果还叠加了 status change,递增会叠加";
+- 未来运维如果用 `tokenVersion` 做"有效会话批次号"的信号量分析,会看到"单次 UpdateUser 却 +2"的异常样本,产生噪声;
+- 双递增让 `UpdateProfile` 的 CAS `WHERE updateTime=?` 在第二次 SQL 之前要依赖第一次 SQL 的结果(事务内一致),调试复杂。
 
-M-R15-1 / L-R15-3 已经为 `MemberType` 的收窄路径(UpdateMember 降级 / UpdateProduct 禁用)建立了"tx 内 `tokenVersion+1` → 签发层吊销"的闭环,避免 Redis 抖动时 5min TTL 让旧全权继续生效。但 **`DeptType` / `DeptStatus` 同样是 loadPerms 全权分支的直接输入**,它们的收窄路径现在还是"仅 `UserDetailsLoader.CleanByUserIds` 尽力而为":
+**修复方案**
 
-```91:106:internal/logic/dept/updateDeptLogic.go
-if deptTypeChanged || statusChanged {
-    userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
-    if err != nil {
-        l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err)
-        return nil
-    }
-    if len(userIds) > 0 {
-        cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
-        defer cancel()
-        l.svcCtx.UserDetailsLoader.CleanByUserIds(cleanCtx, userIds)
-        l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d", req.Id, dept.DeptType, dept.Status, len(userIds))
-    }
-}
-```
+两种路径:
+
+1. 改语义:把"是否需要 bump tokenVersion"收归到 `UpdateUser` 这一层决策,内部只调一次 `IncrementTokenVersionWithTx(userId)` 或直接在 `UpdateProfile` 的 SQL 里多带一个 `bumpTokenVersion bool` 参数,tx 内只做 1 次 `tokenVersion+1`。
+2. 不改语义,在注释里明确声明"devAccessRevoked + statusChanged 重叠时会 +2,但对安全语义等价于 +1",并在 `IncrementTokenVersionWithTx` 的文档注释里补上"可安全叠加调用"。
 
-相同问题也出现在 `UpdateUser` 改 `deptId` 的路径:`UpdateProfileWithTx` 只认 `statusChanged` 去递增 `tokenVersion`,`deptId` 跨越 DEV/NORMAL 边界时没有任何签发层吊销,只依赖 `UserDetailsLoader.Clean` 的尽力而为失效
+无论走哪条都建议新增一条 TC:单请求同时触发 dev 调出 + 冻结,断言新 tokenVersion = 旧 + N(N 取当前实现值),把行为锁死。
 
-收窄方向的具体触发条件(这些全都是"权限从全权收回"的场景):
+---
 
-1. `UpdateDept`:`DeptType` 从 `DEV → NORMAL`——该部门所有在编成员在所属产品里的 `loadPerms` 从"全量权限"降级为"角色/allow-deny 计算";
-2. `UpdateDept`:`Status` 从 `Enabled → Disabled`——DEV 部门全权分支要求 `DeptStatus == StatusEnabled`,禁用即失去全权;NORMAL 部门成员是否被禁用则改变 `jwtauthMiddleware` 对 `ud.Status` 的放行,但 dept.Status 对用户而言不是直接阻断字段,主要影响仍是 DEV 部门的全权分支;
-3. `UpdateUser`:把用户 `deptId` 从 DEV 部门挪到 NORMAL 部门——单一用户的全权被收回。
+### L-R17-1 · `CreateProductLogic` 的 initial admin 用户名 `admin_<productCode>` 可被任意 product ADMIN 预抢注,导致 SuperAdmin 上新产品时撞 UNIQUE(username) 回滚
 
-这三条路径的 TOCTOU 窗口与 M-R15-1 完全一致:
+**位置**
 
-1. SuperAdmin 改部门/用户归属;事务 COMMIT;
-2. post-commit `UserDetailsLoader.Clean*` 因 Redis 抖动失败(3s `DetachCacheCleanCtx` 超时 + 日志标记 `cache_invalidation_skipped_*`,不重试);
-3. 缓存里 `{DeptType=DEV, DeptStatus=Enabled, Perms=[全量]}` 保留到 TTL 过期(最长 5min);
-4. 受影响用户的 access token `TokenVersion` 未递增(DeptType/Status 变更不触发 UpdateProfile 的 `tokenVersion+1`),`jwtauthMiddleware` 的 `TokenVersion != ud.TokenVersion` 兜底不触发;
-5. 用户在这 5min 内继续以 "DEV 全权" 身份调业务接口。
+- `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_` 前缀)
 
-与 R15 不同的是:`UpdateDept` / `UpdateUser.deptId` 的典型使用者是 **SuperAdmin**(`UpdateDept` 已经 `RequireSuperAdmin`;`UpdateUser` 改 deptId 跨 DEV 边界也被 H-R14-1 收敛给 SuperAdmin),因此这条 TOCTOU 的触发点比 `UpdateMember` 更低频——但**影响面更广**:`UpdateDept` 一次性影响"该部门所有成员";`UpdateProduct` 影响"该产品所有成员";两者叠加时,Redis 抖动可以让一个完整部门在 5min 窗口内保留已经被收回的 DEV 全权。
+**描述**
 
-**影响**
+product ADMIN 在自己产品下可用 `CreateUser` 直接创建用户名 `admin_acme`,这个用户跟未来真正要上线的 productCode `acme` 共用一个 UNIQUE(username) 槽位。超管过了几个月要创建产品 `acme`:
 
-- Redis 抖动或长时间网络分区(backfill / 故障演练期间)下,"DEV→NORMAL"或"禁用部门"的操作在 5min 窗口内不生效;
-- 与 M-R15-1 / L-R15-3 的"签发层吊销"设计哲学不一致,审计回归矩阵需要解释"为什么 dept 收窄走尽力而为、member/product 收窄走强制吊销"——当前代码没有任何注释给出差异理由,容易被后续维护者当作遗漏;
-- 若组合 H-R16-1 攻击(tokenVersion 不变),一个原本在 DEV 部门的 ADMIN 被 SuperAdmin 从 DEV 挪到 NORMAL,其全权仍在 5min 内有效——与"先 DEV→NORMAL 后立即 RemoveMember"的组合相比,本路径更难被监控感知(无 `RemoveMember` 事件、只有 `UpdateUser/UpdateDept` 的低敏感事件)。
+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 就行,不需要看列表。
 
 **修复方案**
 
-两处收窄路径在 tx 内补齐 `BatchIncrementTokenVersionWithTx`——复用 `UpdateProduct` 的 L-R15-3 模式,把"找出受影响 userIds"和"批量 +1"收敛进同一个事务,整体回滚语义天然成立。关键判定:只在**真正构成"权限收窄"**时递增,避免 `NORMAL→DEV`(升权)或 `Disabled→Enabled`(重启用)场景误踢用户下线。
+- 短期:把 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` 分离)。
+
+---
+
+### L-R17-2 · `CreateUser` / `CreateProductLogic` 未显式设置 `Avatar`,导致 `sql.NullString` 以 `{Valid:true, String:""}` 默认落库,对消费方而言歧义
 
-**1. `UpdateDept` 修复(DEV→NORMAL 或 DEV 部门 Enabled→Disabled 才递增):**
+**位置**
+
+- `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
-// internal/logic/dept/updateDeptLogic.go
-// 判定"收窄方向":仅在以下三种情况下需要 tokenVersion+1:
-//   (a) DeptType: DEV → NORMAL(该部门所有成员失去 DEV 全权分支)
-//   (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员失去全权)
-//   (c) DeptType 不变但 NORMAL 部门 Status: Enabled → Disabled(无直接授权影响,可选是否递增;保守起见建议递增,因为业务语义是"冻结部门所有活动")
-// NORMAL→DEV(升权)与 Disabled→Enabled(恢复)不递增。
-prevType := req.dept.DeptType // 即原 dept.DeptType
-prevStatus := req.dept.Status // 即原 dept.Status
-nextType := dept.DeptType     // 应用 req 之后
-nextStatus := dept.Status
-devFullAccessRevoked := (prevType == consts.DeptTypeDev && nextType == consts.DeptTypeNormal) ||
-    (prevType == consts.DeptTypeDev && prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled)
-normalDeptFrozen := prevType == consts.DeptTypeNormal && nextType == consts.DeptTypeNormal &&
-    prevStatus == consts.StatusEnabled && nextStatus == consts.StatusDisabled
-
-var revokedUserIds []int64
-err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-    if err := l.svcCtx.SysDeptModel.UpdateWithOptLockTx(ctx, session, dept, expectedUpdateTime); err != nil {
-        return err
-    }
-    if devFullAccessRevoked || normalDeptFrozen {
-        // FindIdsByDeptId 需要提供 Tx 版本;若 tx 版本不存在,可先 pre-commit 拿 userIds,但必须收进同一个事务以避免 TOCTOU。
-        ids, err := l.svcCtx.SysUserModel.FindIdsByDeptIdForShareTx(ctx, session, req.Id)
-        if err != nil {
-            return err
-        }
-        if len(ids) > 0 {
-            if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil {
-                return err
-            }
-            revokedUserIds = ids
-        }
-    }
-    return nil
-})
-// post-commit 继续走 CleanByUserIds + InvalidateProfileCache(与 UpdateProduct 对齐)
+Avatar: sql.NullString{Valid: false},
 ```
 
-若 `SysUserModel` 当前没有 `FindIdsByDeptIdForShareTx`,需补一个带 `LOCK IN SHARE MODE` 的版本——比照 `FindActiveMemberUserIdsByProductCodeTx` 的实现(`internal/model/productmember/sysProductMemberModel.go:102-110`),与并发 `UpdateProfileWithTx`(X 锁 sys_user)互斥,防止"列出 userIds 期间有人刚被挪出本部门"造成吊销漏挂
+或者把 Avatar 字段从 NullString 降级为普通 string + 业务层在 loadSysUser 后显式处理"空串 = 未上传"语义。更 aggressive 一点,直接在 SysUser 的 Insert helper 里对所有 Nullable 字段走显式默认值构造器,避免散落各处的"忘记赋值→依赖零值"。
 
-**2. `UpdateUser` 修复(仅在 deptId 跨 DEV↔NORMAL 边界且方向为收窄时递增):**
+---
 
-```go
-// internal/logic/user/updateUserLogic.go
-// 读取旧 dept(user.DeptId)与新 dept(newDept)的 DeptType:
-// 收窄方向 = (old.DeptType==DEV && new.DeptType==NORMAL)
-// 升权方向 = (old.DeptType==NORMAL && new.DeptType==DEV)——已由 H-R14-1 收敛给 SuperAdmin
-devAccessRevoked := false
-if req.DeptId != nil && *req.DeptId != user.DeptId {
-    var oldDept *deptModel.SysDept
-    if user.DeptId > 0 {
-        oldDept, _ = l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId)
-    }
-    if oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && newDept.DeptType == consts.DeptTypeNormal {
-        devAccessRevoked = true
-    }
+### L-R17-3 · `DeptTreeLogic` 对非超管只返回 DeptPath 子树,但非成员子树被整体丢弃时没有审计日志标记,排障时无法区分"用户无权"和"DB 抖动返空"
+
+**位置**
+
+- `internal/logic/dept/deptTreeLogic.go:52-63`(非超管分支过滤非子树部门时直接 continue,不 log)
+
+**描述**
+
+非超管(含产品 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("原密码错误")
 }
 
-// tx 体内补:
-if devAccessRevoked {
-    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
-        return err
-    }
+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 {
+    // ...
+}
+```
 
-本条优先级明确低于 M-R15-1 / H-R16-1:触发者几乎都是 SuperAdmin(`UpdateDept` 强制超管;`UpdateUser` 改 deptId 跨 DEV 边界也被 H-R14-1 限制为超管),攻击窗口需要 "SuperAdmin 误操作 + Redis 故障" 双重触发;但一旦落在监管敏感的产品上,5min 的残余 DEV 全权可能覆盖多次敏感接口调用。按"与 L-R15-3 的口径对称"的原则把这条修了对长期维护一致性有利,单独不修也可以在审计注释里明确声明"本路径 TOCTOU 由 SuperAdmin 信任边界 + Clean 尽力而为组合承担"——但这会让后续维护者难以推理"为什么同样是授权收窄,member/product 走强吊销、dept/user.deptId 走尽力而为"。
+关注点:这会让"攻击者用正确用户名 + 猜对 oldPwd 等于 newPwd"的响应时间比"猜错 oldPwd"快约 60ms,理论上构成 timing 信号。但这条信号对攻击者几乎无用——要 trigger 这条分支攻击者需要**已经知道 oldPwd**,一旦攻击者已知 oldPwd,ChangePassword 本身就是 game over。所以 timing 差异可以接受
 
 ---
 
-## R15 回归验证(附录)
+### L-R17-5 · `createDeptLogic.go` 的"Insert → FindOneWithTx → Update"三步写回 Path,可以合并为一次 `UPDATE … SET path = CONCAT(?, id, '/') WHERE id=?` 省一次 DB roundtrip
 
-| 条目 | 期望修复 | 代码现状 | 判定 |
-| --- | --- | --- | --- |
-| M-R15-1 MemberType TOCTOU(UpdateMember 降级/禁用不吊销 session) | tx 内 `IncrementTokenVersionWithTx`,post-commit 同步失效 sysUser 低层缓存 | `updateMemberLogic.go:94-154` 新增 `typeDowngraded/statusRevoked` 判定 + `IncrementTokenVersionWithTx` + `InvalidateProfileCache`;`tokenVersionRevocation` 结构封装闭包跨域传 userId,避免事务失败时脏状态泄漏 | ✅ 已闭合(方案 A) |
-| L-R15-1 UpdateUser.deptId=0 改写全局字段 | `deptId=0` 收敛给 SuperAdmin | `updateUserLogic.go:158-170` 非超管直接 403;注释显式声明与 H-R14-1 的结构对称性 | ✅ 已闭合 |
-| L-R15-2 DeptTree fullAccess 泄露组织架构 | `fullAccess = caller.IsSuperAdmin` | `deptTreeLogic.go:50` 已改;产品 ADMIN 走 DeptPath 前缀裁剪;注释清晰说明 sys_dept 全局命名空间与 M-2 的最小授权精神 | ✅ 已闭合 |
-| L-R15-3 UpdateProduct 禁用不吊销成员 session | tx 内 `BatchIncrementTokenVersionWithTx` + FOR SHARE 锁成员行 | `updateProductLogic.go:77-127` 已落地;`FindActiveMemberUserIdsByProductCodeTx` 用 `LOCK IN SHARE MODE` 阻塞并发 AddMember/UpdateMember/RemoveMember 的 X 锁 | ✅ 已闭合 |
-| L-R15-4 降权路径语义需在注释里显式声明 | updateMemberLogic / updateProductLogic 顶部注释说明 | `updateMemberLogic.go:35-43` / `updateProductLogic.go:36-42` 均已补齐,引用 M-R15-1 / L-R15-3 审计条目 | ✅ 已闭合 |
+**位置**
+
+- `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 等价于空动作,无害)。
 
-本轮新增 1 条 High(**H-R16-1**,`RemoveMember` 降权路径与 M-R15-1 同构但未同步吊销 session),1 条 Medium(**M-R16-1**,`UserList` / `UserDetail` 对同产品 MEMBER 暴露 PII),2 条 Low(**L-R16-1**,`UpdateUser` ADMIN 分支短路 DeptPath 前缀校验,构成 L-R15-1 的非零 deptId 同构缺口;**L-R16-2**,`UpdateDept` / `UpdateUser.deptId` 的权限收窄路径仍只走缓存失效、未 tokenVersion+1,与 M-R15-1 的签发层吊销口径不一致)。
+**修复方案**
 
-语义关联:
+- 不改代码,仅补注释:`UpdateStatus` 无条件递增 tokenVersion 是刻意设计,让"冻结-解冻-冻结"这条路径里 Redis 残留缓存的 tokenVersion 比对始终相对于"最新值"计算,即使解冻间隙 DB 抖动,也不会让一个"冻结前未过期的 access token"在解冻后复活。这条语义在 `UpdateUserStatusLogic` 和 `sysUserModel.UpdateStatus` 的函数头注释里都要显式写出来,避免后续 R18 读代码时误以为"解冻无需 bump"做成条件判断反而引入回归。
+
+---
 
-- **H-R16-1** 与 **L-R16-2** 同属"降权吊销覆盖对称性"的残留工单;修完 H-R16-1 应顺带把 L-R16-2 一并处置,否则每轮审计都要重新解释"为什么有些降权走强吊销、有些走尽力而为";
-- **L-R16-1** 是 L-R15-1 在另一方向的同构项,两者合计描述的是"`sys_user.deptId` 作为全局字段,其任何写入都应该是 SuperAdmin 的权限维度"这一不变式;
-- **M-R16-1** 是独立的 PII 最小授权工单,与上述三条互相正交,但同样与 R14 的 H-R14-1 / L-R15-1 共享"产品 ADMIN 的权限边界不应穿透到全局字段"的设计哲学——MemberType 应当反向收敛读权限,不应因"同产品"就默认放行全部 PII。
+## 汇总
 
-优先处置顺序:**H-R16-1 → M-R16-1 → L-R16-1 → L-R16-2**。H-R16-1 最低代价(tx 内加一次 `IncrementTokenVersionWithTx`,与 M-R15-1 逻辑完全同构)但关掉一个 High 级攻击窗口;M-R16-1 对合规侧影响最大,修复面集中在两个 logic 文件;L-R16-1 / L-R16-2 可并入下一轮安全变更统一回归。
+- 本轮新发现 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 及更早各轮修复经本轮复检全部稳定,不存在回归。

+ 41 - 0
internal/logic/auth/access.go

@@ -240,6 +240,47 @@ func CheckRoleLevelAgainst(snap AssignableLevelSnapshot, rolePermsLevel int64) e
 	return nil
 }
 
+// GuardCreateRolePermsLevel 校验 caller 能否在本产品下创建 `reqLevel` 等级的新角色(审计 H-R17-3)。
+//
+// 与 GuardRoleLevelAssignable / CheckRoleLevelAgainst 的差异:
+//   - **分配侧**(BindRoles / SetUserPerms)允许 HasFullPerms 的 caller 无条件分配任意 permsLevel
+//     的既有角色——因为 ADMIN / DEVELOPER 在本产品内已经是全权,多分配不会凭空拉高下属权级;
+//     真正的越权边界由既有角色的 permsLevel 事先决定(UpdateRole 的 L-R12-3 拒非超管提升)。
+//   - **创建侧**却是弹药制造:product ADMIN 可以 CreateRole(PermsLevel=1) 造出"顶格角色",再
+//     走 BindRoles(userId=D, roleIds=[R_super]) 把下属 MEMBER/DEVELOPER 的 UD.MinPermsLevel
+//     顶到 1,绕过 GuardRoleLevelAssignable 的同级拦截——等价于横向提权。因此 CreateRole
+//     必须在 caller permsLevel 与 reqLevel 之间建立与 assignment 侧**不**对称的新约束。
+//
+// 规则:
+//   - SuperAdmin:任意 reqLevel 放行(系统管理员不受产品内 perm 等级约束);
+//   - 非超管的 HasFullPerms 调用者(product ADMIN):reqLevel 必须 >= 2,把"permsLevel=1"
+//     的顶格角色语义保留给 SuperAdmin 所生;同级(ADMIN 自己对应 sentinel 0)不可建;
+//   - 无 FullPerms 的普通调用者:走 CheckRoleLevelAgainst 的标准路径(严格低于 snap.Level)。
+//     目前 RequireProductAdminFor 已经把 DEVELOPER/MEMBER 挡在 CreateRole 外,但把语义写全
+//     可以应对未来把"DEVELOPER 可建 sub-role"放开的扩展(到时候只需松开 RequireProductAdminFor)。
+func GuardCreateRolePermsLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, reqLevel int64) error {
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+	if caller.IsSuperAdmin {
+		return nil
+	}
+	snap, err := LoadCallerAssignableLevel(ctx, svcCtx, caller)
+	if err != nil {
+		return err
+	}
+	if snap.HasFullPerms {
+		// Product ADMIN / DEVELOPER 的 snap 在 assignment 侧等价于顶格(sentinel 0),
+		// 创建侧必须人为把 permsLevel=1 留给 SuperAdmin,避免 ADMIN 通过"建 R_super + BindRoles"
+		// 间接把下属提到 ADMIN 线——横向提权攻击路径(见审计 H-R17-3 描述)。
+		if reqLevel <= 1 {
+			return response.ErrForbidden("非超级管理员不能创建权限级别为 1 的顶格角色")
+		}
+		return nil
+	}
+	return CheckRoleLevelAgainst(snap, reqLevel)
+}
+
 // loadFreshMinPermsLevel 统一的"授权决策点"接口:强一致读 DB,取 userId 在 productCode 下的
 // 最小 permsLevel。返回三元组方便调用方按业务语义决定"无角色"对应放行还是拒绝。
 //   - level:     命中时的最小 permsLevel

+ 8 - 4
internal/logic/auth/changePasswordLogic.go

@@ -57,15 +57,19 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 		return response.ErrForbidden("账号已被冻结")
 	}
 
+	// 审计 L-R17-4:先做字符串等值比较(纳秒级),后做 bcrypt.CompareHashAndPassword
+	// (~60ms CPU)。对合法请求无语义差异;"old == new" 的非法请求可以直接 400,省掉一次
+	// bcrypt。Timing 差异(猜中 old=new 比猜错快 ~60ms)对攻击者几乎无用——能触发这条分支
+	// 的前提是攻击者已知正确的 oldPassword,此时 ChangePassword 本身已是 game over。
+	if req.OldPassword == req.NewPassword {
+		return response.ErrBadRequest("新密码不能与原密码相同")
+	}
+
 	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("新密码不能与原密码相同")
-	}
-
 	hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
 	if err != nil {
 		return err

+ 50 - 0
internal/logic/auth/changePasswordLogic_test.go

@@ -246,6 +246,56 @@ func TestChangePassword_SameOldAndNew(t *testing.T) {
 	assert.Equal(t, "新密码不能与原密码相同", codeErr.Error())
 }
 
+// TC-1179: "新旧密码相同"校验必须排在 bcrypt.CompareHashAndPassword 之前。
+//
+// 用例设计:DB 里存的 Password 哈希对应 "RealOldpass123",但请求传入
+// OldPassword == NewPassword == "Samepass123"(与 DB 存的原密码不匹配)。
+//   - 若校验顺序正确(L-R17-4):先断 `OldPassword == NewPassword`,直接 400 "新密码不能
+//     与原密码相同",UpdatePassword 不应被触达(mock EXPECT 未声明即 Times(0) 判违约)。
+//   - 若顺序被误回滚为"先 bcrypt 后等值判断":bcrypt 会先失败并 400 "原密码错误",
+//     文案不同,用例会失败。
+//
+// 该用例同时保证"同密码短路"这条 timing 分支在 mock 层面可回归观测,不依赖真实 DB 写入。
+func TestChangePassword_SameOldAndNew_ChecksBeforeBcrypt(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(1750)
+	// DB 里的哈希基于 "RealOldpass123"——与 req.OldPassword 不一致。
+	realOld := "RealOldpass123"
+	hashed, err := bcrypt.GenerateFromPassword([]byte(realOld), bcrypt.DefaultCost)
+	require.NoError(t, err)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOne(gomock.Any(), userId).
+		Return(&userModel.SysUser{
+			Id:         userId,
+			Username:   "l_r17_4_subject",
+			Password:   string(hashed),
+			Status:     consts.StatusEnabled,
+			UpdateTime: 4242,
+		}, nil)
+	// 关键护栏:UpdatePassword 绝不应被调用——若 EXPECT 未声明的方法被调用,gomock 会 FAIL。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	svcCtx.TokenOpLimiter = nil
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
+
+	wrongOldButEqualNew := "Samepass123"
+	err = NewChangePasswordLogic(ctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+		OldPassword: wrongOldButEqualNew,
+		NewPassword: wrongOldButEqualNew,
+	})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code(),
+		"L-R17-4:OldPassword==NewPassword 必须在 bcrypt 之前被拦截")
+	assert.Equal(t, "新密码不能与原密码相同", ce.Error(),
+		"文案必须是'新密码不能与原密码相同',若是'原密码错误'说明顺序被误回滚")
+}
+
 // TC-0063: 用户不存在
 func TestChangePassword_UserNotFound(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())

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

@@ -100,12 +100,12 @@ func (l *CreateDeptLogic) CreateDept(req *types.CreateDeptReq) (resp *types.IdRe
 
 		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)
+		// 审计 L-R17-5:以前用 FindOneWithTx 把刚 INSERT 的整行读回来,仅为了把 path 覆写成
+		// `parentPath + deptId + "/"` 后再 UpdateWithTx 走整行回写——3 次 DB 往返。现在改调
+		// UpdatePathWithTx 只改 path/updateTime 两列,减一次 SELECT,sys_dept X 锁持有时长
+		// 同步缩短,降低并发 DeleteDept / UpdateDept 的尾部延迟。
+		finalPath := fmt.Sprintf("%s%d/", parentPath, deptId)
+		return l.svcCtx.SysDeptModel.UpdatePathWithTx(ctx, session, deptId, finalPath, now)
 	})
 	if err != nil {
 		return nil, err

+ 7 - 10
internal/logic/dept/createDeptLogic_mock_test.go

@@ -65,9 +65,9 @@ func TestCreateDept_Mock_DefaultDeptType(t *testing.T) {
 			assert.Equal(t, "NORMAL", data.DeptType)
 			return fakeResult{id: 99}, nil
 		})
-	mockDept.EXPECT().FindOneWithTx(gomock.Any(), nil, int64(99)).
-		Return(&deptModel.SysDept{Id: 99, Path: "/", DeptType: "NORMAL"}, nil)
-	mockDept.EXPECT().UpdateWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
+	// L-R17-5:Insert 后 CreateDept 以单条 `UPDATE sys_dept SET path=?,updateTime=?` 回写 path,
+	// 相对老路径 `FindOneWithTx → UpdateWithTx` 砍掉一次 SELECT、缩短 X 锁;mock 契约必须同步。
+	mockDept.EXPECT().UpdatePathWithTx(gomock.Any(), nil, int64(99), "/99/", gomock.Any()).Return(nil)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: mockDept})
 
@@ -95,9 +95,7 @@ func TestCreateDept_Mock_DevDeptType(t *testing.T) {
 			assert.Equal(t, "DEV", data.DeptType)
 			return fakeResult{id: 100}, nil
 		})
-	mockDept.EXPECT().FindOneWithTx(gomock.Any(), nil, int64(100)).
-		Return(&deptModel.SysDept{Id: 100, Path: "/", DeptType: "DEV"}, nil)
-	mockDept.EXPECT().UpdateWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
+	mockDept.EXPECT().UpdatePathWithTx(gomock.Any(), nil, int64(100), "/100/", gomock.Any()).Return(nil)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: mockDept})
 
@@ -111,7 +109,7 @@ func TestCreateDept_Mock_DevDeptType(t *testing.T) {
 	assert.NotNil(t, resp)
 }
 
-// TC-0098: 事务回滚-UpdateWithTx失败
+// TC-0098: 事务回滚—path 回写失败,整个 tx 必须回滚并把 dbErr 原样冒泡出去。
 func TestCreateDept_Mock_UpdateWithTxFail(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -125,9 +123,8 @@ func TestCreateDept_Mock_UpdateWithTxFail(t *testing.T) {
 		})
 	mockDept.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(fakeResult{id: 99}, nil)
-	mockDept.EXPECT().FindOneWithTx(gomock.Any(), nil, int64(99)).
-		Return(&deptModel.SysDept{Id: 99, Path: "/"}, nil)
-	mockDept.EXPECT().UpdateWithTx(gomock.Any(), nil, gomock.Any()).
+	// L-R17-5:path 回写压缩成 UpdatePathWithTx 单 UPDATE;mock 在此注入失败以验证 tx 回滚。
+	mockDept.EXPECT().UpdatePathWithTx(gomock.Any(), nil, int64(99), "/99/", gomock.Any()).
 		Return(dbErr)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{

+ 20 - 2
internal/logic/dept/deleteDeptLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 
+	"perms-system-server/internal/loaders"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -28,12 +29,19 @@ func NewDeleteDeptLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
 }
 
 // DeleteDept 删除部门。在事务内加行锁后检查是否存在子部门或关联用户,均无则删除。仅超管可调用。
+//
+// 审计 H-R17-2:DeleteWithTx 内部走 sqlc.CachedConn.ExecCtx,其"exec → DelCache"钩子会在外层
+// `TransactCtx` **commit 之前**把 Redis 里的 `sysDeptIdKey` 清掉;此时 DB 对其他事务的可见性
+// 还没切换(RR 下其他事务 FindOne 会按非锁读路径读到**旧行**并把它回填进 Redis),commit 之后
+// DB 已经没有该行,但 Redis 里留着一张"幽灵快照"直到 TTL 到期。叠加 H-R17-1 的 orphan user,
+// 该用户可被按"DEV 部门 + Path"继续授权最长 5min。修复口径:tx 成功后走 detached cleanCtx
+// 再手动 `InvalidateDeptCache`,与 RemoveMember / UpdateMember 的 post-commit 失效套路对齐。
 func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
 		return err
 	}
 
-	return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+	if err := l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		// 锁序协议:先锁本部门行(X 锁),再对子部门 / 关联用户用 FOR SHARE 做"存在性检查"。
 		// 存在性检查不修改目标行,用 S 锁即可,不会与 UpdateUser 改 DeptId(持有 sys_user 行的 X 锁)
 		// 或 CreateDept 插子部门(持有 sys_dept 新行的 X 锁)构成 AB-BA 冲突:
@@ -66,5 +74,15 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		}
 
 		return l.svcCtx.SysDeptModel.DeleteWithTx(ctx, session, req.Id)
-	})
+	}); err != nil {
+		return err
+	}
+
+	// 审计 H-R17-2:post-commit 再补一次 DelCache 兜底,把 tx 体内 sqlc ExecCtx 过早 DelCache
+	// 之后被并发 FindOne 回填的"幽灵快照"显式清掉。detached ctx 与请求生命周期解耦,避免
+	// client 断连时跳过失效。
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+	defer cancel()
+	l.svcCtx.SysDeptModel.InvalidateDeptCache(cleanCtx, req.Id)
+	return nil
 }

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

@@ -3,6 +3,7 @@ package dept
 import (
 	"database/sql"
 	"errors"
+	"fmt"
 	"testing"
 	"time"
 
@@ -15,6 +16,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
 // TC-0106: 正常删除(无子部门)
@@ -116,6 +118,44 @@ func TestDeleteDept_HasAssociatedUsers(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+// TC-1200: H-R17-2 —— DeleteDept 成功后必须在 post-commit 上显式失效 sysDept 缓存。
+//
+// 攻击链:DeleteWithTx 内部走 sqlc.CachedConn.ExecCtx,其"exec → DelCache"钩子跑在
+// 外层 TransactCtx commit 之前。RR 隔离下其他事务在 commit 前按非锁读路径仍能看到旧行,
+// 并把它回填进 Redis;commit 之后 DB 没了该行,但 Redis 里挂着一张"幽灵快照"直到 TTL
+// 到期(5min)。叠加 H-R17-1 的 orphan user,幽灵 dept 会让该用户继续按 dept/Path 被
+// 授权。测试手工把 canary 写进 sysDeptIdKey,模拟上述"commit 后又被回填"的快照,
+// DeleteDept 在 post-commit 阶段必须把它删掉。
+func TestDeleteDept_H_R17_2_InvalidatesCacheAfterCommit(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	cfg := testutil.GetTestConfig()
+	svcCtx := svc.NewServiceContext(cfg)
+	conn := testutil.GetTestSqlConn()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	id, err := insertDeptRaw(ctx, svcCtx, 0, "h_r17_2_"+testutil.UniqueId(), "/")
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", id) })
+
+	cacheKey := fmt.Sprintf("%s:cache:sysDept:id:%d", cfg.CacheRedis.KeyPrefix, id)
+	t.Cleanup(func() { _, _ = rds.Del(cacheKey) })
+	require.NoError(t, rds.Set(cacheKey, `{"Id":`+fmt.Sprint(id)+`,"Name":"ghost"}`),
+		"预置 ghost snapshot 失败,Redis 不可用导致测试前置条件失败")
+	pre, err := rds.Exists(cacheKey)
+	require.NoError(t, err)
+	require.True(t, pre, "canary 必须先出现在 Redis,作为 post-commit DEL 的观察对象")
+
+	err = NewDeleteDeptLogic(ctx, svcCtx).DeleteDept(&types.DeleteDeptReq{Id: id})
+	require.NoError(t, err)
+
+	exists, err := rds.Exists(cacheKey)
+	require.NoError(t, err)
+	assert.False(t, exists,
+		"H-R17-2:DeleteDept commit 之后 InvalidateDeptCache 必须把 sysDeptIdKey DEL 掉;"+
+			"若 canary 残留 ≥1s,说明 post-commit 失效兜底被误撤,5min 内其他路径仍会读到 ghost dept,"+
+			"叠加 orphan user 会产生跨部门授权泄漏")
+}
+
 // TC-0534: deleteDept非超管拒绝
 func TestDeleteDept_NonSuperAdminRejected(t *testing.T) {
 	ctx := ctxhelper.AdminCtx("test_product")

+ 23 - 0
internal/logic/dept/deptTreeLogic.go

@@ -50,7 +50,19 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 	fullAccess := caller.IsSuperAdmin
 
 	if !fullAccess {
+		// 审计 L-R17-3:`caller.DeptPath == ""` 是"DeptTree 返空"的两大分支之一,常见于:
+		//   (a) 数据链异常:L-R16-1 / H-R17-1 那类孤儿账号(deptId 指向已删部门、或 deptId=0
+		//       的非超管遗留数据)——UI 只会看到空列表,与"DB 抖动返空"无法区分;
+		//   (b) 合规隔离:产品 ADMIN 在"尚未完成部门归属"的过渡期内。
+		// 打一条 WARN 让运维侧能追溯"哪些 caller 持续踩到空 DeptPath",便于做数据修复。
 		if caller.DeptPath == "" {
+			l.Errorw("DeptTree returned empty because caller.DeptPath is empty",
+				logx.Field("audit", "dept_tree_empty_caller_deptpath"),
+				logx.Field("userId", caller.UserId),
+				logx.Field("isSuperAdmin", caller.IsSuperAdmin),
+				logx.Field("memberType", caller.MemberType),
+				logx.Field("deptId", caller.DeptId),
+			)
 			return make([]*types.DeptItem, 0), nil
 		}
 		filtered := make([]*deptModel.SysDept, 0, len(list))
@@ -59,6 +71,17 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 				filtered = append(filtered, d)
 			}
 		}
+		// 审计 L-R17-3:caller.DeptPath 不为空,但过滤后一个都没命中——说明 caller.DeptPath
+		// 指向的部门在 sys_dept 里已经消失(被 SuperAdmin 删除、或 DeptPath 字段陈旧未同步
+		// 到新的部门重组路径)。是 H-R17-1 orphan user 的前奏迹象,记 INFO 供运维巡检。
+		if len(list) > 0 && len(filtered) == 0 {
+			l.Infow("DeptTree filtered to empty despite non-empty caller.DeptPath",
+				logx.Field("audit", "dept_tree_empty_after_filter"),
+				logx.Field("userId", caller.UserId),
+				logx.Field("deptPath", caller.DeptPath),
+				logx.Field("totalDepts", len(list)),
+			)
+		}
 		list = filtered
 	}
 

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

@@ -3,8 +3,10 @@ package product
 import (
 	"context"
 	"crypto/rand"
+	"database/sql"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"regexp"
 	"time"
@@ -130,6 +132,19 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	)
 
 	err = l.svcCtx.SysProductModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		// 审计 H-R17-1:在插入 admin user 之前对 sys_dept[adminDeptId] 取 S 锁,与并发 DeleteDept
+		// 的 X 锁互斥。前置非锁读 FindOne(adminDeptId) 已给出 400/409 的早期报错路径,此处的
+		// FOR SHARE 读只是闭合"pre-check 通过 → 并发 DeleteDept 提交 → 本 tx Insert 写入幽灵
+		// deptId"的竞态:若 DeleteDept 已先提交,本读取直接 ErrNotFound 回 400;若 DeleteDept
+		// 后到,X 锁被我们的 S 锁阻塞到本 tx 提交,DeleteDept 的 `FOR SHARE sys_user WHERE
+		// deptId=?` 会看到新 admin 行并拒绝删除,彻底避免 orphan。
+		if _, derr := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, req.AdminDeptId); derr != nil {
+			if errors.Is(derr, sqlx.ErrNotFound) {
+				return response.ErrBadRequest("管理员部门不存在或已删除")
+			}
+			return derr
+		}
+
 		result, err := l.svcCtx.SysProductModel.InsertWithTx(ctx, session, &productModel.SysProduct{
 			Code:       req.Code,
 			Name:       req.Name,
@@ -149,6 +164,9 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 			Username:           adminUsername,
 			Password:           string(hashedPwd),
 			Nickname:           fmt.Sprintf("%s管理员", req.Name),
+			// 审计 L-R17-2:与 CreateUser 对齐,显式声明 Avatar NULL,避免依赖 Go 结构体零值
+			// 在未来字段类型重构时静默漂移(见 createUserLogic.go 对应注释)。
+			Avatar:             sql.NullString{Valid: false},
 			DeptId:             req.AdminDeptId,
 			IsSuperAdmin:       consts.IsSuperAdminNo,
 			MustChangePassword: consts.MustChangePasswordYes,

+ 8 - 2
internal/logic/product/createProductLogic_mock_test.go

@@ -46,10 +46,14 @@ func TestCreateProduct_Mock_UserInsertFail(t *testing.T) {
 	mockUser.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(nil, dbErr)
 
-	// CreateProduct 必填 AdminDeptId,入库前 FindOne + 启用校验
+	// CreateProduct 必填 AdminDeptId,入库前 FindOne + 启用校验;
+	// H-R17-1:tx 内部新增 sys_dept FOR SHARE 读以与 DeleteDept 的 X 锁串行化,
+	// 本断链点必须补 FindOneForShareTx 期望,否则 gomock 会把失败误定位到"没人调 tx"。
 	mockDept := mocks.NewMockSysDeptModel(ctrl)
 	mockDept.EXPECT().FindOne(gomock.Any(), int64(88)).
 		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), int64(88)).
+		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product: mockProduct,
@@ -96,10 +100,12 @@ func TestCreateProduct_Mock_MemberInsertFail(t *testing.T) {
 	mockPM.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(sql.Result(nil), dbErr)
 
-	// CreateProduct 必填 AdminDeptId
+	// CreateProduct 必填 AdminDeptId;H-R17-1 新增 tx 内 FOR SHARE 读,mock 期望需同步补齐。
 	mockDept := mocks.NewMockSysDeptModel(ctrl)
 	mockDept.EXPECT().FindOne(gomock.Any(), int64(88)).
 		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), int64(88)).
+		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product:       mockProduct,

+ 90 - 1
internal/logic/product/createProductLogic_test.go

@@ -477,10 +477,13 @@ func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) {
 	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_m5_code").
 		Return(nil, userModel.ErrNotFound)
 
-	// CreateProduct 现在必填 AdminDeptId,且在入库前 FindOne + 校验启用状态
+	// CreateProduct 现在必填 AdminDeptId:外层 FindOne 做启用状态 pre-check,
+	// tx 内部再用 FindOneForShareTx 取 S 锁闭合 pre-check→insert 之间的并发删除窗口(H-R17-1)。
 	mockDept := mocks.NewMockSysDeptModel(ctrl)
 	mockDept.EXPECT().FindOne(gomock.Any(), int64(77)).
 		Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil)
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), int64(77)).
+		Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product: mockProduct,
@@ -503,3 +506,89 @@ func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) {
 	assert.Contains(t, ce.Error(), "数据冲突",
 		"错误消息应当是通用的'数据冲突,请稍后重试',不再尝试解析索引名文案")
 }
+
+// TC-1202: L-R17-2 —— CreateProduct 落盘的 admin_<code> 账号 Avatar 必须是 SQL NULL,
+// 不得被零值 `sql.NullString{""}` 悄悄写成空串。与 CreateUser 对齐,保证未来 SELECT JOIN
+// 判空语义稳定("空串"与"NULL"在 ORM / SDK 里不等价,会让业务分支误差)。
+func TestCreateProduct_L_R17_2_AdminUserAvatarIsExplicitNull(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	resp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code:        code,
+		Name:        "avatar_null_" + code,
+		AdminDeptId: seedAdminDept(t, ctx, svcCtx),
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", resp.Id)
+	})
+
+	var avatarIsNull int64
+	require.NoError(t, conn.QueryRowCtx(ctx, &avatarIsNull,
+		"SELECT CASE WHEN `avatar` IS NULL THEN 1 ELSE 0 END FROM `sys_user` WHERE `username`=?",
+		"admin_"+code))
+	assert.Equal(t, int64(1), avatarIsNull,
+		"L-R17-2:admin_<code>.avatar 必须落 NULL(而非空串),与 CreateUser 口径对齐")
+}
+
+// TC-1203: H-R17-1 —— CreateProduct tx 内部必须对 adminDeptId 取 S 锁后再 Insert;
+// 若 FindOneForShareTx 返回 ErrNotFound(并发 DeleteDept 已提交),必须 400 "管理员部门不存在或已删除"
+// 且三张表(product/user/product_member)InsertWithTx 都不得被调用。
+func TestCreateProduct_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const pc = "h_r17_1_prod"
+	const deptId = int64(777)
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByCode(gomock.Any(), pc).
+		Return(nil, productModel.ErrNotFound)
+	mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	// Critical: InsertWithTx 全系列都不得被调用——S 锁拿不到时应立刻 400 退出 tx。
+	// 若未来 FindOneForShareTx 的返回被误忽略,gomock 会在未声明 EXPECT 的 Insert* 上立刻报错。
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_"+pc).
+		Return(nil, userModel.ErrNotFound)
+
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	// 外层 pre-check 此刻还能读到启用状态的 dept(快照时间点早于 DeleteDept 提交)。
+	mockDept.EXPECT().FindOne(gomock.Any(), deptId).
+		Return(&deptModel.SysDept{Id: deptId, Path: "/777/", Status: 1}, nil)
+	// tx 内的 FOR SHARE 读 —— DeleteDept 已先于本 tx 提交,回 ErrNotFound。
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), deptId).
+		Return(nil, sqlx.ErrNotFound)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct,
+		User:    mockUser,
+		Dept:    mockDept,
+	})
+
+	resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{
+		Code:        pc,
+		Name:        "H-R17-1",
+		AdminDeptId: deptId,
+	})
+	assert.Nil(t, resp)
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code(),
+		"H-R17-1:tx 内 FindOneForShareTx 命中 ErrNotFound 必须立刻 400,不能继续 Insert "+
+			"导致 orphan admin 挂在 deptId=777 上")
+	assert.Contains(t, ce.Error(), "管理员部门不存在或已删除",
+		"文案必须点出'已删除'情形,帮助调用方立刻识别'并发 DeleteDept 胜出'场景")
+}

+ 18 - 11
internal/logic/pub/syncPermsLogic_test.go

@@ -547,8 +547,15 @@ func primeProductIndex(t *testing.T, rds *redis.Redis, cachePrefix, productCode
 	return idxKey
 }
 
-// TC-1064: 纯新增不触发 CleanByProduct
-func TestSyncPerms_PureAddDoesNotTriggerCleanByProduct(t *testing.T) {
+// TC-1063: 纯新增(added>0 && updated==0 && disabled==0)同样必须触发 CleanByProduct。
+//
+// 口径说明:SyncPerms 的缓存失效条件已经扩到 `added>0 || updated>0 || disabled>0`。
+// 根因见 syncPermsService.go 中的 M-R17-1 注释——**全权用户**(SuperAdmin / 本产品 ADMIN /
+// DEVELOPER / DEV 部门启用成员)的 loadPerms 走 FindAllCodesByProductCode(productCode)
+// 单条路径,返回该产品下所有 Enabled 的 perm 全集;新增任何一条 perm 都会让该集合变大。
+// 若纯新增分支跳过 CleanByProduct,这四类用户的 UD 缓存仍保留旧 perms 快照,最长 5min
+// 延迟才能看到新权限——发版当天"超管登录拉不到 /v2/C"。
+func TestSyncPerms_PureAddTriggersCleanByProduct(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	cfg := testutil.GetTestConfig()
@@ -566,9 +573,9 @@ func TestSyncPerms_PureAddDoesNotTriggerCleanByProduct(t *testing.T) {
 	t.Cleanup(func() { _, _ = rds.Del(idxKey) })
 
 	result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
-		{Code: "r11_4_add_a", Name: "A"},
-		{Code: "r11_4_add_b", Name: "B"},
-		{Code: "r11_4_add_c", Name: "C"},
+		{Code: "r17_1_add_a", Name: "A"},
+		{Code: "r17_1_add_b", Name: "B"},
+		{Code: "r17_1_add_c", Name: "C"},
 	})
 	require.NoError(t, err)
 	require.NotNil(t, result)
@@ -576,13 +583,12 @@ func TestSyncPerms_PureAddDoesNotTriggerCleanByProduct(t *testing.T) {
 	assert.Equal(t, int64(0), result.Updated)
 	assert.Equal(t, int64(0), result.Disabled)
 
-	// 纯新增路径:CleanByProduct 不得被调用,索引集合必须仍保留 canary。
 	exists, err := rds.Exists(idxKey)
 	require.NoError(t, err)
-	assert.True(t, exists,
-		"added=3 / updated=0 / disabled=0 属于纯新增,不得触发 CleanByProduct;"+
-			"productIndexKey 若被删除说明 SyncPerms 仍在走全产品清缓存路径,回归:会把该产品"+
-			"所有在线用户下一次请求同时打穿回 DB")
+	assert.False(t, exists,
+		"added=3 / updated=0 / disabled=0 的纯新增同样必须触发 CleanByProduct;"+
+			"若 canary 仍残留在 productIndexKey,说明 added 分支被误放行(M-R17-1 回归),"+
+			"全权用户的 UD 缓存将在最长 5min TTL 内继续返回旧 perms 集合")
 }
 
 // TC-1065: updated > 0 时必须触发 CleanByProduct
@@ -690,7 +696,8 @@ func TestSyncPerms_M_R14_1_CleanByProductDetachedFromRequestCtx(t *testing.T) {
 	t.Cleanup(cleanProduct)
 	t.Cleanup(func() { testutil.CleanTableByField(parent, conn, "`sys_perm`", "productCode", pc) })
 
-	// 第一次:纯新增打底;此次不应触发 Clean(见 TC-1064),所以 canary 放在此之后。
+	// 第一次:打底;canary 放在此之后。此次 added>0 也会触发 Clean(见 TC-1063),
+	// 因此 primeProductIndex 必须在第一次 sync 返回之后再执行,避免被顺带清掉导致 canary 失效。
 	_, err := ExecuteSyncPerms(parent, svcCtx, appKey, appSecret, []SyncPermItem{
 		{Code: "r14_detach_upd", Name: "OldName"},
 	})

+ 17 - 9
internal/logic/pub/syncPermsService.go

@@ -160,15 +160,23 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}
 
-	// 审计 L-R11-4:纯新增(added>0 && updated==0 && disabled==0)时不需要清 CleanByProduct。
-	// 新增的 perm 在本次 SyncPerms 之前**不可能**已经被绑定到任何 role(sys_role_perm 要求 permId
-	// 引用现有 sys_perm 行,新 permId 在事务提交之前对任何外部事务都不可见,也就没有既存绑定)。
-	// loadPerms 对当前全体 user 的计算结果与上次结果完全一致,集体把该产品缓存清空只会把大量
-	// 在线用户的下一次请求打穿回 DB(SyncPerms 是日数十次的高频事件,每次发版都会触发)。
-	// updated / disabled 任一 > 0 才代表"已有 perm 的启用状态或内容发生变化",那时才必须失效。
-	// 进一步的"按受影响角色/用户做精准失效"留给后续专项(audit L-R11-4 option-1),本轮先消除
-	// 最频繁的"纯新增也全清"这条误伤路径。
-	if updated > 0 || disabled > 0 {
+	// 审计 L-R11-4 / M-R17-1:任一 added / updated / disabled > 0 都必须清 CleanByProduct。
+	//
+	// L-R11-4 曾经把"纯新增(added>0 && updated==0 && disabled==0)"从 CleanByProduct 条件里摘掉,
+	// 依据是"loadPerms 对当前全体 user 的计算结果与上次结果完全一致"——但这个论断只对**走角色/
+	// 授权矩阵路径的普通 MEMBER**成立:他们 loadPerms 走 FindPermIdsByRoleIds + allow/deny 链,
+	// 新 perm 没被任何 role 引用,结果集合不会变。
+	//
+	// M-R17-1 指出漏洞:**全权用户**(SuperAdmin / 本产品 ADMIN / DEVELOPER / DEV 部门启用成员)的
+	// loadPerms 走 FindAllCodesByProductCode(productCode) 单条路径,返回该产品下所有 Enabled
+	// 的 perm 全集,新增任意 perm 都会让集合变大。跳过 CleanByProduct 等价于这四类用户的新权限
+	// 感知延迟最长 5min(UD TTL),发版当天容易出现"超管登录却拉不到 /v2/C"。
+	//
+	// 采用"全产品一刀切"的保守路径(audit M-R17-1 方案 2):CleanByProduct 的穿透开销在 CI/CD
+	// 高频发版场景确实存在,但相比"按 SuperAdmin + ADMIN/DEVELOPER + DEV-dept 精准枚举"需要跨
+	// 三张表聚合 userId 批量 Clean(audit 方案 1),实现复杂度与冷启动放大率权衡下,先保方案
+	// 2 的正确性;若未来 CleanByProduct 的压测数据表明 Redis/DB 穿透不可承受,再回退到方案 1。
+	if added > 0 || updated > 0 || disabled > 0 {
 		// 审计 M-R14-1:事务已提交,沿用 request ctx 做 CleanByProduct 会在调用方(pub 入口、
 		// CLI 入口等)ctx 被 cancel 时立刻放弃 Redis DEL,留下"本产品所有成员的 UD 缓存仍
 		// 携带被禁用 perm"的窗口(最长 5min TTL)。消费方若只看 GetUserPerms 返回、不做

+ 14 - 0
internal/logic/role/createRoleLogic.go

@@ -6,6 +6,7 @@ import (
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
 	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -54,6 +55,19 @@ func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdRe
 		return nil, response.ErrBadRequest("权限级别必须在 1-999 之间")
 	}
 
+	// 审计 H-R17-3:镜像 UpdateRole 的 L-R12-3 防提权("非超管不能提升角色的权限级别"),
+	// 但 CreateRole 的口径比 UpdateRole 更严——UpdateRole 限制的是"已有角色往更高权限挪",
+	// CreateRole 如果不限制,product ADMIN 可以直接造出 permsLevel=1 的顶格角色,再走 BindRoles
+	// 把下属 MEMBER/DEVELOPER 顶到 ADMIN 线(下一次 UD 重建时 MinPermsLevel=1),绕过 BindRoles
+	// 的 GuardRoleLevelAssignable "同级拦截"。GuardCreateRolePermsLevel 把这条短路堵死:
+	//   - SuperAdmin 不受限;
+	//   - 非超管(含 product ADMIN / DEVELOPER)创建时 permsLevel 必须 >= 2,permsLevel=1
+	//     作为"顶格语义"只允许 SuperAdmin 所生。
+	caller := middleware.GetUserDetails(l.ctx)
+	if err := authHelper.GuardCreateRolePermsLevel(l.ctx, l.svcCtx, caller, req.PermsLevel); err != nil {
+		return nil, err
+	}
+
 	now := time.Now().Unix()
 	result, err := l.svcCtx.SysRoleModel.Insert(l.ctx, &roleModel.SysRole{
 		ProductCode: req.ProductCode,

+ 87 - 0
internal/logic/role/createRoleLogic_test.go

@@ -164,3 +164,90 @@ func TestCreateRole_MemberRejected(t *testing.T) {
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 403, ce.Code())
 }
+
+// TC-1197: H-R17-3 —— 非超管 product ADMIN 不得创建 PermsLevel=1 顶格角色。
+//
+// 核心风险:product ADMIN 可用 CreateRole(PermsLevel=1) 造出"顶格角色",再通过
+// BindRoles 给下属 MEMBER/DEVELOPER,绕过 GuardRoleLevelAssignable 的"同级拦截",
+// 等价于横向提权。GuardCreateRolePermsLevel 对 HasFullPerms 的调用者强制 reqLevel >= 2,
+// 把 permsLevel=1 保留给 SuperAdmin。
+func TestCreateRole_H_R17_3_AdminCannotCreatePermsLevel1(t *testing.T) {
+	pc := "h_r17_3_" + testutil.UniqueId()
+	ctx := ctxhelper.AdminCtx(pc)
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc)
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_role`", "productCode", pc)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	_, err := NewCreateRoleLogic(ctx, svcCtx).CreateRole(&types.CreateRoleReq{
+		ProductCode: pc,
+		Name:        "top_role_" + testutil.UniqueId(),
+		PermsLevel:  1,
+	})
+	require.Error(t, err, "product ADMIN 尝试创建 PermsLevel=1 必须 403")
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(),
+		"H-R17-3:非超管创建顶格角色必须 403,防止'建 R_super + BindRoles' 横向提权链路")
+	assert.Contains(t, ce.Error(), "权限级别为 1 的顶格角色",
+		"错误文案必须点名 PermsLevel=1 的边界条件,便于调用方一眼看出约束")
+}
+
+// TC-1198: H-R17-3 正向 —— product ADMIN 可创建 PermsLevel>=2 的次级角色。
+// 防 GuardCreateRolePermsLevel 过度收紧把合法业务路径也误伤。
+func TestCreateRole_H_R17_3_AdminCanCreatePermsLevel2(t *testing.T) {
+	pc := "h_r17_3_ok_" + testutil.UniqueId()
+	ctx := ctxhelper.AdminCtx(pc)
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc)
+
+	resp, err := NewCreateRoleLogic(ctx, svcCtx).CreateRole(&types.CreateRoleReq{
+		ProductCode: pc,
+		Name:        "ok_role_" + testutil.UniqueId(),
+		PermsLevel:  2,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_role`", resp.Id)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	role, err := svcCtx.SysRoleModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(2), role.PermsLevel,
+		"ADMIN 创建 PermsLevel=2 应当成功并如实落盘,作为 H-R17-3 正向基线")
+}
+
+// TC-1199: H-R17-3 —— SuperAdmin 继续不受 PermsLevel=1 约束。
+// SuperAdmin 创建 permsLevel=1 是系统默认的"顶格角色唯一合法来源",必须保持放行。
+func TestCreateRole_H_R17_3_SuperAdminCanCreatePermsLevel1(t *testing.T) {
+	pc := "h_r17_3_su_" + testutil.UniqueId()
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc)
+
+	resp, err := NewCreateRoleLogic(ctx, svcCtx).CreateRole(&types.CreateRoleReq{
+		ProductCode: pc,
+		Name:        "su_top_" + testutil.UniqueId(),
+		PermsLevel:  1,
+	})
+	require.NoError(t, err, "SuperAdmin 必须能够创建 PermsLevel=1,否则顶格角色无合法来源")
+	require.NotNil(t, resp)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_role`", resp.Id)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	role, err := svcCtx.SysRoleModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), role.PermsLevel)
+}

+ 6 - 0
internal/logic/role/deleteRoleLogic.go

@@ -61,6 +61,12 @@ func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleReq) error {
 	// detached ctx 防止请求 ctx 取消把 BatchDel 打断导致旧权限滞留 TTL 窗口。
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
+	// 审计 H-R17-2:DeleteWithTx 走 sqlc `ExecCtx`,DelCache 在 commit 之前执行,中间任意并发
+	// `FindOne(roleId)` / `FindOneByProductCodeName` 都会把旧行回填进 Redis,commit 之后 DB
+	// 已经没有该角色但 Redis 还存活到 TTL。这段"幽灵角色快照"不逃逸权限(ResolveOwnRoleOr404
+	// 之后的 DELETE/UPDATE 走 CAS 最终撞不到行回 409),但会让删除后的后续操作报"谜之 409",
+	// 排障复杂度上升——post-commit 再调用 InvalidateRoleCache 显式清掉低层缓存两把键。
+	l.svcCtx.SysRoleModel.InvalidateRoleCache(cleanCtx, req.Id, role.ProductCode, role.Name)
 	l.svcCtx.UserDetailsLoader.BatchDel(cleanCtx, affectedUserIds, role.ProductCode)
 	return nil
 }

+ 56 - 0
internal/logic/role/deleteRoleLogic_test.go

@@ -2,6 +2,7 @@ package role
 
 import (
 	"errors"
+	"fmt"
 	"testing"
 	"time"
 
@@ -17,6 +18,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
 // TC-0126: 正常删除+级联
@@ -135,6 +137,60 @@ func TestDeleteRole_L_R14_1_CrossProductReturns404(t *testing.T) {
 	assert.Equal(t, roleId, still.Id)
 }
 
+// TC-1201: H-R17-2 —— DeleteRole 成功后必须在 post-commit 上显式失效 sysRole 的两把缓存键
+// (id key + productCode:name key),与 DeleteDept 的 H-R17-2 同构。
+//
+// 失效窗口:DeleteWithTx 里 sqlc.CachedConn.ExecCtx 的 "exec → DelCache" 钩子先于外层
+// TransactCtx commit 执行。commit 前并发 FindOne / FindOneByProductCodeName 会把旧行
+// 回填回 Redis,commit 后 DB 没了 role 但 Redis 还挂着"幽灵 role 快照"到 TTL。叠加
+// 角色树权限模型,后续 ResolveOwnRoleOr404 命中幽灵 snapshot 会产生谜之 409(CAS
+// 命中但 DB 无行),排障代价巨大——post-commit 必须再补一次 InvalidateRoleCache。
+func TestDeleteRole_H_R17_2_InvalidatesCacheAfterCommit(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	cfg := testutil.GetTestConfig()
+	svcCtx := svc.NewServiceContext(cfg)
+	conn := testutil.GetTestSqlConn()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	now := time.Now().Unix()
+	pc := "h_r17_2_" + testutil.UniqueId()
+	roleName := "h_r17_2_role_" + testutil.UniqueId()
+	res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc, Name: roleName, Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
+	idKey := fmt.Sprintf("%s:cache:sysRole:id:%d", cfg.CacheRedis.KeyPrefix, roleId)
+	nameKey := fmt.Sprintf("%s:cache:sysRole:productCode:name:%s:%s",
+		cfg.CacheRedis.KeyPrefix, pc, roleName)
+	t.Cleanup(func() { _, _ = rds.Del(idKey, nameKey) })
+
+	require.NoError(t, rds.Set(idKey,
+		fmt.Sprintf(`{"Id":%d,"ProductCode":%q,"Name":%q,"PermsLevel":1,"Status":1}`, roleId, pc, roleName)),
+		"预置 ghost idKey 失败")
+	require.NoError(t, rds.Set(nameKey,
+		fmt.Sprintf(`{"Id":%d,"ProductCode":%q,"Name":%q,"PermsLevel":1,"Status":1}`, roleId, pc, roleName)),
+		"预置 ghost nameKey 失败")
+
+	require.NoError(t, NewDeleteRoleLogic(ctx, svcCtx).DeleteRole(&types.DeleteRoleReq{Id: roleId}))
+
+	existsId, err := rds.Exists(idKey)
+	require.NoError(t, err)
+	assert.False(t, existsId,
+		"H-R17-2:commit 之后 InvalidateRoleCache 必须 DEL sysRoleIdKey,"+
+			"否则后续 FindOne(roleId) 命中 ghost snapshot → CAS 撞不到行回 409")
+
+	existsName, err := rds.Exists(nameKey)
+	require.NoError(t, err)
+	assert.False(t, existsName,
+		"H-R17-2:InvalidateRoleCache 必须同时 DEL sysRoleProductCodeNameKey,"+
+			"因为 FindOneByProductCodeName 是 CreateRole 前的唯一性校验路径,"+
+			"残留 ghost 会让同名 role 在删除后反复被 409 误判为冲突")
+}
+
 // TC-0540: deleteRole非管理员拒绝
 func TestDeleteRole_MemberRejected(t *testing.T) {
 	pc := "test_product"

+ 93 - 39
internal/logic/user/createUserLogic.go

@@ -2,6 +2,8 @@ package user
 
 import (
 	"context"
+	"database/sql"
+	"errors"
 	"regexp"
 	"strings"
 	"time"
@@ -16,11 +18,21 @@ import (
 	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 )
 
 var usernameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_]{2,64}$`)
 
+// 审计 L-R17-1:非超管创建用户名时保留下列前缀,避免"抢注 admin_<productCode>"这条 DoS 链 ——
+// CreateProductLogic 的 auto-provision 把初始管理员用户名定为 `admin_<productCode>`,与 sys_user
+// 上的 UNIQUE(username) 共用同一槽位;若 product ADMIN 预先以 CreateUser 接口建出 `admin_acme`
+// 这类账号,超管将来创建 productCode=acme 的新产品时会撞 1062,`compensateCreatedRows` 回滚,
+// 造成"新产品一直上线不了"的业务 DoS。把 `admin_` / `svc_` / `root_` / `sys_` 保留给 SuperAdmin
+// 路径(SuperAdmin 仍可通过 CreateUser 走这些前缀;auto-provision 走 SysUserModel.InsertWithTx
+// 不经本正则,因此不受影响)。
+var reservedUsernamePrefixes = []string{"admin_", "svc_", "root_", "sys_"}
+
 type CreateUserLogic struct {
 	logx.Logger
 	ctx    context.Context
@@ -54,6 +66,16 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 	if !usernameRegexp.MatchString(req.Username) {
 		return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
 	}
+	// 审计 L-R17-1:非超管不得以保留前缀创建用户,防止对 CreateProductLogic 的
+	// admin_<productCode> 抢注 DoS。SuperAdmin 不受限,可继续创建运维类系统账号。
+	if !caller.IsSuperAdmin {
+		lowered := strings.ToLower(req.Username)
+		for _, p := range reservedUsernamePrefixes {
+			if strings.HasPrefix(lowered, p) {
+				return nil, response.ErrBadRequest("用户名不能以 " + p + " 开头(该前缀为系统账号保留)")
+			}
+		}
+	}
 	if len(req.Nickname) > 64 {
 		return nil, response.ErrBadRequest("昵称长度不能超过64个字符")
 	}
@@ -83,54 +105,86 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 	if req.DeptId < 0 {
 		return nil, response.ErrBadRequest("部门ID必须为非负整数")
 	}
-	if req.DeptId > 0 {
-		newDept, derr := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId)
-		if derr != nil {
-			return nil, response.ErrBadRequest("部门不存在")
-		}
-		if newDept.Status != consts.StatusEnabled {
-			return nil, response.ErrBadRequest("目标部门已停用")
-		}
-		// 审计 H-R14-1:镜像 UpdateUser 侧护栏,DEV 部门在 loadPerms 里等价于"加入任一产品
-		// 即全权",跨产品信任边界的权限升级(见 H-R14-1 攻击链)必须收敛给 SuperAdmin。
-		// 虽然当前非超管的 DeptPath 前缀校验已经天然约束了这条路径(产品 ADMIN 的
-		// caller.DeptPath 通常不覆盖 DEV 子树),但显式判断能锁死语义,防止未来有人
-		// 调整 caller.DeptPath 归属后意外打开缺口。
-		if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
-			return nil, response.ErrForbidden("仅超级管理员可将用户调入研发部门")
-		}
-		if !caller.IsSuperAdmin {
-			if caller.DeptPath == "" {
-				return nil, response.ErrForbidden("您未归属任何部门,无权创建用户")
-			}
-			if !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-				return nil, response.ErrForbidden("无权在非自己管辖的部门下创建用户")
-			}
-		}
-	} else if !caller.IsSuperAdmin {
+	// 审计 H-R17-1:非超管分支必须指定部门——早在进入事务前就断言,避免把"必填校验失败"
+	// 的请求浪费掉一次 bcrypt + tx 开锁。
+	if req.DeptId <= 0 && !caller.IsSuperAdmin {
 		return nil, response.ErrBadRequest("必须指定部门")
 	}
 
+	// 审计 H-R17-1:bcrypt.GenerateFromPassword 在事务外完成——bcrypt default cost 约 60~100ms,
+	// 若放进事务体会把 sys_dept 的 S 锁持有时长拉高到 100ms+,阻塞并发 DeleteDept / UpdateDept
+	// 的 X 锁,引发写放大。tx 内只做"锁 sys_dept → Insert sys_user"的纯 DB 动作。
 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	if err != nil {
 		return nil, err
 	}
 
 	now := time.Now().Unix()
-	result, err := l.svcCtx.SysUserModel.Insert(l.ctx, &userModel.SysUser{
-		Username:           req.Username,
-		Password:           string(hashedPwd),
-		Nickname:           req.Nickname,
-		Email:              req.Email,
-		Phone:              req.Phone,
-		Remark:             req.Remark,
-		DeptId:             req.DeptId,
-		IsSuperAdmin:       consts.IsSuperAdminNo,
-		// 管理员代填的初始密码默认要求首次登录必须修改,降低"管理员口头下发后长期不换、口令库泄露即广义失陷"的风险(见审计 L-1)。
-		MustChangePassword: consts.MustChangePasswordYes,
-		Status:             consts.StatusEnabled,
-		CreateTime:         now,
-		UpdateTime:         now,
+	var result sql.Result
+	// 审计 H-R17-1:CreateUser 的 Insert 历史上游离事务之外,仅在 tx 前做一次 cached `FindOne(deptId)`
+	// 校验 sys_dept 存在与 Status;但 DeleteDept 已经用 `sys_dept FOR UPDATE` + `sys_user FOR SHARE`
+	// 的 tx 串行化堵住了"既有用户 vs 删部门"竞态,**未来用户 vs 删部门**这一面仍然敞开:
+	//   1. CreateUser 先拿到 dept 快照(可能是 cache stale / non-locking read);
+	//   2. 并发 DeleteDept 在两者间完成事务;
+	//   3. CreateUser 的 non-tx Insert 把 deptId 指向已被删除的 sys_dept 行,产生 orphan user。
+	// 修复口径与 DeleteDept 的事务相对——事务内 `FindOneForShareTx` 对 sys_dept[deptId] 取 S 锁,
+	// DeleteDept 的 X 锁被阻塞直到本 tx 提交;反之 DeleteDept 若先行提交,本 tx 的 S 锁会读到
+	// `sqlx.ErrNotFound`,干净地回 400 "部门不存在或已删除",彻底关闭"未来用户 → 幽灵部门"的窗口。
+	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("目标部门已停用")
+			}
+			// 审计 H-R14-1:镜像 UpdateUser 侧护栏,DEV 部门在 loadPerms 里等价于"加入任一产品
+			// 即全权",跨产品信任边界的权限升级(见 H-R14-1 攻击链)必须收敛给 SuperAdmin。
+			// 虽然当前非超管的 DeptPath 前缀校验已经天然约束了这条路径(产品 ADMIN 的
+			// caller.DeptPath 通常不覆盖 DEV 子树),但显式判断能锁死语义,防止未来有人
+			// 调整 caller.DeptPath 归属后意外打开缺口。
+			if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
+				return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
+			}
+			if !caller.IsSuperAdmin {
+				if caller.DeptPath == "" {
+					return response.ErrForbidden("您未归属任何部门,无权创建用户")
+				}
+				if !strings.HasPrefix(newDept.Path, caller.DeptPath) {
+					return response.ErrForbidden("无权在非自己管辖的部门下创建用户")
+				}
+			}
+		}
+
+		res, ierr := l.svcCtx.SysUserModel.InsertWithTx(ctx, session, &userModel.SysUser{
+			Username:           req.Username,
+			Password:           string(hashedPwd),
+			Nickname:           req.Nickname,
+			// 审计 L-R17-2:显式赋 `sql.NullString{Valid: false}` 把 DB 列落为 NULL。Go 结构体
+			// 零值本身也是 `{Valid:false, String:""}`,行为相同;但显式写出来锁住语义——后续如
+			// 有人把 SysUser.Avatar 改成纯 string 时,这里会立刻产生类型编译错误,避免"零值依赖"
+			// 静默降级为落 `''` 空串(与历史 NULL 行并存的脏数据状态)。
+			Avatar:             sql.NullString{Valid: false},
+			Email:              req.Email,
+			Phone:              req.Phone,
+			Remark:             req.Remark,
+			DeptId:             req.DeptId,
+			IsSuperAdmin:       consts.IsSuperAdminNo,
+			// 管理员代填的初始密码默认要求首次登录必须修改,降低"管理员口头下发后长期不换、口令库泄露即广义失陷"的风险(见审计 L-1)。
+			MustChangePassword: consts.MustChangePasswordYes,
+			Status:             consts.StatusEnabled,
+			CreateTime:         now,
+			UpdateTime:         now,
+		})
+		if ierr != nil {
+			return ierr
+		}
+		result = res
+		return nil
 	})
 	if err != nil {
 		if util.IsDuplicateEntryErr(err) {

+ 10 - 2
internal/logic/user/createUserLogic_mock_test.go

@@ -1,6 +1,7 @@
 package user
 
 import (
+	"context"
 	"errors"
 	"testing"
 
@@ -12,16 +13,23 @@ import (
 	"github.com/go-sql-driver/mysql"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"go.uber.org/mock/gomock"
 )
 
-// TC-0144: 唯一索引冲突消息(Insert层1062错误)
+// TC-0144: 唯一索引冲突消息(Insert层1062错误)——H-R17-1 后 CreateUser 把 Insert
+// 收进 SysUserModel.TransactCtx 闭包,mock 路径必须同步切到 TransactCtx + InsertWithTx,
+// 否则 gomock 在 "Unexpected call to Insert" 上就把契约保真性覆盖掉。
 func TestCreateUser_Mock_InsertDuplicate1062(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
-	mockUser.EXPECT().Insert(gomock.Any(), gomock.Any()).
+	mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).DoAndReturn(
+		func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockUser.EXPECT().InsertWithTx(gomock.Any(), gomock.Any(), gomock.Any()).
 		Return(nil, &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'dupuser' for key 'username'"})
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{

+ 263 - 1
internal/logic/user/createUserLogic_test.go

@@ -16,11 +16,15 @@ import (
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/types"
 	"strings"
 	"sync"
 	"testing"
 	"time"
+
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
 )
 
 func insertTestUser(t *testing.T, ctx context.Context, username, password string) int64 {
@@ -502,7 +506,10 @@ func TestCreateUser_DeptNotExists(t *testing.T) {
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "部门不存在", codeErr.Error())
+	// CreateUser 已把部门存在性校验从"事务外 cached FindOne"下沉到"事务内 FindOneForShareTx
+	// + S 锁"(H-R17-1),不存在的 deptId 在 FOR SHARE 读路径上会返 sqlx.ErrNotFound,
+	// 统一落到 "部门不存在或已删除" 的文案,以覆盖"并发 DeleteDept 胜出"场景。
+	assert.Equal(t, "部门不存在或已删除", codeErr.Error())
 }
 
 // TC-0154: 昵称超过64字符被拒绝
@@ -903,3 +910,258 @@ func TestCreateUser_H_R14_1_SuperAdminCanCreateInDevDept(t *testing.T) {
 	assert.Equal(t, devDeptId, u.DeptId,
 		"SuperAdmin 路径必须真的把 DeptId 写到 DEV,证明护栏未误伤 SuperAdmin 的合法运维链路")
 }
+
+// TC-1192: L-R17-1 —— 非超管不得以保留前缀创建用户名。
+//
+// 场景:产品 ADMIN 预知"下一批上线的产品 code = acme",试图提前以 `admin_acme` 这个用户名
+// 抢注(沿着 UNIQUE(username) 锁住槽位)。若不拦,超管将来 CreateProduct(code=acme) 时
+// auto-provision 的 admin_acme 会撞 1062,compensateCreatedRows 把产品 / 角色 / 权限一并
+// 回滚,造成"新产品一直上线不了"的业务 DoS。
+//
+// 对每个保留前缀都跑一次小子例:`admin_` / `svc_` / `root_` / `sys_`;大小写变体用 `Admin_`
+// 也必须被 `strings.ToLower` 规约后拦截,避免混入 SMTP/SQL 过滤视为"只查小写"的惯性漏洞。
+func TestCreateUser_L_R17_1_ReservedUsernamePrefix_NonSuperAdmin(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r17_1_caller", "/1700/")
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId)
+	})
+
+	adminCtx := callerAdminCtx(1771701, callerDeptId, "/1700/")
+
+	cases := []struct {
+		name     string
+		username string
+		wantPref string
+	}{
+		{"admin_prefix", "admin_acme", "admin_"},
+		{"svc_prefix", "svc_bot", "svc_"},
+		{"root_prefix", "root_ops", "root_"},
+		{"sys_prefix", "sys_health", "sys_"},
+		{"mixed_case_Admin", "Admin_acme", "admin_"},
+		{"mixed_case_SVC", "SVC_Ingest", "svc_"},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			_, err := NewCreateUserLogic(adminCtx, svcCtx).CreateUser(&types.CreateUserReq{
+				Username: tc.username,
+				Password: "Pass123456",
+				DeptId:   callerDeptId,
+			})
+			require.Error(t, err, "非超管以保留前缀创建用户必须被拒绝")
+
+			var ce *response.CodeError
+			require.True(t, errors.As(err, &ce))
+			assert.Equal(t, 400, ce.Code(),
+				"必须 400 而不是 409 —— 不暴露'该用户名是否存在',也不引导攻击者重试其他空位")
+			assert.Contains(t, ce.Error(), tc.wantPref,
+				"错误文案必须点名具体前缀,便于调用方立刻知道该改哪个前缀")
+			assert.Contains(t, ce.Error(), "系统账号保留",
+				"统一关键字,告警 / 审计可按单一关键字聚合此类事件")
+		})
+	}
+}
+
+// TC-1193: L-R17-1 正向回归 —— SuperAdmin 可以以保留前缀创建用户。
+//
+// 防回归:修复方案如果误把 SuperAdmin 也拦住,会让运维路径无法显式建 svc_ / root_ 账号,
+// 退化到"用 auto-provision 顺带带出"的隐式路径;超管豁免必须留有出口。
+func TestCreateUser_L_R17_1_ReservedUsernamePrefix_SuperAdminAllowed(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := "svc_" + testutil.UniqueId()
+	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: username,
+		Password: "Pass123456",
+		DeptId:   0,
+	})
+	require.NoError(t, err, "SuperAdmin 不受保留前缀约束")
+	require.NotNil(t, resp)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, username, u.Username)
+}
+
+// TC-1194: L-R17-2 —— 成功创建后 sys_user.Avatar 显式落 NULL。
+//
+// logic 在 `SysUser{...}` 字面量里必须显式 `Avatar: sql.NullString{Valid: false}`。
+// 行为上 Go 结构体零值也是 `{Valid:false, String:""}`——它确实写 NULL;但显式写出来的
+// 实际意义是"未来如有人把 SysUser.Avatar 改成 string 类型,该字面量立刻编译失败,
+// 避免零值依赖在字段迁移时静默漂移到落 `''` 空串,与历史 NULL 行并存产生脏数据"。
+func TestCreateUser_L_R17_2_AvatarExplicitNull(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := "l_r17_2_" + testutil.UniqueId()
+	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: username,
+		Password: "Pass123456",
+		DeptId:   0,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.False(t, u.Avatar.Valid,
+		"CreateUser 未指定 Avatar 时必须落 NULL(Valid=false),不得落空字符串(Valid=true, String=\"\")")
+	assert.Equal(t, "", u.Avatar.String,
+		"一致性断言:Valid=false 时 String 字段必须保持零值空串")
+}
+
+// TC-1195: H-R17-1 —— CreateUser 必须把 sys_dept S 锁读与 Insert 封在同一事务里。
+//
+// 用 mock 驱动,观察以下调用契约:
+//  1. SysUserModel.TransactCtx 被调用且其闭包被执行;
+//  2. 闭包内对目标 deptId 调用了 SysDeptModel.FindOneForShareTx(而不是非锁读 FindOne);
+//  3. 闭包内对 sys_user 走 InsertWithTx(而不是无事务 Insert);
+//  4. 三者发生在同一 tx 闭包内。
+//
+// 若实现回退为"tx 外 FindOne + tx 外 Insert",FindOneForShareTx 不会被打到 → gomock
+// 的 "expected call" 未满足导致 FAIL;反之,如果 Insert 漏掉 WithTx,本用例的 InsertWithTx
+// EXPECT 也会报 "got 0 calls"。
+func TestCreateUser_H_R17_1_InsertRunsInsideTxWithSharedDeptLock(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const targetDeptId = int64(17701)
+	const callerDeptId = int64(17702)
+	const callerUserId = int64(9911)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+
+	var (
+		findOneForShareCalled bool
+		insertWithTxCalled    bool
+		findOneForShareOrder  int
+		insertWithTxOrder     int
+		stepCounter           int
+	)
+
+	mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId).
+		DoAndReturn(func(ctx context.Context, _ sqlx.Session, _ int64) (*deptModel.SysDept, error) {
+			findOneForShareCalled = true
+			stepCounter++
+			findOneForShareOrder = stepCounter
+			return &deptModel.SysDept{
+				Id:       targetDeptId,
+				Name:     "target_dept",
+				Path:     "/17702/17701/", // 保证 HasPrefix(callerPath="/17702/") 成立
+				DeptType: consts.DeptTypeNormal,
+				Status:   consts.StatusEnabled,
+			}, nil
+		})
+
+	// 关键护栏:必须走 InsertWithTx,不得走 Insert。
+	// 未声明 `mockUser.EXPECT().Insert(...)`,任何对 Insert 的调用都会让 mock FAIL。
+	mockUser.EXPECT().InsertWithTx(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&userModel.SysUser{})).
+		DoAndReturn(func(ctx context.Context, _ sqlx.Session, u *userModel.SysUser) (sql.Result, error) {
+			insertWithTxCalled = true
+			stepCounter++
+			insertWithTxOrder = stepCounter
+			assert.Equal(t, targetDeptId, u.DeptId)
+			assert.False(t, u.Avatar.Valid, "L-R17-2:Avatar 必须显式 Valid=false")
+			return stubInsertResult{id: 77}, nil
+		})
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
+		UserId:        callerUserId,
+		Username:      "h_r17_1_caller",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeAdmin,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/17702/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: "h_r17_1_" + testutil.UniqueId(),
+		Password: "Pass123456",
+		DeptId:   targetDeptId,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, int64(77), resp.Id)
+
+	assert.True(t, findOneForShareCalled, "必须在事务内对 sys_dept 走 FindOneForShareTx(S 锁)")
+	assert.True(t, insertWithTxCalled, "sys_user 落盘必须走 InsertWithTx(同一 tx)")
+	assert.Less(t, findOneForShareOrder, insertWithTxOrder,
+		"锁序契约:先 FindOneForShareTx(sys_dept) 后 InsertWithTx(sys_user),"+
+			"与 DeleteDept 的 `sys_dept FOR UPDATE → sys_user FOR SHARE` 对偶,"+
+			"彻底关闭 future-user vs delete-dept 的 TOCTOU 窗口")
+}
+
+// TC-1196: H-R17-1 —— 事务内 FindOneForShareTx 返回 ErrNotFound 时必须 400 "部门不存在或已删除"。
+//
+// 时序等价于:并发 DeleteDept 已先行提交,S 锁读已看不到该 dept 行;此时 Insert 必须
+// 被拒绝,彻底避免"sys_user.deptId 指向已死 sys_dept.id"的 orphan 行。
+func TestCreateUser_H_R17_1_DeptConcurrentlyDeletedRejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const targetDeptId = int64(17710)
+	const callerDeptId = int64(17711)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+
+	mockUser.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockDept.EXPECT().FindOneForShareTx(gomock.Any(), gomock.Any(), targetDeptId).
+		Return(nil, sqlx.ErrNotFound)
+
+	// InsertWithTx 绝不应该被调用——未声明即 Times(0),调用必 FAIL。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser, Dept: mockDept})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
+		UserId:        9922,
+		Username:      "h_r17_1_caller2",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeAdmin,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/17712/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	_, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: "h_r17_1_del_" + testutil.UniqueId(),
+		Password: "Pass123456",
+		DeptId:   targetDeptId,
+	})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Contains(t, ce.Error(), "部门不存在或已删除",
+		"必须给出"+"已删除"+"提示,调用方才能分辨'真不存在'与'并发删掉'两条路径")
+}
+
+// stubInsertResult 最小实现 sql.Result 供 mock 返回 LastInsertId。
+type stubInsertResult struct{ id int64 }
+
+func (r stubInsertResult) LastInsertId() (int64, error) { return r.id, nil }
+func (r stubInsertResult) RowsAffected() (int64, error) { return 1, nil }

+ 17 - 2
internal/logic/user/updateUserLogic.go

@@ -263,8 +263,23 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		// 的乐观锁),**再** IncrementTokenVersionWithTx。颠倒顺序会让 IncrementTokenVersionWithTx
 		// 先把 updateTime 改到 now(),导致紧随其后的 UpdateProfileWithTx 的乐观锁 WHERE 匹配不到
 		// (affected=0 → ErrUpdateConflict),把本来应该成功的请求误判为并发冲突。
-		// 若同时 statusChanged 也真实发生,UpdateProfileWithTx 内部会先 +1,这里再 +1 等于 +2,
-		// 不破坏 tokenVersion 单调递增语义(claims 比对仍然能正确识别 stale token)。
+		//
+		// 审计 M-R17-2 · 双递增语义澄清:
+		// 当 devAccessRevoked && statusChanged 并发发生在同一请求时(例如一次 UpdateUser 同时把
+		// target 从 DEV 部门调到 NORMAL 并把 status 置为 Disabled),tokenVersion 在本事务内会
+		// 被**连续递增两次** —— UpdateProfileWithTx 内部因 statusChanged=true 先自增 1,下一行
+		// 对 devAccessRevoked 的显式 IncrementTokenVersionWithTx 再自增 1,净效果 +2。
+		// 这在**安全语义上等价于 +1**:tokenVersion 是单调递增信号量,jwtauthMiddleware 只要求
+		// claims 里的 tokenVersion >= DB 当前值才放行,旧 token 只会被一票否决不会"被跳过"。
+		// 运维侧若用 tokenVersion 样本做"活跃会话批次"分析,看到单次 UpdateUser 让 tokenVersion
+		// 跳 2 不是异常,而是这两条收窄路径重叠命中的预期行为。
+		//
+		// 之所以不在本层"收敛到只 +1":
+		//   - UpdateProfileWithTx 的 statusChanged 分支内嵌的 tokenVersion+1 是跨 UpdateUser /
+		//     UpdateUserStatus / UpdateMember 多调用方共享的契约(M-R15-1 / L-R15-3 都依赖它),
+		//     拆出条件开关反而会让签发层吊销的不变量分散到每个调用方,回归面变大;
+		//   - +2 不破坏正确性,只多消耗一次极廉价的 `UPDATE ... SET tokenVersion=tokenVersion+1`。
+		// 因此这里**有意**保留"可能 +2"的行为,与安全性无矛盾。
 		if err := l.svcCtx.SysUserModel.UpdateProfileWithTx(
 			ctx, session, req.Id, user.Username,
 			nickname, email, phone, remark, deptId,

+ 15 - 0
internal/logic/user/updateUserStatusLogic.go

@@ -32,6 +32,21 @@ func NewUpdateUserStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
 
 // UpdateUserStatus 冻结/解冻用户。修改用户启用状态并递增 tokenVersion 使其令牌失效。不能修改自身或超管状态。
 // 若状态未实际变更则不做任何写操作,避免不必要的 tokenVersion 递增踢用户下线。
+//
+// 审计 L-R17-6 · 解冻(Disabled → Enabled)也递增 tokenVersion 是**刻意**设计:
+// sysUserModel.UpdateStatus 的底层 SQL 无条件 `SET tokenVersion = tokenVersion + 1`,不论方向。
+// 两种方向的语义分别是:
+//   - **Enabled → Disabled**(冻结):签发层吊销,已签发的 access/refresh token 立刻失效——
+//     jwtauthMiddleware 的 tokenVersion 比对会把 claims.tokenVersion < DB.tokenVersion 的请求
+//     一票否决,与 UpdateMember 的 M-R15-1 /  ChangePassword 的 tokenVersion 契约完全对齐。
+//   - **Disabled → Enabled**(解冻):用户本来就因 Status!=Enabled 无法登录/刷新 token,再 +1
+//     在业务层等价于空动作;但保留 +1 能覆盖一条极端路径——"冻结→Redis 缓存携带旧 tokenVersion
+//     → 解冻瞬间" 的极短窗口里,若 UD 聚合缓存失效失败(Clean 走 best-effort),旧 access
+//     token 可能在客户端还没到 exp 前凭残留 UD 复活;无条件 +1 让这条复活路径一并堵死。
+//
+// 因此:**不要**把 tokenVersion+1 改成"条件递增"(如仅在冻结时 +1),否则"冻结 → 短窗口
+// 抖动 → 解冻" 这三步会让已经被踢下线的用户靠老 token 悄悄回来。安全语义上"解冻 +1"是刚好
+// 对等的代价(用户反正得重新登录)。
 func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq) error {
 	if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
 		return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")

+ 17 - 2
internal/logic/user/updateUserStatusLogic_test.go

@@ -33,9 +33,12 @@ func TestUpdateUserStatus_Freeze(t *testing.T) {
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+
 	callerId := int64(999999998)
 	logic := NewUpdateUserStatusLogic(ctxhelper.SuperAdminCtxWithUserId(callerId), svcCtx)
-	err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{
+	err = logic.UpdateUserStatus(&types.UpdateUserStatusReq{
 		Id:     userId,
 		Status: 2,
 	})
@@ -44,6 +47,9 @@ func TestUpdateUserStatus_Freeze(t *testing.T) {
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	require.NoError(t, err)
 	assert.Equal(t, int64(2), user.Status)
+	assert.Equal(t, before.TokenVersion+1, user.TokenVersion,
+		"冻结路径 tokenVersion 必须 +1,吊销该用户已签发 access/refresh token;"+
+			"若未递增则 jwtauthMiddleware 的 tokenVersion 比对失效,已签发令牌会继续有效至 exp")
 }
 
 // TC-0201: 正常解冻
@@ -64,9 +70,12 @@ func TestUpdateUserStatus_Unfreeze(t *testing.T) {
 	})
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+
 	callerId := int64(999999998)
 	logic := NewUpdateUserStatusLogic(ctxhelper.SuperAdminCtxWithUserId(callerId), svcCtx)
-	err := logic.UpdateUserStatus(&types.UpdateUserStatusReq{
+	err = logic.UpdateUserStatus(&types.UpdateUserStatusReq{
 		Id:     userId,
 		Status: 1,
 	})
@@ -75,6 +84,12 @@ func TestUpdateUserStatus_Unfreeze(t *testing.T) {
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), user.Status)
+	// L-R17-6:UpdateStatus 底层 SQL 无条件 `SET tokenVersion = tokenVersion + 1`,不论方向;
+	// 解冻场景 +1 是**刻意**设计,用于堵住"冻结→UD 缓存残留→解冻瞬间旧 access token 复活"
+	// 的极端路径。任何"条件 +1 / 仅冻结 +1"的回滚都会被此断言立刻逮到。
+	assert.Equal(t, before.TokenVersion+1, user.TokenVersion,
+		"L-R17-6:解冻路径 tokenVersion 也必须无条件 +1,"+
+			"若回退成'仅冻结 +1',冻结→短抖→解冻 三步会让旧 token 靠残留 UD 复活")
 }
 
 // TC-0202: 非法status(0)

+ 19 - 0
internal/model/dept/sysDeptModel.go

@@ -40,6 +40,13 @@ type (
 		// 去 FOR SHARE sys_user WHERE deptId=X 时能看到新行,改删除为 400,整链路不产生 orphan deptId。
 		// 本方法不走缓存,必须在 TransactCtx / Session 下调用。
 		FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*SysDept, error)
+		// UpdatePathWithTx 只更新 sys_dept 某行的 `path` 与 `updateTime`。专用于 CreateDept 新建
+		// 行之后用 LastInsertId 拼接 path 的"第二步"(审计 L-R17-5):原实现要先 FindOneWithTx
+		// 把刚插入的整行读回来再走 UpdateWithTx,DB 往返 3 次(INSERT + SELECT + UPDATE);新
+		// 方法把它压到 2 次(INSERT + UPDATE),tx 持锁时间对应缩短。
+		// 不走缓存失效(本事务是 sys_dept 新行,无既有低层缓存需要清);必须在 TransactCtx /
+		// Session 下调用,session==nil 直接返错。
+		UpdatePathWithTx(ctx context.Context, session sqlx.Session, id int64, path string, updateTime int64) error
 	}
 
 	customSysDeptModel struct {
@@ -106,6 +113,18 @@ func (m *customSysDeptModel) UpdateWithOptLockTx(ctx context.Context, session sq
 	return nil
 }
 
+// UpdatePathWithTx 见接口注释(审计 L-R17-5)。与 UpdateWithOptLockTx 一样**绕过** m.ExecCtx
+// 的 pre-commit DelCache 钩子——CreateDept 的新行目前也不存在低层缓存条目,无需事务内失效。
+// 仅更新 `path` / `updateTime` 两列,其余字段保持 InsertWithTx 落盘时的值不变。
+func (m *customSysDeptModel) UpdatePathWithTx(ctx context.Context, session sqlx.Session, id int64, path string, updateTime int64) error {
+	if session == nil {
+		return errors.New("UpdatePathWithTx requires a non-nil session")
+	}
+	query := fmt.Sprintf("UPDATE %s SET `path`=?, `updateTime`=? WHERE `id`=?", m.table)
+	_, err := session.ExecCtx(ctx, query, path, updateTime, id)
+	return err
+}
+
 // InvalidateDeptCache 见接口注释(审计 L-R16-2)。与 sysUserModel.InvalidateProfileCache 对齐:
 // post-commit best-effort 失效,ctx 取消与其它错误分档日志,方便 Redis 抖动与主动取消区分告警。
 func (m *customSysDeptModel) InvalidateDeptCache(ctx context.Context, id int64) {

+ 32 - 0
internal/model/role/sysRoleModel.go

@@ -10,6 +10,7 @@ import (
 
 	"perms-system-server/internal/consts"
 
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -40,6 +41,16 @@ type (
 		// 本方法不走缓存,必须在 TransactCtx / Session 下调用;入参 ids 会在内部按升序排序
 		// 取锁以避免死锁。
 		LockRolesForShareTx(ctx context.Context, session sqlx.Session, ids []int64) error
+		// InvalidateRoleCache 失效 sysRole 的 id / (productCode, name) 两把低层缓存键。对齐
+		// sysDeptModel.InvalidateDeptCache 与 sysUserModel.InvalidateProfileCache 的 L-R12-1
+		// 契约(审计 H-R17-2):仅应在事务 commit 成功后调用,兜底 `*WithTx` 路径里 sqlc
+		// `ExecCtx` "exec → DelCache" 钩子过早清缓存之后、commit 之前被并发 `FindOne` 把旧行
+		// 回填进 Redis 的幽灵快照。best-effort:失败只记日志,TTL 兜底。
+		//
+		// 调用方必须传入删除/更新前真实的 (productCode, name)——因为 name 键由这两个字段拼接,
+		// 如果更新修改了 name,post-commit 失效时要同时清老 name 和新 name 两个键(可由调用方
+		// 分别调两次本方法,或在调用方自行按需去重)。
+		InvalidateRoleCache(ctx context.Context, id int64, productCode, name string)
 	}
 
 	customSysRoleModel struct {
@@ -147,6 +158,27 @@ func (m *customSysRoleModel) LockRolesForShareTx(ctx context.Context, session sq
 	return nil
 }
 
+// InvalidateRoleCache 见接口注释(审计 H-R17-2)。与 sysDeptModel.InvalidateDeptCache 同型:
+// post-commit best-effort 失效,ctx 取消与其它错误分档日志,方便 Redis 抖动与主动取消区分告警。
+func (m *customSysRoleModel) InvalidateRoleCache(ctx context.Context, id int64, productCode, name string) {
+	keys := []string{fmt.Sprintf("%s%v", cacheSysRoleIdPrefix, id)}
+	if productCode != "" && name != "" {
+		keys = append(keys, fmt.Sprintf("%s%v:%v", cacheSysRoleProductCodeNamePrefix, productCode, name))
+	}
+	if err := m.DelCacheCtx(ctx, keys...); err != nil {
+		if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+			logx.WithContext(ctx).Errorw("cache invalidation skipped: ctx canceled",
+				logx.Field("audit", "cache_invalidation_skipped_due_to_ctx_cancel"),
+				logx.Field("scope", "sysRoleModel.InvalidateRoleCache"),
+				logx.Field("id", id),
+				logx.Field("err", err.Error()),
+			)
+		} else {
+			logx.WithContext(ctx).Errorf("sysRoleModel.InvalidateRoleCache failed: id=%d err=%v", id, err)
+		}
+	}
+}
+
 // LockByIdTx 见接口注释。注意:本函数不走缓存层,必须在 TransactCtx / Session 下调用;
 // SELECT ... FOR UPDATE 的行锁由 InnoDB 持有到事务结束。
 func (m *customSysRoleModel) LockByIdTx(ctx context.Context, session sqlx.Session, id int64) (*SysRole, error) {

+ 7 - 0
internal/model/user/sysUserModel.go

@@ -277,6 +277,13 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, usern
 //   - expectedUpdateTime 不匹配 → ErrUpdateConflict;上层统一回 409 "数据已被其他操作修改"。
 //   - 避免并发冻结/解冻请求走"last-write-wins",出现两个 admin 同时点"冻结"/"解冻"
 //     时后到者覆盖先到者、tokenVersion 被连续加两次把刚刚解冻的用户再次踢下线的诡异现象。
+//
+// 审计 L-R17-6 · 无条件递增 tokenVersion 是**刻意**设计(不要改成"仅冻结时 +1"的条件递增):
+//   - Enabled→Disabled(冻结):签发层吊销旧 access/refresh token,JWT middleware 凭
+//     `claims.tokenVersion < DB.tokenVersion` 一票否决。
+//   - Disabled→Enabled(解冻):用户已因 Status!=Enabled 无法登录/刷新,+1 在业务上是空动作;
+//     但保留 +1 覆盖"冻结 → Redis Clean 失败 → 解冻"这条极窄路径里"旧 access token 凭残留
+//     UD 复活"的可能,与 UpdateUserStatusLogic 的 L-R17-6 注释同口径。
 func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, username string, status int64, expectedUpdateTime int64) error {
 	// 审计 M-R11-2:username 由调用方(ValidateStatusChange 返回的目标用户对象)显式透传,
 	// 不再内部 FindOne。真实并发安全继续靠 `WHERE updateTime = expectedUpdateTime` 乐观锁兜底。

+ 14 - 0
internal/testutil/mocks/mock_dept_model.go

@@ -299,6 +299,20 @@ func (mr *MockSysDeptModelMockRecorder) Update(ctx, data any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSysDeptModel)(nil).Update), ctx, data)
 }
 
+// UpdatePathWithTx mocks base method.
+func (m *MockSysDeptModel) UpdatePathWithTx(ctx context.Context, session sqlx.Session, id int64, path string, updateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdatePathWithTx", ctx, session, id, path, updateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdatePathWithTx indicates an expected call of UpdatePathWithTx.
+func (mr *MockSysDeptModelMockRecorder) UpdatePathWithTx(ctx, session, id, path, updateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePathWithTx", reflect.TypeOf((*MockSysDeptModel)(nil).UpdatePathWithTx), ctx, session, id, path, updateTime)
+}
+
 // UpdateWithOptLock mocks base method.
 func (m *MockSysDeptModel) UpdateWithOptLock(ctx context.Context, data *dept.SysDept, expectedUpdateTime int64) error {
 	m.ctrl.T.Helper()

+ 12 - 0
internal/testutil/mocks/mock_role_model.go

@@ -291,6 +291,18 @@ func (mr *MockSysRoleModelMockRecorder) InsertWithTx(ctx, session, data any) *go
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWithTx", reflect.TypeOf((*MockSysRoleModel)(nil).InsertWithTx), ctx, session, data)
 }
 
+// InvalidateRoleCache mocks base method.
+func (m *MockSysRoleModel) InvalidateRoleCache(ctx context.Context, id int64, productCode, name string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "InvalidateRoleCache", ctx, id, productCode, name)
+}
+
+// InvalidateRoleCache indicates an expected call of InvalidateRoleCache.
+func (mr *MockSysRoleModelMockRecorder) InvalidateRoleCache(ctx, id, productCode, name any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateRoleCache", reflect.TypeOf((*MockSysRoleModel)(nil).InvalidateRoleCache), ctx, id, productCode, name)
+}
+
 // LockByIdTx mocks base method.
 func (m *MockSysRoleModel) LockByIdTx(ctx context.Context, session sqlx.Session, id int64) (*role.SysRole, error) {
 	m.ctrl.T.Helper()

+ 16 - 3
test-design.md

@@ -181,7 +181,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1021 | POST /api/perm/sync | 既有 404 路径用例的 `LockByCodeTx` mock 显式携带 `Status=1` | 原场景保持不变 | 行为不变 | 适配 | P0 | 所有既有 audit 路径都必须显式带 `Status=1`,否则命中 403 分支 |
 | TC-1022 | POST /api/perm/sync | Dedup / mock / TxLock 三路径下事务内 Status 复核全覆盖 | 同上 | 行为不变 | 适配 | P0 | 对事务内 Status 复核全覆盖 |
 | TC-1023 | POST /api/perm/sync | gRPC `SyncPermissions` 入口同样落入 `Status=1` 契约 | `LockByCodeTx` 必带 `Status=1` | UnmappedCode 仍走 Internal | 适配 | P0 | gRPC 层也落入同一契约 |
-| TC-1063 | POST /api/perm/sync | 纯新增(updated=0, disabled=0)→ **不**触发 CleanByProduct | 预先在 Redis 设置 `productIndexKey` canary;执行 `ExecuteSyncPerms(perms=全新 codes)` | canary 仍在 Redis(未被 CleanByProduct 删除);`added>0` | 契约/性能 | P0 | 主反回归 |
+| TC-1063 | POST /api/perm/sync | 纯新增(added>0 && updated==0 && disabled==0)→ **必须**触发 CleanByProduct | 执行首次 `ExecuteSyncPerms` 打底,之后用 `primeProductIndex` 置 canary;再次触发 `ExecuteSyncPerms(perms=全新 codes)` | canary 被删除;`added>0`;全权用户(SuperAdmin/本产品 ADMIN/DEVELOPER/DEV 启用成员)的 UD 聚合缓存必须立刻刷新,不得在 5min TTL 窗口内继续返回旧 perm 集合 | 契约/安全 | P0 | 全权用户 loadPerms 走 `FindAllCodesByProductCode(productCode)`,新增 perm 若不清 UD 会造成 5min 视图偏差,所有触达 perm 的分支都要失效 |
 | TC-1064 | POST /api/perm/sync | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | 预置 canary + 一条已有 perm;然后 sync 带同 code 但改名 | canary 被删除(CleanByProduct 触达);`updated>0` | 契约 | P0 | update 路径 |
 | TC-1065 | POST /api/perm/sync | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | 同上但 sync 不传原 code;旧 perm 被禁用 | canary 被删除;`disabled>0` | 契约 | P0 | disable 路径 |
 | TC-1118 | POST /api/perm/sync | ExecuteSyncPerms post-commit CleanByProduct 与请求 ctx 解耦 | `parent = WithCancel(bg)`;第一次 sync 注入 perm 打底;第二次 sync 改 Name 前用 `primeProductIndex` 预置 canary;传 `parent` 执行第二次 sync 后 **立即** `cancel(parent)`,再观察 Redis | 第二次 sync 返回 `err==nil`、`updated==1`;`productIndexKey` canary 已被删除 —— 说明 `CleanByProduct` 跑在 `DetachCacheCleanCtx` 上、与 parent cancel 解耦;若回退为"直接用 parent ctx",Redis DEL 会被 cancel 打断 → canary 残留 | 安全/生命周期 | P0 | M-R14-1:与 TC-1117 对偶,堵住"事务已提交但 UD 仍在 5 分钟 TTL 内挂着被禁用 perm" 的窗口 |
@@ -220,6 +220,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1041 | POST /api/auth/changePassword | Model 层并发:同一 expectedUpdateTime 两 goroutine 并行 CAS | 2 个 goroutine 共享 T0,并发 `UpdatePassword` | 恰好 1 个成功、1 个 `ErrUpdateConflict`;DB 最终密码 = 赢者的密码;tokenVersion 只 +1 不是 +2 | 并发/契约 | P0 | 并发单胜者;`tokenVersion` 被累计两次会立即暴露退化 |
 | TC-1042 | POST /api/auth/changePassword | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | 第一次 P0→P1(200),第二次仍送 oldPass=P0 | 第二次 `CodeError.Code()==400`,msg 含 "旧密码错误";**不得**成 409(否则 400/409 语义混淆) | 边界 | P0 | 400/409 分桶契约 |
 | TC-1043 | POST /api/auth/changePassword | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | mock `UpdatePassword(id, username, _, MustChangePasswordNo, snapshotUpdateTime)`,断言第 5 个实参 | mock EXPECT 命中;若回退为"Model 内部再读 updateTime",这里会拿到零值触发失败 | 契约 | P0 | CAS 快照来源契约 |
+| TC-1179 | POST /api/auth/changePassword | "新旧密码相同"校验必须排在 `bcrypt.CompareHashAndPassword` 之前 | 用假冒 OldPassword(bcrypt 比对会失败),但 NewPassword 与 OldPassword 字面相等;请求落 `ChangePassword` | `CodeError.Code()==400`;文案精确等于 "新密码不能与原密码相同"(若文案是"原密码错误"说明顺序被误回滚);`UpdatePassword` 绝不被调用 | 性能/契约 | P1 | 廉价字符串判等要跑在昂贵 bcrypt 之前,防止攻击者借 OldPassword 长度/并发把 bcrypt 放大成 CPU DoS;也把"用户输入了同一个新密码"早期吐回,避免无谓的 hash |
 
 ### 2.6 创建产品 `POST /api/product/create`
 
@@ -271,6 +272,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1033 | POST /api/product/create | 冲突路径下 `SysDeptModel.FindOne` 返 `Status=1` 且 `AdminDeptId` 透传 | mock `SysDeptModel.FindOne` 返 `Status=1` | 行为不变;`AdminDeptId` 透传 | 适配 | P0 | mock 侧补齐 |
 | TC-1034 | POST /api/product/create | mock 侧两处 `CreateProductReq` 补齐 `AdminDeptId` 字段 | 同上,两处 `CreateProductReq` | 行为不变 | 适配 | P0 | mock 侧补齐 |
 | TC-1035 | POST /api/product/create | 一次性票据 `AdminPassword` 长度=16(不再是旧的 24) | 读取一次性票据中的 `AdminPassword` | `len(cred.AdminPassword)==16`(不是旧的 24) | 契约 | P1 | 长度断言回归 |
+| TC-1202 | POST /api/product/create | 落盘的 `admin_<code>` 账号 `sys_user.Avatar` 必须是 SQL NULL | 真实 CreateProduct 完整落盘后查 `CASE WHEN avatar IS NULL` | 字段值 1(NULL 而非空串) | 契约/数据映射 | P1 | 与 CreateUser 同步契约;ORM 层 `sql.NullString{Valid:false}` 必须序列化成 SQL NULL 而非 `""`,否则未来 `WHERE avatar IS NULL` 判空分支会失准 |
+| TC-1203 | POST /api/product/create | tx 内必须在 `InsertWithTx` 之前对 `adminDeptId` 取 S 锁;并发 DeleteDept 已提交 → 立即 400 | mock:`FindOne` 预检通过(status=1);`TransactCtx` 执行闭包;闭包内 `FindOneForShareTx` 返 `sqlx.ErrNotFound` | 返 `CodeError.Code()==400`,文案含 "管理员部门不存在或已删除";`SysProductModel.InsertWithTx` / `SysUserModel.InsertWithTx` / `SysProductMemberModel.InsertWithTx` 一次都未被调用(若被调,gomock 在未声明 EXPECT 上立刻失败) | 并发/事务闭包 | P0 | 锁序:先 pre-check 再 tx 内 FOR SHARE,闭合"pre-check 通过→并发 DeleteDept 提交→Insert 写入幽灵 deptId"的窗口;orphan admin 一旦落盘会挂在已删除部门下,后续 DeptPath 校验全部失效 |
 
 ### 2.7 产品更新/列表/详情
 
@@ -347,6 +350,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1176 | POST /api/dept/update | NORMAL 部门 Status Enabled→Disabled 批量递增 tokenVersion | seed Dept(DeptType=NORMAL, Status=1) + 2 个成员;`{Status:2}` | 2 个成员 tokenVersion +1;部门 `status=2` | 安全/会话吊销 | P0 | 冻结部门必须冻结其成员会话,避免 DB 里 Disabled 但旧 JWT 仍通 |
 | 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` |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -384,6 +388,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-1001 | POST /api/role/* | "id 不存在" vs "跨产品" 响应对比 | 两条路径对照 | code + body 完全一致 | 安全/Oracle | P0 | 彻底消除枚举 oracle |
 | TC-1002 | POST /api/role/* | 超管跨产品访问 | 超管 → 跨产品 role + permIds | 正常返回完整 RoleItem | 正向 | P0 | 审计/运维路径不得被误伤 |
 | TC-1119 | POST /api/role/update | L-R14-1 非超管访问别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `UpdateRole(id)`,该 roleId 实际归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未写入 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 收敛 404 vs 403 枚举 oracle,与 RoleDetail 的 M-N3 口径一致 |
+| 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 的角色 |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -393,6 +400,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0127 | POST /api/role/delete | 事务回滚 | 模拟DeleteWithTx失败 | 级联删除回滚 | 事务验证 | P0 | TransactCtx |
 | TC-0128 | POST /api/role/delete | 无关联数据 | 新角色无绑定 | code=0 | 分支覆盖 | P1 | 删0条 |
 | TC-1120 | POST /api/role/delete | L-R14-1 非超管 DeleteRole 别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `DeleteRole(id)`,该 roleId 归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未删 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 统一收敛,与 UpdateRole / RoleDetail 对齐 |
+| TC-1201 | POST /api/role/delete | 成功删除后 post-commit 必须同时失效 `sysRole:id` 与 `sysRole:productCode:name` 两把键 | 插角色 → 手工 SET ghost 到 `<prefix>:cache:sysRole:id:<id>` 与 `<prefix>:cache:sysRole:productCode:name:<pc>:<name>` → `DeleteRole` | 两把 Redis key 均被 DEL;若 id key 残留则 `FindOne(id)` 命中 ghost → CAS 撞不到行回 409;若 name key 残留则同名 role 在删除后反复被"已存在"误拒(uniqueness oracle) | 缓存一致性/契约 | P0 | `sqlc.CachedConn.ExecCtx` 的 "exec→DelCache" 在 commit 之前执行;commit 前并发 `FindOne` / `FindOneByProductCodeName` 会回填旧行;detached ctx 上补一次 `InvalidateRoleCache` 是闭环关键 |
 
 ### 2.13 绑定角色权限 `POST /api/role/bindPerms`
 
@@ -444,6 +452,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-1100 | POST /api/user/create | 拒绝 deptId<0(避免负数穿透) | 超管 + `DeptId=-1` | 400 "部门ID必须为非负整数";sys_user 无新增行 | 输入校验 | P0 | 防 sys_user.deptId=-1 僵尸账号(FindOne(-1) → 5xx degrade) |
 | TC-1122 | POST /api/user/create | H-R14-1 非超管 caller 把新用户建到 DEV 部门必须 403 | `callerAdminCtx`(ADMIN / DeptPath 包含目标)+ `DeptId=<DeptType=DEV 且启用>` | `CodeError.Code()==403`,文案含 "仅超级管理员可将用户调入研发部门";`sys_user` 无新增行 | 安全/跨产品升权 | P0 | 镜像 `updateUserLogic.go` 的 H-R14-1 护栏,封死"加入 DEV 即全权"跨产品信任穿透 |
 | TC-1123 | POST /api/user/create | H-R14-1 SuperAdmin 仍可把新用户建到 DEV 部门(正向回归) | 超管 + `DeptId=<DEV 启用>` | 创建成功,DB 落盘 `deptId` 与 `deptType=DEV` 对应;caller.IsSuperAdmin 被豁免 | 正常路径 | P0 | 防护栏误伤合法运维路径 |
+| TC-1192 | POST /api/user/create | 非超管不得以保留前缀(`admin_` / `svc_` / `root_` / `sys_`)占名 | `AdminCtx` + `{username:"admin_x", password:"Aa123456"}` 等 4 组 | 每组 `CodeError.Code()==400`;文案含保留前缀提示;`sys_user` 均无新增行 | 安全/命名空间抢注 | P1 | 防产品 ADMIN 预置 `admin_<code>` 等"像系统账号"的用户名再通过 AddMember 等路径拔高成 ADMIN,形成命名空间阴谋 |
+| TC-1193 | POST /api/user/create | SuperAdmin 允许使用保留前缀 | `SuperAdminCtx()` + `{username:"admin_abc", password:"Aa123456"}` | 创建成功;DB 正常落盘 | 正向回归 | P1 | 保留前缀仅限 SuperAdmin;CreateProduct 的 `admin_<code>` 初始化流程、运维脚本必须继续工作 |
+| TC-1194 | POST /api/user/create | 未传 `avatar` 时 `sys_user.avatar` 必须写入 SQL NULL(而非空串) | 正常创建,入参不带 `avatar` | 创建成功;SELECT `CASE WHEN avatar IS NULL THEN 1 ELSE 0 END` 返回 1 | 契约/数据映射 | P1 | `sql.NullString{Valid:false}` 必须被序列化成 SQL NULL,否则将来 `WHERE avatar IS NULL` 判空分支与"空串 vs NULL"在 ORM 层混淆会触发静默偏差 |
+| TC-1195 | POST /api/user/create | mock:`InsertWithTx` 必须在 `TransactCtx` 闭包内、并且发生在 `FindOneForShareTx(sys_dept)` 之后 | gomock 记录 Insert 顺序;`TransactCtx` 执行闭包;闭包内 `FindOneForShareTx` 通过 | `SysUserModel.TransactCtx` 恰好 1 次;`SysDeptModel.FindOneForShareTx` 在 `SysUserModel.InsertWithTx` 之前;插入的 `*SysUser.Avatar.Valid==false` | 并发/事务闭包 | P0 | 锁序:对 `sys_dept[deptId]` 取 S 锁后再 Insert `sys_user`,与 DeleteDept 的 X 锁串行;闭环"pre-check 通过→DeleteDept 提交→本 tx Insert 写入幽灵 deptId"的竞态 |
+| TC-1196 | POST /api/user/create | mock:闭包内 `FindOneForShareTx` 返 `sqlx.ErrNotFound`(并发 DeleteDept 已提交) | 同上 mock,仅把 FOR SHARE 读改成 ErrNotFound | `CodeError.Code()==400`,文案含 "部门不存在或已删除";`SysUserModel.InsertWithTx` 绝不被调用;DB 无新 user | 并发/事务闭包 | P0 | DeleteDept 胜出时必须立即终止;若回退成"忽略 S 锁读的错"继续 Insert,就会产生挂在已删 deptId 上的 orphan user,其 DeptPath 校验永久失效 |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
@@ -518,8 +531,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0197 | POST /api/user/setPerms | PermId不存在 | permId=99999 | code=400, "权限不存在" | 安全 | P0 | 校验PermId |
 | TC-0198 | POST /api/user/setPerms | 权限不属于当前产品 | permId属于其他产品 | code=400, "权限不属于当前产品" | 安全 | P0 | 校验权限归属 |
 | TC-0199 | POST /api/user/setPerms | 非产品成员设置权限被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | SetUserPerms |
-| TC-0200 | POST /api/user/updateStatus | 正常冻结 | `{"id":普通用户,"status":2}` | code=0 | 正常路径 | P0 | updateUserStatusLogic |
-| TC-0201 | POST /api/user/updateStatus | 正常解冻 | `{"id":普通用户,"status":1}` | code=0 | 正常路径 | P0 | status=1 |
+| TC-0200 | POST /api/user/updateStatus | 正常冻结 | `{"id":普通用户,"status":2}` | code=0;DB `sys_user.status=2`;DB `sys_user.tokenVersion = before+1`(冻结方向吊销) | 正常路径/会话吊销 | P0 | updateUserStatusLogic;`UpdateStatus` 底层 `SET tokenVersion = tokenVersion + 1` 与 `jwtauthMiddleware` 的 tokenVersion 比对契约对齐,冻结瞬间立刻使已签发 access/refresh token 失效 |
+| TC-0201 | POST /api/user/updateStatus | 正常解冻(Disabled→Enabled) | `{"id":普通用户,"status":1}` | code=0;DB `sys_user.status=1`;DB `sys_user.tokenVersion = before+1`(解冻方向同样 +1) | 正常路径/会话吊销 | P0 | `UpdateStatus` 的 SQL 无条件 `+1`,不论方向;解冻也 +1 是刻意设计,堵住"冻结→UD 缓存残留→解冻瞬间旧 access token 复活"的极端路径。严禁回滚成"条件 +1 / 仅冻结 +1" |
 | TC-0202 | POST /api/user/updateStatus | 非法status(0) | `{"id":1,"status":0}` | code=400, "状态值无效" | 输入校验 | P0 | status!=1&&!=2 |
 | TC-0203 | POST /api/user/updateStatus | 冻结自己 | id=当前登录userId | code=400, "不能修改自己的状态" | 自我保护 | P0 | callerId==req.Id |
 | TC-0204 | POST /api/user/updateStatus | 冻结超管 | id=超管 | code=403, "不能修改超级管理员的状态" | 超管保护 | P0 | IsSuperAdmin==1 |

+ 52 - 37
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-20
+> 报告日期: 2026-04-20(最新一轮)
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,43 +12,45 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **959** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1195** |
-| ✅ 通过 | **1194** |
+| TC 用例总数 (test-design.md) | **972** |
+| 顶层测试函数数 (Functions) | **1083** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1214** |
+| ✅ 通过 | **1213** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
+| Logic 层语句覆盖率 | **86.9%**(`go tool cover -func` 统计) |
 
 ### 1.1 各测试包结果
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 1.122s |
-| internal/handler/auth | ✅ ok | 2.066s |
-| internal/handler/product | ✅ ok | 2.445s |
-| internal/handler/pub | ✅ ok | 3.058s |
-| internal/loaders | ✅ ok | 3.672s |
-| internal/logic/auth | ✅ ok | 11.403s |
-| internal/logic/dept | ✅ ok | 3.259s |
-| internal/logic/member | ✅ ok | 4.095s |
-| internal/logic/perm | ✅ ok | 4.334s |
-| internal/logic/product | ✅ ok | 13.410s |
-| internal/logic/pub | ✅ ok | 7.748s |
-| internal/logic/role | ✅ ok | 5.826s |
-| internal/logic/user | ✅ ok | 12.039s |
-| internal/middleware | ✅ ok | 7.196s |
-| internal/model/dept | ✅ ok | 6.525s |
-| internal/model/perm | ✅ ok | 8.418s |
-| internal/model/product | ✅ ok | 10.225s |
-| internal/model/productmember | ✅ ok | 10.276s |
-| internal/model/role | ✅ ok | 10.212s |
-| internal/model/roleperm | ✅ ok | 9.936s |
-| internal/model/user | ✅ ok | 17.943s |
-| internal/model/userperm | ✅ ok | 13.279s |
-| internal/model/userrole | ✅ ok | 11.536s |
-| internal/response | ✅ ok | 10.592s |
-| internal/server | ✅ ok | 10.858s |
-| internal/util | ✅ ok | 10.654s |
+| 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 |
 
 ### 1.2 跳过用例说明
 
@@ -187,7 +189,7 @@
 | TC-1021 | 既有 404 路径用例的 `LockByCodeTx` mock 显式携带 `Status=1` | ✅ pass |
 | TC-1022 | Dedup / mock / TxLock 三路径下事务内 Status 复核全覆盖 | ✅ pass |
 | TC-1023 | gRPC `SyncPermissions` 入口同样落入 `Status=1` 契约 | ✅ pass |
-| TC-1063 | 纯新增(updated=0, disabled=0)→ **不**触发 CleanByProduct | ✅ pass |
+| TC-1063 | 纯新增(updated=0, disabled=0)→ **必须**触发 CleanByProduct(M-R17-1 语义收敛) | ✅ pass |
 | TC-1064 | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | ✅ pass |
 | TC-1065 | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | ✅ pass |
 | TC-1118 | M-R14-1:ExecuteSyncPerms post-commit CleanByProduct 与请求 ctx 解耦 | ✅ pass |
@@ -226,6 +228,7 @@
 | TC-1041 | Model 层并发:同一 expectedUpdateTime 两 goroutine 并行 CAS | ✅ pass |
 | TC-1042 | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | ✅ pass |
 | TC-1043 | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | ✅ pass |
+| TC-1179 | `OldPassword==NewPassword` 必须在 `bcrypt.CompareHashAndPassword` 之前被拦截(L-R17-4) | ✅ pass |
 
 ### 2.6 创建产品 `POST /api/product/create`
 
@@ -277,6 +280,8 @@
 | TC-1033 | 冲突路径下 `SysDeptModel.FindOne` 返 `Status=1` 且 `AdminDeptId` 透传 | ✅ pass |
 | TC-1034 | mock 侧两处 `CreateProductReq` 补齐 `AdminDeptId` 字段 | ✅ pass |
 | TC-1035 | 一次性票据 `AdminPassword` 长度=16(不再是旧的 24) | ✅ pass |
+| TC-1202 | 未传 avatar 时 admin 用户行的 `avatar` 列在 DB 层必须是 SQL NULL(L-R17-2) | ✅ pass |
+| TC-1203 | CreateProduct 事务内 `FindOneForShareTx(adminDeptId)==ErrNotFound` 时必须 400 "管理员部门不存在或已删除",product/user/product_member 三张表 InsertWithTx 均不得被调用(H-R17-1) | ✅ pass |
 
 ### 2.7 产品更新/列表/详情
 
@@ -349,6 +354,7 @@
 | TC-1176 | NORMAL 部门 Status Enabled→Disabled 批量递增 tokenVersion | ✅ pass |
 | TC-1177 | NORMAL→DEV(升权方向)tokenVersion 保持不变 | ✅ pass |
 | TC-1178 | Status Disabled→Enabled(恢复启用)tokenVersion 保持不变 | ✅ pass |
+| TC-1200 | DeleteDept 成功提交后必须 post-commit 显式失效 `sysDept:id` 缓存(H-R17-2) | ✅ pass |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -386,6 +392,9 @@
 | TC-1001 | "id 不存在" vs "跨产品" 响应对比 | ✅ pass |
 | TC-1002 | 超管跨产品访问 | ✅ pass |
 | TC-1119 | L-R14-1:非超管 UpdateRole 别产品 roleId 必须 404 "角色不存在" | ✅ pass |
+| TC-1197 | 非超管 product ADMIN 不得创建 `PermsLevel=1` 顶格角色(H-R17-3) | ✅ pass |
+| TC-1198 | 非超管 product ADMIN 可创建 `PermsLevel>=2` 角色(正向回归) | ✅ pass |
+| TC-1199 | SuperAdmin 仍可创建 `PermsLevel=1` 顶格角色(正向回归) | ✅ pass |
 
 ### 2.12 删除角色 `POST /api/role/delete`
 
@@ -395,6 +404,7 @@
 | TC-0127 | 事务回滚 | ✅ pass |
 | TC-0128 | 无关联数据 | ✅ pass |
 | TC-1120 | L-R14-1:非超管 DeleteRole 别产品 roleId 必须 404 "角色不存在" | ✅ pass |
+| TC-1201 | DeleteRole 成功提交后必须 post-commit 同时失效 `sysRole:id` 与 `sysRole:productCode:name` 两把缓存(H-R17-2) | ✅ pass |
 
 ### 2.13 绑定角色权限 `POST /api/role/bindPerms`
 
@@ -446,6 +456,11 @@
 | TC-1100 | 负值 DeptId(-1 / MinInt64)必须 400 | ✅ pass |
 | TC-1122 | H-R14-1:非超管把新用户建到 DEV 部门必须 403 | ✅ pass |
 | TC-1123 | H-R14-1:SuperAdmin 可在 DEV 部门建用户(正向回归) | ✅ pass |
+| TC-1192 | 非超管以保留前缀(`admin_` / `svc_` / `root_` / `sys_`)占名必须 400(L-R17-1) | ✅ pass |
+| TC-1193 | SuperAdmin 以保留前缀创建用户放行(正向回归) | ✅ pass |
+| TC-1194 | 未传 avatar 时 `sys_user.avatar` 列在 DB 层必须为 SQL NULL(L-R17-2) | ✅ pass |
+| TC-1195 | mock 断言 `InsertWithTx` 在 `TransactCtx` 闭包内且顺序 `FindOneForShareTx(dept)` → `InsertWithTx(user)`(H-R17-1) | ✅ pass |
+| TC-1196 | mock 断言 `FindOneForShareTx` 返 `sqlx.ErrNotFound`(并发 DeleteDept 胜出)时必须 400 "部门不存在或已删除",`InsertWithTx` 绝不得被调用(H-R17-1) | ✅ pass |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
@@ -517,8 +532,8 @@
 | TC-0197 | PermId不存在 | ✅ pass |
 | TC-0198 | 权限不属于当前产品 | ✅ pass |
 | TC-0199 | 非产品成员设置权限被拒绝 | ✅ pass |
-| TC-0200 | 正常冻结 | ✅ pass |
-| TC-0201 | 正常解冻 | ✅ pass |
+| TC-0200 | 正常冻结 + `tokenVersion +1`(会话立即失效) | ✅ pass |
+| TC-0201 | 正常解冻 + `tokenVersion +1`(L-R17-6:无条件吊销,堵"UD 缓存残留→旧 token 复活") | ✅ pass |
 | TC-0202 | 非法status(0) | ✅ pass |
 | TC-0203 | 冻结自己 | ✅ pass |
 | TC-0204 | 冻结超管 | ✅ pass |
@@ -1353,7 +1368,7 @@
 
 ## 三、测试结论
 
-- **959 个 TC 全部执行**:通过 **1194**(含 subtests),跳过 **1**,失败 **0**。
-- 26 个测试包全部 OK;整包连跑均绿,无并发 flake 触发。
-- 通过率(扣除主动 skip 的 1 条不可达防御分支):**100%**。
-- 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全)均有独立回归用例覆盖且稳定通过。
+- **972 个 TC 全部执行**:顶层测试函数 **1083**,测试事件(含 `t.Run` 子用例)**1214**;通过 **1213**,跳过 **1**,失败 **0**。
+- 26 个测试包全部 OK;`./internal/logic/...` 语句覆盖率 **86.9%**;整包连跑均绿,无并发 flake 触发。
+- 通过率(扣除主动 skip 的 1 条不可达防御分支 TC-0263):**100%**。
+- 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全、会话吊销 tokenVersion 契约)均有独立回归用例覆盖且稳定通过。