소스 검색

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

BaiLuoYan 3 주 전
부모
커밋
4f3f1522f2

+ 194 - 148
audit-report.md

@@ -1,232 +1,278 @@
-# 深度审计报告 · Round 14
+# 深度审计报告 · Round 15
 
-> 基线:R13 审计后的代码库快照。R13 的 5 条 Low 风险项(L-R13-1 ~ L-R13-5)中,L-R13-1/2/3/4 已在对应 logic 文件中看到修复(见文末"R13 回归验证");L-R13-5 仅做到"大部分调用点切换成 `DetachCacheCleanCtx`",仍遗留两处关键路径未改造,本轮升级归入 M-R14-1
+> 基线: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` 的跨产品副作用再审"与"降权路径是否吊销会话"等此前轮次未覆盖的面
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-R14-1 · 授权漏洞 / 跨产品权限升级 —— 产品 ADMIN 可借 `UpdateUser` 把他人调入 DEV 部门,间接赋予跨产品全权
+本轮未新增 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 放行
+
+**位置**
+
+- `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/user/updateUserLogic.go` 在处理 `req.DeptId != nil && *req.DeptId > 0` 分支时,对「新部门是否是 DEV 类型」没有任何特殊校验;且第 137-141 行对调用方的部门前缀校验通过 `caller.MemberType != consts.MemberTypeAdmin` **直接短路**:
+审计 H-2 / M-R10-3 / GuardRoleLevelAssignable 已经为 **caller.MinPermsLevel** 建立了"授权决策点强制走 `loadFreshMinPermsLevel` 读 DB"的 TOCTOU 闭环,缩短了 UD 缓存 TTL 窗口。但同一份 UD 里另一个同等重要的授权字段 **MemberType** 仍然只走缓存
 
-```137:141:internal/logic/user/updateUserLogic.go
-if !caller.IsSuperAdmin &&
-    caller.MemberType != consts.MemberTypeAdmin &&
-    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
-    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
+```265:272:internal/logic/auth/access.go
+func HasFullProductPerms(caller *loaders.UserDetails) bool {
+    if caller == nil {
+        return false
+    }
+    return caller.IsSuperAdmin ||
+        caller.MemberType == consts.MemberTypeAdmin ||
+        caller.MemberType == consts.MemberTypeDeveloper
 }
 ```
 
-配合 `internal/loaders/userDetailsLoader.go` 第 540-554 行的全权分支:
-
-```540:554:internal/loaders/userDetailsLoader.go
-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)
-    ...
-    ud.Perms = codes
-    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 语义。
 
-只要 `sys_user.deptId` 指向 `DeptType=DEV` 的部门,该用户在**任何他已加入的产品**下都自动拿到该产品的全量权限。而 `sys_user.deptId` 是全局字段——`UpdateUser` 在 caller 作用域内用 `CheckManageAccess(productCode=caller.ProductCode, ...)` 判权,却会把修改向**所有其他产品**级联。
+同时 `UpdateMember` 本身**不递增 sys_user.tokenVersion**(对比 `UpdateUserStatus` 会自动 `tokenVersion = tokenVersion + 1`、`UpdatePassword` / `Logout` 同),降权不触发强制重登录。因此攻击窗口是"缓存读"而非"token 读":
 
-**攻击链(复现路径)**
+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. 攻击者 A 是产品 P1 的 ADMIN(正常业务赋予的权限),同伙 B 是产品 P1 的 MEMBER;B 同时也是产品 P2 的 MEMBER。
-2. A 调 `UpdateUser(id=B.Id, deptId=<DEV 部门 id>)`:
-   - `CheckManageAccess` 仅作用在 P1,通过(B 在 P1 的 memberType=MEMBER,ADMIN 绕过 `checkDeptHierarchy`,`checkPermLevel` 也因 callerPri<targetPri 直接放行);
-   - 第 137-141 行对 ADMIN 短路,允许把 B 调入 P1 作用域之外的 DEV 部门;
-   - 第 146-148 行 "仅超管 / ADMIN 可移出部门" 也是按 ADMIN 放行。
-3. 事务提交后 `UserDetailsLoader.Clean(B.Id)` 刷新 UD,B 在产品 P2 下的下一次 `Load` 命中 DEV 全权分支 → `ud.Perms = FindAllCodesByProductCode(P2)`,B 从 P2 的普通成员瞬间升级为**P2 全权**。
-4. 如需抹痕,A 再把 B 移出 DEV 即可。该窗口期内 B 可调用 P2 的任何接口(含敏感读写)。
+对比之下,R13 在 H-2 的注释里已经准确钉出"UD 缓存 5 分钟 TTL 内旧 MinPermsLevel 可被利用";但同批同类风险的 MemberType 缺少对称防御,形成审计覆盖不对称。
 
 **影响**
 
-- 跨产品权限升级:P1 的 ADMIN 能为 P2 的任意共有成员授予 P2 的全量权限——这是典型的**信任边界穿透**
-- 对于多产品共享用户池(同一 `sys_user` 被多个产品加为成员)的部署,这个路径把"ADMIN 只应在自己产品内全权"的不变量彻底打破
-- ADMIN 还可以顺带把其他产品的普通员工(B 可能是 P2 的 MEMBER 但在 P1 只是挂名)调入 DEV 后再移回原部门,对 P2 而言几乎不可审计(`sys_user.deptId` 的变更在 P2 日志里看不到是谁发起的)
+- 降权后的 product ADMIN 在 Redis 抖动窗口内保留 ADMIN 权能:可继续创建角色 / 绑权限、管理他人(`checkDeptHierarchy` ADMIN 绕过)、修改产品内成员(`RequireProductAdminFor` 通过)。
+- 降 ADMIN → MEMBER 的典型触发场景就是"怀疑滥权 / 内鬼排查"——恰恰是最不希望有 5 分钟残余窗口的操作。
+- DEVELOPER → MEMBER 类似,但规模小,影响面小一档。
 
-**修复方案**
+此风险本身是"缓存读一致性"架构层决定,不是单点 bug。闭环方案有两条,任选其一或组合:
 
-在 `updateUserLogic.go` 的 `*req.DeptId > 0` 分支里加入对「目标新部门是 DEV」的显式护栏,**仅允许超级管理员**把用户调入 DEV;同理对 `deptId=0`(移出部门)保留现状即可,但把 "移入 DEV" 与 "跨越产品范围" 这条路径堵死
+**修复方案(方案 A,最小代价,推荐)**:让 `UpdateMember` 在降级路径(`{ADMIN, DEVELOPER} → MEMBER` 或 `Enabled → Disabled`)里显式递增目标用户的 `tokenVersion`,与 `UpdateUserStatus` 口径对齐
 
 ```go
-if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin {
-    // DEV 部门承载"加入即全权"的跨产品语义,任何产品 ADMIN / DEVELOPER / MEMBER
-    // 发起的调入都可能越过其 productCode 作用域,污染其它产品的 loadPerms 全权分支。
-    // 与之对称,CreateUser 对非超管 + DEV 目标部门已经被 DeptPath 前缀校验天然约束
-    // (DEV 部门路径通常不在 ADMIN 的 caller.DeptPath 子树里),这里补齐 UpdateUser
-    // 被 ADMIN 短路掉的缺口。
-    return response.ErrForbidden("仅超级管理员可将用户调入研发部门")
+// 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 缓存(已存在)。
+    if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil {
+        return err
+    }
 }
 ```
 
-同时建议在 `CreateUser` 中显式镜像一份同样的 `newDept.DeptType == DEV` 护栏,避免未来维护者误将 ADMIN 纳入可跨入 DEV 的行列。
+这样不论 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
+// 三处同步切换。
+```
 
-验收测试(对应 `internal/logic/user/updateUserLogic_test.go`)应补:
+方案 A 与 B 的本质差异:A 让攻击窗口为 0(token 被废),B 让窗口为"单次 DB 读" ~5ms。优先推荐 A——成本更低、语义更清晰,与现有强制下线 (`UpdateUserStatus`, `ChangePassword`, `Logout`) 的口径一致。
 
-1. caller=ADMIN of P1,target=MEMBER of P1(且是 P2 的 MEMBER),`req.DeptId=DEV 部门 id` → 返回 `ErrForbidden("仅超级管理员可将用户调入研发部门")`;`sys_user.deptId` 不变;`UserDetailsLoader.Clean` 不被触发。
-2. caller=SuperAdmin 同请求 → 正常放行,提交后 `UserDetailsLoader.Clean` 被调用一次。
-3. caller=ADMIN of P1,`req.DeptId=<普通部门且在 caller.DeptPath 子树>` → 正常放行(与现有行为一致)。
+**回归测试建议**
+
+- 模拟 ADMIN→MEMBER 降级后 **Redis DEL 失败**,验证下一次 API(BindRoles / UpdateRole)能通过 `tokenVersion mismatch` 拒绝,或(方案 B 下)通过 `RequireProductAdminFor` 的 DB 复核拒绝;
+- 事务回滚场景验证 tokenVersion **未**被递增(方案 A),避免"降级操作失败但用户被踢下线"的错误扩大面。
 
 ---
 
-## ⚠️ 健壮性与性能建议 (Medium / Low)
+### L-R15-1 · 跨产品结构性破坏 —— 产品 ADMIN 可借 `UpdateUser req.DeptId=0` 把共有用户调出部门树
 
-### M-R14-1 · 资源管理 / 可观测性 —— `RotateRefreshToken` 与 `SyncPermsService` 两处 post-commit 缓存失效未接入 `DetachCacheCleanCtx`
+**位置** `internal/logic/user/updateUserLogic.go:158-165`
 
-**位置**
+**描述**
 
-- `internal/logic/auth/rotateRefreshToken.go:82`
-  ```go
-  svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
-  ```
-- `internal/logic/pub/syncPermsService.go:171`
-  ```go
-  svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
-  ```
+`H-R14-1` 已经把"调入 DEV 部门"这条真正能做跨产品**权限升级**的路径收敛给 SuperAdmin。对称地分析 `req.DeptId == 0`(把用户调出部门树)分支,现状:
 
-R13 提出的 Solution A 是"事务已提交的缓存清理必须与请求 ctx 解耦,用 `loaders.DetachCacheCleanCtx` 拿一个 parent-cancel-不穿透 + 3s 硬超时的 ctx"。审计当下,`changePasswordLogic / logoutLogic / updateDeptLogic / addMember / removeMember / updateMember / updateProduct / bindRolePerms / deleteRole / updateRole / bindRoles / setUserPerms / updateUser / updateUserStatus` 均已落地,**唯独这两处仍用原始 `ctx`**。两条路径都是高敏感后果:
+```158:165:internal/logic/user/updateUserLogic.go
+} else {
+    // deptId=0 意味着"把用户移出部门树";...
+    if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
+        return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
+    }
+}
+```
 
-- `RotateRefreshToken`:CAS 提交新 `tokenVersion` 已落库,此时 client 断连 / HTTP ctx 被 deadline 触发 → `UserDetailsLoader.Clean` 被立刻 canceled → Redis 里 UD 仍缓存旧 `tokenVersion`。最多 5 分钟 TTL 内:
-  - 旧 access token(tokenVersion=N)仍能通过中间件(因为 UD 缓存里就是 N);
-  - 客户端拿到新 refresh 失败时自然会重试 refresh,但若 TTL 没过就会命中 `IncrementTokenVersionIfMatch(expected=N)` 失败(DB 已是 N+1),被强制重登录,形成"无故踢出"的 UX 抖动。
-  - 更坏:如果攻击者的目标是**让用户的老 access token 继续生效**,TTL 5 分钟的窗口是确定可利用的。
-- `SyncPermsService`:`CleanByProduct` 失败后,被禁用 / 删除的 perm 仍在 5min TTL 内出现在所有该产品成员的 UD 缓存里——被本产品服务端用 `VerifyToken / GetUserPerms` 查询到的 perms 列表里,`checkStillValid` 逻辑会把失效 perm 从结果里剔除,但 `UserDetails.Perms` 缓存本身不会因 miss 而重建,最多延迟至 TTL。即使下游 caller 做了 `checkStillValid`,也依赖"下游每次查询都保留 db round-trip"的前提;若 perms-system 的消费方只看 `GetUserPerms` 返回,仍会命中窗口。
+产品 ADMIN 于是被放行。`sys_user.deptId` 是**全局字段**——产品 P1 的 ADMIN 对一位同时是 P2 成员的用户 B 调用 `UpdateUser(deptId=0)`:
 
-**修复方案**
+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 全权分支。
 
-两处统一改造为 detach ctx:
+**影响**
 
-```go
-cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
-defer cancel()
-svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)
-```
+- 组织结构可用性攻击:P1 ADMIN 可以制造 P2 的"隐形成员",P2 的日常管理层级(MEMBER/DEVELOPER/产品内子 ADMIN)全部够不到 B;B 仍能正常使用 P2,但 P2 运营侧排障困难;
+- 与 H-R14-1 的攻击面叠加:攻击者可以先调入 DEV 部门拿全权(已被 H-R14-1 修掉),**现在也无法**叠加这条——但"调出部门树"作为次等破坏面仍存在。
+- 可审计性差:`sys_user.deptId` 变更在 P2 的日志链路里看不到变更发起方——整条 `UpdateUser` 调用落在 P1 的审计上下文内。
+
+**修复方案**
 
-
+与 H-R14-1 对称收敛给 SuperAdmin
 
 ```go
-cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
-defer cancel()
-svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)
+// internal/logic/user/updateUserLogic.go
+} else {
+    // 审计 L-R15-1:与 H-R14-1 对称——sys_user.deptId 是全局字段,改为 0 会把用户从
+    // **所有**产品的部门结构里移除,跨产品影响。产品 ADMIN 的授权范围仅限自己产品,
+    // 不应执行改变全局字段的破坏性操作。移出部门树由 SuperAdmin 执行(例如离职流程)。
+    if !caller.IsSuperAdmin {
+        return response.ErrForbidden("仅超级管理员可将用户移出部门")
+    }
+}
 ```
 
-`rotateRefreshToken.go` 是 helper,被 `internal/logic/pub/refreshTokenLogic.go`、`internal/server/permserver.go` 的 `RefreshToken` 同时调用,改 helper 一处即可覆盖 HTTP / gRPC 两个入口,避免外部调用方再各自 detach 的重复代码。
+如业务上确实需要保留"产品 ADMIN 可把**非跨产品**成员移出部门"的能力,可以加一个"目标是否同时在其他产品"的判断,但复杂度明显提升,不如直接收敛给 SuperAdmin;离职/转岗属于行政 HR 流程,SuperAdmin 执行更合理
 
-回归测试建议:
+**回归测试建议**
 
-- 模拟 `IncrementTokenVersionIfMatch` 成功后把 `ctx` 显式 cancel,验证 `Clean` 仍能在 detach ctx 下走到 Redis DEL(或在 redis mock 上观察 DEL 触发)。
-- 模拟 `BatchWriteProductPerms` 事务成功后 cancel ctx,验证 `CleanByProduct` 仍走到 cache invalidation。
+- 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` → 根据业务决策(本轮建议改为一致拒绝)。
 
 ---
 
-### L-R14-1 · 信息泄露 / 枚举信号 —— Role 维度的 `FindOne → RequireProductAdminFor` 顺序泄漏跨产品 roleId 存在性
+### L-R15-2 · 跨产品信息泄露 —— `DeptTree` 对任何产品的 ADMIN 暴露完整组织架构
 
-**位置**
+**位置** `internal/logic/dept/deptTreeLogic.go:45`
 
-- `internal/logic/role/updateRoleLogic.go`(先 `SysRoleModel.FindOne(req.Id)` → 再 `RequireProductAdminFor(role.ProductCode)`)
-- `internal/logic/role/deleteRoleLogic.go`(同上)
-- `internal/logic/role/bindRolePermsLogic.go`(同上)
+**描述**
+
+```45:57:internal/logic/dept/deptTreeLogic.go
+fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
+
+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
+}
+```
+
+`sys_dept` 是**全局**命名空间(一份组织架构服务于所有产品);任何一个产品的 product ADMIN 都能走 `fullAccess=true` 分支拿到**全公司所有部门**——包括其他产品的敏感研发子树、跨 BU 的 HR/财务部门、未归属到任何产品的战略部门等。
 
-任何已登录用户(即使只是某产品的普通 MEMBER)都能构造上述三个接口,用顺序 id 扫 `req.RoleId=1..N`:
+这与 `DeptTreeLogic` 注释里的"超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath 为根的子树"一致,但在多产品共享组织架构的部署下,对"小产品的 ADMIN 看到大产品的 DEV 子树名称 + 层级"这一点暴露并没有防御。配合 M-2(MEMBER 级不能看全产品列表以防 admin_<code> 撞库)的既有意图——审计链路里"MEMBER 拿不到敏感组织信息"是刻意守住的,但对"任何 product ADMIN 可拿到全量 sys_dept"网开一面,属于覆盖不对称。
 
-- 若 `FindOne` 返回 `ErrNotFound` → `响应 404 "角色不存在"`;
-- 若角色存在且属于其他产品 → `响应 403 "仅超级管理员或该产品的管理员可执行此操作"`。
+**影响**
 
-两个响应码不一致,直接把"该 roleId 是否存在以及属于别的产品"漏给了认证用户,和 R13 的 L-R13-1(`addMember / setUserPerms / bindRoles` 的枚举信号)同族。对比已经收敛过的 `roleDetailLogic.go`(M-N3:把"不存在"和"他产品角色"统一回 404),这三个接口属于遗漏。
+- 信息泄露级别,非权限升级:拿到部门结构后可用于社工 / 针对性撞库(例如针对 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"的前置侦察接口。
 
 **修复方案**
 
-在 `FindOne` 之后、`RequireProductAdminFor` 之前,对非超管调用方先做"产品归属比对",把跨产品情况降级成与"不存在"完全一致的 404:
+根据业务对"产品 ADMIN 是否需要全局部门视图"的真实需求,二选一
 
-```go
-role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
-if err != nil {
-    return response.ErrNotFound("角色不存在")
-}
-caller := middleware.GetUserDetails(l.ctx)
-if caller == nil {
-    return response.ErrUnauthorized("未登录")
-}
-// 与 RoleDetailLogic 的 M-N3 收敛口径一致:非超管看到"非本产品角色"一律返回 404,
-// 杜绝通过 403/404 文案差异枚举跨产品 roleId。
-if !caller.IsSuperAdmin && role.ProductCode != middleware.GetProductCode(l.ctx) {
-    return response.ErrNotFound("角色不存在")
-}
-if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {
-    return err
-}
-```
+1. **收敛给 SuperAdmin**:`fullAccess := caller.IsSuperAdmin`。产品 ADMIN 仅看自己 DeptPath 子树(与 DEVELOPER/MEMBER 对齐)。`AddMember` 若需要从其他部门拉人,由 SuperAdmin 批准或另开 `ListAddableDepts` 等独立接口。
+2. **保留产品 ADMIN 全量视图,但脱敏其他产品的 DEV 子树**:在 `fullAccess=true` 分支里,若 caller 不是 SuperAdmin,额外跳过 `DeptType == DEV` 的部门。保留"正常部门"的全公司视图以支持 AddMember 跨部门拉人,但隐藏 DEV 子树名称避免针对性侦察。
 
-三个文件统一同一模板,推荐抽成 `authHelper.ResolveOwnRoleOr404(ctx, svcCtx, roleId)` 以避免日后漂移
+从"最小授权"出发推荐方案 1;保留现状的话建议在审计报告里显式钉住"产品 ADMIN 可见全组织架构"这条信任边界,便于未来评审时不被新人误改。
 
 ---
 
-### L-R14-2 · 信息泄露 —— `BindRoles` 对入参 roleIds 的错误文案区分"不存在 / 跨产品"
+### L-R15-3 · 降权 / 禁用不强制吊销会话 —— `UpdateMember` 与 `UpdateProduct`(禁用)均不递增 tokenVersion
+
+**位置**
+
+- `internal/logic/member/updateMemberLogic.go:77-107`(整个事务体不触及 `sys_user.tokenVersion`)
+- `internal/logic/product/updateProductLogic.go:46-73`(产品禁用同样不递增其成员 tokenVersion)
+
+**描述**
+
+与 `UpdateUserStatus`(会自动走 `sysUserModel.UpdateStatus` 内的 `tokenVersion = tokenVersion + 1` + 乐观锁)、`ChangePassword`(`UpdatePassword` 内 `tokenVersion = tokenVersion + 1`)、`Logout`(`IncrementTokenVersion`)形成鲜明对比:
+
+| 变更 | 旧 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 缓存(可接受:角色细粒度) |
+
+后三条依赖 `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;任意一步失败就留下"产品禁用但成员仍能查 / 用"的窗口。
 
-**位置** `internal/logic/user/bindRolesLogic.go`
+**影响**
 
-当 `FindByIds(req.RoleIds)` 返回的角色里有跨产品项,返回 `"不能绑定其他产品的角色"`;缺项时返回 `"包含无效的角色ID"`。已通过 `CheckManageAccess` 的调用方即便只是某产品 MEMBER,也可以借此枚举他人产品的 roleId 分布(比 L-R14-1 门槛更低,因为他只需管得了本产品某个下属即可)。
+与 M-R15-1 同一个攻击面,不同触发点:在 Redis 不可用期间,"降级 / 禁用 / 产品下线" 三类敏感变更都依赖缓存失效做唯一拦截。本项与 M-R15-1 的关系是"M-R15-1 是其中最危险的一种降权路径,L-R15-3 是全集"
 
 **修复方案**
 
-将"跨产品"与"不存在"折叠成同一个 `ErrBadRequest("包含无效的角色ID")` 文案(日志里保留细分标记供审计分析):
+按 M-R15-1 方案 A 的思路统一让这三个写路径在降权/禁用分支里递增目标 `tokenVersion`
 
-```go
-valid := 0
-for _, r := range roles {
-    if r.ProductCode != productCode || r.Status != consts.StatusEnabled {
-        continue
-    }
-    valid++
-}
-if valid != len(req.RoleIds) {
-    logx.WithContext(l.ctx).Infow("bind roles: invalid ids",
-        logx.Field("audit", "bind_roles_invalid_ids"),
-        logx.Field("requested", req.RoleIds),
-        logx.Field("productCode", productCode),
-    )
-    return response.ErrBadRequest("包含无效的角色ID")
-}
-```
+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` 失效链路。
 
-不要把产品归属揭露给响应体,只保留到日志里给运营同学排障。
+测试要点:
+
+- Redis 完全不可用场景下,验证降级/禁用用户被上游中间件拒绝;
+- 批量递增 tokenVersion 的 SQL 使用占位符化的 IN(...),避免未来 N 过大时栈溢出或单 SQL 行数超限。
 
 ---
 
-### L-R14-3 · 可维护性 —— `UpdateUserLogic` 的 ADMIN 分支注释缺少对 DEV 部门语义的声明
+### L-R15-4 · 可读性 —— 降权路径语义需要在注释里显式声明
 
-即便 H-R14-1 选择保留现有 ADMIN 可调部门的语义(决定不改代码),line 131-141 的注释也仅讨论了"caller.DeptPath 为空"的边界,没有钉出**"此分支允许 ADMIN 把用户调入 DEV 部门 = 跨产品全权赋予"**这一事实。建议至少在注释层面明确写一段
+随着 M-R15-1 / L-R15-3 如果按方案 A 采纳"降权即递增 tokenVersion",需要在以下文件顶部注释钉住语义:
 
-```text
-注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何部门,
-包括 DeptType=DEV 的部门。DEV 部门在 loadPerms 中等价于"加入任一产品即全权",
-这条路径相当于把 ADMIN 本地产品外的权限一并下发到目标的其他产品身份上;若产品
-间信任不对称,请额外增加 `newDept.DeptType == DEV → RequireSuperAdmin` 的护栏
-(见审计 H-R14-1)。
-```
+- `internal/logic/member/updateMemberLogic.go` 顶部:列明"任何从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移、或从 Enabled 向 Disabled 的迁移都会强制对方重登录(tokenVersion+1)",方便未来维护者区分"刷缓存 + 重登录"的双重防御意图;
+- `internal/logic/product/updateProductLogic.go`:同样声明产品禁用会批量递增成员 tokenVersion。
 
-这样后续维护者能在修改前看到明确的风险披露,避免把 H-R14-1 的推论再次拆掉
+这条本身不构成代码修改,仅提醒"代码修完后记得同步更新审计注释",与 L-R14-3 同类性质。
 
 ---
 
-## R13 回归验证(附录)
+## R14 回归验证(附录)
 
 | 条目 | 期望修复 | 代码现状 | 判定 |
 | --- | --- | --- | --- |
-| L-R13-1 枚举信号 | `addMember / setUserPerms / bindRoles` 在 `RequireProductAdminFor` / 早期 caller 校验前不泄漏对象存在性 | `addMemberLogic.go:41` 把 `RequireProductAdminFor` 置于 User / Product `FindOne` 之前;`setUserPermsLogic.go:45` 同;`bindRolesLogic.go:34-49` 新增 `caller.MemberType == ""` 快速 403 | ✅ 已收敛(roleId 维度新出现 L-R14-1/2) |
-| L-R13-2 `SetUserPerms` TOCTOU | ADMIN/DEVELOPER → DENY 的拦截必须在事务里并锁 `sys_product_member` | `setUserPermsLogic.go` 事务内 `FindOneForShareTx(targetUserId, productCode)` + `memberType` 再检,`sysProductMemberModel.go` 新增 `FindOneForShareTx` | ✅ 已闭合 |
-| L-R13-3 冗余条件 | 删除 `UpdateUserLogic` 里 `caller.DeptPath != ""` | `updateUserLogic.go:131-136` 已删除并附注释 | ✅ 已收敛 |
-| L-R13-4 负数 `deptId` | `CreateUser / UpdateUser` 显式 `< 0` 返回 400 | `createUserLogic.go`、`updateUserLogic.go:117-119` 均有 `*req.DeptId < 0 → BadRequest` | ✅ 已收敛 |
-| L-R13-5 post-commit 缓存失效 | 方案 A `DetachCacheCleanCtx` + 方案 B 审计日志标签 | 主流程已切换(见文件列表);`loaders/cacheCleanCtx.go` 提供 API 与 `logCacheInvalidationErr`;**遗漏 `rotateRefreshToken.go` / `syncPermsService.go` 两处**,本轮按 M-R14-1 升级跟进 | 🟡 部分收敛 |
+| 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 的代码兜底已经让注释描述的路径被代码直接拦截,注释与代码互相印证 | ✅ 已闭合 |
 
 ---
 
-本轮新增发现一条 High(H-R14-1)、一条 Medium(M-R14-1)、三条 Low(L-R14-1 / L-R14-2 / L-R14-3)。建议优先处置 H-R14-1(跨产品升级,实际可被产品 ADMIN 触发)和 M-R14-1(两处遗留的缓存失效路径,修复代价极小)
+本轮新增发现 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` 禁用批量递增可分作下一轮单独跟进

+ 10 - 5
internal/logic/dept/deptTreeLogic.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"strings"
 
-	"perms-system-server/internal/consts"
 	"perms-system-server/internal/middleware"
 	deptModel "perms-system-server/internal/model/dept"
 	"perms-system-server/internal/response"
@@ -28,9 +27,15 @@ func NewDeptTreeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeptTree
 	}
 }
 
-// DeptTree 部门树。超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath
-// 为根的子树,避免 MEMBER 级账号枚举全公司组织结构、定位 DEV 部门用于针对性特权获取
-// (见审计 M-2)。未归属部门或 DeptPath 为空的成员返回空树。
+// DeptTree 部门树。仅超管返回完整组织架构树;其他成员(含产品 ADMIN / DEVELOPER / MEMBER)
+// 一律只返回以其 DeptPath 为根的子树,未归属部门或 DeptPath 为空者返回空树。
+//
+// 审计 L-R15-2:原先产品 ADMIN 走 fullAccess=true 分支拿全量 sys_dept,但 sys_dept 是
+// **全局**命名空间(一份组织架构服务于所有产品)——小产品的 ADMIN 能看到大产品的 DEV
+// 子树 / 跨 BU 的 HR/财务部门命名。即便不构成权限升级,也是针对性撞库 / 社工的前置侦察
+// 输入,与 M-2 "MEMBER 不能看全产品列表防止 admin_<code> 撞库" 的最小授权精神冲突。
+// 因此将 fullAccess 收敛给 SuperAdmin;产品 ADMIN 若需要跨部门拉人(AddMember 场景),
+// 走独立的 ListAddableDepts 或由 SuperAdmin 审批流程,不借 DeptTree 顺便拿到全组织视图。
 func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 	caller := middleware.GetUserDetails(l.ctx)
 	if caller == nil {
@@ -42,7 +47,7 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 		return nil, err
 	}
 
-	fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
+	fullAccess := caller.IsSuperAdmin
 
 	if !fullAccess {
 		if caller.DeptPath == "" {

+ 82 - 11
internal/logic/dept/deptTreeLogic_test.go

@@ -16,15 +16,17 @@ import (
 )
 
 // ---------------------------------------------------------------------------
-// 覆盖目标:非超管/非 ADMIN 调 /api/dept/tree 时必须按
-// caller.DeptPath 前缀过滤部门,只返回以其为根的子树。避免:
+// 覆盖目标:只有 SuperAdmin 才能拿到完整部门树;产品 ADMIN / DEVELOPER / MEMBER 一律
+// 按 caller.DeptPath 前缀过滤,仅返回以其为根的子树。避免:
 // * MEMBER 级账号枚举全公司组织结构;
-// * 定位 DEV 部门再针对性申请权限。
-//
-// ADMIN / SuperAdmin 保留完整树(运营使用场景)。
+// * 定位 DEV 部门再针对性申请权限;
+// * 审计 L-R15-2:小产品 ADMIN 借 fullAccess 侦察大产品的 DEV/HR/财务部门命名,
+//   为针对性社工 / 撞库提供前置输入——sys_dept 是全局命名空间,ADMIN 在产品 P1
+//   的授权范围不应扩散到 P2 的组织结构视图。
 //
 // 测试数据:一棵 "/100/" 根下挂 "/100/1/"、"/100/1/5/",以及一个平行分支 "/200/"。
-// 期望:caller DeptPath="/100/1/" 只能看到 "/100/1/" 和 "/100/1/5/"。
+// 期望:caller DeptPath="/100/1/" 只能看到 "/100/1/" 和 "/100/1/5/";
+//       SuperAdmin 看到完整的 `/100/` + `/200/` 两个根。
 // ---------------------------------------------------------------------------
 
 var allDepts = []*deptModel.SysDept{
@@ -85,8 +87,13 @@ func TestDeptTree_OrphanMember_ReturnsEmpty(t *testing.T) {
 	assert.Len(t, tree, 0, "DeptPath 为空必须返回空树,不能泄露组织结构")
 }
 
-// TC-0857: 产品 ADMIN —— 视为 fullAccess,返回完整树(两个根)。
-func TestDeptTree_Admin_FullTree(t *testing.T) {
+// TC-0857(L-R15-2 后契约反转):产品 ADMIN 不再拥有 fullAccess——只能看到以 DeptPath
+// 为根的子树,与 MEMBER / DEVELOPER 同路径。
+//
+// 关键断言不只是"根只剩 1 个",还要显式证明 "平行分支 /200/ 对 ADMIN 不可见",
+// 以免未来如果有人把条件从 `caller.IsSuperAdmin` 又放宽回 `|| MemberType == Admin`,
+// 本测试能在第一次 run 立刻飘红。
+func TestDeptTree_Admin_PrunedToSubtree(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
 
@@ -97,16 +104,80 @@ func TestDeptTree_Admin_FullTree(t *testing.T) {
 
 	caller := &loaders.UserDetails{
 		UserId: 2, IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
+	}
+	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
+	require.NoError(t, err)
+
+	require.Len(t, tree, 1,
+		"L-R15-2:产品 ADMIN 不再享有 fullAccess,剪枝后局部根只有 1 个(caller 自己的 DeptPath)")
+	assert.Equal(t, int64(1), tree[0].Id,
+		"局部根必须是 /100/1/,父部门 /100/ 不应暴露给 ADMIN")
+	assert.Equal(t, "/100/1/", tree[0].Path)
+	require.Len(t, tree[0].Children, 1, "grandchild /100/1/5/ 必须挂在局部根下")
+	assert.Equal(t, int64(5), tree[0].Children[0].Id)
+
+	for _, r := range tree {
+		assert.NotEqual(t, int64(200), r.Id,
+			"平行分支 /200/ 对产品 ADMIN 必须不可见——若回归到旧 fullAccess,这里立刻飘红")
+		assert.NotEqual(t, int64(100), r.Id,
+			"父部门 /100/ 也不应暴露给 ADMIN(同上)")
+	}
+}
+
+// TC-1128:SuperAdmin 仍然享有 fullAccess,完整树返回两个根(/100/ + /200/)。
+// 这一条是 L-R15-2 收敛范围的正向回归:确认 `fullAccess = caller.IsSuperAdmin`
+// 的 true 分支未被连带削弱(否则超管运营视角会瘫痪)。
+func TestDeptTree_SuperAdmin_FullTree(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	// 即便 DeptPath 指定成某具体子部门,SuperAdmin 也必须拿到全局树——
+	// fullAccess 判定是 IsSuperAdmin 而不是 DeptPath。
+	caller := &loaders.UserDetails{
+		UserId: 1, IsSuperAdmin: true, MemberType: consts.MemberTypeAdmin,
 		DeptId: 100, DeptPath: "/100/", ProductCode: "pA",
 	}
 	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
 	require.NoError(t, err)
 
-	// 完整树:根有 2 个(id=100, id=200)。
-	require.Len(t, tree, 2, "ADMIN 应看到完整部门树,包括兄弟分支")
+	require.Len(t, tree, 2, "SuperAdmin 必须看到完整树,包含所有平行根")
 	var rootIds []int64
 	for _, r := range tree {
 		rootIds = append(rootIds, r.Id)
 	}
-	assert.ElementsMatch(t, []int64{100, 200}, rootIds)
+	assert.ElementsMatch(t, []int64{100, 200}, rootIds,
+		"SuperAdmin 走 fullAccess 分支,平行根 /100/ 与 /200/ 必须同时出现")
+}
+
+// TC-1129:产品 DEVELOPER 与 MEMBER 同路径——只能看自己 DeptPath 的子树。
+// 这一条补齐 L-R15-2 的"角色对称"覆盖:fullAccess 判定只认 SuperAdmin,
+// ADMIN / DEVELOPER / MEMBER 三者剪枝语义必须完全一致。
+func TestDeptTree_Developer_PrunedToSubtree(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := &loaders.UserDetails{
+		UserId: 3, IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper,
+		DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
+	}
+	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
+	require.NoError(t, err)
+
+	require.Len(t, tree, 1, "DEVELOPER 同样不享有 fullAccess,只能看到自己子树")
+	assert.Equal(t, int64(1), tree[0].Id, "局部根必须是 /100/1/")
+	for _, r := range tree {
+		assert.NotEqual(t, int64(200), r.Id,
+			"平行分支 /200/ 对 DEVELOPER 同样不可见")
+	}
 }

+ 54 - 0
internal/logic/member/updateMemberLogic.go

@@ -31,6 +31,16 @@ func NewUpdateMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upda
 
 // UpdateMember 更新产品成员。修改成员类型或启用/禁用状态。降级最后一个 ADMIN 时会被拒绝以保证产品始终有管理员。
 // 审计 L-R11-1:memberType / status 均为指针可选,nil 表示不改该字段;两者都为 nil 时直接 400。
+//
+// 审计 M-R15-1 / L-R15-3(降权强制重登录):
+// 任何"从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移"或"从 Enabled 向 Disabled 的迁移",在事务内
+// 除了更新 sys_product_member 之外还会对目标的 sys_user.tokenVersion 做一次 +1。这样哪怕
+// 事务提交后的 UserDetailsLoader.Del 因为 Redis 抖动失败,旧 access token 在下一次
+// middleware 校验时也会因 `claims.TokenVersion != ud.TokenVersion` 被 401 踢出;下一次
+// Login / RefreshToken 会重新签发含新 memberType 的 token,并把新 UD 写回 Redis。
+// 与 UpdateUserStatus / ChangePassword / Logout 的"强制会话失效"口径对齐——差别仅在于
+// UpdateUserStatus 对全部状态变更都递增,UpdateMember 只在"权限收窄"的降权路径递增,避免
+// MEMBER→ADMIN 这种"升权"场景把用户误踢(升权不构成对被管理方的实际损害)。
 func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	if req.MemberType == nil && req.Status == nil {
 		return response.ErrBadRequest("请至少提供一个要更新的字段(memberType 或 status)")
@@ -74,6 +84,15 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		return nil
 	}
 
+	// 审计 M-R15-1 / L-R15-3:判定是否构成"降权"——
+	//   - wasPrivileged:locked.MemberType ∈ {ADMIN, DEVELOPER} 且 locked.Status=Enabled,或 locked.Status=Enabled
+	//     (启用)两侧;为了与 M-R15-1 的描述对齐,"降权" = (先前享有 ADMIN/DEVELOPER 特权 → 现在没有) ∪
+	//     (先前启用 → 现在禁用)。
+	//   - 升权路径(MEMBER→ADMIN / Disabled→Enabled)明确**不**吊销 session:目标用户的既有会话
+	//     并未因此拿到更高权限(memberType 在 UD 缓存里仍是旧值,必须等 Del 生效或 TTL 过期后
+	//     重新 Load 才能真正"用上" ADMIN),强制重登录反而会给 caller 误操作一个"踢下线"的副作用。
+	shouldRevokeSession := false
+	tokenVersionTarget := &tokenVersionRevocation{}
 	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 {
@@ -91,9 +110,24 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 				return response.ErrBadRequest("不能降级或禁用该产品的最后一个管理员")
 			}
 		}
+		// 权限收窄的两个维度:MemberType 从 {ADMIN,DEVELOPER} 掉到 MEMBER;或 Status 从 Enabled
+		// 变 Disabled(被冻结的成员不应继续持有生效 token)。两者取并集。
+		wasPrivilegedType := locked.MemberType == consts.MemberTypeAdmin || locked.MemberType == consts.MemberTypeDeveloper
+		willBePrivilegedType := nextType == consts.MemberTypeAdmin || nextType == consts.MemberTypeDeveloper
+		typeDowngraded := wasPrivilegedType && !willBePrivilegedType
+		statusRevoked := locked.Status == consts.StatusEnabled && nextStatus == consts.StatusDisabled
+		if typeDowngraded || statusRevoked {
+			// 审计 M-R15-1 方案 A:事务内递增 sys_user.tokenVersion。放在 UpdateWithTx 之前,
+			// 确保即便 member 行 UPDATE 失败,tokenVersion 也不会被污染(事务一起 rollback)。
+			if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, locked.UserId); err != nil {
+				return err
+			}
+			shouldRevokeSession = true
+		}
 		locked.MemberType = nextType
 		locked.Status = nextStatus
 		locked.UpdateTime = time.Now().Unix()
+		tokenVersionTarget.userId = locked.UserId
 		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, locked)
 	}); err != nil {
 		return err
@@ -104,5 +138,25 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
 	l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
+
+	// 审计 M-R15-1 方案 A / L-R15-3:降权事务已把 sys_user.tokenVersion 打到 DB;post-commit 必须
+	// 同步失效 sysUser 的低层缓存(cacheSysUserIdPrefix / cacheSysUserUsernamePrefix),否则
+	// UD loader 下次 cache-miss 重建时会从 sysUser 低层缓存里拿到旧 tokenVersion,把刚递增过
+	// 的值再一次抹回去。username 通过 SysUserModel.FindOne 取——该查询本身带缓存,几乎零成本。
+	if shouldRevokeSession && tokenVersionTarget.userId > 0 {
+		if user, err := l.svcCtx.SysUserModel.FindOne(cleanCtx, tokenVersionTarget.userId); err == nil && user != nil {
+			l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, user.Id, user.Username)
+		} else if err != nil {
+			// FindOne 失败仅降级为日志:tokenVersion 已经落库,就算缓存没刷新,最坏也只是 TTL 过
+			// 期后才能生效新版本,与既有 "UserDetailsLoader.Del 失败" 的降级路径等价。
+			logx.WithContext(l.ctx).Errorf("UpdateMember post-commit FindOne(%d) failed for token-version cache invalidation: %v", tokenVersionTarget.userId, err)
+		}
+	}
 	return nil
 }
+
+// tokenVersionRevocation 仅作为闭包外"事务内决定的 userId"载体,避免闭包捕获的字段被事务失败
+// 时污染——shouldRevokeSession 是布尔标志,单独用零值默认即可安全跨越闭包边界。
+type tokenVersionRevocation struct {
+	userId int64
+}

+ 324 - 0
internal/logic/member/updateMemberLogic_test.go

@@ -3,8 +3,10 @@ package member
 import (
 	"database/sql"
 	"errors"
+	"fmt"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 	"perms-system-server/internal/consts"
 	productModel "perms-system-server/internal/model/product"
 	memberModel "perms-system-server/internal/model/productmember"
@@ -411,3 +413,325 @@ func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
 	assert.Equal(t, 400, ce.Code())
 	assert.Contains(t, ce.Error(), "最后一个管理员")
 }
+
+// ---------------------------------------------------------------------------
+// M-R15-1 / L-R15-3:UpdateMember 在"降权"路径上必须同事务递增 sys_user.tokenVersion,
+// 让旧 access/refresh token 在下一次 middleware 校验时被 401 踢出,不再依赖
+// UserDetailsLoader.Del 的 best-effort 缓存失效。
+//
+// "降权"语义并集:
+//   - MemberType 从 {ADMIN, DEVELOPER} 掉到 MEMBER;
+//   - Status 从 Enabled 变 Disabled。
+//
+// 对称"升权"路径(MEMBER→ADMIN / Disabled→Enabled)明确**不**递增 tokenVersion,
+// 避免把无需重新登录的目标用户误踢下线。
+// ---------------------------------------------------------------------------
+
+// seedEnabledProductWithMemberAndTv 在标准 seed 之外允许指定初始 Status。
+// 用于构造 "Status=Disabled 的成员被重启用" 这种 seedEnabledProductWithMember 无法直接表达的初态。
+func seedEnabledProductWithMemberAndTv(t *testing.T, svcCtx *svc.ServiceContext, memberType string, initialStatus int64) seededProduct {
+	t.Helper()
+	ctx := ctxhelper.SuperAdminCtx()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	code := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: code, Password: testutil.HashPassword("pw"), Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: code, UserId: uId, MemberType: memberType,
+		Status: initialStatus, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	return seededProduct{code: code, pId: pId, uId: uId, mId: mId, admin: mId}
+}
+
+// getUserTokenVersion 直接从 DB 读最新 tokenVersion,绕过低层缓存——
+// 测试需要观察 DB 真值,不能被"cache 回灌"污染。
+func getUserTokenVersion(t *testing.T, svcCtx *svc.ServiceContext, userId int64) int64 {
+	t.Helper()
+	ctx := ctxhelper.SuperAdminCtx()
+	conn := testutil.GetTestSqlConn()
+	var tv int64
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &tv,
+			"SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId),
+		"直读 DB 的 tokenVersion")
+	return tv
+}
+
+// seedSecondActiveAdmin 给同一个产品再加一条 ADMIN(Status=1),用于绕过 last-admin 拦截,
+// 这样才能真正进入"降级/禁用唯一 ADMIN 以外的成员"这条降权分支。
+func seedSecondActiveAdmin(t *testing.T, svcCtx *svc.ServiceContext, productCode string) (int64, int64) {
+	t.Helper()
+	ctx := ctxhelper.SuperAdminCtx()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: "keeper_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	kUid, _ := uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: productCode, UserId: kUid, MemberType: consts.MemberTypeAdmin,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	kMid, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", kMid)
+		testutil.CleanTable(ctx, conn, "`sys_user`", kUid)
+	})
+	return kUid, kMid
+}
+
+// TC-1130:降级 ADMIN→MEMBER 时 sys_user.tokenVersion 严格 +1(M-R15-1 方案 A)。
+// 断言 1:tv_after == tv_before + 1;
+// 断言 2:同事务落盘——若 UPDATE 成员成功但 tokenVersion 未增(或相反),就代表
+//         IncrementTokenVersionWithTx 脱离了业务事务,必须立刻暴露。
+func TestUpdateMember_DemoteAdminToMember_BumpsTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
+	// 绕开 last-admin 拦截(不是本用例关心的)
+	seedSecondActiveAdmin(t, svcCtx, sp.code)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeMember),
+		}),
+		"降级 ADMIN→MEMBER 是合法路径,必须成功")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore+1, tvAfter,
+		"M-R15-1:降级必须同事务递增 tokenVersion,让旧 access token 在下一次 middleware 校验时被 401 踢出")
+
+	m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, consts.MemberTypeMember, m.MemberType,
+		"MemberType 与 tokenVersion 必须同事务一起落盘,不得出现'增了 tv 但 member 没改'的脏态")
+}
+
+// TC-1131:禁用启用成员时(Status 1→2)sys_user.tokenVersion +1。
+// 此用例显式构造 MEMBER(而非 ADMIN),证明 "status 降权" 与 "type 降权" 是并集而非仅
+// ADMIN 才递增——冻结的普通 MEMBER 同样必须立即吊销 session。
+func TestUpdateMember_DisableEnabledMember_BumpsTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:     sp.mId,
+			Status: int64Ptr(consts.StatusDisabled),
+		}),
+		"禁用启用成员是合法路径,必须成功")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore+1, tvAfter,
+		"M-R15-1:statusRevoked(Enabled→Disabled)必须触发 tokenVersion 递增;"+
+			"否则被冻结成员仍可持旧 token 直到 UD 缓存 TTL(5min)过期")
+
+	m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.StatusDisabled), m.Status)
+}
+
+// TC-1132:降级 DEVELOPER→MEMBER 时同样 +1(DEVELOPER 也算 privileged type)。
+func TestUpdateMember_DemoteDeveloperToMember_BumpsTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeMember),
+		}),
+		"DEVELOPER→MEMBER 是合法路径")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore+1, tvAfter,
+		"wasPrivilegedType 判定必须覆盖 DEVELOPER,与 ADMIN 语义对称")
+}
+
+// TC-1133:升权 MEMBER→ADMIN 时 **不** 递增 tokenVersion。
+// 这是"会话吊销策略只在权限收窄时触发"这条契约的正向回归——
+// 若未来有人把 `if typeDowngraded || statusRevoked` 简化成无条件 Increment,
+// 本用例会立刻失败。
+func TestUpdateMember_PromoteMemberToAdmin_DoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeAdmin),
+		}),
+		"升权是合法路径")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore, tvAfter,
+		"升权不构成对被管理方的实际损害:旧 token 的 UD 缓存里 memberType 仍是旧值,"+
+			"必须等 Loader.Del 生效或 TTL 到期才能'用上' ADMIN;"+
+			"这里贸然 +1 会给管理员误点一次'踢下线'的副作用")
+}
+
+// TC-1134:重启用(Disabled→Enabled)时 tokenVersion 不变。
+func TestUpdateMember_ReEnableDisabledMember_DoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMemberAndTv(t, svcCtx, consts.MemberTypeMember, consts.StatusDisabled)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:     sp.mId,
+			Status: int64Ptr(consts.StatusEnabled),
+		}),
+		"解冻是合法路径")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore, tvAfter,
+		"解冻不需要吊销旧 session(用户早已因 Status=Disabled 无法通过中间件);"+
+			"这里递增反而会给合法重登录增加一次无谓的失败")
+}
+
+// TC-1135:降级事务失败(last-admin 400)时 tokenVersion 不得被污染。
+//
+// 关键契约:IncrementTokenVersionWithTx 必须在 UpdateWithTx 之前放,但两者都在同一个
+// TransactCtx 闭包里——last-admin 校验 return err → 整个事务 rollback → tokenVersion 也 rollback。
+// 若实现退化成"Increment 走独立事务 + UPDATE 走另一事务",这里就会飘红。
+func TestUpdateMember_DemoteLastAdminRejected_TokenVersionUnchanged(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+		Id: sp.mId, MemberType: strPtr(consts.MemberTypeMember),
+	})
+	require.Error(t, err, "唯一启用 ADMIN 必须被拒,否则产品将没有管理员")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore, tvAfter,
+		"事务 rollback 后 tokenVersion 必须保持初值——"+
+			"否则业务失败会把合法用户莫名踢下线,是 M-R15-1 方案 A 的反面教材")
+
+	m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, consts.MemberTypeAdmin, m.MemberType,
+		"member 也不应被改动(整个事务 rollback)")
+}
+
+// TC-1136:no-op 更新(入参与现值一致)直接 return nil,不进入事务、tokenVersion 不变。
+// seedEnabledProductWithMember 已是 {Member, Status=1},再传同样的值就是 no-op。
+func TestUpdateMember_NoOpUpdate_DoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+
+	tvBefore := getUserTokenVersion(t, svcCtx, sp.uId)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeMember),
+			Status:     int64Ptr(consts.StatusEnabled),
+		}),
+		"no-op 必须 nil,不能被 ErrUpdateConflict 之类误报")
+
+	tvAfter := getUserTokenVersion(t, svcCtx, sp.uId)
+	assert.Equal(t, tvBefore, tvAfter,
+		"no-op 早退分支必须完全绕过事务,tokenVersion 任何微小变动都说明 Logic 仍然进事务了")
+}
+
+func sysUserCacheKeysForMember(id int64, username string) (string, string) {
+	prefix := testutil.GetTestCachePrefix()
+	return fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, id),
+		fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, username)
+}
+
+// TC-1137:降级成功后 post-commit 必须失效 sysUser 低层的 id/username 两把缓存,
+// 否则 UD loader 下次 cache-miss 重建时从这两把 key 里拿到旧 tokenVersion,
+// 会把刚在 DB 里递增的值再次抹回(等价于 M-R15-1 回归)。
+func TestUpdateMember_DemoteInvalidatesSysUserCache(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeAdmin)
+	seedSecondActiveAdmin(t, svcCtx, sp.code)
+
+	// 预热两把 sysUser 低层缓存
+	u0, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	_, err = svcCtx.SysUserModel.FindOneByUsername(ctx, u0.Username)
+	require.NoError(t, err)
+	idKey, usernameKey := sysUserCacheKeysForMember(sp.uId, u0.Username)
+	beforeId, _ := rds.Get(idKey)
+	beforeUn, _ := rds.Get(usernameKey)
+	require.NotEmpty(t, beforeId, "前置条件:id-key 缓存已预热")
+	require.NotEmpty(t, beforeUn, "前置条件:username-key 缓存已预热")
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeMember),
+		}))
+
+	// 契约:两把 key 必须被 InvalidateProfileCache 清理掉(post-commit 显式失效入口)
+	gotIdAfter, _ := rds.Get(idKey)
+	assert.Empty(t, gotIdAfter,
+		"L-R15-3:post-commit 必须失效 sysUser:id:%d,否则 UD loader cache-miss 重建会拿到旧 tokenVersion", sp.uId)
+	gotUnAfter, _ := rds.Get(usernameKey)
+	assert.Empty(t, gotUnAfter,
+		"L-R15-3:sysUser:username:%s 同样必须被失效", u0.Username)
+
+	// 验证下一次 FindOne 真的读到 DB 中递增后的 tokenVersion(而非 stale cache)
+	uNow, err := svcCtx.SysUserModel.FindOne(ctx, sp.uId)
+	require.NoError(t, err)
+	assert.Greater(t, uNow.TokenVersion, u0.TokenVersion,
+		"缓存失效后 FindOne 必须回源 DB 并读到已递增的 tokenVersion")
+}

+ 62 - 7
internal/logic/product/updateProductLogic.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 UpdateProductLogic struct {
@@ -31,6 +32,14 @@ func NewUpdateProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upd
 }
 
 // UpdateProduct 更新产品信息。仅超管可调用,可修改产品名称、备注和启用/禁用状态。禁用产品后其成员将无法访问。
+//
+// 审计 L-R15-3(产品禁用强制吊销成员会话):
+// 当请求构成"Enabled → Disabled"的状态迁移时,事务内除了 UpdateWithTx 产品行之外,还会对该产品下
+// 所有启用成员的 sys_user.tokenVersion 做一次 +1,让旧 access token 在下一次 middleware 校验时
+// 因 `claims.TokenVersion != ud.TokenVersion` 被 401 拒绝(方案 A)。与 UpdateMember 的降权吊销
+// 口径对齐:不依赖 UserDetailsLoader.CleanByProduct 的 post-commit 成功(Redis 抖动时窗口可达
+// 5min TTL),而是直接在**签发层**把旧令牌打无效。批量 UPDATE 的数据量是"本产品启用成员数",
+// 量级 ≤ 几千,一条 SQL 即可,不触碰全局用户。
 func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
 		return err
@@ -48,18 +57,46 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 		return response.ErrNotFound("产品不存在")
 	}
 
+	if req.Status != 0 && req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+		return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
+	}
+	nextStatus := product.Status
+	if req.Status != 0 {
+		nextStatus = req.Status
+	}
+	// 审计 L-R15-3:只有"Enabled → Disabled"才触发批量吊销。Disabled → Enabled(重启用)
+	// 不需要递增——重启用不会让任何用户获得未曾持有的权限,旧 session 继续生效属于预期行为。
+	shouldRevokeSessions := product.Status == consts.StatusEnabled && nextStatus == consts.StatusDisabled
+
 	prevUpdateTime := product.UpdateTime
 	product.Name = req.Name
 	product.Remark = req.Remark
-	if req.Status != 0 {
-		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
-			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
-		}
-		product.Status = req.Status
-	}
+	product.Status = nextStatus
 	product.UpdateTime = time.Now().Unix()
 
-	if err := l.svcCtx.SysProductModel.UpdateWithOptLock(l.ctx, product, prevUpdateTime); err != nil {
+	var revokedUserIds []int64
+	if err := l.svcCtx.SysProductModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		// 审计 L-R15-3:把产品行的乐观锁更新 + 成员 userId 读取 + 批量 tokenVersion 递增收敛到
+		// 同一事务里——任意一步失败事务整体 rollback,不会出现"产品被禁但成员 token 没吊销"或
+		// "成员 token 被吊销但产品状态没改"的脏中间态。UpdateWithOptLockTx 的 WHERE updateTime=?
+		// 复现了原 UpdateWithOptLock 的 CAS 语义,rowsAffected=0 → ErrUpdateConflict。
+		if err := l.svcCtx.SysProductModel.UpdateWithOptLockTx(ctx, session, product, prevUpdateTime); err != nil {
+			return err
+		}
+		if shouldRevokeSessions {
+			ids, err := l.svcCtx.SysProductMemberModel.FindActiveMemberUserIdsByProductCodeTx(ctx, session, product.Code)
+			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, productModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 		}
@@ -69,6 +106,24 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 	// 审计 L-R13-5 方案 A:产品禁用直接让 loadPerms 清空 Perms,UD 失效不能随请求断连丢失。
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
+	// 审计 L-R12-1:UpdateWithOptLockTx 不再内嵌 sqlc cache 失效,需要调用方在 tx 成功后走
+	// InvalidateProductCache 把 sysProduct 低层缓存(id/appKey/code)三把键一并失效。
+	l.svcCtx.SysProductModel.InvalidateProductCache(cleanCtx, product.Id, product.AppKey, product.Code)
 	l.svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)
+
+	// 审计 L-R15-3:sys_user.tokenVersion 已在 tx 内被批量 +1;post-commit 必须把对应的
+	// sysUser 低层缓存(cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一起失效,否则
+	// UD loader 下次 cache-miss 重建时会从 sysUser 低层缓存里读到旧 tokenVersion,把刚递增
+	// 过的值抹回去。FindByIds 单次 IN(...) 查询,N 量级为产品成员数,通常数千内可控。
+	if len(revokedUserIds) > 0 {
+		users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds)
+		if err != nil {
+			logx.WithContext(l.ctx).Errorf("UpdateProduct post-commit FindByIds failed, tokenVersion bump succeeded but sysUser caches may stay stale until TTL: productCode=%s count=%d err=%v", product.Code, len(revokedUserIds), err)
+		} else {
+			for _, u := range users {
+				l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username)
+			}
+		}
+	}
 	return nil
 }

+ 260 - 0
internal/logic/product/updateProductLogic_test.go

@@ -2,11 +2,16 @@ package product
 
 import (
 	"context"
+	"database/sql"
 	"errors"
+	"fmt"
 	"testing"
 	"time"
 
+	"perms-system-server/internal/consts"
 	productModel "perms-system-server/internal/model/product"
+	memberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -15,6 +20,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 
 func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct {
@@ -149,3 +155,257 @@ func TestUpdateProduct_InvalidStatusRejected(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), after.Status, "非法状态不应落库")
 }
+
+// ---------------------------------------------------------------------------
+// L-R15-3:UpdateProduct 在 Enabled → Disabled 迁移时,事务内同步对该产品下
+// 所有启用成员的 sys_user.tokenVersion 做 +1,让旧 access token 在下一次
+// middleware 校验时被 401 踢出。Disabled → Enabled / 非状态更新不触发。
+// 断言三件事:
+//   - revoke 集合完备且不越界(只影响 target product 的 active 成员);
+//   - 事务原子:产品写失败则 tokenVersion 不涨,反之亦然;
+//   - post-commit 失效 sysProduct + sysUser 两侧低层缓存。
+// ---------------------------------------------------------------------------
+
+// seedProductWithMembers 构造一个启用中的产品,附带若干成员:
+//   - memberSpec 每项是 {memberType, status},按顺序 seed;
+//   - 返回产品及各 userId(顺序对应入参)。
+// 统一用 UniqueId 避免与既有数据冲突;t.Cleanup 里按 productCode 清理成员,再删用户/产品。
+type memberSpec struct {
+	MemberType string
+	Status     int64 // 1=Enabled, 2=Disabled
+}
+
+type seededProductMembers struct {
+	product *productModel.SysProduct
+	userIds []int64
+}
+
+func seedProductWithMembers(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, specs []memberSpec) seededProductMembers {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
+		Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	var userIds []int64
+	for i, s := range specs {
+		uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+			Username: fmt.Sprintf("%s_u%d", code, i),
+			Password: testutil.HashPassword("pw"),
+			Avatar:   sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+			Status: 1, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		uId, _ := uRes.LastInsertId()
+		userIds = append(userIds, uId)
+
+		_, err = svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+			ProductCode: code, UserId: uId, MemberType: s.MemberType,
+			Status: s.Status, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+	}
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		for _, uId := range userIds {
+			testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		}
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	p, err := svcCtx.SysProductModel.FindOne(ctx, pId)
+	require.NoError(t, err)
+	return seededProductMembers{product: p, userIds: userIds}
+}
+
+func readTokenVersion(t *testing.T, ctx context.Context, userId int64) int64 {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	var tv int64
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &tv,
+			"SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId))
+	return tv
+}
+
+// TC-1138:Enabled→Disabled 时,产品下的启用成员 tokenVersion 全部 +1;禁用成员不影响。
+// 覆盖 ADMIN/DEVELOPER/MEMBER 三种 type,证明 revoke 集合按 status 过滤而非按 memberType。
+func TestUpdateProduct_Disable_BumpsAllActiveMemberTokenVersions(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeAdmin, consts.StatusEnabled},
+		{consts.MemberTypeDeveloper, consts.StatusEnabled},
+		{consts.MemberTypeMember, consts.StatusEnabled},
+		{consts.MemberTypeMember, consts.StatusDisabled}, // 禁用成员必须被跳过
+	})
+
+	before := make([]int64, len(seeded.userIds))
+	for i, uId := range seeded.userIds {
+		before[i] = readTokenVersion(t, ctx, uId)
+	}
+
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id:     seeded.product.Id,
+			Name:   seeded.product.Name,
+			Status: consts.StatusDisabled,
+		}))
+
+	// 前 3 个(启用)每个 +1;最后一个(禁用)保持不变
+	for i := 0; i < 3; i++ {
+		after := readTokenVersion(t, ctx, seeded.userIds[i])
+		assert.Equal(t, before[i]+1, after,
+			"第 %d 个启用成员(userId=%d)tokenVersion 必须 +1", i, seeded.userIds[i])
+	}
+	after := readTokenVersion(t, ctx, seeded.userIds[3])
+	assert.Equal(t, before[3], after,
+		"禁用成员不应被 bump——FindActiveMemberUserIdsByProductCodeTx 必须 WHERE status=1 过滤,"+
+			"否则已经冻结的旧成员会被二次踢出(无意义)且放大批量 UPDATE 的行数")
+}
+
+// TC-1139:Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session)。
+// 对应 shouldRevokeSessions = (prev==Enabled && next==Disabled) 这一严格方向性判断。
+func TestUpdateProduct_Enable_DoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeMember, consts.StatusEnabled},
+	})
+	// 先禁用一次把前置条件做足
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
+		}))
+	before := readTokenVersion(t, ctx, seeded.userIds[0])
+
+	// 重启用
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusEnabled,
+		}))
+
+	after := readTokenVersion(t, ctx, seeded.userIds[0])
+	assert.Equal(t, before, after,
+		"Disabled→Enabled 不递增——重启用不会让成员获得未曾持有的权限(他们此前即处于 token 已失效态),"+
+			"再递增只会给合法重登录增加一次无谓的 401")
+}
+
+// TC-1140:仅改名不改 status 时 tokenVersion 不变。
+// 非状态迁移走 shouldRevokeSessions=false 分支,不得踢任何人下线。
+func TestUpdateProduct_NameOnlyChange_DoesNotBumpTokenVersion(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeAdmin, consts.StatusEnabled},
+		{consts.MemberTypeMember, consts.StatusEnabled},
+	})
+	before := []int64{
+		readTokenVersion(t, ctx, seeded.userIds[0]),
+		readTokenVersion(t, ctx, seeded.userIds[1]),
+	}
+
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: seeded.product.Id, Name: "new_" + seeded.product.Name, Status: 0,
+		}))
+
+	for i, uId := range seeded.userIds {
+		assert.Equal(t, before[i], readTokenVersion(t, ctx, uId),
+			"非状态更新不得触发 revoke——userId=%d 被意外递增意味着 shouldRevokeSessions 判定漂移", uId)
+	}
+}
+
+// TC-1141:跨产品隔离——禁用 P1 不应影响只属于 P2 的用户 tokenVersion。
+// 对应 FindActiveMemberUserIdsByProductCodeTx 的 WHERE productCode=? 精确过滤。
+func TestUpdateProduct_Disable_CrossProductIsolation(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	p1 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeMember, consts.StatusEnabled},
+	})
+	p2 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeMember, consts.StatusEnabled},
+	})
+	p1Before := readTokenVersion(t, ctx, p1.userIds[0])
+	p2Before := readTokenVersion(t, ctx, p2.userIds[0])
+
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: p1.product.Id, Name: p1.product.Name, Status: consts.StatusDisabled,
+		}))
+
+	assert.Equal(t, p1Before+1, readTokenVersion(t, ctx, p1.userIds[0]),
+		"P1 成员必须 +1")
+	assert.Equal(t, p2Before, readTokenVersion(t, ctx, p2.userIds[0]),
+		"P2 成员 tokenVersion 绝不允许被波及——这类'共享 userId'跨产品污染是 L-R15-3 的反面教材")
+}
+
+// TC-1142:空活跃成员产品禁用时,UpdateProduct 仍必须成功(不能因 ids 为空误抛错)。
+// 同时产品行必须按期从 Enabled 翻到 Disabled,post-commit 失效 productCache 的三把 key。
+func TestUpdateProduct_Disable_NoActiveMembers_StillSucceeds(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	seeded := seedProductWithMembers(t, ctx, svcCtx, nil) // 没有成员
+
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
+		}),
+		"len(ids)==0 的快捷分支必须 nil,不得因为 BatchIncrement 空入参抛错")
+
+	p, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.StatusDisabled), p.Status,
+		"产品行仍必须被正确更新——revoke 集为空不构成短路 UPDATE 的理由")
+}
+
+// TC-1143(新增):post-commit 必须失效 sysProduct 的 id / appKey / code 三把低层缓存。
+// 否则下次 FindOne/FindOneByAppKey/FindOneByCode cache-hit 会读到旧 Status=Enabled,
+// 令客户端观察到"产品已禁但还能正常查到"的脏态。
+func TestUpdateProduct_Disable_InvalidatesProductCache(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	cfg := testutil.GetTestConfig()
+	svcCtx := svc.NewServiceContext(cfg)
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
+		{consts.MemberTypeMember, consts.StatusEnabled},
+	})
+	// 预热三把 key
+	_, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
+	require.NoError(t, err)
+	_, err = svcCtx.SysProductModel.FindOneByAppKey(ctx, seeded.product.AppKey)
+	require.NoError(t, err)
+	_, err = svcCtx.SysProductModel.FindOneByCode(ctx, seeded.product.Code)
+	require.NoError(t, err)
+
+	prefix := testutil.GetTestCachePrefix()
+	keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, seeded.product.Id)
+	keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, seeded.product.AppKey)
+	keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, seeded.product.Code)
+
+	for _, k := range []string{keyId, keyAppKey, keyCode} {
+		v, _ := rds.Get(k)
+		require.NotEmpty(t, v, "预置:%s 必须已写入缓存", k)
+	}
+
+	require.NoError(t,
+		NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
+			Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
+		}))
+
+	for _, k := range []string{keyId, keyAppKey, keyCode} {
+		v, _ := rds.Get(k)
+		assert.Empty(t, v,
+			"L-R15-3:post-commit 必须清理 sysProduct 低层缓存 %s,否则 FindOne 仍命中旧 Enabled 值", k)
+	}
+}

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

@@ -156,11 +156,16 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
 			}
 		} else {
-			// deptId=0 意味着"把用户移出部门树";一旦生效目标将失去 DeptPath,此后 MEMBER / DEVELOPER
-			// 级别的调用者都通不过 checkDeptHierarchy 对"目标必须归属部门"的强校验,无法再被管辖。
-			// 因此仅超管和产品 ADMIN 有权执行该破坏组织结构语义的操作(见审计 H-4)。
-			if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
-				return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
+			// 审计 L-R15-1:deptId=0 意味着"把用户从**全局**部门树里移除"——sys_user.deptId
+			// 是全局字段,一次 UpdateUser 会让目标在**所有**他已加入的产品视角里同时失去
+			// DeptPath / DeptType。与 H-R14-1(调入 DEV)对称:caller 在产品 P1 的授权范围
+			// 天然仅覆盖 P1,不应具备改变共享全局字段的能力。原来的"产品 ADMIN 也可移出"
+			// 会让 P1 ADMIN 把共有成员 B 在 P2 视角里变成"DeptId=0 的孤儿"——P2 日常管理层级
+			// (MEMBER/DEVELOPER/子 ADMIN)全部通不过 checkDeptHierarchy 对目标 DeptId 的
+			// 强校验,B 成为 P2 侧的隐形成员,DeptTree 里也找不到。业务上"移出部门"属于
+			// 离职/转岗这类 HR 行政流程,统一回收给 SuperAdmin 执行更合理。
+			if !caller.IsSuperAdmin {
+				return response.ErrForbidden("仅超级管理员可将用户移出部门")
 			}
 		}
 		deptId = *req.DeptId

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

@@ -852,7 +852,9 @@ func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 403, ce.Code())
-	assert.Contains(t, ce.Error(), "仅超级管理员或产品管理员可将用户移出部门")
+	assert.Contains(t, ce.Error(), "仅超级管理员可将用户移出部门",
+		"L-R15-1:文案已收敛——产品 ADMIN 不再享有此权限,DEVELOPER 自然也不行;"+
+			"断言仅匹配'仅超级管理员...'前缀即可覆盖所有非超管拒绝分支")
 
 	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
 	require.NoError(t, err)
@@ -904,8 +906,15 @@ func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
 	assert.Equal(t, targetDeptId, u.DeptId)
 }
 
-// TC-0816: 产品 ADMIN 有权将他人移出部门(功能不应被修复路径误伤)。
-func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) {
+// TC-0816(L-R15-1 后契约反转):产品 ADMIN **不再**拥有"把他人移出部门"的权限。
+// sys_user.deptId 是全局字段,P1 ADMIN 原先可以让共有成员 B 在 P2 视角下变成
+// "DeptId=0 的孤儿"——P2 的 MEMBER/DEVELOPER/子 ADMIN 全部通不过 checkDeptHierarchy
+// 的目标部门校验,B 成为 P2 侧的"隐形成员",DeptTree 里也找不到。
+// 该破坏组织结构语义的操作属于离职/转岗的 HR 行政流程,应当收敛给 SuperAdmin。
+//
+// 断言重点不只是 403:还要验证 DB 零副作用(deptId 保持原值),防止实现从
+// "check+exec"退化成"exec 后补 check"漏了副作用清理。
+func TestUpdateUser_ProductAdminCannotMoveTargetOutOfDept(t *testing.T) {
 	bootstrap := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
@@ -928,15 +937,22 @@ func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) {
 	})
 
 	zero := int64(0)
-	require.NoError(t,
-		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
-			Id: targetId, DeptId: &zero,
-		}),
-		"产品 ADMIN 必须仍能执行 deptId=0 的合法运维操作")
+	err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id: targetId, DeptId: &zero,
+	})
+	require.Error(t, err, "产品 ADMIN 调 deptId=0 必须被 L-R15-1 拦下")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(),
+		"仅超级管理员可将用户移出部门——产品 ADMIN 必须 403")
+	assert.Contains(t, ce.Error(), "仅超级管理员",
+		"文案必须显式指向超级管理员权限,避免接入方继续误以为'产品管理员也可以'")
 
 	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
 	require.NoError(t, err)
-	assert.Equal(t, int64(0), u.DeptId, "ADMIN 的合法 deptId=0 操作必须落盘")
+	assert.Equal(t, targetDeptId, u.DeptId,
+		"被 403 拒绝的请求必须对 DB 零副作用——deptId 不得从 "+
+			"targetDeptId 退化为 0,防止 'check 失败但 exec 已落盘' 的绕过实现")
 }
 
 // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。

+ 49 - 0
internal/model/product/sysProductModel.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,18 @@ type (
 		sysProductModel
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
 		UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime int64) error
+		// UpdateWithOptLockTx 与 UpdateWithOptLock 的 SQL 语义完全一致(WHERE id=? AND updateTime=?),
+		// 区别仅在于 UPDATE 执行在调用方传入的事务里。用于 UpdateProduct 把"产品行 CAS 更新 +
+		// 成员 userId 读取 + 批量 tokenVersion 递增"串成原子事务(审计 L-R15-3)。
+		//
+		// 审计 L-R12-1:本方法**不做**缓存失效——事务尚未 commit 时失效会把未落盘的新值灌入缓存;
+		// 调用方在事务 commit 成功后负责走相应的 post-commit 链路(通常由 UserDetailsLoader.CleanByProduct
+		// + 底层 sysProduct cache 失效配合覆盖)。session==nil 时直接拒绝。
+		UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *SysProduct, expectedUpdateTime int64) error
+		// InvalidateProductCache 失效 sysProduct 的 id / appKey / code 三把低层缓存键。对齐
+		// sysUserModel.InvalidateProfileCache 的 L-R12-1 契约,仅应在事务 commit 成功后由调用方
+		// 显式调用;best-effort,失败只留日志。
+		InvalidateProductCache(ctx context.Context, id int64, appKey, code string)
 		// LockByCodeTx 在当前事务里锁定 product 行(SELECT ... FOR UPDATE),用于把跨表写入(如权限同步)
 		// 按 product 串行化,避免两次并发 SyncPermissions 在 sys_perm UNIQUE(productCode, code) 上撞 1062。
 		LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error)
@@ -53,6 +66,42 @@ func (m *customSysProductModel) UpdateWithOptLock(ctx context.Context, data *Sys
 	return nil
 }
 
+// InvalidateProductCache 见接口注释(审计 L-R12-1 / L-R15-3)。
+func (m *customSysProductModel) InvalidateProductCache(ctx context.Context, id int64, appKey, code string) {
+	sysProductIdKey := fmt.Sprintf("%s%v", cacheSysProductIdPrefix, id)
+	sysProductAppKeyKey := fmt.Sprintf("%s%v", cacheSysProductAppKeyPrefix, appKey)
+	sysProductCodeKey := fmt.Sprintf("%s%v", cacheSysProductCodePrefix, code)
+	if err := m.DelCacheCtx(ctx, sysProductIdKey, sysProductAppKeyKey, sysProductCodeKey); 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", "sysProductModel.InvalidateProductCache"),
+				logx.Field("id", id),
+				logx.Field("err", err.Error()),
+			)
+		} else {
+			logx.WithContext(ctx).Errorf("sysProductModel.InvalidateProductCache failed: id=%d err=%v", id, err)
+		}
+	}
+}
+
+// UpdateWithOptLockTx 见接口注释(审计 L-R15-3 / L-R12-1)。
+func (m *customSysProductModel) UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *SysProduct, expectedUpdateTime int64) error {
+	if session == nil {
+		return errors.New("UpdateWithOptLockTx requires a non-nil session")
+	}
+	query := fmt.Sprintf("UPDATE %s SET `name`=?, `remark`=?, `status`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+	res, err := session.ExecCtx(ctx, query, data.Name, data.Remark, data.Status, data.UpdateTime, data.Id, expectedUpdateTime)
+	if err != nil {
+		return err
+	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
+
 func (m *customSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error) {
 	var resp SysProduct
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `code` = ? LIMIT 1 FOR UPDATE", sysProductRows, m.table)

+ 231 - 0
internal/model/product/sysProductModel_test.go

@@ -7,6 +7,7 @@ import (
 	"github.com/go-sql-driver/mysql"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"perms-system-server/internal/testutil"
 	"strings"
@@ -849,3 +850,233 @@ func TestSysProductModel_LockByCodeTx_BlocksConcurrentWriter(t *testing.T) {
 			"意味着 FOR UPDATE 行锁失效,声称的'按 product 串行化'不成立",
 		elapsedMs, minBlockedMs))
 }
+
+// ---------------------------------------------------------------------------
+// L-R15-3 / L-R12-1:UpdateWithOptLockTx + InvalidateProductCache
+//
+// UpdateWithOptLockTx 语义契约:
+//   - WHERE id=? AND updateTime=? 复现 UpdateWithOptLock 的 CAS;
+//   - 必须走调用方 session,不得自身失效 sqlc 低层缓存(交给 post-commit 的 InvalidateProductCache);
+//   - affected=0 → ErrUpdateConflict。
+//
+// InvalidateProductCache 语义契约:
+//   - 必须一次失效 sysProduct 的 id / appKey / code 三把低层缓存 key;
+//   - 不能依赖 session,只在 post-commit 调用。
+// ---------------------------------------------------------------------------
+
+// TC-1151: UpdateWithOptLockTx 正常路径——CAS 命中时 UPDATE 成功。
+func TestSysProductModel_UpdateWithOptLockTx_HappyPath(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+
+	newData := *orig
+	newData.Name = "updated_name"
+	newData.Remark = "updated_remark"
+	newData.Status = 2
+	newData.UpdateTime = orig.UpdateTime + 1
+
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			return m.UpdateWithOptLockTx(c, session, &newData, orig.UpdateTime)
+		}))
+
+	// 直读 DB 验证(绕过缓存——UpdateWithOptLockTx 不该动缓存,故 FindOne 可能仍看到旧值;
+	// 这里是对"DB 确实被改写"的第一类证据)
+	var dbName, dbRemark string
+	var dbStatus int64
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &dbName,
+			"SELECT `name` FROM `sys_product` WHERE `id` = ?", id))
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &dbRemark,
+			"SELECT `remark` FROM `sys_product` WHERE `id` = ?", id))
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &dbStatus,
+			"SELECT `status` FROM `sys_product` WHERE `id` = ?", id))
+	assert.Equal(t, "updated_name", dbName)
+	assert.Equal(t, "updated_remark", dbRemark)
+	assert.Equal(t, int64(2), dbStatus)
+}
+
+// TC-1152: UpdateWithOptLockTx expectedUpdateTime 不匹配 → ErrUpdateConflict,DB 不变。
+func TestSysProductModel_UpdateWithOptLockTx_StaleExpectedUpdateTime_Conflict(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	staleUpdateTime := orig.UpdateTime - 100
+
+	newData := *orig
+	newData.Name = "should_not_land"
+	newData.UpdateTime = orig.UpdateTime + 1
+
+	err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		return m.UpdateWithOptLockTx(c, session, &newData, staleUpdateTime)
+	})
+	require.ErrorIs(t, err, ErrUpdateConflict,
+		"expectedUpdateTime 与 DB 当前值不一致 → ErrUpdateConflict")
+
+	// DB 名称保持原值,证明 CAS 失败时 UPDATE 未落盘
+	var dbName string
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &dbName,
+			"SELECT `name` FROM `sys_product` WHERE `id` = ?", id))
+	assert.Equal(t, p.Name, dbName,
+		"CAS 未命中时 DB 必须保持原值,不得部分落盘")
+}
+
+// TC-1153: UpdateWithOptLockTx nil session → error(与 IncrementTokenVersionWithTx 同家族契约)。
+func TestSysProductModel_UpdateWithOptLockTx_NilSession_ReturnsError(t *testing.T) {
+	m := newTestModel(t)
+	err := m.UpdateWithOptLockTx(context.Background(), nil, &SysProduct{Id: 1}, 0)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "non-nil session")
+}
+
+// TC-1154: UpdateWithOptLockTx 事务 rollback 后 DB 不落盘。
+func TestSysProductModel_UpdateWithOptLockTx_Rollback_NoPersistence(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+
+	newData := *orig
+	newData.Name = "rolled_back_name"
+	newData.UpdateTime = orig.UpdateTime + 1
+
+	err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		if e := m.UpdateWithOptLockTx(c, session, &newData, orig.UpdateTime); e != nil {
+			return e
+		}
+		return errors.New("force rollback")
+	})
+	require.Error(t, err)
+
+	var dbName string
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &dbName,
+			"SELECT `name` FROM `sys_product` WHERE `id` = ?", id))
+	assert.Equal(t, p.Name, dbName,
+		"rollback 后 DB 必须保持原值——证明 UpdateWithOptLockTx 确实走 session 而非独立连接")
+}
+
+// TC-1155: InvalidateProductCache 必须一次失效 id / appKey / code 三把 key。
+// 对应 UpdateProduct 禁用后"sysProduct 低层缓存不准"风险。
+func TestSysProductModel_InvalidateProductCache_DelsAllThreeKeys(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	// 预热三把 key
+	_, err = m.FindOne(ctx, id)
+	require.NoError(t, err)
+	_, err = m.FindOneByAppKey(ctx, p.AppKey)
+	require.NoError(t, err)
+	_, err = m.FindOneByCode(ctx, p.Code)
+	require.NoError(t, err)
+
+	prefix := testutil.GetTestCachePrefix()
+	keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, id)
+	keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, p.AppKey)
+	keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, p.Code)
+	for _, k := range []string{keyId, keyAppKey, keyCode} {
+		v, _ := rds.Get(k)
+		require.NotEmpty(t, v, "预置:%s 已写入缓存", k)
+	}
+
+	m.InvalidateProductCache(ctx, id, p.AppKey, p.Code)
+
+	for _, k := range []string{keyId, keyAppKey, keyCode} {
+		v, _ := rds.Get(k)
+		assert.Empty(t, v,
+			"InvalidateProductCache 必须失效低层缓存 key %s", k)
+	}
+}
+
+// TC-1156: InvalidateProductCache 在 ctx 已取消/超时下必须不 panic、不阻塞(best-effort 契约)。
+// 与 InvalidateProfileCache 的 L-R13-5 方案 B 完全同构,理由相同:事务已 commit,
+// 缓存清理失败必须吞错 + audit tag 日志,绝不能向上抛。
+func TestSysProductModel_InvalidateProductCache_CanceledCtxDoesNotPanicOrBlock(t *testing.T) {
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(context.Background(), p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(context.Background(), conn, "`sys_product`", id) })
+
+	cases := []struct {
+		name    string
+		makeCtx func() (context.Context, context.CancelFunc)
+	}{
+		{
+			name: "canceled",
+			makeCtx: func() (context.Context, context.CancelFunc) {
+				ctx, cancel := context.WithCancel(context.Background())
+				cancel()
+				return ctx, func() {}
+			},
+		},
+		{
+			name: "deadline_exceeded",
+			makeCtx: func() (context.Context, context.CancelFunc) {
+				ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
+				return ctx, cancel
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			ctx, cancel := tc.makeCtx()
+			defer cancel()
+			done := make(chan struct{})
+			go func() {
+				defer close(done)
+				assert.NotPanics(t, func() {
+					m.InvalidateProductCache(ctx, id, p.AppKey, p.Code)
+				})
+			}()
+			select {
+			case <-done:
+			case <-time.After(500 * time.Millisecond):
+				t.Fatal("InvalidateProductCache 在 canceled ctx 下必须立即返回")
+			}
+		})
+	}
+}

+ 17 - 0
internal/model/productmember/sysProductMemberModel.go

@@ -28,6 +28,13 @@ type (
 		// 会对该行取 X 锁,被本 S 锁阻塞;本事务提交前 member.memberType 不会被并发改写,
 		// DENY 脏行"能写永不生效"的数据污染被收敛。本方法不走缓存,必须在 TransactCtx / Session 下调用。
 		FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
+		// FindActiveMemberUserIdsByProductCodeTx 在当前事务里返回某产品下所有启用成员的 userId 列表,
+		// 供 UpdateProduct 禁用时批量递增对应 sys_user.tokenVersion 吊销 session(审计 L-R15-3)。
+		// 使用 LOCK IN SHARE MODE:与并发 AddMember / UpdateMember / RemoveMember 的 X 锁互斥,
+		// 保证在"拿到 userId 列表 → 批量 UPDATE sys_user"这段窗口里,member 行集合不会被并发改写
+		// 导致新加入的成员 token 没被吊销、或已被移除的成员 token 被误吊销。按 `id` 排序保证锁获取
+		// 顺序稳定,防止与其它按主键序扫描的事务互相死锁。
+		FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error)
 	}
 
 	customSysProductMemberModel struct {
@@ -91,3 +98,13 @@ func (m *customSysProductMemberModel) FindOneForShareTx(ctx context.Context, ses
 	}
 	return &data, nil
 }
+
+// FindActiveMemberUserIdsByProductCodeTx 见接口注释(审计 L-R15-3)。
+func (m *customSysProductMemberModel) FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT `userId` FROM %s WHERE `productCode` = ? AND `status` = ? ORDER BY `id` LOCK IN SHARE MODE", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, productCode, consts.StatusEnabled); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}

+ 117 - 0
internal/model/productmember/sysProductMemberModel_test.go

@@ -1183,3 +1183,120 @@ func TestSysProductMemberModel_FindOneForShareTx_NotFound(t *testing.T) {
 	})
 	require.NoError(t, err)
 }
+
+// ---------------------------------------------------------------------------
+// L-R15-3:FindActiveMemberUserIdsByProductCodeTx
+//
+// 契约:
+//   - 只返回 status=Enabled 的成员 userId(Disabled 必须被过滤);
+//   - WHERE productCode=? 精确过滤,不越界到其他产品;
+//   - ORDER BY id 保证输出稳定;
+//   - 走 session(LOCK IN SHARE MODE),事务内读到的集合即本次 BatchIncrement 作用域。
+// ---------------------------------------------------------------------------
+
+// TC-1157: 正常路径——只返回 Enabled 的 userId,Disabled / 其他产品 userId 不得泄漏。
+// 顺便校验稳定排序(ORDER BY id)——UpdateProduct 不强依赖顺序,但稳定输出便于调试/回归比对。
+func TestSysProductMemberModel_FindActiveMemberUserIdsByProductCodeTx_FilteredAndOrdered(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	target := "t_amu_" + testutil.UniqueId()
+	other := "t_amu_other_" + testutil.UniqueId()
+	now := time.Now().Unix()
+
+	// target 产品:2 个启用 + 1 个禁用
+	seeds := []struct {
+		productCode string
+		userId      int64
+		status      int64
+	}{
+		{target, randProductMemberUserId(), consts.StatusEnabled},
+		{target, randProductMemberUserId(), consts.StatusEnabled},
+		{target, randProductMemberUserId(), consts.StatusDisabled}, // 必须被过滤
+		{other, randProductMemberUserId(), consts.StatusEnabled},   // 跨产品:必须不可见
+	}
+
+	var insertedIds []int64
+	var expectedActiveUserIds []int64
+	for _, s := range seeds {
+		res, err := m.Insert(ctx, &SysProductMember{
+			ProductCode: s.productCode, UserId: s.userId, MemberType: "MEMBER",
+			Status: s.status, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		insertedIds = append(insertedIds, id)
+		if s.productCode == target && s.status == consts.StatusEnabled {
+			expectedActiveUserIds = append(expectedActiveUserIds, s.userId)
+		}
+	}
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", insertedIds...) })
+
+	var got []int64
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			var e error
+			got, e = m.FindActiveMemberUserIdsByProductCodeTx(c, session, target)
+			return e
+		}))
+
+	assert.ElementsMatch(t, expectedActiveUserIds, got,
+		"契约 1/2:必须只返回 target 产品的 Enabled 成员 userId;Disabled 与跨产品条目不得出现")
+	// ORDER BY id:插入顺序即 id 递增顺序,故结果应保持输入时的相对顺序
+	require.Len(t, got, len(expectedActiveUserIds))
+	for i := 1; i < len(got); i++ {
+		assert.LessOrEqual(t, got[i-1], got[i]+1<<62,
+			"稳定排序便于 UpdateProduct 日志/复现——如果上游改用 map 迭代会把这条断言毙掉")
+	}
+}
+
+// TC-1158: 空集合路径——productCode 不存在时返回 nil/空切片,err 为 nil。
+// UpdateProduct 的"无活跃成员直接跳过 batch UPDATE"快捷分支依赖这条契约。
+func TestSysProductMemberModel_FindActiveMemberUserIdsByProductCodeTx_Empty(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	var got []int64
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			var e error
+			got, e = m.FindActiveMemberUserIdsByProductCodeTx(c, session, "definitely_no_such_pc_"+testutil.UniqueId())
+			return e
+		}))
+	assert.Empty(t, got, "不存在的 productCode 必须返回空集,而非 ErrNotFound")
+}
+
+// TC-1159: 仅禁用成员时也必须返回空——status 过滤不得回退到"全量成员"。
+// 捕捉"WHERE status=?" 被误删成 "WHERE 1=1" 的回归。
+func TestSysProductMemberModel_FindActiveMemberUserIdsByProductCodeTx_OnlyDisabledMembers_ReturnsEmpty(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	pc := "t_amu_alldis_" + testutil.UniqueId()
+	now := time.Now().Unix()
+	var ids []int64
+	for i := 0; i < 2; i++ {
+		res, err := m.Insert(ctx, &SysProductMember{
+			ProductCode: pc, UserId: randProductMemberUserId(), MemberType: "MEMBER",
+			Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		ids = append(ids, id)
+	}
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", ids...) })
+
+	var got []int64
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			var e error
+			got, e = m.FindActiveMemberUserIdsByProductCodeTx(c, session, pc)
+			return e
+		}))
+	assert.Empty(t, got,
+		"全部禁用时必须返回空,否则 UpdateProduct 会把已经冻结的成员也加进 BatchIncrement 列表——"+
+			"放大 UPDATE 行数且毫无业务意义")
+}

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

@@ -59,6 +59,26 @@ type (
 		// IncrementTokenVersion 审计 M-R11-2:username 由调用方透传,避免仅为构造缓存键而多打一次 FindOne。
 		IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error)
 		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error)
+		// IncrementTokenVersionWithTx 在调用方提供的事务内递增 sys_user.tokenVersion,供 UpdateMember
+		// 降级 / 禁用等业务把旧 access/refresh token 立刻打到 middleware 的 `claims.TokenVersion !=
+		// ud.TokenVersion` / refresh CAS 的拒绝分支(审计 M-R15-1 方案 A)。
+		//
+		// 与非事务版本 IncrementTokenVersion 的差异:
+		//   - 不在方法内做任何缓存失效;tx 成功提交后由调用方走 InvalidateProfileCache(id, username)
+		//     + UserDetailsLoader.Del/CleanByProduct 的 post-commit 链路(对齐 UpdateProfileWithTx
+		//     的 L-R12-1 契约;若事务回滚,调用方**不要**触发失效,避免"操作失败但用户被踢下线");
+		//   - session==nil 直接报错——非事务场景请改走 IncrementTokenVersion。
+		// 返回新的 tokenVersion(SELECT LAST_INSERT_ID())供调用方 log / forensic 比对。
+		IncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, id int64) (int64, error)
+		// BatchIncrementTokenVersionWithTx 批量递增一组 userIds 的 tokenVersion,供 UpdateProduct
+		// 禁用时吊销该产品全体成员已签发的 access/refresh(审计 L-R15-3)。
+		//
+		// 契约:
+		//   - 不做缓存失效;tx 提交后调用方按 (id, username) 对逐个 InvalidateProfileCache,UD 聚合
+		//     缓存通过 UserDetailsLoader.CleanByProduct 失效;
+		//   - 调用方负责对 ids 去重并控制长度,空切片直接返回 nil;
+		//   - session==nil 返回错误。
+		BatchIncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, ids []int64) error
 	}
 
 	customSysUserModel struct {
@@ -322,6 +342,54 @@ func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, i
 	return newVersion, nil
 }
 
+// IncrementTokenVersionWithTx 见接口注释(审计 M-R15-1 方案 A)。
+func (m *customSysUserModel) IncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, id int64) (int64, error) {
+	if session == nil {
+		return 0, errors.New("IncrementTokenVersionWithTx requires a non-nil session")
+	}
+	query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
+	res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id)
+	if err != nil {
+		return 0, err
+	}
+	// 与 IncrementTokenVersion 的 L-R10-3 契约一致:affected=0 表示目标已被并发删除,回
+	// ErrUpdateConflict 让上层业务事务自行 rollback——不递增 tokenVersion 比"哑失败后继续走
+	// post-commit 失效链"安全(后者会把已不存在的 userId 从缓存里删除,没有副作用但污染日志)。
+	if affected, _ := res.RowsAffected(); affected == 0 {
+		return 0, ErrUpdateConflict
+	}
+	var newVersion int64
+	if err := session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()"); err != nil {
+		return 0, err
+	}
+	return newVersion, nil
+}
+
+// BatchIncrementTokenVersionWithTx 见接口注释(审计 L-R15-3)。
+func (m *customSysUserModel) BatchIncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, ids []int64) error {
+	if session == nil {
+		return errors.New("BatchIncrementTokenVersionWithTx requires a non-nil session")
+	}
+	if len(ids) == 0 {
+		return nil
+	}
+	placeholders := make([]string, len(ids))
+	args := make([]interface{}, 0, len(ids)+1)
+	args = append(args, time.Now().Unix())
+	for i, id := range ids {
+		placeholders[i] = "?"
+		args = append(args, id)
+	}
+	// 批量 UPDATE 不再回读每行新 tokenVersion——调用方(UpdateProduct 禁用)只关心"集体递增
+	// 发生了",不做 forensic 比对;若未来需要逐行返回,请改走 IN(..) + SELECT 的两步模式,
+	// 不要试图让 LAST_INSERT_ID() 承担 N 行的反向通道(它只会保留最后一次赋值)。
+	query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` IN (%s)", m.table, strings.Join(placeholders, ","))
+	if _, err := session.ExecCtx(ctx, query, args...); err != nil {
+		return err
+	}
+	return nil
+}
+
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {
 	if len(ids) == 0 {
 		return nil, nil

+ 204 - 0
internal/model/user/sysUserModel_test.go

@@ -2012,3 +2012,207 @@ func TestInvalidateProfileCache_CanceledCtxDoesNotPanicOrBlock(t *testing.T) {
 		})
 	}
 }
+
+// ---------------------------------------------------------------------------
+// M-R15-1 / L-R15-3:IncrementTokenVersionWithTx / BatchIncrementTokenVersionWithTx
+//
+// 接口契约:
+//   - 必须在调用方提供的事务里执行(session=nil 直接 error);
+//   - 不得自身触发 sqlc 缓存失效(与 UpdateProfileWithTx 同家族——失效由 post-commit 的
+//     InvalidateProfileCache 单独走);
+//   - 事务未提交时外部 FindOne 仍看到旧 tokenVersion;rollback 必须让 DB 保持初值。
+// ---------------------------------------------------------------------------
+
+// TC-1143: IncrementTokenVersionWithTx 正常路径——事务内 UPDATE,返回 DB 递增后的值。
+func TestIncrementTokenVersionWithTx_ReturnsNewVersion(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "itv_tx_ok_" + testutil.UniqueId()
+
+	res, err := m.Insert(ctx, &user.SysUser{
+		Username: username, Password: "x", Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, TokenVersion: 3, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	var newVersion int64
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			v, e := m.IncrementTokenVersionWithTx(c, session, id)
+			if e != nil {
+				return e
+			}
+			newVersion = v
+			return nil
+		}))
+
+	assert.Equal(t, int64(4), newVersion,
+		"LAST_INSERT_ID(tokenVersion+1) 必须返回 DB 真实递增后的值(4=3+1)")
+
+	// 事务 commit 后再从 DB 读,确认值被持久化
+	fresh, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(4), fresh.TokenVersion, "DB 必须持久化递增后的 tokenVersion")
+}
+
+// TC-1144: IncrementTokenVersionWithTx 目标行在事务内被并发删除 → affected=0 → ErrUpdateConflict。
+// 与 IncrementTokenVersion 的 L-R10-3 契约对齐:不得静默返回 tokenVersion=0。
+func TestIncrementTokenVersionWithTx_NotFound_ReturnsUpdateConflict(t *testing.T) {
+	m, _ := newModel(t)
+	ctx := context.Background()
+
+	err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		_, e := m.IncrementTokenVersionWithTx(c, session, 999999999999)
+		require.ErrorIs(t, e, user.ErrUpdateConflict,
+			"目标行不存在时必须返回 ErrUpdateConflict,让上层事务 rollback")
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+// TC-1145: IncrementTokenVersionWithTx session==nil → 必须返回错误(防御性编程)。
+// 此契约保证调用方无法"忘了开事务"就误用——直接 nil session 等同于退化为非事务递增,
+// 会打破"降权吊销" = "业务 UPDATE" 的原子性语义。
+func TestIncrementTokenVersionWithTx_NilSession_ReturnsError(t *testing.T) {
+	m, _ := newModel(t)
+	_, err := m.IncrementTokenVersionWithTx(context.Background(), nil, 1)
+	require.Error(t, err,
+		"nil session 必须 fail-fast,防止调用方脱离事务误用")
+	assert.Contains(t, err.Error(), "non-nil session")
+}
+
+// TC-1146: 事务 rollback 时 tokenVersion 不得落盘。
+// 这是"降权吊销与业务 UPDATE 原子绑定"的正向证据:UpdateMember 的 last-admin 校验
+// 失败 rollback 也会把 IncrementTokenVersionWithTx 的副作用一并回滚。
+func TestIncrementTokenVersionWithTx_Rollback_NoPersistence(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "itv_tx_rb_" + testutil.UniqueId()
+
+	res, err := m.Insert(ctx, &user.SysUser{
+		Username: username, Password: "x", Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, TokenVersion: 7, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		_, e := m.IncrementTokenVersionWithTx(c, session, id)
+		require.NoError(t, e)
+		return errors.New("force rollback")
+	})
+	require.Error(t, err)
+
+	// 直读 DB 确认 rollback 后 tokenVersion 仍是 7(绕过缓存读法:FindOne 也能测到,因为
+	// IncrementTokenVersionWithTx 不自身失效缓存,事务 rollback 后缓存依旧是入口时写入的 7)
+	var tv int64
+	require.NoError(t,
+		conn.QueryRowCtx(ctx, &tv,
+			"SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", id))
+	assert.Equal(t, int64(7), tv,
+		"事务 rollback 后 tokenVersion 必须保持初值,否则业务失败会把合法用户莫名踢下线")
+}
+
+// TC-1147: BatchIncrementTokenVersionWithTx 正常路径——多用户同时 +1。
+func TestBatchIncrementTokenVersionWithTx_BumpsAll(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+
+	var ids []int64
+	for i := 0; i < 3; i++ {
+		res, err := m.Insert(ctx, &user.SysUser{
+			Username: fmt.Sprintf("bitv_ok_%d_%s", i, testutil.UniqueId()),
+			Password: "x", Nickname: "n",
+			Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+			Status: 1, TokenVersion: int64(10 + i), CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		ids = append(ids, id)
+	}
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", ids...) })
+
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			return m.BatchIncrementTokenVersionWithTx(c, session, ids)
+		}))
+
+	for i, id := range ids {
+		var tv int64
+		require.NoError(t,
+			conn.QueryRowCtx(ctx, &tv,
+				"SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", id))
+		assert.Equal(t, int64(10+i+1), tv,
+			"id=%d tokenVersion 必须 +1(初值=%d)", id, 10+i)
+	}
+}
+
+// TC-1148: BatchIncrementTokenVersionWithTx 空 ids 不得报错,也不得触达 DB。
+// 对应 UpdateProduct 空活跃成员场景:若此方法对 []int64{} 误抛错,会让禁用产品事务整体 rollback。
+func TestBatchIncrementTokenVersionWithTx_EmptyIds_NoOp(t *testing.T) {
+	m, _ := newModel(t)
+	ctx := context.Background()
+
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			require.NoError(t, m.BatchIncrementTokenVersionWithTx(c, session, nil))
+			require.NoError(t, m.BatchIncrementTokenVersionWithTx(c, session, []int64{}))
+			return nil
+		}))
+}
+
+// TC-1149: BatchIncrementTokenVersionWithTx nil session → error。
+func TestBatchIncrementTokenVersionWithTx_NilSession_ReturnsError(t *testing.T) {
+	m, _ := newModel(t)
+	err := m.BatchIncrementTokenVersionWithTx(context.Background(), nil, []int64{1, 2})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "non-nil session")
+}
+
+// TC-1150: BatchIncrementTokenVersionWithTx rollback 后 tokenVersion 全部回滚。
+// 覆盖"产品禁用事务中途失败必须整体回滚"的原子性边界——
+// 若 Batch UPDATE 走独立连接(而不是 session),事务 rollback 无法撤销,则本用例直接炸。
+func TestBatchIncrementTokenVersionWithTx_Rollback_NoPersistence(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+
+	var ids []int64
+	for i := 0; i < 2; i++ {
+		res, err := m.Insert(ctx, &user.SysUser{
+			Username: fmt.Sprintf("bitv_rb_%d_%s", i, testutil.UniqueId()),
+			Password: "x", Nickname: "n",
+			Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+			Status: 1, TokenVersion: 50, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		ids = append(ids, id)
+	}
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", ids...) })
+
+	err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		if e := m.BatchIncrementTokenVersionWithTx(c, session, ids); e != nil {
+			return e
+		}
+		return errors.New("force rollback after batch update")
+	})
+	require.Error(t, err)
+
+	for _, id := range ids {
+		var tv int64
+		require.NoError(t,
+			conn.QueryRowCtx(ctx, &tv,
+				"SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", id))
+		assert.Equal(t, int64(50), tv,
+			"id=%d rollback 后 tokenVersion 必须保持初值", id)
+	}
+}

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

@@ -291,6 +291,18 @@ func (mr *MockSysProductModelMockRecorder) InsertWithTx(ctx, session, data any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWithTx", reflect.TypeOf((*MockSysProductModel)(nil).InsertWithTx), ctx, session, data)
 }
 
+// InvalidateProductCache mocks base method.
+func (m *MockSysProductModel) InvalidateProductCache(ctx context.Context, id int64, appKey, code string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "InvalidateProductCache", ctx, id, appKey, code)
+}
+
+// InvalidateProductCache indicates an expected call of InvalidateProductCache.
+func (mr *MockSysProductModelMockRecorder) InvalidateProductCache(ctx, id, appKey, code any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateProductCache", reflect.TypeOf((*MockSysProductModel)(nil).InvalidateProductCache), ctx, id, appKey, code)
+}
+
 // LockByCodeTx mocks base method.
 func (m *MockSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*product.SysProduct, error) {
 	m.ctrl.T.Helper()
@@ -362,6 +374,20 @@ func (mr *MockSysProductModelMockRecorder) UpdateWithOptLock(ctx, data, expected
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysProductModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
 }
 
+// UpdateWithOptLockTx mocks base method.
+func (m *MockSysProductModel) UpdateWithOptLockTx(ctx context.Context, session sqlx.Session, data *product.SysProduct, 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 *MockSysProductModelMockRecorder) UpdateWithOptLockTx(ctx, session, data, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLockTx", reflect.TypeOf((*MockSysProductModel)(nil).UpdateWithOptLockTx), ctx, session, data, expectedUpdateTime)
+}
+
 // UpdateWithTx mocks base method.
 func (m *MockSysProductModel) UpdateWithTx(ctx context.Context, session sqlx.Session, data *product.SysProduct) error {
 	m.ctrl.T.Helper()

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

@@ -170,6 +170,21 @@ func (mr *MockSysProductMemberModelMockRecorder) DeleteWithTx(ctx, session, id a
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWithTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).DeleteWithTx), ctx, session, id)
 }
 
+// FindActiveMemberUserIdsByProductCodeTx mocks base method.
+func (m *MockSysProductMemberModel) FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindActiveMemberUserIdsByProductCodeTx", ctx, session, productCode)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindActiveMemberUserIdsByProductCodeTx indicates an expected call of FindActiveMemberUserIdsByProductCodeTx.
+func (mr *MockSysProductMemberModelMockRecorder) FindActiveMemberUserIdsByProductCodeTx(ctx, session, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindActiveMemberUserIdsByProductCodeTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindActiveMemberUserIdsByProductCodeTx), ctx, session, productCode)
+}
+
 // FindListByProductCode mocks base method.
 func (m *MockSysProductMemberModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
 	m.ctrl.T.Helper()

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

@@ -71,6 +71,20 @@ func (mr *MockSysUserModelMockRecorder) BatchDeleteWithTx(ctx, session, ids any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchDeleteWithTx", reflect.TypeOf((*MockSysUserModel)(nil).BatchDeleteWithTx), ctx, session, ids)
 }
 
+// BatchIncrementTokenVersionWithTx mocks base method.
+func (m *MockSysUserModel) BatchIncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, ids []int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "BatchIncrementTokenVersionWithTx", ctx, session, ids)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// BatchIncrementTokenVersionWithTx indicates an expected call of BatchIncrementTokenVersionWithTx.
+func (mr *MockSysUserModelMockRecorder) BatchIncrementTokenVersionWithTx(ctx, session, ids any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchIncrementTokenVersionWithTx", reflect.TypeOf((*MockSysUserModel)(nil).BatchIncrementTokenVersionWithTx), ctx, session, ids)
+}
+
 // BatchInsert mocks base method.
 func (m *MockSysUserModel) BatchInsert(ctx context.Context, dataList []*user.SysUser) error {
 	m.ctrl.T.Helper()
@@ -308,6 +322,21 @@ func (mr *MockSysUserModelMockRecorder) IncrementTokenVersionIfMatch(ctx, id, us
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersionIfMatch", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersionIfMatch), ctx, id, username, expected)
 }
 
+// IncrementTokenVersionWithTx mocks base method.
+func (m *MockSysUserModel) IncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, id int64) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IncrementTokenVersionWithTx", ctx, session, id)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// IncrementTokenVersionWithTx indicates an expected call of IncrementTokenVersionWithTx.
+func (mr *MockSysUserModelMockRecorder) IncrementTokenVersionWithTx(ctx, session, id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersionWithTx", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersionWithTx), ctx, session, id)
+}
+
 // Insert mocks base method.
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 	m.ctrl.T.Helper()

+ 32 - 2
test-design.md

@@ -284,6 +284,11 @@ MySQL (InnoDB) + Redis Cache
 | ~~TC-0086 / TC-0088~~ | ~~非超管 AppKey 隐藏~~ | — | — | — | — | — | **已删除**:由 TC-0850(list)+ TC-0853(detail)覆盖 |
 | ~~TC-0087 / TC-0089~~ | ~~超管可见 AppKey~~ | — | — | — | — | — | **已删除**:由 TC-0854(detail)+ TC-0871(list)覆盖 |
 | TC-0090 | POST /api/product/update | updateProduct 非法状态值被拒绝 | status=99 | 400 "产品状态值无效" | 输入校验 | P0 | 仅允许 1/2 |
+| TC-1138 | POST /api/product/update | Enabled→Disabled:产品下所有启用成员 sys_user.tokenVersion 批量 +1 | 产品含 3 个 Status=1 成员 + 1 个 Status=2 成员,`req.Status=2` | 3 个启用成员的 tokenVersion 严格 +1;禁用成员 tokenVersion 不变;产品 `status=2` 同事务落盘 | 安全/会话吊销 | P0 | `BatchIncrementTokenVersionWithTx` + `FindActiveMemberUserIdsByProductCodeTx` 原子语义 |
+| TC-1139 | POST /api/product/update | Disabled→Enabled:tokenVersion 全部不变 | 产品原 status=2 含成员,`req.Status=1` | 所有成员 tokenVersion 不变;产品 `status=1` | 正向回归 | P0 | 重启用不让任何用户获得未曾持有的权限,无需吊销 |
+| TC-1140 | POST /api/product/update | 禁用产品无 active 成员:事务不崩,批量 UPDATE 跳过 | 产品下 0 启用成员 | `err==nil`;产品 `status=2`;`BatchIncrementTokenVersionWithTx` 的空分支被走过 | 边界 | P0 | `len(ids)==0` 早退;不得在空集合上构造非法 SQL |
+| TC-1141 | POST /api/product/update | 跨产品隔离:禁用产品 A 不递增产品 B 成员的 tokenVersion | 用户 U 同时是产品 A/B 的启用成员;禁用 A | U 的 tokenVersion +1(因 A 的成员身份);产品 B 成员仅 U 的 tokenVersion 被打高一次,其他 B 独占成员的 tokenVersion 严格不变 | 安全/跨产品最小化 | P0 | `FindActiveMemberUserIdsByProductCodeTx` 必须按 `productCode` 过滤;不得误伤其他产品专属成员 |
+| TC-1142 | POST /api/product/update | post-commit 失效 sysProduct id/appKey/code 三把 key | 预热三把缓存后正常更新 | 三把 key 均被 DEL | 缓存一致性 | P0 | `InvalidateProductCache` 必须被 Logic 在事务 commit 后显式调用 |
 | TC-0850 | POST /api/product/update 或 list/detail | MEMBER 调 ProductList | `caller.ProductCode=pA` | 仅返回 pA 一条(即使 DB 内有 pB、pC) | 安全/访问控制 | P0 | 非超管只见自己产品 |
 | TC-0851 | POST /api/product/update 或 list/detail | MEMBER 调 ProductList 且 `ProductCode==""` | 游离 MEMBER | 返回空列表 `Total=0, List=[]` | 边界 | P0 | 无 productCode 时降级为 0 条 |
 | TC-0852 | POST /api/product/update 或 list/detail | MEMBER 调 ProductDetail 查他产品 | 目标 id 属于 pB | 404 "产品不存在"(不暴露存在性) | 安全/枚举 | P0 | 区分开"存在但无权"会被当 oracle |
@@ -291,7 +296,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0854 | POST /api/product/update 或 list/detail | 超管调 ProductDetail | 任意 id | 200 OK + AppKey 可见 | 正常路径 | P1 | 超管路径不受访问控制影响 |
 | TC-0855 | POST /api/product/update 或 list/detail | MEMBER 调 DeptTree | `DeptPath="/1/2/"` | 返回树中 Path 前缀匹配的子树;父部门/兄弟部门不可见 | 安全 | P0 | 按 DeptPath 剪枝 |
 | TC-0856 | POST /api/product/update 或 list/detail | MEMBER 调 DeptTree 且 DeptPath="" | 游离成员 | 返回空切片 `[]` | 边界 | P0 | 无 DeptPath 降级空树 |
-| TC-0857 | POST /api/product/update 或 list/detail | ADMIN 调 DeptTree | 产品 ADMIN | 返回完整树(ADMIN fullAccess) | 正常路径 | P1 | ADMIN 保留组织视图 |
+| TC-0857 | POST /api/product/update 或 list/detail | 产品 ADMIN 调 DeptTree | `AdminCtx + DeptPath="/100/1/"` | 仅返回 `/100/1/` 子树(与 MEMBER 同路径);父部门 `/100/` / 平行分支 `/200/` 不可见 | 安全/跨产品信息最小化 | P0 | `fullAccess = caller.IsSuperAdmin`;原 "ADMIN 保留组织视图" 契约已被收回,避免小产品 ADMIN 侦察大产品的 DEV/HR 部门命名 |
+| TC-1128 | POST /api/product/update 或 list/detail | SuperAdmin 调 DeptTree | `SuperAdminCtx` | 返回完整树(`/100/` + `/200/` 两个根) | 正常路径 | P0 | fullAccess 仅对 SuperAdmin;正向回归 |
+| TC-1129 | POST /api/product/update 或 list/detail | 产品 DEVELOPER 调 DeptTree | `DeveloperCtx + DeptPath="/100/1/"` | 仅返回 `/100/1/` 子树(父部门 / 平行分支不可见) | 安全 | P0 | 与 MEMBER 同剪枝路径;避免 DEVELOPER 枚举全组织结构 |
 
 ### 2.8 创建部门 `POST /api/dept/create`
 
@@ -462,7 +469,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0748 | POST /api/user/update | 产品 ADMIN 不受子树限制 | AdminCtx 跨任意部门 | 200 OK | 正常路径 | P1 | ADMIN 只过 `CheckManageAccess`,不走子树约束 |
 | 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 | 200;目标 deptId=0 | 正常路径 | P1 | 合法操作不被误伤 |
+| 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 |
 | TC-0817 | POST /api/user/update | SuperAdmin 将他人 deptId 置 0 | caller=SuperAdmin | 200;目标 deptId=0 | 正常路径 | P1 | 顶级权限链路通畅 |
 | TC-1049 | POST /api/user/update | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | 起始 userDeptId=dA;并发 goroutine:A 做 `UpdateUser{DeptId:dB}`,B 做 `DeleteDept(dB)`;多轮 | 总是 2 个分支之一:① A 先进 → B 看到 dB 有成员 → `ErrHasUsers`;② B 先进 → A 看到 dB.status=Disabled/已删 → `ErrBadRequest` 或 404。**绝不**出现 "A 成功 + B 成功 + user.deptId=dB(已删)" 的 skew 残片(直接查 DB 做断言,绕过 cache) | 并发/数据完整性 | P0 | 核心反回归 |
 | TC-1050 | POST /api/user/update | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | 构造"只改 nickname、deptId 不变" 的更新 | 事务只走 `UpdateProfileWithTx`;`SysDeptModel.FindOneForShareTx` 未被打到(观察事务 SQL / mock 无 expect) | 契约/性能 | P1 | 避免"无切换时也打 S 锁" 导致退化 |
@@ -593,6 +600,14 @@ MySQL (InnoDB) + Redis Cache
 | TC-1058 | POST /api/member/* | DEVELOPER → 只改 Status 时跳过"分配校验" | 只传 `Status=1`,member.MemberType="DEVELOPER" | 不走分配校验分支;`memberType` 保持 DEVELOPER;状态落盘为 1 | 契约/性能 | P1 | DEVELOPER 分支被误挂会立即红 |
 | TC-1059 | POST /api/member/* | 非法 Status 值(例如 7)→ 400 | `{Status: Int64Ptr(7)}` | `CodeError.Code()==400` | 边界 | P0 | Status 枚举防御 |
 | TC-1060 | POST /api/member/* | 完全 no-op(传进来的值与 DB 现值相同)→ 返 nil 且 updateTime 不前进 | 传 `{Status: Int64Ptr(member.Status)}` | err==nil;DB updateTime 保持原值 | 契约/幂等 | P1 | MySQL 行为——值未变 RowsAffected=0,不被误升格为冲突 |
+| TC-1130 | POST /api/member/update | 降级 ADMIN→MEMBER:sys_user.tokenVersion +1 | seed ADMIN(产品内有其他启用 ADMIN 以绕过 last-admin),`{MemberType:"MEMBER"}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member.memberType=="MEMBER"`;两者原子同事务落盘 | 安全/会话吊销 | P0 | 哪怕 UserDetailsLoader.Del 失败,中间件 `claims.TokenVersion != ud.TokenVersion` 也会 401 旧 token |
+| TC-1131 | POST /api/member/update | 禁用启用成员:sys_user.tokenVersion +1 | seed MEMBER(Status=1),`{Status:2}` | `tokenVersion +1`;`sys_product_member.status==2` | 安全/会话吊销 | P0 | 冻结的成员不应继续持有生效 token |
+| TC-1132 | POST /api/member/update | 降级 DEVELOPER→MEMBER:sys_user.tokenVersion +1 | seed DEVELOPER,`{MemberType:"MEMBER"}` | `tokenVersion +1`;`memberType=="MEMBER"` | 安全/会话吊销 | P0 | DEVELOPER 同样算"特权身份",降为 MEMBER 必须视作"权限收窄" |
+| TC-1133 | POST /api/member/update | 升权 MEMBER→ADMIN:sys_user.tokenVersion 不变 | seed MEMBER,`{MemberType:"ADMIN"}` | `tokenVersion` 与之前完全相等;`memberType=="ADMIN"` | 正向回归 | P0 | 升权不构成对被管理方的实际损害,不应把目标用户误踢下线 |
+| TC-1134 | POST /api/member/update | 重启用 Disabled→Enabled:sys_user.tokenVersion 不变 | seed MEMBER(Status=2),`{Status:1}` | `tokenVersion` 不变;`status==1` | 正向回归 | P0 | 解冻不需要递增,维持既有会话有效 |
+| TC-1135 | POST /api/member/update | 降级事务失败(last-admin 400):sys_user.tokenVersion 不变 | 唯一启用 ADMIN,`{MemberType:"MEMBER"}` | 返回 400 "最后一个管理员";DB `sys_user.tokenVersion` 严格等于初值;`sys_product_member` 行内容也保持原状 | 事务回滚 | P0 | 关键:tokenVersion 增量必须与 member 更新在同一事务里;业务失败不得污染 tokenVersion |
+| TC-1136 | POST /api/member/update | no-op 更新不递增 tokenVersion | 传进来的 memberType/status 与 DB 现值相同 | `tokenVersion` 不变;早退分支不进事务 | 正向/幂等 | P0 | `locked.MemberType==nextType && locked.Status==nextStatus` 早退 |
+| TC-1137 | POST /api/member/update | 降级成功后 post-commit 失效 sysUser id-key / username-key 两把缓存 | seed ADMIN→降级 MEMBER,先预热 `FindOne(id)` + `FindOneByUsername(name)` 把缓存灌入 Redis | 事务成功返回后两把 cache key 均被 DEL;下一次 FindOne 取到 DB 中递增后的 tokenVersion | 缓存一致性 | P0 | UD loader 下次 cache-miss 重建时不得从旧 sysUser 缓存把 tokenVersion 抹回 |
 | 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 检查,不误伤合法路径 |
@@ -980,6 +995,21 @@ MySQL (InnoDB) + Redis Cache
 | TC-1081 | SysUserModel | 两段式 E2E:UpdateProfileWithTx(不碰缓存) + InvalidateProfileCache(清缓存) | - | 事务 commit 后 (a) 立即 FindOne (b) 调 invalidate 后再 FindOne | (a) 命中缓存返回旧值;(b) 回源 DB 取到新值 | P0 | 锁死"事务提交 → 缓存权威"的正确顺序,防止未来有人漏掉 invalidate |
 | TC-1082 | SysUserModel | UpdateUser tx 分支(改 deptId)post-commit 失效 sysUser 两级缓存 | - | 超管改 deptId 触发 tx 分支 | sysUser:id / sysUser:username / ud:{userId}:{product} 三把 key 均为空;下一轮 FindOne 返回新 deptId | P0 | Logic 层在 TransactCtx 返回后先 `SysUserModel.InvalidateProfileCache` 再 `UserDetailsLoader.Clean` |
 | TC-1083 | SysUserModel | (保留编号) | - | — | — | — | — |
+| TC-1143 | SysUserModel | `IncrementTokenVersionWithTx` 正常路径:事务内 `tokenVersion +1` 返回新值 | seed user(tv=5),TransactCtx 内调用 | 返回 6;事务 commit 后 DB 落盘为 6;`updateTime` 前进 | 正常路径 | P0 | LAST_INSERT_ID(tokenVersion+1) 原子自增契约 |
+| TC-1144 | SysUserModel | `IncrementTokenVersionWithTx` 目标行不存在 → ErrUpdateConflict | 调用不存在的 id | 返回 `(0, ErrUpdateConflict)`;事务内未生成 LAST_INSERT_ID | 异常路径 | P0 | `affected==0` 必须升格为 ErrUpdateConflict,不得静默返回 0 |
+| TC-1145 | SysUserModel | `IncrementTokenVersionWithTx` session==nil 报错 | session=nil | 返回错误("requires a non-nil session");不发 SQL | 契约/防御 | P0 | 非事务场景请改走 IncrementTokenVersion;不应被静默降级 |
+| TC-1146 | SysUserModel | `IncrementTokenVersionWithTx` 事务 rollback 时 DB 不落盘 | TransactCtx 内先 Increment 再 return err | DB 中 tokenVersion 保持初值;`updateTime` 不变 | 事务 | P0 | tokenVersion 增量必须能随业务事务整体回滚 |
+| TC-1147 | SysUserModel | `BatchIncrementTokenVersionWithTx` 正常批量 | 3 个 user,tv=0/5/10 | 三者 tokenVersion 各 +1(1/6/11) | 正常路径 | P0 | `IN(?,?,?)` 一次性批量递增 |
+| TC-1148 | SysUserModel | `BatchIncrementTokenVersionWithTx` 空 ids 直接返回 nil 且不发 SQL | ids=[] | `err==nil`;DB 任何行 tokenVersion 不变;事务内没有任何 UPDATE | 边界 | P0 | 空集合直接短路 |
+| TC-1149 | SysUserModel | `BatchIncrementTokenVersionWithTx` session==nil 报错 | session=nil | 返回错误;不发 SQL | 契约 | P0 | 与单个版本对称 |
+| TC-1150 | SysUserModel | `BatchIncrementTokenVersionWithTx` 事务 rollback 时全体回滚 | TransactCtx 内先 batch 再 return err | 所有目标 tokenVersion 保持初值 | 事务 | P0 | 原子性跨多行 |
+| TC-1151 | SysProductModel | `UpdateWithOptLockTx` 正常事务内 CAS | seed product,TransactCtx 内用正确 expectedUpdateTime | err==nil;commit 后 DB 落盘新 name/status;`updateTime` 推进 | 正常路径 | P0 | 事务版本复现 UpdateWithOptLock 的 CAS 语义 |
+| TC-1152 | SysProductModel | `UpdateWithOptLockTx` expectedUpdateTime 错位 → ErrUpdateConflict | 传入过期的 expectedUpdateTime | 返回 `ErrUpdateConflict`;事务 rollback 后 DB 无变更 | 异常/并发 | P0 | WHERE updateTime=? 打空 → affected=0 |
+| TC-1153 | SysProductModel | `UpdateWithOptLockTx` session==nil 报错 | session=nil | 返回错误;不发 SQL | 契约 | P0 | 与 sysUserModel 对称 |
+| TC-1154 | SysProductModel | `InvalidateProductCache` 一次性失效 id/appKey/code 三把 key | 预热三把缓存后调用 | 三把 key 均为空;`Get` 返回空串 | 缓存一致性 | P0 | post-commit 显式失效入口 |
+| TC-1155 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 返回启用成员 userId | 产品下 3 个 Status=1 + 1 个 Status=2 + 1 个他产品 | 返回 3 个启用成员 userId(disabled 与他产品均不在列) | 正常路径 | P0 | WHERE productCode=? AND status=1;跨产品 / 跨状态隔离 |
+| TC-1156 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 空产品 / 无启用成员 → 空切片 | 产品下 0 启用成员 | 返回 `len==0`;err==nil | 边界 | P0 | 与 UpdateProductLogic 的 `len(ids)==0` 分支对接 |
+| TC-1157 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 按 `id` 升序输出 | 乱序插入多个成员 | 返回切片严格按 `id` 升序 | 契约/死锁防护 | P1 | `ORDER BY id`:锁获取顺序稳定,避免与按主键扫描的其它事务互相死锁 |
 
 ### 8.2 SysProductModel
 

+ 66 - 42
test-report.md

@@ -12,9 +12,9 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **912** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1142** |
-| ✅ 通过 | **1141** |
+| TC 用例总数 (test-design.md) | **942** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1178** |
+| ✅ 通过 | **1177** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
@@ -23,32 +23,32 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| internal/handler | ✅ ok | 1.525s |
-| internal/handler/auth | ✅ ok | 0.827s |
-| internal/handler/product | ✅ ok | 1.845s |
-| internal/handler/pub | ✅ ok | 2.606s |
-| internal/loaders | ✅ ok | 3.202s |
-| internal/logic/auth | ✅ ok | 11.241s |
-| internal/logic/dept | ✅ ok | 2.601s |
-| internal/logic/member | ✅ ok | 2.941s |
-| internal/logic/perm | ✅ ok | 2.584s |
-| internal/logic/product | ✅ ok | 11.301s |
-| internal/logic/pub | ✅ ok | 6.463s |
-| internal/logic/role | ✅ ok | 3.622s |
-| internal/logic/user | ✅ ok | 10.440s |
-| internal/middleware | ✅ ok | 5.157s |
-| internal/model/dept | ✅ ok | 5.713s |
-| internal/model/perm | ✅ ok | 6.380s |
-| internal/model/product | ✅ ok | 7.341s |
-| internal/model/productmember | ✅ ok | 7.511s |
-| internal/model/role | ✅ ok | 7.733s |
-| internal/model/roleperm | ✅ ok | 7.391s |
-| internal/model/user | ✅ ok | 15.081s |
-| internal/model/userperm | ✅ ok | 7.425s |
-| internal/model/userrole | ✅ ok | 6.755s |
-| internal/response | ✅ ok | 5.293s |
-| internal/server | ✅ ok | 5.746s |
-| internal/util | ✅ ok | 4.965s |
+| 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 |
 
 ### 1.2 跳过用例说明
 
@@ -293,7 +293,15 @@
 | TC-0854 | 超管调 ProductDetail | ✅ pass |
 | TC-0855 | MEMBER 调 DeptTree | ✅ pass |
 | TC-0856 | MEMBER 调 DeptTree 且 DeptPath="" | ✅ pass |
-| TC-0857 | ADMIN 调 DeptTree | ✅ pass |
+| TC-0857 | 产品 ADMIN 调 DeptTree 仅见 DeptPath 子树 | ✅ pass |
+| TC-1128 | SuperAdmin 调 DeptTree 仍见完整树 | ✅ pass |
+| TC-1129 | DEVELOPER 调 DeptTree 同样剪枝到 DeptPath 子树 | ✅ pass |
+| TC-1138 | Enabled→Disabled 产品时,所有启用成员 tokenVersion 全部 +1;禁用成员不受影响 | ✅ pass |
+| TC-1139 | Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session) | ✅ pass |
+| TC-1140 | 仅改 Name / Remark 不改 Status 时 tokenVersion 不变 | ✅ pass |
+| TC-1141 | 禁用 P1 不得影响只属于 P2 的用户 tokenVersion(跨产品隔离) | ✅ pass |
+| TC-1142 | 禁用无启用成员产品时仍成功,产品行落盘 Disabled | ✅ pass |
+| TC-1143 | post-commit 失效 sysProduct 的 id / appKey / code 三把低层缓存 | ✅ pass |
 
 ### 2.8 创建部门 `POST /api/dept/create`
 
@@ -463,7 +471,7 @@
 | TC-0748 | 产品 ADMIN 不受子树限制 | ✅ pass |
 | TC-0814 | DEVELOPER 将他人 deptId 置 0 | ✅ pass |
 | TC-0815 | MEMBER 将他人 deptId 置 0 | ✅ pass |
-| TC-0816 | 产品 ADMIN 将他人 deptId 置 0 | ✅ pass |
+| TC-0816 | 产品 ADMIN 将他人 deptId 置 0 应 403 "仅超级管理员可将用户移出部门" | ✅ pass |
 | TC-0817 | SuperAdmin 将他人 deptId 置 0 | ✅ pass |
 | TC-1049 | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | ✅ pass |
 | TC-1050 | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | ✅ pass |
@@ -597,6 +605,14 @@
 | TC-1107 | addMember 非 ADMIN caller + 不存在 productCode → 403(阻断 productCode 枚举) | ✅ pass |
 | TC-1108 | addMember 非 ADMIN caller + 非法 MemberType → 403(权限优先于字面校验) | ✅ pass |
 | TC-1109 | addMember 超管 + 非法 MemberType → 400(正向回归,权限闸没误伤字面 400) | ✅ pass |
+| TC-1130 | 降级 ADMIN→MEMBER 同事务递增 sys_user.tokenVersion | ✅ pass |
+| TC-1131 | 禁用启用成员(Status 1→2)时 tokenVersion+1 | ✅ pass |
+| TC-1132 | 降级 DEVELOPER→MEMBER 时 tokenVersion+1(privileged 并集) | ✅ pass |
+| TC-1133 | 升权 MEMBER→ADMIN **不** 递增 tokenVersion | ✅ pass |
+| TC-1134 | 重启用(Disabled→Enabled)tokenVersion 不变 | ✅ pass |
+| TC-1135 | 降级最后一个 ADMIN 被拒时 tokenVersion 未被污染(事务 rollback 原子性) | ✅ pass |
+| TC-1136 | no-op 更新(与现值一致)不进入事务,tokenVersion 不变 | ✅ pass |
+| TC-1137 | 降级后 post-commit 失效 sysUser id/username 两把低层缓存 | ✅ pass |
 
 ---
 
@@ -972,6 +988,14 @@
 | TC-1081 | SysUserModel — 两段式 E2E:UpdateProfileWithTx(不碰缓存) + InvalidateProfileCache(清缓存) | ✅ pass |
 | TC-1082 | SysUserModel — UpdateUser tx 分支(改 deptId)post-commit 失效 sysUser 两级缓存 | ✅ pass |
 | TC-1083 | SysUserModel — (保留编号) | ✅ pass |
+| TC-1143 | SysUserModel — `IncrementTokenVersionWithTx` 事务内 UPDATE 返回 LAST_INSERT_ID(DB 真实递增值) | ✅ pass |
+| TC-1144 | SysUserModel — `IncrementTokenVersionWithTx` 目标行被并发删除 → `ErrUpdateConflict`(affected=0) | ✅ pass |
+| TC-1145 | SysUserModel — `IncrementTokenVersionWithTx` nil session → fail-fast 报错 | ✅ pass |
+| TC-1146 | SysUserModel — `IncrementTokenVersionWithTx` 事务 rollback 时 DB 不落盘 | ✅ pass |
+| TC-1147 | SysUserModel — `BatchIncrementTokenVersionWithTx` 多用户全部 +1 | ✅ pass |
+| TC-1148 | SysUserModel — `BatchIncrementTokenVersionWithTx` 空 ids 不报错、不触达 DB | ✅ pass |
+| TC-1149 | SysUserModel — `BatchIncrementTokenVersionWithTx` nil session → fail-fast 报错 | ✅ pass |
+| TC-1150 | SysUserModel — `BatchIncrementTokenVersionWithTx` 事务 rollback 时全部回滚 | ✅ pass |
 
 ### 8.2 SysProductModel
 
@@ -980,6 +1004,12 @@
 | TC-0423 | FindList — 正常分页 | ✅ pass |
 | TC-0424 | FindList — 空表 | ✅ pass |
 | TC-0425 | FindList — count失败 | ✅ pass |
+| TC-1151 | SysProductModel — `UpdateWithOptLockTx` 正常事务内 CAS 命中 → DB 落盘 | ✅ pass |
+| TC-1152 | SysProductModel — `UpdateWithOptLockTx` expectedUpdateTime 错位 → `ErrUpdateConflict`,DB 不变 | ✅ pass |
+| TC-1153 | SysProductModel — `UpdateWithOptLockTx` nil session → fail-fast 报错 | ✅ pass |
+| TC-1154 | SysProductModel — `UpdateWithOptLockTx` 事务 rollback 时 DB 不落盘 | ✅ pass |
+| TC-1155 | SysProductModel — `InvalidateProductCache` 一次性失效 id / appKey / code 三把低层缓存 | ✅ pass |
+| TC-1156 | SysProductModel — `InvalidateProductCache` 在 canceled/deadline ctx 下不 panic、不阻塞 | ✅ pass |
 
 ### 8.3 SysPermModel
 
@@ -1090,6 +1120,9 @@
 | TC-0870 | SysProductMemberModel — 存在 1 个 active + 1 个 disabled admin | ✅ pass |
 | TC-1110 | FindOneForShareTx — 事务内读到最新行、字段对齐(S 锁契约) | ✅ pass |
 | TC-1111 | FindOneForShareTx — 不存在 id 返回 `sqlx.ErrNotFound`(供上层区分"被并发删除") | ✅ pass |
+| TC-1157 | SysProductMemberModel — `FindActiveMemberUserIdsByProductCodeTx` 仅返回 Enabled userId,过滤 Disabled 与其他产品 | ✅ pass |
+| TC-1158 | SysProductMemberModel — `FindActiveMemberUserIdsByProductCodeTx` productCode 不存在时返回空切片、err=nil | ✅ pass |
+| TC-1159 | SysProductMemberModel — `FindActiveMemberUserIdsByProductCodeTx` 仅禁用成员时返回空(status 过滤不得退化为全量) | ✅ pass |
 
 ## 九、访问控制 (auth/access.go)
 
@@ -1303,16 +1336,7 @@
 
 ## 三、测试结论
 
-- **912 个 TC 全部执行**:通过 **1141**(含 subtests),跳过 **1**,失败 **0**。
-- 26 个测试包全部 OK;本轮整包连跑均绿,无并发 flake 触发。
+- **942 个 TC 全部执行**:通过 **1177**(含 subtests),跳过 **1**,失败 **0**。
+- 26 个测试包全部 OK;整包连跑均绿,无并发 flake 触发。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全)均有独立回归用例覆盖且稳定通过。
-
-### 3.1 本轮新增 / 调整回归覆盖(R14 防线)
-
-| 方向 | 覆盖用例 | 要点 |
-| :--- | :--- | :--- |
-| **M-R14-1 post-commit 缓存清理与请求 ctx 解耦** | TC-1117、TC-1118 | `RotateRefreshToken` 与 `ExecuteSyncPerms` 两处事务/CAS 提交之后的 `UserDetailsLoader.Clean` / `CleanByProduct` 必须跑在 `DetachCacheCleanCtx` 返回的独立 ctx 上,避免 HTTP deadline / client 断连在 5 分钟 TTL 内把"旧 tokenVersion UD" 或 "挂着被禁用 perm 的 UD" 留在 Redis |
-| **L-R14-1 跨产品 roleId 枚举 Oracle 闭合** | TC-1119 / TC-1120 / TC-1121 | `UpdateRole` / `DeleteRole` / `BindRolePerms` 三路径统一通过 `authHelper.ResolveOwnRoleOr404` 返回 404 "角色不存在",与 RoleDetail 的 M-N3 口径完全对齐,攻击者无法再借 404 vs 403 差分枚举他产品 roleId |
-| **L-R14-2 BindRoles 文案统一** | TC-0188 / TC-0189 / TC-0190 / TC-1078 / TC-1127 | 跨产品 / 已禁用 / 不存在 / race_deleted 四条路径对外统一为 400 "包含无效的角色ID",详细 reason 仅落审计日志;既堵死文案枚举 Oracle,又顺带收敛原 TC-1078 的并发 flake |
-| **H-R14-1 跨产品信任边界(DEV 部门)收敛** | TC-1122 / TC-1123 / TC-1124 / TC-1125 / TC-1126 | `CreateUser` 与 `UpdateUser` 对"调入 DEV 部门"的动作统一收敛给 SuperAdmin,阻断"P1.ADMIN 把 P1/P2 共同成员挪进 DEV → 在 P2 瞬间全权"的跨产品升权攻击链;同时以正向用例确认 SuperAdmin 合法运维 / ADMIN 跨子树调入 **非 DEV** 部门均不受误伤 |