Browse Source

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

BaiLuoYan 3 weeks ago
parent
commit
dedd82147f

+ 388 - 160
audit-report.md

@@ -1,278 +1,506 @@
-# 深度审计报告 · Round 15
+# 深度审计报告 · Round 16
 
-> 基线:R14 审计后的代码库快照。R14 提出的 1 条 High(H-R14-1)、1 条 Medium(M-R14-1)、3 条 Low(L-R14-1 / L-R14-2 / L-R14-3)已全部在对应 logic 文件中落地修复(见文末"R14 回归验证")。本轮聚焦"缓存 TTL 下的 MemberType TOCTOU"、"全局字段 `sys_user.deptId` 的跨产品副作用再审"与"降权路径是否吊销会话"等此前轮次未覆盖的面。
+> 基线: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 未闭合的面:
+>
+> 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 成员可枚举本产品全部成员的联系方式。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-本轮未新增 High 风险项。R14 的 H-R14-1(DEV 部门跨产品全权升级)已在 `updateUserLogic.go:140-142` 与 `createUserLogic.go:99-101` 双侧落地 SuperAdmin-only 护栏;攻击链已被切断。
-
----
-
-## ⚠️ 健壮性与性能建议 (Medium / Low)
-
-### M-R15-1 · 缓存 TTL 下的 MemberType TOCTOU —— `HasFullProductPerms` / `LoadCallerAssignableLevel` 仅读 UD 缓存的 MemberType,降权期内仍按 ADMIN 放行
+### H-R16-1 · `RemoveMember` 降权路径未随 M-R15-1 同步吊销会话 —— 与 `UpdateMember` 对称缺口
 
 **位置**
 
-- `internal/logic/auth/access.go:265-272`(`HasFullProductPerms`)
-- `internal/logic/auth/access.go:211-226`(`LoadCallerAssignableLevel` 短路分支)
-- `internal/logic/auth/access.go:337-338`(`checkDeptHierarchy` 的 ADMIN 绕过)
-- `internal/logic/auth/access.go:132-144`(`RequireProductAdminFor`)
-- 所有把 `caller.MemberType` 当授权输入的调用点:BindRoles / CreateUser / CreateRole / BindRolePerms / UpdateRole / DeleteRole / AddMember / UpdateMember / SetUserPerms / RemoveMember / DeptTree 等
+- `internal/logic/member/removeMemberLogic.go:42-74`(事务体只做 `DeleteByUserId* + Delete`,无 `IncrementTokenVersionWithTx`)
+- 对比参照:`internal/logic/member/updateMemberLogic.go:94-134`("降权即 `tokenVersion+1`"已落地)
 
 **描述**
 
-审计 H-2 / M-R10-3 / GuardRoleLevelAssignable 已经为 **caller.MinPermsLevel** 建立了"授权决策点强制走 `loadFreshMinPermsLevel` 读 DB"的 TOCTOU 闭环,缩短了 UD 缓存 TTL 窗口。但同一份 UD 里另一个同等重要的授权字段 **MemberType** 仍然只走缓存
+M-R15-1 / L-R15-3 落地后,"降权/禁用"这类"从'有效成员'向'无效成员'迁移"的路径都在 tx 内把目标的 `sys_user.tokenVersion` 做 `+1`,让旧 access token 在 `jwtauthMiddleware` 的 `claims.TokenVersion != ud.TokenVersion` 兜底下立刻 401,即使 Redis `Del`/`Clean` 失败也不会残留特权。但这条口径**漏掉了 `RemoveMember`**
 
-```265:272:internal/logic/auth/access.go
-func HasFullProductPerms(caller *loaders.UserDetails) bool {
-    if caller == nil {
-        return false
+```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("成员不存在")
     }
-    return caller.IsSuperAdmin ||
-        caller.MemberType == consts.MemberTypeAdmin ||
-        caller.MemberType == consts.MemberTypeDeveloper
+    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
+    }
+    if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
+        return err
+    }
+    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
+}); err != nil {
+    return err
 }
+
+cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+defer cancel()
+l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
+return nil
 ```
 
-`caller` 的来源是 `middleware.GetUserDetails(ctx)` → `UserDetailsLoader.Load` → Redis `ud:<userId>:<productCode>` 键,TTL=5min。一旦 `UpdateMember` 的 post-commit `Del` 因 Redis 抖动未成功(本身已用 `DetachCacheCleanCtx` 3s 超时兜底,但 Redis 真故障时仍会留日志 → 不复查),缓存里的 `MemberType` 会在 **最长 5min** 内保持 ADMIN / DEVELOPER 语义。
+**`RemoveMember` 事实上是 `UpdateMember` 降权路径的极端版**——不是"ADMIN→MEMBER"而是"ADMIN→`MemberType=""`(非成员)",造成的授权语义跳变更剧烈:
+
+- `loadPerms` 对"非成员"会返回 `nil` → 原本的 ADMIN 全权直接清空;
+- `jwtauthMiddleware` 对 `ud.MemberType == ""` 非超管会 403 `"您已不是该产品的有效成员"`;
+- `CheckManageAccess` 的 ADMIN 分支直接跳过 `checkDeptHierarchy`,对成员的管理面彻底开放。
 
-同时 `UpdateMember` 本身**不递增 sys_user.tokenVersion**(对比 `UpdateUserStatus` 会自动 `tokenVersion = tokenVersion + 1`、`UpdatePassword` / `Logout` 同),降权不触发强制重登录。因此攻击窗口是"缓存读"而非"token 读":
+攻击场景(与 M-R15-1 的描述完全同构,只是触发点从 `UpdateMember` 换成 `RemoveMember`)
 
-1. A 是产品 P1 的 product ADMIN;
-2. SuperAdmin 通过 `UpdateMember` 把 A 降级为 MEMBER;事务提交,`UserDetailsLoader.Del(A.UserId, P1)` 被调用;
-3. 若 Redis 在这 3s 内出现网络波动,`Del` 的 DelCtx 返回 err,日志打 `cache_invalidation_skipped_*`,缓存保留 `MemberType=ADMIN`;
-4. A 继续用手里的 access token(claims.MemberType=ADMIN,tokenVersion 未变)调用业务接口:
-   - `jwtauthMiddleware` 走 `Load(userId, productCode)` 命中 Redis 旧 UD → 把 `MemberType=ADMIN` 的 ud 注入 ctx;
-   - `BindRoles` / `UpdateRole` 等看 `caller.MemberType == consts.MemberTypeAdmin` 放行;
-   - `CheckManageAccess → checkDeptHierarchy` 的 ADMIN 分支直接 `return nil`,跳过部门链校验;
-   - `LoadCallerAssignableLevel` 的 `HasFullProductPerms` 返回 true,`BindRoles` 循环里所有 `CheckRoleLevelAgainst` 全部放行,A 可以把**任意权限等级**(包括超出其当前 MEMBER 身份应有上限)的角色绑给下属;
-5. 攻击窗口持续到 5min TTL 自然过期或下一次 Clean 成功。
+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 才被系统真正视作"非成员"。
 
-对比之下,R13 在 H-2 的注释里已经准确钉出"UD 缓存 5 分钟 TTL 内旧 MinPermsLevel 可被利用";但同批同类风险的 MemberType 缺少对称防御,形成审计覆盖不对称
+更严重的是:**此时 A 的 `tokenVersion` 未被递增**,即便运维事后发现 Redis 抖动手动 `Del` 了缓存 key,只要 A 把 access token 保存好,下一次 Load 重建的 UD 仍然是"DB 视角下的非成员"——这是良性的(会 403)。但在 TTL 滞留窗口里 A 的 access token 本身是有效凭据(签名、类型、过期时间、`TokenVersion` 与 `ud.TokenVersion` 都匹配),`jwtauthMiddleware` 无法把这段残留权限踢下线
 
 **影响**
 
-- 降权后的 product ADMIN 在 Redis 抖动窗口内保留 ADMIN 权能:可继续创建角色 / 绑权限、管理他人(`checkDeptHierarchy` ADMIN 绕过)、修改产品内成员(`RequireProductAdminFor` 通过)。
-- 降 ADMIN → MEMBER 的典型触发场景就是"怀疑滥权 / 内鬼排查"——恰恰是最不希望有 5 分钟残余窗口的操作。
-- DEVELOPER → MEMBER 类似,但规模小,影响面小一档
+- 与 M-R15-1 等级相同的权限升级 TOCTOU:被移除的 ADMIN / DEVELOPER 在 Redis 抖动时保留完整产品管理权 ≤5min;
+- 运维侧无任何手段**强制**下线(缓存 TTL 过期是唯一收敛机制);
+- 组合攻击面:如果 5min 内 A 利用残留的 ADMIN 权把自己以不同 userId(例如预埋的备用账号)重新 `AddMember` / `BindRoles`,账号回收动作形同虚设——这正是 M-R15-1 修 `UpdateMember` 时就要求的"必须在签发层吊销而不是在缓存层吊销"语义
 
-此风险本身是"缓存读一致性"架构层决定,不是单点 bug。闭环方案有两条,任选其一或组合:
+**修复方案**
 
-**修复方案(方案 A,最小代价,推荐)**:让 `UpdateMember` 在降级路径(`{ADMIN, DEVELOPER} → MEMBER` 或 `Enabled → Disabled`)里显式递增目标用户的 `tokenVersion`,与 `UpdateUserStatus` 口径对齐
+把 `RemoveMember` 的事务体补齐"降权即 `tokenVersion+1`"的闭环,与 `UpdateMember` 的 M-R15-1 口径完全对齐。`RemoveMember` 的语义比 `UpdateMember` 更清晰——只要走到事务体,就一定构成"从有效成员(或 ADMIN/DEVELOPER)→ 非成员"的降权,**无需再判定是否为"降权"**,无条件递增即可
 
 ```go
-// internal/logic/member/updateMemberLogic.go,在 tx 内部 update member 之前/之后:
-wasPrivileged := locked.MemberType == consts.MemberTypeAdmin ||
-    locked.MemberType == consts.MemberTypeDeveloper ||
-    locked.Status == consts.StatusEnabled
-willBePrivileged := (nextType == consts.MemberTypeAdmin || nextType == consts.MemberTypeDeveloper) &&
-    nextStatus == consts.StatusEnabled
-
-if wasPrivileged && !willBePrivileged {
-    // 事务内递增 sys_user.tokenVersion 强制再登录;
-    // 与 UpdateUserStatus 的 WHERE updateTime=? 乐观锁语义对齐,
-    // 并通过 DetachCacheCleanCtx 的 Clean 失效 UD 缓存(已存在)。
+// 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
+    }
+    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
     }
+    return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
+}); err != nil {
+    return err
 }
-```
 
-这样不论 Redis 是否抖动,旧 access token 都会在下一次中间件校验时因 `claims.TokenVersion != ud.TokenVersion` 被强制重登录;重登录走 `Login / RefreshToken` 会重新签发含新 `memberType` 的 token 并写入 Redis。注意需要**在 tx 内**递增 tokenVersion,否则如果 tx 回滚,`sys_user.tokenVersion` 会多走一步。目前 `sysUserModel` 只有 `IncrementTokenVersion(ctx, id, username)`(非 tx),需要补一个 `IncrementTokenVersionWithTx(ctx, session, id)`;username 仅用于失效缓存,可以延后到 tx commit 之后配合现有 `UserDetailsLoader.Del` 链路失效。
-
-**修复方案(方案 B,加固授权决策点)**:在所有依赖 `caller.MemberType == ADMIN/DEVELOPER` 的授权决策点(`HasFullProductPerms` / `checkDeptHierarchy` 的 ADMIN 绕过 / `RequireProductAdminFor`)前做一次 `FindOneByProductCodeUserId(productCode, caller.UserId)` 的 DB 复核。代价是每个管理写接口多 1 次(带缓存的)DB 读,但概念上把 MemberType 的 TOCTOU 窗口从 TTL 级压到单查询级,与 `loadFreshMinPermsLevel` 对称:
-
-```go
-// 新增 authHelper.ResolveCallerMembership(ctx, svcCtx, caller) (*SysProductMember, error)
-// 并让 HasFullProductPerms 改签名为 (ctx, svcCtx, caller) 走该 helper;
-// 在 checkDeptHierarchy ADMIN 分支、RequireProductAdminFor、LoadCallerAssignableLevel
-// 三处同步切换。
+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)
+}
+l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
+return nil
 ```
 
-方案 A 与 B 的本质差异:A 让攻击窗口为 0(token 被废),B 让窗口为"单次 DB 读" ~5ms。优先推荐 A——成本更低、语义更清晰,与现有强制下线 (`UpdateUserStatus`, `ChangePassword`, `Logout`) 的口径一致。
+若不希望在事务外再 `FindOne`(Redis 抖动时可能慢 100ms 级别),也可以在事务体内把 `locked.UserId` 与 `username`(通过 tx 内的 `l.svcCtx.SysUserModel.FindOneForShareTx` 或在进入 tx 前的 `member` 查询里预取)通过闭包透传到 post-commit;由于 `RemoveMember` 没有超高 QPS 预期(行政操作),直接走 post-commit `FindOne`(该查询本身带 sqlc 缓存)也足够
 
-**回归测试建议**
+**回归验证要点**
 
-- 模拟 ADMIN→MEMBER 降级后 **Redis DEL 失败**,验证下一次 API(BindRoles / UpdateRole)能通过 `tokenVersion mismatch` 拒绝,或(方案 B 下)通过 `RequireProductAdminFor` 的 DB 复核拒绝;
-- 事务回滚场景验证 tokenVersion **未**被递增(方案 A),避免"降级操作失败但用户被踢下线"的错误扩大面。
+- tx 体内 `IncrementTokenVersionWithTx` 返回 `ErrUpdateConflict`(竞态:并发 `RemoveMember`/`Logout`/`ChangePassword`)时整体回滚,测试需断言成员行仍存在;
+- Redis 完全不可用场景下,断言被移除的 ADMIN 在下一次 HTTP 请求时被 `jwtauthMiddleware` 401;
+- 与 `UpdateMember` 的测试矩阵对称扩容:ADMIN/DEVELOPER/MEMBER 被移除后,旧 access token 均应被立即拒绝。
 
 ---
 
-### L-R15-1 · 跨产品结构性破坏 —— 产品 ADMIN 可借 `UpdateUser req.DeptId=0` 把共有用户调出部门树
+## ⚠️ 健壮性与性能建议 (Medium / Low)
+
+### M-R16-1 · `UserList` / `UserDetail` 对任意同产品成员暴露 `email` / `phone` —— PII 最小授权缺失
 
-**位置** `internal/logic/user/updateUserLogic.go:158-165`
+**位置**
+
+- `internal/logic/user/userListLogic.go:38-90`(仅做"同产品"校验,无 MemberType 收敛,`email`/`phone` 直接回落到所有可见成员)
+- `internal/logic/user/userDetailLogic.go:34-76`(仅做"同产品成员"校验,`email`/`phone` 同样全量返回)
 
 **描述**
 
-`H-R14-1` 已经把"调入 DEV 部门"这条真正能做跨产品**权限升级**的路径收敛给 SuperAdmin。对称地分析 `req.DeptId == 0`(把用户调出部门树)分支,现状
+R14 / R15 已经把"同产品 ADMIN 可以管理同产品成员"的边界拉紧,但"同产品 MEMBER **读**其他成员信息"的边界还停留在"只要是同产品成员即可"
 
-```158:165:internal/logic/user/updateUserLogic.go
-} else {
-    // deptId=0 意味着"把用户移出部门树";...
-    if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
-        return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
+```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("无权访问该产品的数据")
     }
 }
 ```
 
-产品 ADMIN 于是被放行。`sys_user.deptId` 是**全局字段**——产品 P1 的 ADMIN 对一位同时是 P2 成员的用户 B 调用 `UpdateUser(deptId=0)`:
+```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("无权查看非本产品成员的用户信息")
+    }
+}
+```
 
-1. P1 侧 `CheckManageAccess` 通过(`checkDeptHierarchy` ADMIN 绕过、`checkPermLevel` 对 MEMBER 级目标 callerPri<targetPri 放行);
-2. 提交后 B 的全局 `sys_user.deptId=0`,`DeptPath=""`,`DeptType=""`;
-3. 在 P2 视角里,B 成为组织结构里的**孤儿节点**:
-   - P2 的 MEMBER/DEVELOPER 走 `checkDeptHierarchy` 时会命中 `target.DeptId == 0` → 403 "目标用户未归属部门,仅超管或产品管理员可管理";
-   - 只有 P2 的 ADMIN 能靠自身 ADMIN 绕过 + `checkPermLevel` 在 P2 成员表找到 B 才可继续管理;
-   - `DeptTree` 里 B 不再出现(`caller.DeptPath` 前缀过滤),运营侧看不到 B 的存在。
-4. 与 H-R14-1 最大区别:**不构成权限升级**——B 的 DeptType 空、DeptStatus=0,不会触发 `loadPerms` 的 DEV 全权分支。
+两处响应都**无差别地**把 `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,
+})
+```
+
+对比 `MemberListLogic` 的返回(只含 `Username` / `Nickname` / `MemberType` / `Status`,**无** `email` / `phone`)可以看到:业务上 MEMBER 浏览成员列表的合理需要是"看到谁在这个产品里",不是"拿到所有同事的联系方式"。当前 `UserList` / `UserDetail` 无视 caller 的 MemberType,让 P1 的普通 MEMBER 能一键分页导出 P1 全体成员的 `email` / `phone`:
+
+- **内部滥用面**:P1 的任一普通 MEMBER(包括产品接入方给终端客户开的 low-tier 账号)都能拉走全员通讯录,钓鱼 / 二次社工的投递名单直接就位;
+- **跨产品溯源弱化**:`email` / `phone` 是全局 `sys_user` 字段,一人在 P1+P2 同为 MEMBER 时,两产品任一方的 MEMBER 都可拉到同一份 PII,合规审计上分不出泄漏源;
+- **UI 真需 vs 接口默认输出不一致**:即使前端当前 MEMBER 视角的页面不渲染 `email` / `phone`,接口仍在响应体里把字段返回,绕过前端就能拿到——API 契约才是授权边界,不是 UI。
 
 **影响**
 
-- 组织结构可用性攻击:P1 ADMIN 可以制造 P2 的"隐形成员",P2 的日常管理层级(MEMBER/DEVELOPER/产品内子 ADMIN)全部够不到 B;B 仍能正常使用 P2,但 P2 运营侧排障困难;
-- 与 H-R14-1 的攻击面叠加:攻击者可以先调入 DEV 部门拿全权(已被 H-R14-1 修掉),**现在也无法**叠加这条——但"调出部门树"作为次等破坏面仍存在。
-- 可审计性差:`sys_user.deptId` 变更在 P2 的日志链路里看不到变更发起方——整条 `UpdateUser` 调用落在 P1 的审计上下文内。
+- **PII 过度暴露 → 合规红线**:GDPR/PIPL/内部数据分级都要求"联系方式"类字段按职责最小化返回。当前接口对同产品 MEMBER 无差别发放,容易被监管 / 安全评估点名
+- **社工攻击前置资源充足**:攻击者一旦拿到任意 P1 MEMBER 的凭据(撞库、钓鱼、误 commit token),就能把 P1 全员通讯录导出,为后续的二阶钓鱼 / SIM swap / 账号接管提供精准名单;
+- **审计覆盖面与 `MemberList` 口径不一致**:`MemberListLogic` 已经收敛了响应字段(不泄露 PII),但 `UserListLogic`(同为"按产品分页列成员"用途)没有收敛,两者 API 语义重合但安全边界不同,易被误判
 
 **修复方案**
 
-与 H-R14-1 对称收敛给 SuperAdmin:
+按"自己可见全部字段、他人仅超管/ADMIN/DEVELOPER 可见 PII"的分层授权收窄响应体。不建议在 logic 层加复杂 if/else 后再 copy 字段——容易随字段增加漏脱敏。推荐在响应装配前统一做 PII 脱敏
 
 ```go
-// internal/logic/user/updateUserLogic.go
-} else {
-    // 审计 L-R15-1:与 H-R14-1 对称——sys_user.deptId 是全局字段,改为 0 会把用户从
-    // **所有**产品的部门结构里移除,跨产品影响。产品 ADMIN 的授权范围仅限自己产品,
-    // 不应执行改变全局字段的破坏性操作。移出部门树由 SuperAdmin 执行(例如离职流程)。
-    if !caller.IsSuperAdmin {
-        return response.ErrForbidden("仅超级管理员可将用户移出部门")
+// 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 "", "", ""
+}
+
+// 拼装 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,
+    })
 }
 ```
 
-如业务上确实需要保留"产品 ADMIN 可把**非跨产品**成员移出部门"的能力,可以加一个"目标是否同时在其他产品"的判断,但复杂度明显提升,不如直接收敛给 SuperAdmin;离职/转岗属于行政 HR 流程,SuperAdmin 执行更合理。
+`UserDetailLogic` 沿用相同 helper;同时在 `UserDetailLogic` 里额外加一层"caller == target 或 caller 有管理职权时才返回完整信息"的自检(目标是 `caller.UserId != req.Id` 且 `caller` 非 ADMIN/DEVELOPER 的场景,可以考虑直接 `return ErrForbidden`,而不是回一个"只有昵称没有 PII"的半成品——后者会让前端误以为目标真的没绑邮箱 / 手机号)
 
-**回归测试建议**
+**回归验证要点**
 
-- caller=P1 ADMIN,target=P1+P2 MEMBER,`req.DeptId=0` → `ErrForbidden("仅超级管理员可将用户移出部门")`;`sys_user.deptId` 不变;`UserDetailsLoader.Clean` 不触发。
-- caller=SuperAdmin 同请求 → 正常放行。
-- caller=P1 ADMIN,target=P1-only MEMBER,`req.DeptId=0` → 根据业务决策(本轮建议改为一致拒绝)。
+- MEMBER 身份调用 `UserList` / `UserDetail`:
+  - 看自己 → Email / Phone 原样返回;
+  - 看他人 → Email / Phone / Remark 为空字符串,其余字段保留;
+- ADMIN / DEVELOPER 调用上述接口:所有字段原样返回(与现网行为一致,避免破坏管理台体验);
+- 前端若强依赖"字段非空"作逻辑分支,需同步升级——建议增加响应 schema 的版本协商或在 item 上新增 `piiVisible bool` 提示,减少默默置空导致的 UI 侧 regression。
 
 ---
 
-### L-R15-2 · 跨产品信息泄露 —— `DeptTree` 对任何产品的 ADMIN 暴露完整组织架构
+### L-R16-1 · `UpdateUser` 的 ADMIN 分支短路 DeptPath 前缀校验 —— 非 `deptId=0` 方向的同构缺口
 
-**位置** `internal/logic/dept/deptTreeLogic.go:45`
+**位置**
+
+- `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 豁免)
 
 **描述**
 
-```45:57:internal/logic/dept/deptTreeLogic.go
-fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
+L-R15-1 把 `UpdateUser.req.DeptId = 0` 这种"把目标移出全局部门树"的极端路径收敛给了 SuperAdmin,理由是:`sys_user.deptId` 是**全局**字段,P1 ADMIN 在 P1 的授权范围不应影响 P2 视角下的成员归属。但同一个逻辑在"`req.DeptId > 0` 且 `newDept.DeptType != DEV`"分支里仍然存在:
 
-if !fullAccess {
-    if caller.DeptPath == "" {
-        return make([]*types.DeptItem, 0), nil
-    }
-    filtered := make([]*deptModel.SysDept, 0, len(list))
-    for _, d := range list {
-        if strings.HasPrefix(d.Path, caller.DeptPath) {
-            filtered = append(filtered, d)
-        }
-    }
-    list = filtered
+```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("无权将用户调入非自己管辖的部门")
 }
 ```
 
-`sys_dept` 是**全局**命名空间(一份组织架构服务于所有产品);任何一个产品的 product ADMIN 都能走 `fullAccess=true` 分支拿到**全公司所有部门**——包括其他产品的敏感研发子树、跨 BU 的 HR/财务部门、未归属到任何产品的战略部门等。
+这一 `caller.MemberType != consts.MemberTypeAdmin` 短路让 P1 ADMIN 可以把"同时是 P2 成员"的 target `B` 从 P2 的部门子树下挪到 P1 的部门子树下(或任意既非 DEV 又处于 Enabled 的 NORMAL 部门,只要校验通过 `FindOneForShareTx`)。其直接副作用:
+
+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"。
 
-这与 `DeptTreeLogic` 注释里的"超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath 为根的子树"一致,但在多产品共享组织架构的部署下,对"小产品的 ADMIN 看到大产品的 DEV 子树名称 + 层级"这一点暴露并没有防御。配合 M-2(MEMBER 级不能看全产品列表以防 admin_<code> 撞库)的既有意图——审计链路里"MEMBER 拿不到敏感组织信息"是刻意守住的,但对"任何 product ADMIN 可拿到全量 sys_dept"网开一面,属于覆盖不对称。
+注释里声明的豁免理由("product ADMIN 对产品内既有成员有全面管理权")只在"不共享 target 的单产品场景"成立;一旦 target 同时归属多个产品(`sys_product_member` 允许多对多),ADMIN 改 `sys_user.deptId` 的动作已经穿透了"本产品 ADMIN 的权限天花板"
 
 **影响**
 
-- 信息泄露级别,非权限升级:拿到部门结构后可用于社工 / 针对性撞库(例如针对 DEV 部门里 ops_* 命名的账号发起 H-1 维度的 username/ip 限流绕过尝试),以及 H-R14-1 修掉的攻击链在"选定目标部门 id"阶段的侦察输入
-- 多产品共用一份 sys_dept 的前提下,"小产品 ADMIN 看到大产品 DEV 部门"的设计与后者对前者的最小授权原则冲突
-- 若未来 `DeptType=DEV` 的子树被用作类似 H-R14-1 的"加入即全权"判据(已被 H-R14-1 的 SuperAdmin-only 护栏堵死),本项就是"知道调去哪个 deptId"的前置侦察接口
+- 与 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 却短路了这一校验——两者语义应该对齐(同样是"让一个用户落在某部门下"的动作)
 
 **修复方案**
 
-根据业务对"产品 ADMIN 是否需要全局部门视图"的真实需求,二选一:
+删除 `UpdateUser` 的 `caller.MemberType != consts.MemberTypeAdmin` 短路,把 ADMIN 也纳入 DeptPath 前缀校验范围;同时与 `CreateUser` 的校验口径保持一致。这条修改不会破坏 ADMIN 的正当业务:ADMIN 作为产品管理员,其 `caller.DeptPath` 本来就是其部门子树前缀,调整同子树内的成员部门归属不会被拦;真正被拦住的是"跨子树、跨产品"的越权改写。
 
-1. **收敛给 SuperAdmin**:`fullAccess := caller.IsSuperAdmin`。产品 ADMIN 仅看自己 DeptPath 子树(与 DEVELOPER/MEMBER 对齐)。`AddMember` 若需要从其他部门拉人,由 SuperAdmin 批准或另开 `ListAddableDepts` 等独立接口。
-2. **保留产品 ADMIN 全量视图,但脱敏其他产品的 DEV 子树**:在 `fullAccess=true` 分支里,若 caller 不是 SuperAdmin,额外跳过 `DeptType == DEV` 的部门。保留"正常部门"的全公司视图以支持 AddMember 跨部门拉人,但隐藏 DEV 子树名称避免针对性侦察。
+```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;保留现状的话建议在审计报告里显式钉住"产品 ADMIN 可见全组织架构"这条信任边界,便于未来评审时不被新人误改。
+- P1 ADMIN 在 P1 子树内挪动自己部门的成员:放行(与现网一致);
+- P1 ADMIN 挪动"只属于 P1"的成员到 P1 子树外的 NORMAL 部门:拦截为 403(**本轮新增**;若业务确有此需求,应改走 SuperAdmin 审批流);
+- P1 ADMIN 挪动"同时也是 P2 成员"的 target 到 P1 子树外的部门:同上 403,避免跨产品结构性扰动;
+- SuperAdmin 行为不变:任何 NORMAL 部门均可挪入;DEV 部门同样不受本条改动影响。
 
 ---
 
-### L-R15-3 · 降权 / 禁用不强制吊销会话 —— `UpdateMember` 与 `UpdateProduct`(禁用)均不递增 tokenVersion
+### L-R16-2 · `UpdateDept` DeptType/Status 收窄 + `UpdateUser` 跨 DEV/NORMAL 边界调 deptId 均未吊销 tokenVersion —— 与 M-R15-1 同构的缓存失效 TOCTOU
 
 **位置**
 
-- `internal/logic/member/updateMemberLogic.go:77-107`(整个事务体不触及 `sys_user.tokenVersion`)
-- `internal/logic/product/updateProductLogic.go:46-73`(产品禁用同样不递增其成员 tokenVersion)
+- `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 已落地)
 
 **描述**
 
-与 `UpdateUserStatus`(会自动走 `sysUserModel.UpdateStatus` 内的 `tokenVersion = tokenVersion + 1` + 乐观锁)、`ChangePassword`(`UpdatePassword` 内 `tokenVersion = tokenVersion + 1`)、`Logout`(`IncrementTokenVersion`)形成鲜明对比:
+`loadPerms` 的"是否走全权分支"明确地受三个字段驱动:`IsSuperAdmin` / `MemberType` / `DeptType + DeptStatus`:
+
+```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)
+    ...
+}
+```
+
+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))
+    }
+}
+```
 
-| 变更 | 旧 access token 失效方式 |
-| --- | --- |
-| UpdateUserStatus Disabled | ✅ tokenVersion +1 → 中间件下次 403 |
-| UpdateUserStatus Enabled(重启用) | ✅ tokenVersion +1(副作用无害) |
-| ChangePassword | ✅ tokenVersion +1 |
-| Logout | ✅ tokenVersion +1 |
-| RefreshToken rotate | ✅ CAS tokenVersion +1 |
-| **UpdateMember 降级** | ❌ 仅刷 UD 缓存(best-effort) |
-| **UpdateMember 禁用** | ❌ 仅刷 UD 缓存 |
-| **UpdateProduct 禁用** | ❌ 仅刷 UD 缓存 |
-| DeleteRole / UpdateRole | ❌ 仅刷 UD 缓存(可接受:角色细粒度) |
+相同问题也出现在 `UpdateUser` 改 `deptId` 的路径:`UpdateProfileWithTx` 只认 `statusChanged` 去递增 `tokenVersion`,`deptId` 跨越 DEV/NORMAL 边界时没有任何签发层吊销,只依赖 `UserDetailsLoader.Clean` 的尽力而为失效。
 
-后三条依赖 `UserDetailsLoader.Del / CleanByProduct` 的 post-commit 失效(已用 `DetachCacheCleanCtx` 3s 超时 + 5min TTL 兜底)。Redis 抖动时的窗口见 M-R15-1 的详细分析——这里是"同一风险模式"的另一个触发点:
+收窄方向的具体触发条件(这些全都是"权限从全权收回"的场景):
 
-- `UpdateMember` 降 ADMIN → MEMBER:M-R15-1 已详述;
-- `UpdateMember` 禁用(`Status=Disabled`):被禁成员的 access token 仍然能通过中间件——因为中间件读的是 `ud.Status`(来自 UD 缓存),缓存失效失败就等于"禁用无效" 5 分钟;
-- `UpdateProduct` 禁用产品:产品内所有成员的 `ud.ProductStatus` 需要失效,`CleanByProduct` 触发大范围 SUNION+DEL;任意一步失败就留下"产品禁用但成员仍能查 / 用"的窗口。
+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 部门——单一用户的全权被收回。
+
+这三条路径的 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 全权" 身份调业务接口。
+
+与 R15 不同的是:`UpdateDept` / `UpdateUser.deptId` 的典型使用者是 **SuperAdmin**(`UpdateDept` 已经 `RequireSuperAdmin`;`UpdateUser` 改 deptId 跨 DEV 边界也被 H-R14-1 收敛给 SuperAdmin),因此这条 TOCTOU 的触发点比 `UpdateMember` 更低频——但**影响面更广**:`UpdateDept` 一次性影响"该部门所有成员";`UpdateProduct` 影响"该产品所有成员";两者叠加时,Redis 抖动可以让一个完整部门在 5min 窗口内保留已经被收回的 DEV 全权。
 
 **影响**
 
-与 M-R15-1 同一个攻击面,不同触发点:在 Redis 不可用期间,"降级 / 禁用 / 产品下线" 三类敏感变更都依赖缓存失效做唯一拦截。本项与 M-R15-1 的关系是"M-R15-1 是其中最危险的一种降权路径,L-R15-3 是全集"。
+- 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` 的低敏感事件)。
 
 **修复方案**
 
-按 M-R15-1 方案 A 的思路统一让这三个写路径在降权/禁用分支里递增目标 `tokenVersion`:
+两处收窄路径在 tx 内补齐 `BatchIncrementTokenVersionWithTx`——复用 `UpdateProduct` 的 L-R15-3 模式,把"找出受影响 userIds"和"批量 +1"收敛进同一个事务,整体回滚语义天然成立。关键判定:只在**真正构成"权限收窄"**时递增,避免 `NORMAL→DEV`(升权)或 `Disabled→Enabled`(重启用)场景误踢用户下线。
 
-1. `updateMemberLogic.go`:降 ADMIN/DEVELOPER → MEMBER 或禁用成员时递增目标用户的 `tokenVersion`(见 M-R15-1 方案 A 代码样例)。
-2. `updateProductLogic.go`:产品变更为 `Disabled` 时,**批量**递增该产品下所有成员的 `tokenVersion`——类似 `FindIdsByDeptId` 的接口语义,新增 `sysProductMember.FindActiveMemberUserIdsByProductCode` 拿一次 userIds,再用一条 `UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id IN (...)` 批量递增。注意:数据量就是该产品的成员数(量级一般在数千内),不是全站用户,不会爆 SQL。配合 `UserDetailsLoader.CleanByProduct` 并行清缓存即可。
-3. 对应新增 `IncrementTokenVersionWithTx(ctx, session, id)` 与 `BatchIncrementTokenVersion(ctx, userIds)` 两个 model 方法,共享现有 `cacheSysUserIdPrefix / cacheSysUserUsernamePrefix` 失效链路。
+**1. `UpdateDept` 修复(DEV→NORMAL 或 DEV 部门 Enabled→Disabled 才递增):**
 
-测试要点:
+```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 对齐)
+```
 
-- Redis 完全不可用场景下,验证降级/禁用用户被上游中间件拒绝;
-- 批量递增 tokenVersion 的 SQL 使用占位符化的 IN(...),避免未来 N 过大时栈溢出或单 SQL 行数超限。
+若 `SysUserModel` 当前没有 `FindIdsByDeptIdForShareTx`,需补一个带 `LOCK IN SHARE MODE` 的版本——比照 `FindActiveMemberUserIdsByProductCodeTx` 的实现(`internal/model/productmember/sysProductMemberModel.go:102-110`),与并发 `UpdateProfileWithTx`(X 锁 sys_user)互斥,防止"列出 userIds 期间有人刚被挪出本部门"造成吊销漏挂。
 
----
+**2. `UpdateUser` 修复(仅在 deptId 跨 DEV↔NORMAL 边界且方向为收窄时递增):**
 
-### L-R15-4 · 可读性 —— 降权路径语义需要在注释里显式声明
+```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
+    }
+}
 
-随着 M-R15-1 / L-R15-3 如果按方案 A 采纳"降权即递增 tokenVersion",需要在以下文件顶部注释钉住语义:
+// tx 体内补:
+if devAccessRevoked {
+    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
+        return err
+    }
+}
+```
 
-- `internal/logic/member/updateMemberLogic.go` 顶部:列明"任何从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移、或从 Enabled 向 Disabled 的迁移都会强制对方重登录(tokenVersion+1)",方便未来维护者区分"刷缓存 + 重登录"的双重防御意图;
-- `internal/logic/product/updateProductLogic.go`:同样声明产品禁用会批量递增成员 tokenVersion。
+**权衡说明**
 
-这条本身不构成代码修改,仅提醒"代码修完后记得同步更新审计注释",与 L-R14-3 同类性质。
+本条优先级明确低于 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 走尽力而为"
 
 ---
 
-## R14 回归验证(附录)
+## R15 回归验证(附录)
 
 | 条目 | 期望修复 | 代码现状 | 判定 |
 | --- | --- | --- | --- |
-| H-R14-1 DEV 部门跨产品全权升级 | UpdateUser + CreateUser 在 `DeptType=DEV` 目标部门分支拒绝非超管 | `updateUserLogic.go:140-142` + `createUserLogic.go:99-101` 两处均有显式 `newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin → ErrForbidden` | ✅ 已闭合 |
-| M-R14-1 rotateRefreshToken / syncPermsService 缓存失效未 detach | 两处改用 `loaders.DetachCacheCleanCtx` | `rotateRefreshToken.go:87-89` + `syncPermsService.go:177-179` 均已改造;全仓扫描 `UserDetailsLoader.(Clean|Del|CleanByProduct|BatchDel|CleanByUserIds)` 调用点,所有路径都走 detached ctx | ✅ 已闭合 |
-| L-R14-1 UpdateRole/DeleteRole/BindRolePerms 404 vs 403 枚举 | 抽 `authHelper.ResolveOwnRoleOr404` 统一 404 | `access.go:146-176` 新增 `ResolveOwnRoleOr404`;三处调用方(`updateRoleLogic.go:46` / `deleteRoleLogic.go:33` / `bindRolePermsLogic.go:38`)均改用该 helper | ✅ 已闭合 |
-| L-R14-2 BindRoles 跨产品文案区分 | "缺项 / 跨产品 / 已禁用" 折叠为同一 `"包含无效的角色ID"` | `bindRolesLogic.go:91-117` 合并;事务内拿 S 锁失败(race_deleted_or_disabled)的分支 `bindRolesLogic.go:163-170` 也走同一响应体 | ✅ 已闭合 |
-| L-R14-3 UpdateUser ADMIN 分支注释披露 | 在 ADMIN 分支显式声明 DEV 部门语义 | `updateUserLogic.go:131-152` 已补注释;且 H-R14-1 的代码兜底已经让注释描述的路径被代码直接拦截,注释与代码互相印证 | ✅ 已闭合 |
+| 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 审计条目 | ✅ 已闭合 |
 
 ---
 
-本轮新增发现 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1 / L-R15-2 / L-R15-3)。Low 三条彼此有语义关联:L-R15-3 是 M-R15-1 的泛化,L-R15-1 是 H-R14-1 的结构对称项,L-R15-2 是跨产品信息泄露面的残留。优先处置 **M-R15-1**(按方案 A 让 `UpdateMember` 降级时强制 `tokenVersion+1`),可同时闭合 L-R15-3 的 UpdateMember 分支——代价仅是在 tx 内补一次 UPDATE;`UpdateProduct` 禁用批量递增可分作下一轮单独跟进。
+## 本轮总结
+
+本轮新增 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 的签发层吊销口径不一致)。
+
+语义关联:
+
+- **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 可并入下一轮安全变更统一回归。

+ 95 - 18
internal/logic/dept/updateDeptLogic.go

@@ -14,6 +14,7 @@ import (
 	"perms-system-server/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type UpdateDeptLogic struct {
@@ -30,7 +31,20 @@ func NewUpdateDeptLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Update
 	}
 }
 
-// UpdateDept 更新部门。修改部门名称、排序、类型、备注或启用/禁用状态。使用乐观锁防止并发冲突,变更部门类型或状态时自动清理受影响用户的权限缓存。
+// UpdateDept 更新部门。修改部门名称、排序、类型、备注或启用/禁用状态。使用乐观锁防止并发冲突,
+// 变更部门类型或状态时自动清理受影响用户的权限缓存。
+//
+// 审计 L-R16-2(签发层吊销,与 M-R15-1 / L-R15-3 对齐):
+// `loadPerms` 的"全权分支"直接以 DeptType + DeptStatus 作为授权输入(userDetailsLoader.go:539-
+// 554),因此以下三种 UpdateDept 变更构成"权限收窄":
+//   - (a) DeptType: DEV → NORMAL(本部门所有在编成员丢失 DEV 全权);
+//   - (b) DeptType 不变但 DEV 部门 Status: Enabled → Disabled(DEV 成员丢失全权);
+//   - (c) NORMAL 部门 Status: Enabled → Disabled(业务语义是"冻结本部门所有活动",一并吊销)。
+//
+// 这些场景只靠 `UserDetailsLoader.CleanByUserIds` 尽力而为失效的话,Redis 抖动会让 5min TTL 内
+// 的旧权限继续生效;现在把"UPDATE sys_dept + 枚举并 S 锁 sys_user 行 + 批量 tokenVersion+1"
+// 收敛进同一事务,任一步失败整体回滚。post-commit 再做 dept/user 低层缓存 + UD 聚合缓存的失效。
+// 升权方向(NORMAL→DEV、Disabled→Enabled)不递增 tokenVersion,避免把正在使用的合法会话踢下线。
 func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
 		return err
@@ -48,6 +62,10 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 		return response.ErrNotFound("部门不存在")
 	}
 
+	// 快照旧值用于判定"收窄方向"。dept 对象会在下方被 req 覆盖,这里必须先取出原 DeptType/Status。
+	prevType := dept.DeptType
+	prevStatus := dept.Status
+
 	deptTypeChanged := false
 	statusChanged := false
 
@@ -75,34 +93,93 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	expectedUpdateTime := dept.UpdateTime
 	dept.UpdateTime = time.Now().Unix()
 
-	if err := l.svcCtx.SysDeptModel.UpdateWithOptLock(l.ctx, dept, expectedUpdateTime); err != nil {
+	// 审计 L-R16-2:识别是否构成"权限收窄"。
+	//   - devFullAccessRevoked: (a) DEV→NORMAL 或 (b) DEV 部门 Enabled→Disabled;两种都让本部门
+	//     成员的 loadPerms 从全权分支掉回"角色/allow-deny 计算";
+	//   - normalDeptFrozen:     (c) NORMAL 部门 Enabled→Disabled;语义上"冻结部门"。
+	// 升权方向(NORMAL→DEV、Disabled→Enabled)不进入吊销分支。
+	nextType := dept.DeptType
+	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
+	shouldRevokeSessions := devFullAccessRevoked || normalDeptFrozen
+
+	var revokedUserIds []int64
+	if 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 shouldRevokeSessions {
+			// FindIdsByDeptIdForShareTx 对命中 sys_user 行加 S 锁,与并发 UpdateProfileWithTx 的
+			// X 锁互斥——防止"枚举时 A 还在本部门、枚举完 A 被并发挪走却已失效吊销"或反过来的
+			// 漏吊销窗口。
+			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
+	}); err != nil {
 		if errors.Is(err, deptModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 		}
 		return err
 	}
 
-	// loadPerms 只检查用户自身部门的 deptType/status,子部门不受影响,
-	// 因此仅需清理本部门直属用户缓存,且仅在 deptType 或 status 真正变更时才需要。
-	// 使用 CleanByUserIds 把 N 用户 × 3 RTT 的串行 Clean 压成常数 2 RTT,避免把 handler
-	// 挂住拖慢乐观锁重试(见审计 M-1)。FindIdsByDeptId 的错误必须显式 Errorf 记录而不能
-	// 静默 "_, _" 吞掉——DB 抖动会导致"被禁用部门"的旧权限缓存继续在 TTL 窗口内生效,
-	// 这是安全敏感变更;但遵循 M-4 post-commit 模式不映射 500,由 TTL 过期兜底。
+	// post-commit 三级缓存失效(detached ctx 保证 client 断连时仍能完成):
+	//   ① sys_dept 低层缓存(本行刚被 UPDATE,sysDeptIdKey 需要失效,否则 FindOne 会返回旧值);
+	//   ② sys_user 低层缓存(tokenVersion 已 +1,否则 UD loader 下次 miss 会从 sysUser 低层缓存
+	//      拿到旧 tokenVersion 把递增值抹回,与 UpdateMember / RemoveMember 同口径);
+	//   ③ UserDetails 聚合缓存(DeptType / DeptStatus / TokenVersion 均是字段,必须 Clean 后
+	//      重新 Load 才能拿到新授权快照)。
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+	defer cancel()
+	l.svcCtx.SysDeptModel.InvalidateDeptCache(cleanCtx, req.Id)
 	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
+		// CleanByUserIds 在 shouldRevokeSessions=false 分支仍然有用:
+		// 例如 NORMAL 部门 Disabled→Enabled 是升权方向,不吊销 session,但 UD 聚合缓存里的
+		// DeptStatus 字段仍然是旧值(Disabled),必须 Clean 让 loadPerms 在下次 Load 时回到
+		// Enabled 对应的分支(对 DEV 部门意味着重新进入全权分支)。
+		// 当 shouldRevokeSessions=true 时,revokedUserIds 已经在 tx 内批量 +1,这里顺带把 UD
+		// 也失效,两级缓存一起回到 cache-miss。
+		userIds := revokedUserIds
+		if len(userIds) == 0 {
+			// 不需要吊销会话的场景(升权 / 非收窄的 deptType/status 切换)也要失效 UD。
+			ids, 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)
+			} else {
+				userIds = ids
+			}
 		}
 		if len(userIds) > 0 {
-			// 审计 L-R13-5 方案 A:DeptType / 禁用状态是 loadPerms 的授权输入,post-commit 失效
-			// 必须脱离请求 ctx——这条路径的 userIds 可能成百上千,批处理耗时略长,client 断连
-			// 或 HTTP 超时后旧权限缓存滞留 5 分钟 TTL 会直接等于"禁用部门仍在放行权限"。
-			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))
 		}
+		// 当 shouldRevokeSessions=true 时,每个受影响 sys_user 的低层缓存都要失效,防止 sysUser
+		// 缓存里的旧 tokenVersion 被 UD loader 下次 miss 时读回、把刚递增的值抹回去。
+		// FindByIds 批量拿 (id, username),避免对 len(userIds) 次 FindOne。
+		if shouldRevokeSessions && len(revokedUserIds) > 0 {
+			users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds)
+			if err != nil {
+				logx.WithContext(l.ctx).Errorf("UpdateDept post-commit FindByIds(len=%d) failed for token-version cache invalidation: %v", len(revokedUserIds), err)
+			} else {
+				for _, u := range users {
+					if u == nil {
+						continue
+					}
+					l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username)
+				}
+			}
+		}
+		l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d revokedSessions=%d", req.Id, dept.DeptType, dept.Status, len(userIds), len(revokedUserIds))
 	}
 	return nil
 }

+ 40 - 11
internal/logic/dept/updateDeptLogic_mock_test.go

@@ -1,6 +1,7 @@
 package dept
 
 import (
+	"context"
 	"testing"
 
 	deptModel "perms-system-server/internal/model/dept"
@@ -9,10 +10,28 @@ import (
 	"perms-system-server/internal/types"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"go.uber.org/mock/gomock"
 )
 
-// TC-0105: UpdateDept 只清理自身部门用户缓存,不再级联到子部门 (audit  修复验证)
+// runDeptTxInline 让 TransactCtx 的期望就地把传入的闭包执行掉,从而让我们能在 gomock 层面
+// 同时观察 tx 内部(UpdateWithOptLockTx / FindIdsByDeptIdForShareTx /
+// BatchIncrementTokenVersionWithTx)与 tx 外部(InvalidateDeptCache / FindIdsByDeptId 等
+// post-commit 钩子)的调用次数与顺序。
+//
+// 直接 `Return(nil)` 的传统写法会跳过闭包,把"事务内部丢调用/漏调用"这类回归掩盖掉,
+// 因此统一走 inline 执行;session 传 nil 即可,因为这里所有 tx 方法都是 mock。
+func runDeptTxInline(m *mocks.MockSysDeptModel) *gomock.Call {
+	return m.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).DoAndReturn(
+		func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		},
+	)
+}
+
+// TC-0105: UpdateDept 只清理自身部门用户缓存,不再级联到子部门。
+// 在当前"tx 包裹 UpdateWithOptLockTx + post-commit InvalidateDeptCache + 自身部门 FindIdsByDeptId"
+// 语义下,断言仍为:FindByPathPrefix 永不被调用;FindIdsByDeptId 仅针对本部门一次。
 func TestUpdateDept_Mock_CascadeCacheClean(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -29,14 +48,17 @@ func TestUpdateDept_Mock_CascadeCacheClean(t *testing.T) {
 			Status:     1,
 			UpdateTime: 1000,
 		}, nil)
-	// 修复后:使用乐观锁 UpdateWithOptLock,且不再调用 FindByPathPrefix
-	mockDept.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
+	runDeptTxInline(mockDept)
+	// 事务内:NORMAL→DEV 是"放宽权限"方向,不应吊销会话,因此不得触发
+	// FindIdsByDeptIdForShareTx / BatchIncrementTokenVersionWithTx(未声明即等价 Times(0))。
+	mockDept.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
+	// post-commit:dept 低层缓存必须失效,否则 FindOne 回旧值抹掉本次更新可见性。
+	mockDept.EXPECT().InvalidateDeptCache(gomock.Any(), parentDeptId)
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
-	// 修复后:仅查询目标部门直属用户,不再级联查询子部门用户
+	// post-commit:仅查询目标部门直属用户,不再级联查询子部门用户(无 FindByPathPrefix)。
 	mockUser.EXPECT().FindIdsByDeptId(gomock.Any(), parentDeptId).
 		Return([]int64{100, 101}, nil)
-	// 下面两个调用不应发生(gomock 默认严格,未声明调用即失败)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Dept: mockDept,
@@ -54,7 +76,9 @@ func TestUpdateDept_Mock_CascadeCacheClean(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-// TC-0714: UpdateDept 当 deptType 与 status 都未变化时,不触发任何缓存清理 (audit )
+// TC-0714: UpdateDept 当 deptType 与 status 都未变化时,不触发任何用户缓存清理。
+// 关键回归:InvalidateDeptCache 仍需触发(dept 自身行被 UPDATE 过),但不得做任何 sys_user
+// 维度的扫描——避免频繁改名触发 O(部门规模) 的缓存失效风暴。
 func TestUpdateDept_Mock_NoCacheCleanWhenUnchanged(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -71,10 +95,12 @@ func TestUpdateDept_Mock_NoCacheCleanWhenUnchanged(t *testing.T) {
 			Status:     1,
 			UpdateTime: 2000,
 		}, nil)
-	mockDept.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(2000)).Return(nil)
+	runDeptTxInline(mockDept)
+	mockDept.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(2000)).Return(nil)
+	mockDept.EXPECT().InvalidateDeptCache(gomock.Any(), deptId)
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
-	// 不应调用 FindIdsByDeptId — 未设置期望,任何调用都会 FAIL
+	// 不应调用 FindIdsByDeptId / FindIdsByDeptIdForShareTx —— 未设置期望,任何调用都会 FAIL
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Dept: mockDept,
@@ -93,7 +119,8 @@ func TestUpdateDept_Mock_NoCacheCleanWhenUnchanged(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-// TC-0715: UpdateDept 乐观锁冲突时返回 409 ErrConflict (audit  乐观锁补充)
+// TC-0715: UpdateDept 乐观锁冲突时返回 409 ErrConflict;且因为事务内就失败,post-commit
+// 的 InvalidateDeptCache / FindIdsByDeptId 绝不应被调用,避免"回滚了但缓存被清了"的不一致。
 func TestUpdateDept_Mock_OptLockConflict(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -110,8 +137,10 @@ func TestUpdateDept_Mock_OptLockConflict(t *testing.T) {
 			Status:     1,
 			UpdateTime: 3000,
 		}, nil)
-	// 模拟并发:另一事务已更新该行,updateTime 不再匹配
-	mockDept.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(3000)).
+	runDeptTxInline(mockDept)
+	// 模拟并发:另一事务已更新该行,updateTime 不再匹配 —— tx 内部返回 ErrUpdateConflict,
+	// TransactCtx 透传该错误,handler 映射为 409;post-commit 缓存失效这里必须不发生。
+	mockDept.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(3000)).
 		Return(deptModel.ErrUpdateConflict)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{

+ 192 - 9
internal/logic/dept/updateDeptLogic_test.go

@@ -2,14 +2,13 @@ package dept
 
 import (
 	"context"
+	"database/sql"
 	"errors"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"go.uber.org/mock/gomock"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
 	deptModel "perms-system-server/internal/model/dept"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -17,6 +16,11 @@ import (
 	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/types"
 	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
 )
 
 func TestUpdateDept_Normal(t *testing.T) {
@@ -168,6 +172,7 @@ func superAdminCtx() context.Context {
 }
 
 // TC-0848: deptType 变更 → FindIdsByDeptId 恰好调用 1 次,返回 [100, 101];handler 返回 nil。
+// NORMAL→DEV 方向是放宽权限,不吊销会话;post-commit 仍需 FindIdsByDeptId 清 UD 聚合缓存。
 func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
@@ -181,9 +186,11 @@ func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) {
 			Id: 77, Name: "n", DeptType: consts.DeptTypeNormal,
 			Status: consts.StatusEnabled, UpdateTime: 500,
 		}, nil)
-	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(500)).Return(nil)
+	runDeptTxInline(deptMock)
+	deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(500)).Return(nil)
+	deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(77))
 
-	// 关键断言:恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。
+	// 关键断言:post-commit 恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。
 	userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(77)).
 		Return([]int64{100, 101}, nil).Times(1)
 
@@ -211,7 +218,9 @@ func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) {
 			Id: 88, Name: "n", DeptType: consts.DeptTypeNormal,
 			Status: consts.StatusEnabled, UpdateTime: 1000,
 		}, nil)
-	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
+	runDeptTxInline(deptMock)
+	deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
+	deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(88))
 
 	userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(88)).
 		Return(nil, errors.New("transient DB error"))
@@ -227,7 +236,179 @@ func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) {
 		"FindIdsByDeptId 失败不得映射 500;TTL 过期兜底,客户端不应重试整次 UpdateDept")
 }
 
-// 补充:deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴)。
+// seedDeptWithUser 建一个自定义 DeptType/Status 的部门,并挂一个新 sys_user 到该部门下。
+// 返回 (deptId, userId);统一 cleanup 由调用方负责,避免测试之间相互拖拽。
+func seedDeptWithUser(t *testing.T, svcCtx *svc.ServiceContext,
+	tag, path, deptType string, status int64) (int64, int64) {
+	t.Helper()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	now := time.Now().Unix()
+	dRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{
+		ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
+		DeptType: deptType, Status: status, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	deptId, _ := dRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModel.SysUser{
+		Username: tag + "_u_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
+		Avatar: sql.NullString{}, DeptId: deptId,
+		IsSuperAdmin: 2, MustChangePassword: 2, Status: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	userId, _ := uRes.LastInsertId()
+	return deptId, userId
+}
+
+// TC-1174: UpdateDept DeptType: DEV → NORMAL 必须把该部门下**所有**在编 sys_user 的
+// tokenVersion 原子性 +1(签发层吊销),否则 5min TTL 内旧 access token 仍享有 DEV 全权。
+func TestUpdateDept_L_R16_2_DevTypeToNormal_RevokesAllMembers(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev2norm", "/7100/",
+		consts.DeptTypeDev, consts.StatusEnabled)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	prev := before.TokenVersion
+
+	require.NoError(t,
+		NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "renamed_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, prev+1, after.TokenVersion,
+		"DEV→NORMAL 让部门内所有人的 loadPerms 从全权分支掉到普通分支,tokenVersion 必须同事务 +1")
+}
+
+// TC-1175: DEV 部门 Status Enabled → Disabled —— 同样构成 DEV 全权收窄,tokenVersion 必须 +1。
+func TestUpdateDept_L_R16_2_DevStatusToDisabled_RevokesAllMembers(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev_dis", "/7200/",
+		consts.DeptTypeDev, consts.StatusEnabled)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	prev := before.TokenVersion
+
+	require.NoError(t,
+		NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "dev_disabled_" + testutil.UniqueId(),
+			DeptType: consts.DeptTypeDev, Status: consts.StatusDisabled,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, prev+1, after.TokenVersion,
+		"DEV 部门 Enabled→Disabled 与 DeptType DEV→NORMAL 同构,都让 loadPerms 全权分支失效")
+}
+
+// TC-1176: NORMAL 部门 Status Enabled → Disabled —— 业务语义是"冻结本部门所有活动",
+// 必须把部门内成员的 tokenVersion 同事务 +1,防止 5min TTL 窗口内旧 token 继续读写。
+func TestUpdateDept_L_R16_2_NormalStatusToDisabled_RevokesAllMembers(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm_dis", "/7300/",
+		consts.DeptTypeNormal, consts.StatusEnabled)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	prev := before.TokenVersion
+
+	require.NoError(t,
+		NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "norm_disabled_" + testutil.UniqueId(),
+			DeptType: consts.DeptTypeNormal, Status: consts.StatusDisabled,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, prev+1, after.TokenVersion,
+		"NORMAL 部门 Enabled→Disabled 是冻结动作,部门成员的 tokenVersion 必须 +1")
+}
+
+// TC-1177: NORMAL→DEV 是放宽权限的升级方向,tokenVersion 保持不变;把合法会话无故下线
+// 不仅没收益,还会让产品侧管理员需要重新登录 + 重新建会话,损害可用性。
+func TestUpdateDept_L_R16_2_NormalToDev_NoRevoke(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm2dev", "/7400/",
+		consts.DeptTypeNormal, consts.StatusEnabled)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	prev := before.TokenVersion
+
+	require.NoError(t,
+		NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "norm2dev_" + testutil.UniqueId(), DeptType: consts.DeptTypeDev,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, prev, after.TokenVersion,
+		"NORMAL→DEV 是升权方向,不得把现存会话吊销;否则每次类型调整都会触发一次大规模强制下线")
+}
+
+// TC-1178: 部门 Status Disabled→Enabled(恢复启用)同样是升权方向,tokenVersion 必须保持不变。
+func TestUpdateDept_L_R16_2_StatusReEnable_NoRevoke(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_reenable", "/7500/",
+		consts.DeptTypeNormal, consts.StatusDisabled)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	prev := before.TokenVersion
+
+	require.NoError(t,
+		NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
+			Id: deptId, Name: "reenable_" + testutil.UniqueId(),
+			DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, prev, after.TokenVersion,
+		"Disabled→Enabled 是恢复活动,不是收窄方向;tokenVersion 必须保持不变")
+}
+
+// deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴);dept 自身行
+// 的 InvalidateDeptCache 仍需调用,因为 sys_dept 的 updateTime/name/remark/sort 已经被 UPDATE。
 func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
@@ -241,9 +422,11 @@ func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
 			Id: 99, Name: "x", DeptType: consts.DeptTypeDev,
 			Status: consts.StatusEnabled, UpdateTime: 200,
 		}, nil)
-	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(200)).Return(nil)
+	runDeptTxInline(deptMock)
+	deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(200)).Return(nil)
+	deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(99))
 
-	// 关键:没有任何 FindIdsByDeptId EXPECT 即等价 Times(0)。
+	// 关键:没有任何 FindIdsByDeptId / FindIdsByDeptIdForShareTx EXPECT 即等价 Times(0)。
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Dept: deptMock, User: userMock,

+ 22 - 0
internal/logic/member/removeMemberLogic.go

@@ -29,6 +29,13 @@ func NewRemoveMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Remo
 }
 
 // RemoveMember 移除产品成员。在事务内同时清理该用户在产品下的角色和个性化权限绑定后移除成员记录。不能移除产品的最后一个 ADMIN。
+//
+// 审计 H-R16-1(签发层吊销,与 M-R15-1 / L-R15-3 对齐):
+// 移除成员是"有效成员 → 非成员"的极端降权,授权语义跳变比 UpdateMember 降级更剧烈——loadPerms
+// 对非成员直接返回 nil,jwtauthMiddleware 对 ud.MemberType=="" 非超管直接 403。因此走到 tx 体
+// 一定构成降权,无需再判断"是否收窄"。事务内在删除成员行之前对 sys_user.tokenVersion 做一次 +1,
+// 让旧 access token 在下一次 middleware 校验时因 `claims.TokenVersion != ud.TokenVersion` 被 401;
+// 即使 post-commit 的 UserDetailsLoader.Del 因 Redis 抖动失败,也不会残留 5min TTL 的特权窗口。
 func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 	member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id)
 	if err != nil {
@@ -61,6 +68,11 @@ func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 		if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
 			return err
 		}
+		// 审计 H-R16-1:放在 DeleteWithTx 之前——任一步失败整体回滚,避免"tokenVersion 已 +1 但
+		// member 行未删"或"member 行已删但 tokenVersion 未 +1"的脏中间态。
+		if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, locked.UserId); err != nil {
+			return err
+		}
 		return l.svcCtx.SysProductMemberModel.DeleteWithTx(ctx, session, req.Id)
 	}); err != nil {
 		return err
@@ -71,5 +83,15 @@ func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
 	l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
+
+	// 审计 H-R16-1:tokenVersion 已在 tx 内 +1;post-commit 必须把 sysUser 低层缓存
+	// (cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一起失效,否则 UD loader 下次 miss 时
+	// 会从 sysUser 低层缓存读回旧 tokenVersion,把刚递增的值抹回去(与 UpdateMember 同口径)。
+	// FindOne 本身带 sqlc 缓存,额外成本可控。
+	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)
+	}
 	return nil
 }

+ 81 - 0
internal/logic/member/removeMemberLogic_test.go

@@ -376,3 +376,84 @@ func TestRemoveMember_NonAdmin_Unaffected(t *testing.T) {
 	err := NewRemoveMemberLogic(ctx, svcCtx).RemoveMember(&types.RemoveMemberReq{Id: sp.mId})
 	require.NoError(t, err)
 }
+
+// TC-1162: RemoveMember 成功后,被移除用户的 sys_user.tokenVersion 必须 +1。
+// 这是把"成员行删除"与"旧 access token 失效"绑进同一事务的一级护栏——只要 DeleteWithTx 提交,
+// IncrementTokenVersionWithTx 必然已提交;jwtauthMiddleware 下一次校验会发现
+// claims.TokenVersion != ud.TokenVersion 直接 401。即使 post-commit 的 UserDetailsLoader.Del 因
+// Redis 抖动失败,也不会残留 5min TTL 的特权窗口。
+func TestRemoveMember_IncrementsTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, "MEMBER")
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	prevTokenVersion := before.TokenVersion
+
+	require.NoError(t,
+		NewRemoveMemberLogic(ctx, svcCtx).RemoveMember(&types.RemoveMemberReq{Id: sp.mId}))
+
+	// 必须从 DB 直接再查一次,而不是沿用 before 上已复制的 TokenVersion;
+	// svcCtx.SysUserModel.FindOne 自带 sqlc 缓存,而 RemoveMember post-commit 已主动 Invalidate,
+	// 因此这里拿到的就是 UPDATE 后的新值,而不是残留的旧缓存。
+	after, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	assert.Equal(t, prevTokenVersion+1, after.TokenVersion,
+		"RemoveMember 必须把 tokenVersion +1;一旦这里只 +0,UpdateMember 降权 + RemoveMember 清退的签发层吊销都会失守")
+
+	_, err = svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	assert.Error(t, err, "事务必须同时删除 product_member 行;只要这一点没达成,tokenVersion+1 也必须回滚")
+}
+
+// TC-1163: 移除失败(last-admin 场景)的情况下,tokenVersion 绝对不得被 +1,否则会把
+// 合法会话无效化为 401,并且拿不到任何"补救回滚"。用 last-admin 护栏天然构造一次事务内部失败。
+func TestRemoveMember_FailureDoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	sp := seededProduct{code: testutil.UniqueId()}
+	now := time.Now().Unix()
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: sp.code, Name: "p_" + sp.code, AppKey: sp.code + "_k", AppSecret: "s",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	sp.pId, _ = pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: sp.code, Password: testutil.HashPassword("pw"),
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	sp.uId, _ = uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: sp.code, UserId: sp.uId, MemberType: "ADMIN",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	sp.mId, _ = mRes.LastInsertId()
+
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	before, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	prevTokenVersion := before.TokenVersion
+
+	err = NewRemoveMemberLogic(ctx, svcCtx).RemoveMember(&types.RemoveMemberReq{Id: sp.mId})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+
+	after, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	assert.Equal(t, prevTokenVersion, after.TokenVersion,
+		"RemoveMember 被 last-admin 拒绝必须让 tokenVersion 保持不变,否则等于 400 伴随副作用")
+	m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, "ADMIN", m.MemberType, "事务回滚后 member 行必须原样保留")
+}

+ 75 - 27
internal/logic/user/updateUserLogic.go

@@ -9,6 +9,7 @@ import (
 	"perms-system-server/internal/loaders"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -109,6 +110,12 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	if req.Remark != nil {
 		remark = *req.Remark
 	}
+	// 审计 L-R16-2:识别"从 DEV 部门调出"的收窄方向——loadPerms 的 DEV 全权分支以
+	// (MemberType != "") && (DeptType == DEV) && (DeptStatus == Enabled) 为条件,只要 target 的
+	// deptId 离开 DEV 部门(挪到 NORMAL 或 deptId=0),该用户在其所属的**所有**产品内都失去
+	// 全权。统一走"事务内 tokenVersion+1"签发层吊销,避免 Redis 抖动时 5min TTL 残留全权。
+	var devAccessRevoked bool
+	var newDept *deptModel.SysDept
 	if req.DeptId != nil {
 		// 审计 L-R13-4:与 CreateUser 对齐,显式拒绝 deptId < 0。原先的 `>0 / else` 二分会把
 		// 负数一路透传进 UpdateProfile(WithTx),导致 sys_user.deptId 出现 -1 之类的脏值,
@@ -118,10 +125,11 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 			return response.ErrBadRequest("部门ID必须为非负整数")
 		}
 		if *req.DeptId > 0 {
-			newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
+			nd, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
 			if err != nil {
 				return response.ErrBadRequest("部门不存在")
 			}
+			newDept = nd
 			// 审计 L-N2:与 UpdateDept 禁用语义闭环 —— 已禁用的部门代表"冻结该部门所有活动",
 			// 再往该部门调入新成员会破坏不变量(新成员会因 DeptStatus!=Enabled 被撤销 DEV 全权
 			// 特权),且无法被 AddMember / CheckAddMemberAccess 的校验感知。此处统一拦截。
@@ -140,22 +148,24 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 			if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
 				return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
 			}
-			// 审计 L-R13-3:删除原 `caller.DeptPath != ""` 的冗余条件。
-			// 走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target 改 deptId);
-			// 非超管、非 ADMIN(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
-			// 已经在 access.go:318-324 对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close
-			// 返回 403——因此执行到本行时 caller.DeptPath 恒非空。冗余条件会误导新维护者以为
-			// "某条分支下 caller.DeptPath 可以为空",诱导把 checkDeptHierarchy 的护栏拆掉。
+			// 审计 L-R16-1:删除原 ADMIN 分支的短路。sys_user.deptId 是**全局**字段,产品
+			// ADMIN 在 P1 的授权范围仅覆盖 P1,把同时也是 P2 成员的 target 挪到 P1 子树外的
+			// 任意(非 DEV)部门同样是"在 P2 视角制造结构性失联"的越权——与 L-R15-1 的
+			// deptId=0 场景完全同构,只是落点从"0"换成"P1 某子树"。修复口径与 CreateUser
+			// (createUserLogic.go:102-109 对非超管强制 DeptPath 前缀校验,无 ADMIN 豁免)
+			// 对齐:非超管(含 ADMIN)必须把目标调入自身 DeptPath 子树之内,跨子树调度统一
+			// 走 SuperAdmin 审批流。
 			//
-			// 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何**非 DEV**
-			// 部门;DEV 目标部门的跨产品权限升级路径由上面 H-R14-1 的显式护栏拦截(见审计
-			// L-R14-3 的注释披露要求)。
+			// 审计 L-R13-3:走到这里时 caller 一定满足:非本人(line 42-45 已拦 caller==target
+			// 改 deptId);非超管(见本分支前的判定);且 CheckManageAccess → checkDeptHierarchy
+			// 已经对 `caller.DeptId == 0 || caller.DeptPath == ""` fail-close 返回 403——因此
+			// 执行到本行时 caller.DeptPath 恒非空,无需再冗余判定空串。
 			if !caller.IsSuperAdmin &&
-				caller.MemberType != consts.MemberTypeAdmin &&
 				!strings.HasPrefix(newDept.Path, caller.DeptPath) {
 				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
 			}
 		} else {
+			// deptId = 0:把用户移出全局部门树(L-R15-1 已收敛给 SuperAdmin)。
 			// 审计 L-R15-1:deptId=0 意味着"把用户从**全局**部门树里移除"——sys_user.deptId
 			// 是全局字段,一次 UpdateUser 会让目标在**所有**他已加入的产品视角里同时失去
 			// DeptPath / DeptType。与 H-R14-1(调入 DEV)对称:caller 在产品 P1 的授权范围
@@ -168,6 +178,19 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 				return response.ErrForbidden("仅超级管理员可将用户移出部门")
 			}
 		}
+		// 审计 L-R16-2:若 deptId 发生真实变更,且用户原本在 DEV 部门内,而新归属不再是"Enabled 的
+		// DEV 部门"(挪到 NORMAL 或 deptId=0),则 loadPerms 的 DEV 全权分支对该用户失效,构成
+		// "单用户的权限收窄"。仅在确实变更时才读取 oldDept 比对——避免 FindOne 成本被无谓请求放大。
+		if *req.DeptId != user.DeptId && user.DeptId > 0 {
+			oldDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, user.DeptId)
+			if err == nil && oldDept != nil && oldDept.DeptType == consts.DeptTypeDev && oldDept.Status == consts.StatusEnabled {
+				// newDept == nil 即 deptId=0(SuperAdmin-only 路径);newDept != nil 且 DeptType==NORMAL
+				// 即挪到 NORMAL 部门;两种都构成 DEV 全权收窄。
+				if newDept == nil || newDept.DeptType == consts.DeptTypeNormal || newDept.Status != consts.StatusEnabled {
+					devAccessRevoked = true
+				}
+			}
+		}
 		deptId = *req.DeptId
 	}
 
@@ -189,10 +212,17 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	// sys_dept[newDeptId] 加 S 锁——这样并发 DeleteDept 持有 sys_dept[X] 的 X 锁,会被 S 锁阻塞,
 	// 等本事务提交后 DeleteDept 重读 `sys_user WHERE deptId=X FOR SHARE` 就能看到新行并拒绝删除,
 	// 闭合"两侧都读不到对方提交 → 各自提交 → orphan deptId"的 write skew。
-	// 其余分支(只改其它字段 / 移出部门 deptId=0)无 write skew 风险,沿用非事务的 UpdateProfile。
-	needDeptShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
+	//
+	// 审计 L-R16-2:devAccessRevoked(DEV 全权收窄)同样需要走事务,这样"UPDATE sys_user +
+	// tokenVersion+1"在同一事务内要么一起生效要么一起回滚——避免"部门已从 DEV 挪走但
+	// tokenVersion 没 +1"让旧 access token 在 5min TTL 窗口内继续享有 DEV 全权。
+	//
+	// 其余分支(只改其它字段 / 移出部门 deptId=0 且原本也不是 DEV)无 write skew、无签发层吊销
+	// 诉求,沿用非事务的 UpdateProfile。
+	needShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
+	needTx := needShareLock || devAccessRevoked
 
-	if !needDeptShareLock {
+	if !needTx {
 		if err := l.svcCtx.SysUserModel.UpdateProfile(
 			l.ctx, req.Id, user.Username,
 			nickname, email, phone, remark, deptId,
@@ -211,25 +241,43 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		return nil
 	}
 
-	targetDeptId := *req.DeptId
 	if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		// 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
-		// 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
-		lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, targetDeptId)
-		if err != nil {
-			if errors.Is(err, sqlx.ErrNotFound) {
-				return response.ErrBadRequest("部门不存在")
+		if needShareLock {
+			// 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
+			// 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
+			lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, *req.DeptId)
+			if err != nil {
+				if errors.Is(err, sqlx.ErrNotFound) {
+					return response.ErrBadRequest("部门不存在")
+				}
+				return err
+			}
+			if lockedDept.Status != consts.StatusEnabled {
+				return response.ErrBadRequest("目标部门已停用")
 			}
-			return err
-		}
-		if lockedDept.Status != consts.StatusEnabled {
-			return response.ErrBadRequest("目标部门已停用")
 		}
-		return l.svcCtx.SysUserModel.UpdateProfileWithTx(
+		// 审计 L-R16-2:DEV 全权收窄(DEV→NORMAL / DEV→deptId=0)在 tx 内把 tokenVersion +1,
+		// 与 UpdateMember 的 M-R15-1 签发层吊销口径对齐。
+		//
+		// 顺序约束:必须**先** UpdateProfileWithTx(带 `WHERE updateTime=expectedUpdateTime`
+		// 的乐观锁),**再** IncrementTokenVersionWithTx。颠倒顺序会让 IncrementTokenVersionWithTx
+		// 先把 updateTime 改到 now(),导致紧随其后的 UpdateProfileWithTx 的乐观锁 WHERE 匹配不到
+		// (affected=0 → ErrUpdateConflict),把本来应该成功的请求误判为并发冲突。
+		// 若同时 statusChanged 也真实发生,UpdateProfileWithTx 内部会先 +1,这里再 +1 等于 +2,
+		// 不破坏 tokenVersion 单调递增语义(claims 比对仍然能正确识别 stale token)。
+		if err := l.svcCtx.SysUserModel.UpdateProfileWithTx(
 			ctx, session, req.Id, user.Username,
 			nickname, email, phone, remark, deptId,
 			newStatus, statusChanged, user.UpdateTime,
-		)
+		); err != nil {
+			return err
+		}
+		if devAccessRevoked {
+			if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, req.Id); err != nil {
+				return err
+			}
+		}
+		return nil
 	}); err != nil {
 		if errors.Is(err, userModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")

+ 191 - 25
internal/logic/user/updateUserLogic_test.go

@@ -5,9 +5,6 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"github.com/zeromicro/go-zero/core/stores/redis"
 	"math"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
@@ -24,6 +21,10 @@ import (
 	"sync/atomic"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
 func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 {
@@ -784,8 +785,12 @@ func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) {
 	assert.Equal(t, dstDeptId, user.DeptId)
 }
 
-// TC-0748: -F —— 产品 ADMIN 调用者被豁免 DeptPath 前缀校验(可跨部门转移)。
-func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) {
+// TC-0748: 产品 ADMIN 也必须满足 DeptPath 前缀校验,才能将成员调入目标部门。
+// 背景:sys_user.deptId 是全局字段,产品 ADMIN 在 P1 的授权边界止于 P1;如果允许 ADMIN 跨
+// 子树挪动同时归属 P1/P2 的共享成员,会在 P2 视角造成"目标 DeptPath 不再以任何 P2 管理员
+// 的 DeptPath 为前缀"的结构性失联——与"deptId=0 把用户挪出部门树"是同构的攻击链。
+// 因此 UpdateUser 对非超管(含 ADMIN)的 DeptPath 前缀校验必须生效;仅 SuperAdmin 可跨子树。
+func TestUpdateUser_ProductAdminMustPassSubtreeCheck(t *testing.T) {
 	bootstrap := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
@@ -809,11 +814,19 @@ func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) {
 	})
 
 	newDept := anywhereDept
-	require.NoError(t,
-		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
-			Id: targetId, DeptId: &newDept,
-		}),
-		"产品 ADMIN 在 UpdateUser 的 DeptPath 前缀校验中被豁免")
+	err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id: targetId, DeptId: &newDept,
+	})
+	require.Error(t, err, "ADMIN 跨子树调入非自己管辖的部门必须被拒绝")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "无权将用户调入")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, targetHomeDept, u.DeptId,
+		"被拒绝的请求必须对 DB 零副作用,避免 403 伴随脏写入")
 }
 
 func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
@@ -1262,11 +1275,12 @@ func insertTestDeptWithType(t *testing.T, ctx context.Context, svcCtx *svc.Servi
 // TC-1124: H-R14-1 —— 产品 ADMIN 将目标用户调入 DEV 部门必须 403,防止跨产品权限升级。
 //
 // 攻击链回放:
-//   P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser
-//   把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target
-//   在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的
-//   全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。
-//   因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。
+//
+//	P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser
+//	把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target
+//	在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的
+//	全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。
+//	因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。
 //
 // 断言:403 + 文案"仅超级管理员可将用户调入研发部门";DB 零副作用(deptId 仍为原值)。
 func TestUpdateUser_H_R14_1_AdminCannotMoveToDevDept(t *testing.T) {
@@ -1345,24 +1359,23 @@ func TestUpdateUser_H_R14_1_SuperAdminCanMoveToDevDept(t *testing.T) {
 	assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径下 DeptId 必须真的写入 DEV 部门")
 }
 
-// TC-1126: H-R14-1 负向回归 —— 确认 DEV 护栏不会把"ADMIN 跨子树调入 NORMAL"的合法动作误伤。
-// ADMIN 跨子树调入 NORMAL 部门本就有 TC-0748 保证放行;本用例是为 H-R14-1 修复后再做一次确认,
-// 防止未来在 DEV 护栏基础上把判断一不小心写成 `newDept.DeptType != DeptTypeNormal || !SuperAdmin`
-// 之类的过度收紧。
-func TestUpdateUser_H_R14_1_AdminCanMoveToCrossSubtreeNormal(t *testing.T) {
+// TC-1126: 对偶正向 —— ADMIN 在"自己管辖子树内"的跨节点调动(同 DeptPath 前缀)必须放行,
+// 避免 L-R16-1 把 DeptPath 前缀校验拉齐之后,误伤 ADMIN 在自己子树内的日常维护动作。
+// 覆盖的不变量是:"严格前缀匹配"而不是"严格等于"——同子树 = 允许,跨子树 = 拒绝。
+func TestUpdateUser_AdminCanMoveWithinOwnSubtree(t *testing.T) {
 	bootstrap := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
 
 	adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin_x", "/8500/", consts.DeptTypeNormal)
-	farDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_far", "/8600/", consts.DeptTypeNormal)
+	siblingDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_sibling", "/8500/2/", consts.DeptTypeNormal)
 	srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src_x", "/8500/1/", consts.DeptTypeNormal)
 	targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt_x", srcDeptId)
 	mId := insertTestMember(t, svcCtx, "test_product", targetId)
 	t.Cleanup(func() {
 		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
-		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, farDeptId, srcDeptId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, siblingDeptId, srcDeptId)
 	})
 
 	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
@@ -1372,14 +1385,167 @@ func TestUpdateUser_H_R14_1_AdminCanMoveToCrossSubtreeNormal(t *testing.T) {
 		DeptId: adminDeptId, DeptPath: "/8500/", MinPermsLevel: math.MaxInt64,
 	})
 
-	newDept := farDeptId
+	newDept := siblingDeptId
 	require.NoError(t,
 		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
 			Id: targetId, DeptId: &newDept,
 		}),
-		"ADMIN 跨子树调入 NORMAL 部门必须仍然放行;H-R14-1 修复只封堵 DEV 这一条路径")
+		"ADMIN 在自己子树内(/8500/ 前缀下)的跨节点调动必须放行,否则 ADMIN 无法履职")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, siblingDeptId, u.DeptId)
+}
+
+// TC-1170: 跨产品场景下,P1 ADMIN 不能把同时是 P2 成员的 target 挪到 P1 子树之外的 NORMAL
+// 部门。攻击面:调用者 ADMIN 在产品 P1 的授权面仅覆盖 P1,但 sys_user.deptId 是**全局**字段,
+// 一旦放行,target 在 P2 视角里的 DeptPath 会落到一个 P2 管理层根本看不到的子树上
+// (P2 的 checkDeptHierarchy 立刻把所有 P2 管理员对 target 的管理动作全部 403)——与
+// deptId=0 的 L-R15-1 攻击链完全同构。必须在 UpdateUser 这一层把 ADMIN 跨子树动作拦截。
+func TestUpdateUser_AdminWithDualProductTarget_CrossSubtreeBlocked(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	p1 := "p_dual_1_" + testutil.UniqueId()
+	p2 := "p_dual_2_" + testutil.UniqueId()
+
+	adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_p1_admin", "/9100/")
+	// target 原属 P1 子树;攻击方向是"挪到 /9200/ —— 完全不在 /9100/ 前缀下"。
+	srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_src", "/9100/1/")
+	otherSubtreeId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_other", "/9200/")
+	targetId := insertTestUserWithDept(t, bootstrap, "dual_tgt", srcDeptId)
+	m1 := insertTestMember(t, svcCtx, p1, targetId)
+	m2 := insertTestMember(t, svcCtx, p2, targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", m1, m2)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, otherSubtreeId)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 66610, Username: "dual_admin",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, ProductCode: p1,
+		DeptId: adminDeptId, DeptPath: "/9100/", MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := otherSubtreeId
+	err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id: targetId, DeptId: &newDept,
+	})
+	require.Error(t, err, "跨产品共享成员 + ADMIN 跨子树调度 = L-R15-1 同构越权,必须 403")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
 
 	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
 	require.NoError(t, err)
-	assert.Equal(t, farDeptId, u.DeptId)
+	assert.Equal(t, srcDeptId, u.DeptId,
+		"DB 不得被污染——否则 P2 视角会立刻失联:P2 所有管理动作对 target 都会 403")
+}
+
+// TC-1171: UpdateUser 把 target 从"DEV+Enabled 部门"调到 NORMAL 部门 → sys_user.tokenVersion +1。
+// 收窄方向:loadPerms 的 DEV 全权分支以 (MemberType!="" && DeptType=DEV && DeptStatus=Enabled)
+// 为条件,DEV→NORMAL 让本用户在**所有**他已加入的产品里同时失去全权。必须在 UpdateUser 的
+// 事务内把 sys_user.tokenVersion 原子性 +1,让 jwtauthMiddleware 下一次校验 401 旧 access token,
+// 而不是等 UD 聚合缓存的 5min TTL 自然过期。
+func TestUpdateUser_L_R16_2_DevToNormal_RevokesSession(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev", "/1100/", consts.DeptTypeDev)
+	normalDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_normal", "/1200/", consts.DeptTypeNormal)
+	targetId := insertTestUserWithDept(t, bootstrap, "l_r16_tgt", devDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId, normalDeptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	prevTokenVersion := before.TokenVersion
+
+	newDept := normalDeptId
+	require.NoError(t,
+		NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}),
+		"SuperAdmin 调出 DEV 是合法运维,必须成功")
+
+	after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, normalDeptId, after.DeptId, "deptId 必须真实落到 NORMAL 部门")
+	assert.Equal(t, prevTokenVersion+1, after.TokenVersion,
+		"DEV→NORMAL 构成 DEV 全权收窄,必须同事务 +1;否则 5min TTL 窗口内旧 access token 仍拥有全权")
+}
+
+// TC-1172: UpdateUser 把 target 从"DEV+Enabled 部门"挪到 deptId=0(移出部门树,SuperAdmin-only)
+// 同样是 DEV 全权收窄,必须把 tokenVersion +1;这里覆盖"newDept == nil 分支"的 devAccessRevoked 判定。
+func TestUpdateUser_L_R16_2_DevToDeptZero_RevokesSession(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev0", "/1300/", consts.DeptTypeDev)
+	targetId := insertTestUserWithDept(t, bootstrap, "l_r16_dev0_tgt", devDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	prevTokenVersion := before.TokenVersion
+
+	zero := int64(0)
+	require.NoError(t,
+		NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &zero,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), after.DeptId)
+	assert.Equal(t, prevTokenVersion+1, after.TokenVersion,
+		"DEV→deptId=0 同样构成 DEV 全权收窄,tokenVersion 必须 +1")
+}
+
+// TC-1173: UpdateUser 从 NORMAL→NORMAL 挪动 target 不得递增 tokenVersion —— 不构成任何权限收窄,
+// 升级为"吊销会话"会把合法用户无故踢下线,损害可用性。
+func TestUpdateUser_L_R16_2_NormalToNormal_NoRevoke(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_src", "/1400/")
+	dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_dst", "/1500/")
+	targetId := insertTestUserWithDept(t, bootstrap, "l_r16_n2n_tgt", srcDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId)
+	})
+
+	before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	prevTokenVersion := before.TokenVersion
+
+	newDept := dstDeptId
+	require.NoError(t,
+		NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}))
+
+	after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, dstDeptId, after.DeptId)
+	assert.Equal(t, prevTokenVersion, after.TokenVersion,
+		"NORMAL→NORMAL 不是收窄方向,tokenVersion 必须保持不变,否则等于无故 401 合法会话")
 }

+ 7 - 3
internal/logic/user/userDetailLogic.go

@@ -61,14 +61,18 @@ func (l *UserDetailLogic) UserDetail(req *types.UserDetailReq) (resp *types.User
 		avatar = user.Avatar.String
 	}
 
+	// 审计 M-R16-1:与 UserList 同口径——只在 SuperAdmin / 产品 ADMIN / 产品 DEVELOPER / caller 本人
+	// 才回填 email/phone/remark;普通 MEMBER 看他人时三个 PII 字段置空,避免全员通讯录外泄。
+	email, phone, remark := maskUserPII(caller, user)
+
 	return &types.UserItem{
 		Id:         user.Id,
 		Username:   user.Username,
 		Nickname:   user.Nickname,
 		Avatar:     avatar,
-		Email:      user.Email,
-		Phone:      user.Phone,
-		Remark:     user.Remark,
+		Email:      email,
+		Phone:      phone,
+		Remark:     remark,
 		DeptId:     user.DeptId,
 		Status:     user.Status,
 		RoleIds:    roleIds,

+ 111 - 2
internal/logic/user/userDetailLogic_test.go

@@ -4,8 +4,6 @@ import (
 	"context"
 	"database/sql"
 	"errors"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
@@ -19,6 +17,9 @@ import (
 	"perms-system-server/internal/types"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestUserDetail_Success(t *testing.T) {
@@ -178,3 +179,111 @@ func TestUserDetail_H1_SuperAdmin_KeepsPII(t *testing.T) {
 	assert.Equal(t, "13700000000", resp.Phone)
 	assert.Equal(t, "nb", resp.Remark)
 }
+
+// seedPIITarget 插入一个"有完整 PII 的 target 用户"并挂到指定产品下,返回 userId / mId 供清理。
+// 统一 helper 以便 TC-1164~1166 共用;PII 字段值用固定常量,保证断言直接对比字符串即可。
+const (
+	piiEmail  = "[email protected]"
+	piiPhone  = "13911118888"
+	piiRemark = "target remark only admin can see"
+)
+
+func seedPIITarget(t *testing.T, svcCtx *svc.ServiceContext, productCode string) (int64, int64) {
+	t.Helper()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{
+		Username: "pii_target_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
+		Nickname: "t", Email: piiEmail, Phone: piiPhone, Remark: piiRemark,
+		IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, DeptId: 1,
+	})
+	now := time.Now().Unix()
+	mRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &memberModel.SysProductMember{
+		ProductCode: productCode, UserId: targetId, MemberType: consts.MemberTypeMember,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+	return targetId, mId
+}
+
+// TC-1164: 产品 ADMIN 看同产品他人 —— PII 完整返回。
+// ADMIN 是产品层授权面,负责人员/角色维护,必须能看到联系方式去做"找到此人"。
+func TestUserDetail_M_R16_1_ProductAdmin_KeepsPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "m_r16_admin_" + testutil.UniqueId()
+
+	targetId, mId := seedPIITarget(t, svcCtx, productCode)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserDetailLogic(ctxhelper.AdminCtx(productCode), svcCtx).
+		UserDetail(&types.UserDetailReq{Id: targetId})
+	require.NoError(t, err)
+	assert.Equal(t, piiEmail, resp.Email, "ADMIN 必须看到 Email 原值")
+	assert.Equal(t, piiPhone, resp.Phone, "ADMIN 必须看到 Phone 原值")
+	assert.Equal(t, piiRemark, resp.Remark, "ADMIN 必须看到 Remark 原值")
+}
+
+// TC-1165: 产品 DEVELOPER 看同产品他人 —— PII 完整返回。
+// DEVELOPER 是全权分支,授权读取元数据里就包含"看到成员详情";不应随 M-R16-1 一起被脱敏误伤。
+func TestUserDetail_M_R16_1_ProductDeveloper_KeepsPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "m_r16_dev_" + testutil.UniqueId()
+
+	targetId, mId := seedPIITarget(t, svcCtx, productCode)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserDetailLogic(ctxhelper.DeveloperCtx(productCode), svcCtx).
+		UserDetail(&types.UserDetailReq{Id: targetId})
+	require.NoError(t, err)
+	assert.Equal(t, piiEmail, resp.Email)
+	assert.Equal(t, piiPhone, resp.Phone)
+	assert.Equal(t, piiRemark, resp.Remark)
+}
+
+// TC-1166: 产品 MEMBER 看同产品他人 —— PII 必须被置空(不能再返回 email/phone/remark)。
+// 这是 M-R16-1 的核心契约:普通成员拿到的 UserItem 里仅保留 Username/Nickname/DeptId/Status
+// 这类"组织结构可见"的字段,拒绝把全员通讯录外泄给普通成员。
+func TestUserDetail_M_R16_1_ProductMember_MasksPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "m_r16_mb_" + testutil.UniqueId()
+
+	targetId, mId := seedPIITarget(t, svcCtx, productCode)
+	// caller 本身也要挂到这个产品下,否则 FindOneByProductCodeUserId(caller.ProductCode, target.Id)
+	// 会直接 403——那就测不到脱敏逻辑。ctxhelper.MemberCtx 里 UserId=4 与 target 不同,才能触发
+	// "caller 看他人"的分支。
+	callerCtx := ctxhelper.MemberCtx(productCode)
+	callerId := int64(4)
+	now := time.Now().Unix()
+	callerMRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &memberModel.SysProductMember{
+		ProductCode: productCode, UserId: callerId, MemberType: consts.MemberTypeMember,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	callerMId, _ := callerMRes.LastInsertId()
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId, callerMId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserDetailLogic(callerCtx, svcCtx).
+		UserDetail(&types.UserDetailReq{Id: targetId})
+	require.NoError(t, err, "脱敏不应该变成 403 —— 同产品可读的组织信息依然返回,只是 PII 字段置空")
+	assert.Empty(t, resp.Email, "MEMBER 看他人:Email 必须为空字符串")
+	assert.Empty(t, resp.Phone, "MEMBER 看他人:Phone 必须为空字符串")
+	assert.Empty(t, resp.Remark, "MEMBER 看他人:Remark 必须为空字符串")
+	// 未脱敏的字段必须原样回填,否则是多脱一刀(误伤 UI)。
+	assert.Equal(t, targetId, resp.Id)
+	assert.NotEmpty(t, resp.Username, "Username 不应被脱敏")
+}

+ 27 - 3
internal/logic/user/userListLogic.go

@@ -3,6 +3,8 @@ package user
 import (
 	"context"
 
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
@@ -74,14 +76,15 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 			memberType = memberMap[u.Id]
 		}
 
+		email, phone, remark := maskUserPII(caller, u)
 		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,
+			Email:      email,
+			Phone:      phone,
+			Remark:     remark,
 			DeptId:     u.DeptId,
 			Status:     u.Status,
 			MemberType: memberType,
@@ -94,3 +97,24 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 		List:  items,
 	}, nil
 }
+
+// maskUserPII 审计 M-R16-1:同产品普通 MEMBER 不应拉到他人联系方式。
+// 规则:SuperAdmin / 产品 ADMIN / 产品 DEVELOPER,或 caller 看自己,返回原值;其他情况(含普通
+// MEMBER 看他人、无 MemberType 的只读视角)把 email/phone/remark 三个 PII 字段置空。
+// 返回字段保留其它字段(Username / Nickname / DeptId / Status 等)的原值:
+// 业务上"看得到谁在这个产品里"是合理读权限,但"拉通讯录"不是。
+//
+// 与 MemberList 的口径对齐:MemberListLogic 根本不在响应体里返回 email/phone;UserList/UserDetail
+// 则保留"自己 + 管理层"可读的最小授权子集,以便个人资料页、管理台审核流正常工作。
+func maskUserPII(caller *loaders.UserDetails, u *userModel.SysUser) (email, phone, remark string) {
+	if caller == nil || u == nil {
+		return "", "", ""
+	}
+	if caller.IsSuperAdmin ||
+		caller.MemberType == consts.MemberTypeAdmin ||
+		caller.MemberType == consts.MemberTypeDeveloper ||
+		caller.UserId == u.Id {
+		return u.Email, u.Phone, u.Remark
+	}
+	return "", "", ""
+}

+ 134 - 0
internal/logic/user/userListLogic_test.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	productMemberModel "perms-system-server/internal/model/productmember"
+	userModelPkg "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -225,6 +226,139 @@ func TestUserList_NonSuperAdminWithoutProductCode_Rejected(t *testing.T) {
 	assert.Contains(t, ce.Error(), "非超管用户必须指定产品编码")
 }
 
+// TC-1167 / TC-1168 / TC-1169 统一使用这套 PII 种子,保证 UserList 三类调用者看到的
+// Email/Phone/Remark 与 UserDetail 口径完全对齐,避免"详情脱敏但列表不脱敏"的侧信道泄漏。
+
+func seedPIIMemberForList(t *testing.T, svcCtx *svc.ServiceContext, productCode string,
+	memberType string) (callerUserId, targetUserId, targetMId, callerMId int64) {
+	t.Helper()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	now := time.Now().Unix()
+
+	tRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModelPkg.SysUser{
+		Username: "list_pii_tgt_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
+		Email: "[email protected]", Phone: "13922223333", Remark: "list-target-remark",
+		IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, DeptId: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	targetUserId, _ = tRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &productMemberModel.SysProductMember{
+		ProductCode: productCode, UserId: targetUserId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	targetMId, _ = mRes.LastInsertId()
+
+	// caller 只在 MEMBER 场景需要真实落库(和 UserDetail 对齐:非超管必须是产品成员才能 list)。
+	// ADMIN / DEVELOPER 走 ctxhelper 预设的 UserId 即可,不必落库。
+	if memberType == "MEMBER" {
+		callerUserId = 4
+		cRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &productMemberModel.SysProductMember{
+			ProductCode: productCode, UserId: callerUserId, MemberType: "MEMBER",
+			Status: 1, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		callerMId, _ = cRes.LastInsertId()
+	}
+	return
+}
+
+// TC-1167: 产品 ADMIN 列表视角 —— 必须拿到所有成员的 Email/Phone/Remark 原值。
+func TestUserList_M_R16_1_ProductAdmin_KeepsPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "list_m_r16_admin_" + testutil.UniqueId()
+
+	_, targetId, targetMId, _ := seedPIIMemberForList(t, svcCtx, productCode, "ADMIN")
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserListLogic(ctxhelper.AdminCtx(productCode), svcCtx).UserList(&types.UserListReq{
+		ProductCode: productCode, Page: 1, PageSize: 100,
+	})
+	require.NoError(t, err)
+	items := resp.List.([]types.UserItem)
+	var found *types.UserItem
+	for i := range items {
+		if items[i].Id == targetId {
+			found = &items[i]
+			break
+		}
+	}
+	require.NotNil(t, found, "ADMIN 列表必须能看到本产品下的 target 成员")
+	assert.Equal(t, "[email protected]", found.Email)
+	assert.Equal(t, "13922223333", found.Phone)
+	assert.Equal(t, "list-target-remark", found.Remark)
+}
+
+// TC-1168: 产品 DEVELOPER 列表视角 —— 与 ADMIN 同口径,PII 完整返回。
+func TestUserList_M_R16_1_ProductDeveloper_KeepsPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "list_m_r16_dev_" + testutil.UniqueId()
+
+	_, targetId, targetMId, _ := seedPIIMemberForList(t, svcCtx, productCode, "DEVELOPER")
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserListLogic(ctxhelper.DeveloperCtx(productCode), svcCtx).UserList(&types.UserListReq{
+		ProductCode: productCode, Page: 1, PageSize: 100,
+	})
+	require.NoError(t, err)
+	items := resp.List.([]types.UserItem)
+	for _, item := range items {
+		if item.Id == targetId {
+			assert.Equal(t, "[email protected]", item.Email)
+			assert.Equal(t, "13922223333", item.Phone)
+			assert.Equal(t, "list-target-remark", item.Remark)
+			return
+		}
+	}
+	t.Fatal("DEVELOPER 列表必须能看到 target")
+}
+
+// TC-1169: 产品 MEMBER 列表视角 —— Email/Phone/Remark 必须为空字符串;
+// 其它字段(Username/Nickname/DeptId/Status 等)照常回填,保证 MEMBER 的"通讯录视图"退化为
+// "看得到谁在这个产品里",而不是"拉到每个人的私信渠道"。
+func TestUserList_M_R16_1_ProductMember_MasksPII(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := ctxhelper.SuperAdminCtx()
+	productCode := "list_m_r16_mb_" + testutil.UniqueId()
+
+	_, targetId, targetMId, callerMId := seedPIIMemberForList(t, svcCtx, productCode, "MEMBER")
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId, callerMId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+	})
+
+	resp, err := NewUserListLogic(ctxhelper.MemberCtx(productCode), svcCtx).UserList(&types.UserListReq{
+		ProductCode: productCode, Page: 1, PageSize: 100,
+	})
+	require.NoError(t, err)
+	items := resp.List.([]types.UserItem)
+	var found *types.UserItem
+	for i := range items {
+		if items[i].Id == targetId {
+			found = &items[i]
+			break
+		}
+	}
+	require.NotNil(t, found, "MEMBER 列表仍应能看到 target 的存在,仅 PII 字段被清空")
+	assert.Empty(t, found.Email, "MEMBER 视角下 Email 必须是空字符串")
+	assert.Empty(t, found.Phone, "MEMBER 视角下 Phone 必须是空字符串")
+	assert.Empty(t, found.Remark, "MEMBER 视角下 Remark 必须是空字符串")
+	assert.NotEmpty(t, found.Username, "Username 不应被脱敏,保证 UI 还能渲染出成员行")
+}
+
 // TC-0207: 非超管访问其他产品数据被拒绝
 func TestUserList_NonSuperAdminWrongProductCode_Rejected(t *testing.T) {
 	ctx := ctxhelper.AdminCtx("product_a")

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

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -19,6 +20,20 @@ type (
 		sysDeptModel
 		FindAll(ctx context.Context) ([]*SysDept, error)
 		UpdateWithOptLock(ctx context.Context, data *SysDept, expectedUpdateTime int64) error
+		// UpdateWithOptLockTx 与 UpdateWithOptLock 语义等价,但 UPDATE 在调用方提供的事务里执行。
+		// 用于 UpdateDept 需要在同一事务里做"乐观锁更新 sys_dept + 批量递增 sys_user.tokenVersion"
+		// 的场景(审计 L-R16-2):任一步失败整体回滚,不会出现"部门已改但成员 tokenVersion 未递增"
+		// 或"tokenVersion 已递增但部门未改"的脏中间态。
+		//
+		// 契约(与 UpdateProfileWithTx 的 L-R12-1 对齐):
+		//   - 不在方法内做缓存失效;commit 成功后由调用方显式调用 InvalidateDeptCache(id);
+		//   - session==nil 返回错误;
+		//   - affected=0 → ErrUpdateConflict,交由上层映射 409。
+		UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *SysDept, expectedUpdateTime int64) error
+		// InvalidateDeptCache 失效 sysDept 的 id 键缓存,仅应在事务 commit 成功后调用。
+		// best-effort:失效失败只留日志,最终由 TTL 兜底(审计 L-R16-2,与 InvalidateProfileCache
+		// 相同口径)。
+		InvalidateDeptCache(ctx context.Context, id int64)
 		// FindOneForShareTx 在当前事务里对 sys_dept 目标行加 S 锁(SELECT ... LOCK IN SHARE MODE),
 		// 用于"UpdateUser 改 deptId 到 X"与"DeleteDept 删除 X"之间的 write skew 闭环(审计 M-R11-3)。
 		// DeleteDept 会先对 sys_dept[X] 取 X 锁——被本 S 锁阻塞;等 UpdateUser 提交后 DeleteDept 再
@@ -71,3 +86,40 @@ func (m *customSysDeptModel) UpdateWithOptLock(ctx context.Context, data *SysDep
 	}
 	return nil
 }
+
+// UpdateWithOptLockTx 见接口注释(审计 L-R16-2)。
+// 实现上**绕过** m.ExecCtx 的 pre-commit DelCache 语义——仅调用 session.ExecCtx,缓存失效由
+// 调用方在事务 commit 成功后显式走 InvalidateDeptCache。
+func (m *customSysDeptModel) UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *SysDept, expectedUpdateTime int64) error {
+	if session == nil {
+		return errors.New("UpdateWithOptLockTx requires a non-nil session")
+	}
+	query := fmt.Sprintf("UPDATE %s SET `name`=?, `sort`=?, `deptType`=?, `remark`=?, `status`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+	res, err := session.ExecCtx(ctx, query, data.Name, data.Sort, data.DeptType, data.Remark, data.Status, data.UpdateTime, data.Id, expectedUpdateTime)
+	if err != nil {
+		return err
+	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
+
+// InvalidateDeptCache 见接口注释(审计 L-R16-2)。与 sysUserModel.InvalidateProfileCache 对齐:
+// post-commit best-effort 失效,ctx 取消与其它错误分档日志,方便 Redis 抖动与主动取消区分告警。
+func (m *customSysDeptModel) InvalidateDeptCache(ctx context.Context, id int64) {
+	sysDeptIdKey := fmt.Sprintf("%s%v", cacheSysDeptIdPrefix, id)
+	if err := m.DelCacheCtx(ctx, sysDeptIdKey); 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", "sysDeptModel.InvalidateDeptCache"),
+				logx.Field("id", id),
+				logx.Field("err", err.Error()),
+			)
+		} else {
+			logx.WithContext(ctx).Errorf("sysDeptModel.InvalidateDeptCache failed: id=%d err=%v", id, err)
+		}
+	}
+}

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

@@ -29,6 +29,13 @@ type (
 		FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, map[int64]string, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error)
 		FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error)
+		// FindIdsByDeptIdForShareTx 在调用方事务里取目标 deptId 下所有用户 id,并对命中行加 S 锁
+		// (LOCK IN SHARE MODE)。用于 UpdateDept 收窄(DEV→NORMAL / DEV 部门 Enabled→Disabled /
+		// NORMAL 部门 Enabled→Disabled)后需要批量递增 tokenVersion 时,阻塞并发
+		// UpdateProfileWithTx 对同一批 sys_user 行的 X 锁,避免"枚举 userIds 的瞬间有行刚被挪出
+		// 本部门 / 新行刚被挪入"导致的漏吊销 / 多吊销(审计 L-R16-2)。
+		// session==nil 返回错误。
+		FindIdsByDeptIdForShareTx(ctx context.Context, session sqlx.Session, deptId int64) ([]int64, error)
 		// UpdateProfile 更新用户资料字段(昵称 / 邮箱 / 手机 / 备注 / 部门 / 状态),username 仅用于
 		// 构造旧缓存键 `cacheSysUserUsernamePrefix` 做失效,**不会**被写入 SET 子句。若未来确实需要
 		// 修改 username,请独立实现 `UpdateUsernameTx`:
@@ -148,6 +155,21 @@ func (m *customSysUserModel) FindIdsByDeptId(ctx context.Context, deptId int64)
 	return ids, nil
 }
 
+// FindIdsByDeptIdForShareTx 见接口注释(审计 L-R16-2)。
+func (m *customSysUserModel) FindIdsByDeptIdForShareTx(ctx context.Context, session sqlx.Session, deptId int64) ([]int64, error) {
+	if session == nil {
+		return nil, errors.New("FindIdsByDeptIdForShareTx requires a non-nil session")
+	}
+	var ids []int64
+	// ORDER BY id 让 S 锁的申请顺序确定,与并发的 UpdateProfileWithTx(按 id 触发 X 锁)交叉时
+	// 形成固定加锁偏序,减小死锁概率;同时方便上层日志对照。
+	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `deptId` = ? ORDER BY `id` LOCK IN SHARE MODE", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, deptId); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error {
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)

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

@@ -245,6 +245,18 @@ func (mr *MockSysDeptModelMockRecorder) InsertWithTx(ctx, session, data any) *go
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWithTx", reflect.TypeOf((*MockSysDeptModel)(nil).InsertWithTx), ctx, session, data)
 }
 
+// InvalidateDeptCache mocks base method.
+func (m *MockSysDeptModel) InvalidateDeptCache(ctx context.Context, id int64) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "InvalidateDeptCache", ctx, id)
+}
+
+// InvalidateDeptCache indicates an expected call of InvalidateDeptCache.
+func (mr *MockSysDeptModelMockRecorder) InvalidateDeptCache(ctx, id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateDeptCache", reflect.TypeOf((*MockSysDeptModel)(nil).InvalidateDeptCache), ctx, id)
+}
+
 // TableName mocks base method.
 func (m *MockSysDeptModel) TableName() string {
 	m.ctrl.T.Helper()
@@ -301,6 +313,20 @@ func (mr *MockSysDeptModelMockRecorder) UpdateWithOptLock(ctx, data, expectedUpd
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysDeptModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
 }
 
+// UpdateWithOptLockTx mocks base method.
+func (m *MockSysDeptModel) UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *dept.SysDept, expectedUpdateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateWithOptLockTx", ctx, session, data, expectedUpdateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateWithOptLockTx indicates an expected call of UpdateWithOptLockTx.
+func (mr *MockSysDeptModelMockRecorder) UpdateWithOptLockTx(ctx, session, data, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLockTx", reflect.TypeOf((*MockSysDeptModel)(nil).UpdateWithOptLockTx), ctx, session, data, expectedUpdateTime)
+}
+
 // UpdateWithTx mocks base method.
 func (m *MockSysDeptModel) UpdateWithTx(ctx context.Context, session sqlx.Session, data *dept.SysDept) error {
 	m.ctrl.T.Helper()

+ 15 - 0
internal/testutil/mocks/mock_user_model.go

@@ -199,6 +199,21 @@ func (mr *MockSysUserModelMockRecorder) FindIdsByDeptId(ctx, deptId any) *gomock
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindIdsByDeptId", reflect.TypeOf((*MockSysUserModel)(nil).FindIdsByDeptId), ctx, deptId)
 }
 
+// FindIdsByDeptIdForShareTx mocks base method.
+func (m *MockSysUserModel) FindIdsByDeptIdForShareTx(ctx context.Context, session sqlx.Session, deptId int64) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindIdsByDeptIdForShareTx", ctx, session, deptId)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindIdsByDeptIdForShareTx indicates an expected call of FindIdsByDeptIdForShareTx.
+func (mr *MockSysUserModelMockRecorder) FindIdsByDeptIdForShareTx(ctx, session, deptId any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindIdsByDeptIdForShareTx", reflect.TypeOf((*MockSysUserModel)(nil).FindIdsByDeptIdForShareTx), ctx, session, deptId)
+}
+
 // FindListByPage mocks base method.
 func (m *MockSysUserModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*user.SysUser, int64, error) {
 	m.ctrl.T.Helper()

+ 19 - 2
test-design.md

@@ -342,6 +342,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-0847 | POST /api/dept/update 或 delete/tree | `CleanByUserIds` 空 ids 切片 | `[]` | 立即返回,不 panic,不调用 Redis | 边界 | P1 | 防未来调用方传空列表打空 RTT |
 | TC-0848 | POST /api/dept/update 或 delete/tree | `UpdateDept` 改 deptType 时调 CleanByUserIds | mock: FindIdsByDeptId → [100,101],断言 CleanByUserIds 路径(通过 mock 的 FindIdsByDeptId 期望 +真实 loader 执行) | 无错误返回;FindIdsByDeptId 被调用恰好 1 次 | 行为 | P0 | UpdateDept 在变更时才触达用户列表 |
 | TC-0849 | POST /api/dept/update 或 delete/tree | `UpdateDept` 的 `FindIdsByDeptId` 失败 | mock 返回 err | 返回 `nil`(不是 500);旧权限缓存 TTL 兜底 | 容错 | P0 | 修复后的 degraded 成功语义 |
+| TC-1174 | POST /api/dept/update | DeptType DEV→NORMAL 必须批量递增部门下所有成员 tokenVersion | seed Dept(DeptType=DEV, Status=1) + 3 个成员;`{DeptType:"NORMAL"}` | 3 个成员 `sys_user.tokenVersion` 严格 +1;部门 `deptType="NORMAL"` 同事务落盘 | 安全/会话吊销 | P0 | DEV→NORMAL = 全权分支失效,必须把还挂在 DEV 身份上的 access token 打掉;`BatchIncrementTokenVersionWithTx` + `FindIdsByDeptIdForShareTx` 原子语义 |
+| TC-1175 | POST /api/dept/update | DEV 部门 Status Enabled→Disabled 批量递增 tokenVersion | seed Dept(DeptType=DEV, Status=1) + 2 个成员;`{Status:2}` | 2 个成员 tokenVersion +1;部门 `status=2` | 安全/会话吊销 | P0 | 禁用 DEV 部门 = 全权分支熄灯,对偶 TC-1174,同样走收窄分支 |
+| 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 |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -466,7 +471,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0175 | POST /api/user/update | updateUser 乐观锁冲突 -> 409 | 基于过期 updateTime 更新 | 返回 CodeError(409, "数据已被其他操作修改...") | 并发/异常 | P0 | ErrUpdateConflict 透传 |
 | TC-0746 | POST /api/user/update | DEVELOPER 把成员挪出自己子树 | Caller.DeptPath=/100/,targetNewDept.DeptPath=/999/ | 403 "无权将用户转移到该部门",DB 保持原 deptId | 安全 | P0 | `strings.HasPrefix(newDept.DeptPath, caller.DeptPath)` 前缀校验 |
 | TC-0747 | POST /api/user/update | DEVELOPER 在自己子树内移动成员 | Caller=/100/,newDept=/100/200/ | 200 OK,DB 更新 deptId | 正常路径 | P0 | 子树内放行 |
-| TC-0748 | POST /api/user/update | 产品 ADMIN 不受子树限制 | AdminCtx 跨任意部门 | 200 OK | 正常路径 | P1 | ADMIN 只过 `CheckManageAccess`,不走子树约束 |
+| TC-0748 | POST /api/user/update | 产品 ADMIN 把 target 挪到子树外必须 403 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、DeptPath="/500/"`> | 403 "无权将用户转移到该部门";DB `sys_user.deptId` 保持原值 | 安全 | P0 | ADMIN 必须与 DEVELOPER 同口径过 `strings.HasPrefix(newDept.DeptPath, caller.DeptPath)`;不得借 ADMIN 身份绕过子树约束 |
 | TC-0814 | POST /api/user/update | DEVELOPER 将他人 deptId 置 0 | caller=DEVELOPER | 403;目标 deptId 不变 | 安全/越权 | P0 | 防"把用户挪出部门树以逃出管理视野" |
 | TC-0815 | POST /api/user/update | MEMBER 将他人 deptId 置 0 | caller=MEMBER | 403;目标 deptId 不变 | 安全/越权 | P0 | 同上 |
 | TC-0816 | POST /api/user/update | 产品 ADMIN 将他人 deptId 置 0 | caller=ADMIN | 403 "仅超级管理员可将用户移出部门";目标 deptId 不变 | 安全/跨产品结构破坏 | P0 | 原"产品 ADMIN 可移出"契约被收回:`sys_user.deptId` 是全局字段,P1 ADMIN 原先可让 P2 视角下共有成员变"孤儿"(P2 的 MEMBER/DEVELOPER/子 ADMIN 均通不过 `checkDeptHierarchy`)。收敛给 SuperAdmin |
@@ -478,8 +483,12 @@ MySQL (InnoDB) + Redis Cache
 | TC-1103 | POST /api/user/bindRoles | 超管即便 `MemberType==""` 也必须继续走 `FindOne`(不能被 L-R13-1 误伤) | 超管 ctx (MemberType=SuperAdmin) + 不存在 userId | 404 "用户不存在"(超管应继续原路径) | 正向回归 | P0 | 防 L-R13-1 闸门把超管正常链路误拦 |
 | TC-1124 | POST /api/user/update | H-R14-1 ADMIN 把目标调入 DEV 部门必须 403 | `AdminCtx + DeptPath="/"`(豁免子树校验)+ `req.DeptId=<DEV 部门>` | 403 "仅超级管理员可将用户调入研发部门";DB `sys_user.deptId` 不变 | 安全/跨产品升权 | P0 | 堵死 ADMIN 借 DeptPath 子树豁免 + `DeptType=DEV` 全权分支对他产品共有成员升权的攻击链 |
 | TC-1125 | POST /api/user/update | H-R14-1 SuperAdmin 仍可把目标调入 DEV 部门(正向回归) | SuperAdmin + `req.DeptId=<DEV 部门>` | 200 OK;DB `sys_user.deptId` 落到 DEV 部门 id | 正常路径 | P0 | 保留 SuperAdmin 跨产品调度语义 |
-| TC-1126 | POST /api/user/update | H-R14-1 ADMIN 把目标调入**非 DEV** 部门(跨子树)依然放行 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、/500/>` | 200 OK;DB 落盘新 deptId | 正常路径 | P0 | 确认 ADMIN 快速通道仅限 "非 DEV",与 L-R14-3 注释披露一致;不得误伤合法跨部门运维 |
+| TC-1126 | POST /api/user/update | 产品 ADMIN 在自己子树内挪动 target(非 DEV)放行 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、DeptPath="/300/400/"`> | 200 OK;DB 落盘新 deptId | 正常路径 | P0 | ADMIN 快速通道仅限"同子树内 + 非 DEV";保证合法子树内调动不被误伤 |
 | TC-1127 | POST /api/user/bindRoles | L-R14-2 三路径(跨产品 / 已禁用 / 不存在)统一文案对比 | 同一 `AdminCtx`:分别构造 (A) 跨产品 roleId、(B) 本产品禁用 roleId、(C) 不存在 roleId | 三条 `CodeError.Code()==400` 全等;`Error()` 均为 "包含无效的角色ID";不依赖顺序 | 安全/Oracle | P0 | 阻断已认证调用方借文案差异枚举他产品 roleId 分布 / 启停状态 |
+| TC-1170 | POST /api/user/update | 跨产品 P1 ADMIN 不得把共有 target 挪到 P1 子树之外的 NORMAL | `AdminCtx(P1, DeptPath="/100/")`;target 是 P1+P2 共有成员;`req.DeptId=<NORMAL, DeptPath="/200/">` | `CodeError.Code()==403`,文案含 "无权将用户转移到该部门";DB `sys_user.deptId` 不变;`sys_user.tokenVersion` 不变 | 安全/跨产品升权 | P0 | 反回归:P1 ADMIN 原先被"豁免子树"时可把 P2 视角下的共有成员挪出 P2 ADMIN 管辖范围,形成 P2 侧不可管的孤儿 |
+| TC-1171 | POST /api/user/update | target 从"DEV+Enabled 部门" → NORMAL 部门:sys_user.tokenVersion +1 | target seed 在 `DeptType=DEV, Status=1` 部门;`req.DeptId=<NORMAL 部门>` | DB `sys_user.tokenVersion` 严格 +1;`sys_user.deptId` 落盘为新 deptId;两者同事务 | 安全/会话吊销 | P0 | 单人移出 DEV = 该用户 DEV 全权分支作废,必须把旧 access token 踢下线 |
+| TC-1172 | POST /api/user/update | target 从"DEV+Enabled 部门" → deptId=0(移出部门树):sys_user.tokenVersion +1 | SuperAdmin + target 在 `DEV+Status=1` 部门;`req.DeptId=Int64Ptr(0)` | `sys_user.tokenVersion +1`;`sys_user.deptId=0` | 安全/会话吊销 | P0 | SuperAdmin 的"移出部门树"动作对 DEV 成员同样构成收窄;覆盖 `newDeptId==0` 分支 |
+| TC-1173 | POST /api/user/update | NORMAL→NORMAL 不递增 tokenVersion | target 在 `NORMAL 部门 A`;`req.DeptId=<NORMAL 部门 B>` | DB `sys_user.tokenVersion` 与初值严格相等;`deptId` 落盘为 B | 正向回归 | P0 | 非收窄方向不得把合法用户误踢下线;与 TC-1171 对偶 |
 
 ### 2.16 用户列表/详情/状态 及其他用户操作
 
@@ -548,6 +557,12 @@ MySQL (InnoDB) + Redis Cache
 | TC-1104 | POST /api/user/setPerms | 非 ADMIN caller + **不存在**的 userId 必须 403(而不是 404)以消除 userId 枚举 oracle | `MemberCtx` + `UserId=999999999` | `CodeError.Code()==403`,文案含 "仅超级管理员或该产品的管理员";DB `sys_user_perm` 无写入 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysUserModel.FindOne(userId)` |
 | TC-1105 | POST /api/user/setPerms | DENY TOCTOU:预检读 member=MEMBER 通过,事务内 S 锁快照返回 ADMIN → 400 并回滚 | `FindOneByProductCodeUserId → MEMBER`;装饰 `FindOneForShareTx → ADMIN` 返回 | `CodeError.Code()==400`,文案含 "产品管理员或开发者";`sys_user_perm` 无脏 DENY 行 | 对抗/一致性 | P0 | 若 L-R13-2 事务内复核被拆除,脏 DENY 行会落盘("能写永不生效") |
 | TC-1106 | POST /api/user/setPerms | ALLOW-only 请求 **不得** 走 `FindOneForShareTx` S 锁路径(避免把热路径退化到锁链) | `Perms=[{PermId, ALLOW}]`;装饰 member model 断言 `FindOneForShareTx` 调用数=0 | 正常落盘 1 行 ALLOW;mock 上 `FindOneForShareTx` 未被调用 | 契约/性能 | P1 | 防把 S 锁挂到全量路径导致并发降级 |
+| TC-1164 | POST /api/user/detail | 产品 ADMIN 看同产品他人:返回完整 Email/Phone/Remark | `AdminCtx(P1)`;target 是 P1 MEMBER,带完整 Email/Phone/Remark | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | ADMIN 是 PII 最小授权白名单之一;守护默认脱敏上线后不伤 ADMIN 视角 |
+| TC-1165 | POST /api/user/detail | 产品 DEVELOPER 看同产品他人:返回完整 Email/Phone/Remark | `DeveloperCtx(P1)`;target 是 P1 MEMBER | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | DEVELOPER 被纳入 PII 白名单;与 ADMIN 同口径 |
+| TC-1166 | POST /api/user/detail | 产品 MEMBER 看同产品他人:Email/Phone/Remark 必须为空字符串 | `MemberCtx(P1)`;target 是 P1 的其他成员 | `Email==""`、`Phone==""`、`Remark==""`;nickname/deptId 等非 PII 字段仍然返回;DB 原值未被改动 | 安全/最小授权 | P0 | 普通 MEMBER 视角不得窥视他人 PII;避免 PII 明文外泄 |
+| TC-1167 | POST /api/user/list | 产品 ADMIN 列表视角:全部成员 PII 原值返回 | `AdminCtx(P1)`;两个带 PII 的目标成员 | 列表响应里每条 `Email/Phone/Remark` 与 DB 严格相等 | 正向回归 | P0 | 列表入口与 detail 口径必须一致,否则 ADMIN 真实排障能力塌方 |
+| TC-1168 | POST /api/user/list | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | `DeveloperCtx(P1)` | 同上 | 正向回归 | P0 | 与 detail TC-1165 对偶 |
+| TC-1169 | POST /api/user/list | 产品 MEMBER 列表视角:其他成员 PII 必须被置空 | `MemberCtx(P1)`;列表包含 2 个他人 PII | 他人 Email/Phone/Remark 均为空字符串;其他字段(id / nickname / deptId / memberType)保持完整 | 安全/最小授权 | P0 | 列表是 PII 最大暴露面;必须与 TC-1166 共同守护 |
 
 ### 2.17 成员管理
 
@@ -611,6 +626,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1107 | POST /api/member/add | 非 ADMIN caller + **不存在的 productCode**:必须 403(不是 404)以消除 productCode 枚举 oracle | `MemberCtx("other_product")` + `ProductCode="does_not_exist"` | `CodeError.Code()==403`(不是 404 "产品不存在");DB 无 `sys_product_member` 新增 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysProductModel.FindOneByCode` |
 | TC-1108 | POST /api/member/add | 非 ADMIN caller + 非法 `MemberType`:返回 403 而不是 400(权限优先于字面校验) | `MemberCtx` + `MemberType="INVALID"` | `CodeError.Code()==403`(不是 400 "无效的成员类型") | 安全/枚举 | P0 | 防通过 400/404 差分探测产品/用户存在性 |
 | TC-1109 | POST /api/member/add | 超管 + 非法 `MemberType`:正常 400 | `SuperAdminCtx` + `MemberType="INVALID"`(产品存在) | `CodeError.Code()==400`,文案含 "无效的成员类型" | 正向回归 | P0 | 确认权限通过后仍走字面 400 检查,不误伤合法路径 |
+| TC-1162 | POST /api/member/remove | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | seed 2 个 ADMIN 绕过 last-admin,`{id: targetMemberId}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member` 行被删;post-commit 产品成员缓存失效 | 安全/会话吊销 | P0 | 镜像 updateMember 的 tokenVersion 契约,避免被踢出产品后旧 access token 仍能访问该产品 |
+| TC-1163 | POST /api/member/remove | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | 唯一启用 ADMIN,`{id: adminMemberId}` | 返回 400 "不能移除该产品的最后一个管理员";DB `sys_user.tokenVersion` 与初值严格相等;`sys_product_member` 行仍在 | 事务回滚 | P0 | tokenVersion 增量必须与 member 删除同事务;失败路径不得污染 tokenVersion 让合法会话被无故踢下线 |
 
 ---
 

+ 49 - 32
test-report.md

@@ -12,9 +12,9 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **942** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1178** |
-| ✅ 通过 | **1177** |
+| TC 用例总数 (test-design.md) | **959** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1195** |
+| ✅ 通过 | **1194** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
@@ -23,32 +23,32 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 0.759s |
-| internal/handler/auth | ✅ ok | 0.987s |
-| internal/handler/product | ✅ ok | 1.536s |
-| internal/handler/pub | ✅ ok | 2.134s |
-| internal/loaders | ✅ ok | 2.738s |
-| internal/logic/auth | ✅ ok | 11.225s |
-| internal/logic/dept | ✅ ok | 2.894s |
-| internal/logic/member | ✅ ok | 3.392s |
-| internal/logic/perm | ✅ ok | 3.719s |
-| internal/logic/product | ✅ ok | 12.261s |
-| internal/logic/pub | ✅ ok | 6.728s |
-| internal/logic/role | ✅ ok | 4.993s |
-| internal/logic/user | ✅ ok | 11.294s |
-| internal/middleware | ✅ ok | 5.923s |
-| internal/model/dept | ✅ ok | 6.073s |
-| internal/model/perm | ✅ ok | 6.701s |
-| internal/model/product | ✅ ok | 7.671s |
-| internal/model/productmember | ✅ ok | 7.503s |
-| internal/model/role | ✅ ok | 7.455s |
-| internal/model/roleperm | ✅ ok | 7.156s |
-| internal/model/user | ✅ ok | 15.030s |
-| internal/model/userperm | ✅ ok | 7.400s |
-| internal/model/userrole | ✅ ok | 6.229s |
-| internal/response | ✅ ok | 5.326s |
-| internal/server | ✅ ok | 5.692s |
-| internal/util | ✅ ok | 5.379s |
+| 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 |
 
 ### 1.2 跳过用例说明
 
@@ -344,6 +344,11 @@
 | TC-0847 | `CleanByUserIds` 空 ids 切片 | ✅ pass |
 | TC-0848 | `UpdateDept` 改 deptType 时调 CleanByUserIds | ✅ pass |
 | TC-0849 | `UpdateDept` 的 `FindIdsByDeptId` 失败 | ✅ pass |
+| TC-1174 | DeptType DEV→NORMAL 批量递增部门下所有成员 tokenVersion | ✅ pass |
+| TC-1175 | DEV 部门 Status Enabled→Disabled 批量递增 tokenVersion | ✅ pass |
+| TC-1176 | NORMAL 部门 Status Enabled→Disabled 批量递增 tokenVersion | ✅ pass |
+| TC-1177 | NORMAL→DEV(升权方向)tokenVersion 保持不变 | ✅ pass |
+| TC-1178 | Status Disabled→Enabled(恢复启用)tokenVersion 保持不变 | ✅ pass |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -468,7 +473,7 @@
 | TC-0175 | updateUser 乐观锁冲突 -> 409 | ✅ pass |
 | TC-0746 | DEVELOPER 把成员挪出自己子树 | ✅ pass |
 | TC-0747 | DEVELOPER 在自己子树内移动成员 | ✅ pass |
-| TC-0748 | 产品 ADMIN 不受子树限制 | ✅ pass |
+| TC-0748 | 产品 ADMIN 挪目标到子树外必须 403 | ✅ pass |
 | TC-0814 | DEVELOPER 将他人 deptId 置 0 | ✅ pass |
 | TC-0815 | MEMBER 将他人 deptId 置 0 | ✅ pass |
 | TC-0816 | 产品 ADMIN 将他人 deptId 置 0 应 403 "仅超级管理员可将用户移出部门" | ✅ pass |
@@ -478,7 +483,11 @@
 | TC-1101 | 负值 `*req.DeptId`(-1 / MinInt64)必须 400 且不得落盘 | ✅ pass |
 | TC-1124 | H-R14-1:ADMIN 调入 DEV 部门必须 403 | ✅ pass |
 | TC-1125 | H-R14-1:SuperAdmin 仍可调入 DEV 部门(正向回归) | ✅ pass |
-| TC-1126 | H-R14-1:ADMIN 跨子树调入非 DEV 部门仍应放行 | ✅ pass |
+| TC-1126 | 产品 ADMIN 在自己子树内挪目标(非 DEV)放行 | ✅ pass |
+| TC-1170 | 跨产品 P1 ADMIN 不得把共有 target 挪到 P1 子树之外 | ✅ pass |
+| TC-1171 | target 从 DEV+Enabled 部门 → NORMAL 部门:tokenVersion +1 | ✅ pass |
+| TC-1172 | target 从 DEV+Enabled 部门 → deptId=0:tokenVersion +1 | ✅ pass |
+| TC-1173 | NORMAL→NORMAL 不递增 tokenVersion(正向回归) | ✅ pass |
 
 ### 2.16 用户列表/详情/状态 及其他用户操作
 
@@ -550,6 +559,12 @@
 | TC-1105 | SetUserPerms DENY TOCTOU:事务内读到 ADMIN → 400 + 事务回滚(无 DENY 脏行) | ✅ pass |
 | TC-1106 | SetUserPerms 纯 ALLOW 必须短路、不调 FindOneForShareTx(S 锁开销不扩散) | ✅ pass |
 | TC-1127 | L-R14-2:BindRoles 跨产品 / 已禁用 / 不存在三路径文案必须统一为"包含无效的角色ID" | ✅ pass |
+| TC-1164 | 产品 ADMIN 看同产品他人 UserDetail:返回完整 Email/Phone/Remark | ✅ pass |
+| TC-1165 | 产品 DEVELOPER 看同产品他人 UserDetail:返回完整 Email/Phone/Remark | ✅ pass |
+| TC-1166 | 产品 MEMBER 看同产品他人 UserDetail:Email/Phone/Remark 必须为空字符串 | ✅ pass |
+| TC-1167 | 产品 ADMIN 列表视角:全部成员 PII 原值返回 | ✅ pass |
+| TC-1168 | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | ✅ pass |
+| TC-1169 | 产品 MEMBER 列表视角:其他成员 Email/Phone/Remark 必须为空字符串 | ✅ pass |
 
 ### 2.17 成员管理
 
@@ -613,6 +628,8 @@
 | TC-1135 | 降级最后一个 ADMIN 被拒时 tokenVersion 未被污染(事务 rollback 原子性) | ✅ pass |
 | TC-1136 | no-op 更新(与现值一致)不进入事务,tokenVersion 不变 | ✅ pass |
 | TC-1137 | 降级后 post-commit 失效 sysUser id/username 两把低层缓存 | ✅ pass |
+| TC-1162 | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | ✅ pass |
+| TC-1163 | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | ✅ pass |
 
 ---
 
@@ -1336,7 +1353,7 @@
 
 ## 三、测试结论
 
-- **942 个 TC 全部执行**:通过 **1177**(含 subtests),跳过 **1**,失败 **0**。
+- **959 个 TC 全部执行**:通过 **1194**(含 subtests),跳过 **1**,失败 **0**。
 - 26 个测试包全部 OK;整包连跑均绿,无并发 flake 触发。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全)均有独立回归用例覆盖且稳定通过。