Sfoglia il codice sorgente

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

BaiLuoYan 3 settimane fa
parent
commit
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)
 ## 🚩 核心逻辑漏洞 (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
 ```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
 ```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"
 	"context"
 	"strings"
 	"strings"
 
 
-	"perms-system-server/internal/consts"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/middleware"
 	deptModel "perms-system-server/internal/model/dept"
 	deptModel "perms-system-server/internal/model/dept"
 	"perms-system-server/internal/response"
 	"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) {
 func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 	caller := middleware.GetUserDetails(l.ctx)
 	caller := middleware.GetUserDetails(l.ctx)
 	if caller == nil {
 	if caller == nil {
@@ -42,7 +47,7 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
+	fullAccess := caller.IsSuperAdmin
 
 
 	if !fullAccess {
 	if !fullAccess {
 		if caller.DeptPath == "" {
 		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 级账号枚举全公司组织结构;
 // * MEMBER 级账号枚举全公司组织结构;
-// * 定位 DEV 部门再针对性申请权限。
-//
-// ADMIN / SuperAdmin 保留完整树(运营使用场景)。
+// * 定位 DEV 部门再针对性申请权限;
+// * 审计 L-R15-2:小产品 ADMIN 借 fullAccess 侦察大产品的 DEV/HR/财务部门命名,
+//   为针对性社工 / 撞库提供前置输入——sys_dept 是全局命名空间,ADMIN 在产品 P1
+//   的授权范围不应扩散到 P2 的组织结构视图。
 //
 //
 // 测试数据:一棵 "/100/" 根下挂 "/100/1/"、"/100/1/5/",以及一个平行分支 "/200/"。
 // 测试数据:一棵 "/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{
 var allDepts = []*deptModel.SysDept{
@@ -85,8 +87,13 @@ func TestDeptTree_OrphanMember_ReturnsEmpty(t *testing.T) {
 	assert.Len(t, tree, 0, "DeptPath 为空必须返回空树,不能泄露组织结构")
 	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)
 	ctrl := gomock.NewController(t)
 	t.Cleanup(ctrl.Finish)
 	t.Cleanup(ctrl.Finish)
 
 
@@ -97,16 +104,80 @@ func TestDeptTree_Admin_FullTree(t *testing.T) {
 
 
 	caller := &loaders.UserDetails{
 	caller := &loaders.UserDetails{
 		UserId: 2, IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
 		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",
 		DeptId: 100, DeptPath: "/100/", ProductCode: "pA",
 	}
 	}
 	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
 	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
-	// 完整树:根有 2 个(id=100, id=200)。
-	require.Len(t, tree, 2, "ADMIN 应看到完整部门树,包括兄弟分支")
+	require.Len(t, tree, 2, "SuperAdmin 必须看到完整树,包含所有平行根")
 	var rootIds []int64
 	var rootIds []int64
 	for _, r := range tree {
 	for _, r := range tree {
 		rootIds = append(rootIds, r.Id)
 		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 时会被拒绝以保证产品始终有管理员。
 // UpdateMember 更新产品成员。修改成员类型或启用/禁用状态。降级最后一个 ADMIN 时会被拒绝以保证产品始终有管理员。
 // 审计 L-R11-1:memberType / status 均为指针可选,nil 表示不改该字段;两者都为 nil 时直接 400。
 // 审计 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 {
 func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	if req.MemberType == nil && req.Status == nil {
 	if req.MemberType == nil && req.Status == nil {
 		return response.ErrBadRequest("请至少提供一个要更新的字段(memberType 或 status)")
 		return response.ErrBadRequest("请至少提供一个要更新的字段(memberType 或 status)")
@@ -74,6 +84,15 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		return nil
 		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 {
 	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)
 		locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
 		if err != nil {
 		if err != nil {
@@ -91,9 +110,24 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 				return response.ErrBadRequest("不能降级或禁用该产品的最后一个管理员")
 				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.MemberType = nextType
 		locked.Status = nextStatus
 		locked.Status = nextStatus
 		locked.UpdateTime = time.Now().Unix()
 		locked.UpdateTime = time.Now().Unix()
+		tokenVersionTarget.userId = locked.UserId
 		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, locked)
 		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, locked)
 	}); err != nil {
 	}); err != nil {
 		return err
 		return err
@@ -104,5 +138,25 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
 	defer cancel()
 	l.svcCtx.UserDetailsLoader.Del(cleanCtx, member.UserId, member.ProductCode)
 	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
 	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 (
 import (
 	"database/sql"
 	"database/sql"
 	"errors"
 	"errors"
+	"fmt"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	productModel "perms-system-server/internal/model/product"
 	productModel "perms-system-server/internal/model/product"
 	memberModel "perms-system-server/internal/model/productmember"
 	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.Equal(t, 400, ce.Code())
 	assert.Contains(t, ce.Error(), "最后一个管理员")
 	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"
 	"perms-system-server/internal/types"
 
 
 	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 )
 
 
 type UpdateProductLogic struct {
 type UpdateProductLogic struct {
@@ -31,6 +32,14 @@ func NewUpdateProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upd
 }
 }
 
 
 // UpdateProduct 更新产品信息。仅超管可调用,可修改产品名称、备注和启用/禁用状态。禁用产品后其成员将无法访问。
 // 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 {
 func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
 	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
 		return err
 		return err
@@ -48,18 +57,46 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 		return response.ErrNotFound("产品不存在")
 		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
 	prevUpdateTime := product.UpdateTime
 	product.Name = req.Name
 	product.Name = req.Name
 	product.Remark = req.Remark
 	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()
 	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) {
 		if errors.Is(err, productModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 		}
 		}
@@ -69,6 +106,24 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 	// 审计 L-R13-5 方案 A:产品禁用直接让 loadPerms 清空 Perms,UD 失效不能随请求断连丢失。
 	// 审计 L-R13-5 方案 A:产品禁用直接让 loadPerms 清空 Perms,UD 失效不能随请求断连丢失。
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
 	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.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
 	return nil
 }
 }

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

@@ -2,11 +2,16 @@ package product
 
 
 import (
 import (
 	"context"
 	"context"
+	"database/sql"
 	"errors"
 	"errors"
+	"fmt"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
+	"perms-system-server/internal/consts"
 	productModel "perms-system-server/internal/model/product"
 	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/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil"
@@ -15,6 +20,7 @@ import (
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 )
 )
 
 
 func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct {
 func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct {
@@ -149,3 +155,257 @@ func TestUpdateProduct_InvalidStatusRejected(t *testing.T) {
 	require.NoError(t, err)
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), after.Status, "非法状态不应落库")
 	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("无权将用户调入非自己管辖的部门")
 				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
 			}
 			}
 		} else {
 		} 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
 		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
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 403, ce.Code())
 	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)
 	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
 	require.NoError(t, err)
 	require.NoError(t, err)
@@ -904,8 +906,15 @@ func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
 	assert.Equal(t, targetDeptId, u.DeptId)
 	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()
 	bootstrap := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
 	conn := testutil.GetTestSqlConn()
@@ -928,15 +937,22 @@ func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) {
 	})
 	})
 
 
 	zero := int64(0)
 	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)
 	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
 	require.NoError(t, err)
 	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 有权将他人移出部门(豁免路径)。
 // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。

+ 49 - 0
internal/model/product/sysProductModel.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 
 
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 )
@@ -19,6 +20,18 @@ type (
 		sysProductModel
 		sysProductModel
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
 		UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime 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),用于把跨表写入(如权限同步)
 		// LockByCodeTx 在当前事务里锁定 product 行(SELECT ... FOR UPDATE),用于把跨表写入(如权限同步)
 		// 按 product 串行化,避免两次并发 SyncPermissions 在 sys_perm UNIQUE(productCode, code) 上撞 1062。
 		// 按 product 串行化,避免两次并发 SyncPermissions 在 sys_perm UNIQUE(productCode, code) 上撞 1062。
 		LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error)
 		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
 	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) {
 func (m *customSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error) {
 	var resp SysProduct
 	var resp SysProduct
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `code` = ? LIMIT 1 FOR UPDATE", sysProductRows, m.table)
 	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/go-sql-driver/mysql"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil"
 	"strings"
 	"strings"
@@ -849,3 +850,233 @@ func TestSysProductModel_LockByCodeTx_BlocksConcurrentWriter(t *testing.T) {
 			"意味着 FOR UPDATE 行锁失效,声称的'按 product 串行化'不成立",
 			"意味着 FOR UPDATE 行锁失效,声称的'按 product 串行化'不成立",
 		elapsedMs, minBlockedMs))
 		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 不会被并发改写,
 		// 会对该行取 X 锁,被本 S 锁阻塞;本事务提交前 member.memberType 不会被并发改写,
 		// DENY 脏行"能写永不生效"的数据污染被收敛。本方法不走缓存,必须在 TransactCtx / Session 下调用。
 		// DENY 脏行"能写永不生效"的数据污染被收敛。本方法不走缓存,必须在 TransactCtx / Session 下调用。
 		FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
 		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 {
 	customSysProductMemberModel struct {
@@ -91,3 +98,13 @@ func (m *customSysProductMemberModel) FindOneForShareTx(ctx context.Context, ses
 	}
 	}
 	return &data, nil
 	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)
 	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 审计 M-R11-2:username 由调用方透传,避免仅为构造缓存键而多打一次 FindOne。
 		IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error)
 		IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error)
 		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (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 {
 	customSysUserModel struct {
@@ -322,6 +342,54 @@ func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, i
 	return newVersion, nil
 	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) {
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {
 	if len(ids) == 0 {
 	if len(ids) == 0 {
 		return nil, nil
 		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)
 	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.
 // LockByCodeTx mocks base method.
 func (m *MockSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*product.SysProduct, error) {
 func (m *MockSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*product.SysProduct, error) {
 	m.ctrl.T.Helper()
 	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)
 	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.
 // UpdateWithTx mocks base method.
 func (m *MockSysProductModel) UpdateWithTx(ctx context.Context, session sqlx.Session, data *product.SysProduct) error {
 func (m *MockSysProductModel) UpdateWithTx(ctx context.Context, session sqlx.Session, data *product.SysProduct) error {
 	m.ctrl.T.Helper()
 	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)
 	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.
 // FindListByProductCode mocks base method.
 func (m *MockSysProductMemberModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
 func (m *MockSysProductMemberModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
 	m.ctrl.T.Helper()
 	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)
 	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.
 // BatchInsert mocks base method.
 func (m *MockSysUserModel) BatchInsert(ctx context.Context, dataList []*user.SysUser) error {
 func (m *MockSysUserModel) BatchInsert(ctx context.Context, dataList []*user.SysUser) error {
 	m.ctrl.T.Helper()
 	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)
 	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.
 // Insert mocks base method.
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 	m.ctrl.T.Helper()
 	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-0086 / TC-0088~~ | ~~非超管 AppKey 隐藏~~ | — | — | — | — | — | **已删除**:由 TC-0850(list)+ TC-0853(detail)覆盖 |
 | ~~TC-0087 / TC-0089~~ | ~~超管可见 AppKey~~ | — | — | — | — | — | **已删除**:由 TC-0854(detail)+ TC-0871(list)覆盖 |
 | ~~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-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-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-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 |
 | 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-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-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-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`
 ### 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-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-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-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-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-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 锁" 导致退化 |
 | 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-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-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-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-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-1108 | POST /api/member/add | 非 ADMIN caller + 非法 `MemberType`:返回 403 而不是 400(权限优先于字面校验) | `MemberCtx` + `MemberType="INVALID"` | `CodeError.Code()==403`(不是 400 "无效的成员类型") | 安全/枚举 | P0 | 防通过 400/404 差分探测产品/用户存在性 |
 | TC-1109 | POST /api/member/add | 超管 + 非法 `MemberType`:正常 400 | `SuperAdminCtx` + `MemberType="INVALID"`(产品存在) | `CodeError.Code()==400`,文案含 "无效的成员类型" | 正向回归 | P0 | 确认权限通过后仍走字面 400 检查,不误伤合法路径 |
 | TC-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-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-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-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
 ### 8.2 SysProductModel
 
 

+ 66 - 42
test-report.md

@@ -12,9 +12,9 @@
 | 指标 | 数值 |
 | 指标 | 数值 |
 | :--- | :--- |
 | :--- | :--- |
 | 测试包总数 | **26** |
 | 测试包总数 | **26** |
-| TC 用例总数 (test-design.md) | **912** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1142** |
-| ✅ 通过 | **1141** |
+| TC 用例总数 (test-design.md) | **942** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1178** |
+| ✅ 通过 | **1177** |
 | ⏭️ 跳过 | **1** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
 | 通过率 (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 跳过用例说明
 ### 1.2 跳过用例说明
 
 
@@ -293,7 +293,15 @@
 | TC-0854 | 超管调 ProductDetail | ✅ pass |
 | TC-0854 | 超管调 ProductDetail | ✅ pass |
 | TC-0855 | MEMBER 调 DeptTree | ✅ pass |
 | TC-0855 | MEMBER 调 DeptTree | ✅ pass |
 | TC-0856 | MEMBER 调 DeptTree 且 DeptPath="" | ✅ 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`
 ### 2.8 创建部门 `POST /api/dept/create`
 
 
@@ -463,7 +471,7 @@
 | TC-0748 | 产品 ADMIN 不受子树限制 | ✅ pass |
 | TC-0748 | 产品 ADMIN 不受子树限制 | ✅ pass |
 | TC-0814 | DEVELOPER 将他人 deptId 置 0 | ✅ pass |
 | TC-0814 | DEVELOPER 将他人 deptId 置 0 | ✅ pass |
 | TC-0815 | MEMBER 将他人 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-0817 | SuperAdmin 将他人 deptId 置 0 | ✅ pass |
 | TC-1049 | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | ✅ pass |
 | TC-1049 | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | ✅ pass |
 | TC-1050 | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | ✅ pass |
 | TC-1050 | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | ✅ pass |
@@ -597,6 +605,14 @@
 | TC-1107 | addMember 非 ADMIN caller + 不存在 productCode → 403(阻断 productCode 枚举) | ✅ pass |
 | TC-1107 | addMember 非 ADMIN caller + 不存在 productCode → 403(阻断 productCode 枚举) | ✅ pass |
 | TC-1108 | addMember 非 ADMIN caller + 非法 MemberType → 403(权限优先于字面校验) | ✅ pass |
 | TC-1108 | addMember 非 ADMIN caller + 非法 MemberType → 403(权限优先于字面校验) | ✅ pass |
 | TC-1109 | addMember 超管 + 非法 MemberType → 400(正向回归,权限闸没误伤字面 400) | ✅ 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-1081 | SysUserModel — 两段式 E2E:UpdateProfileWithTx(不碰缓存) + InvalidateProfileCache(清缓存) | ✅ pass |
 | TC-1082 | SysUserModel — UpdateUser tx 分支(改 deptId)post-commit 失效 sysUser 两级缓存 | ✅ pass |
 | TC-1082 | SysUserModel — UpdateUser tx 分支(改 deptId)post-commit 失效 sysUser 两级缓存 | ✅ pass |
 | TC-1083 | SysUserModel — (保留编号) | ✅ 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
 ### 8.2 SysProductModel
 
 
@@ -980,6 +1004,12 @@
 | TC-0423 | FindList — 正常分页 | ✅ pass |
 | TC-0423 | FindList — 正常分页 | ✅ pass |
 | TC-0424 | FindList — 空表 | ✅ pass |
 | TC-0424 | FindList — 空表 | ✅ pass |
 | TC-0425 | FindList — count失败 | ✅ 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
 ### 8.3 SysPermModel
 
 
@@ -1090,6 +1120,9 @@
 | TC-0870 | SysProductMemberModel — 存在 1 个 active + 1 个 disabled admin | ✅ pass |
 | TC-0870 | SysProductMemberModel — 存在 1 个 active + 1 个 disabled admin | ✅ pass |
 | TC-1110 | FindOneForShareTx — 事务内读到最新行、字段对齐(S 锁契约) | ✅ pass |
 | TC-1110 | FindOneForShareTx — 事务内读到最新行、字段对齐(S 锁契约) | ✅ pass |
 | TC-1111 | FindOneForShareTx — 不存在 id 返回 `sqlx.ErrNotFound`(供上层区分"被并发删除") | ✅ 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)
 ## 九、访问控制 (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%**。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全)均有独立回归用例覆盖且稳定通过。
 - 核心业务路径(登录、刷新 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** 部门均不受误伤 |