Bläddra i källkod

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

BaiLuoYan 4 veckor sedan
förälder
incheckning
acadaebbc2
43 ändrade filer med 1881 tillägg och 555 borttagningar
  1. 467 360
      audit-report.md
  2. 16 0
      internal/logic/auth/access.go
  3. 9 0
      internal/logic/auth/logoutLogic.go
  4. 106 0
      internal/logic/auth/logoutRateLimit_audit_test.go
  5. 5 1
      internal/logic/member/addMemberLogic.go
  6. 11 9
      internal/logic/member/removeMemberLogic.go
  7. 9 0
      internal/logic/member/removeMemberLogic_mock_test.go
  8. 17 10
      internal/logic/member/updateMemberLogic.go
  9. 3 1
      internal/logic/pub/loginLogic.go
  10. 19 8
      internal/logic/pub/loginService.go
  11. 85 0
      internal/logic/pub/loginService_enum_audit_test.go
  12. 9 0
      internal/logic/pub/refreshTokenLogic.go
  13. 104 0
      internal/logic/pub/refreshTokenRateLimit_audit_test.go
  14. 5 1
      internal/logic/role/deleteRoleLogic.go
  15. 2 1
      internal/logic/role/deleteRoleLogic_mock_test.go
  16. 6 3
      internal/logic/user/bindRolesLogic.go
  17. 2 1
      internal/logic/user/createUserLogic.go
  18. 4 0
      internal/logic/user/setUserPermsLogic.go
  19. 174 0
      internal/logic/user/setUserPermsSelfEscalation_audit_test.go
  20. 172 0
      internal/logic/user/updateUserDeptScope_audit_test.go
  21. 17 3
      internal/logic/user/updateUserLogic.go
  22. 2 1
      internal/logic/user/updateUserLogic_test.go
  23. 2 11
      internal/logic/user/updateUserStatusLogic.go
  24. 3 16
      internal/logic/user/userListLogic.go
  25. 5 12
      internal/logic/user/userListLogic_mock_test.go
  26. 4 4
      internal/middleware/jwtauthMiddleware.go
  27. 159 0
      internal/middleware/jwtauth_checkorder_audit_test.go
  28. 14 1
      internal/middleware/ratelimitMiddleware.go
  29. 1 1
      internal/model/perm/sysPermModel.go
  30. 24 2
      internal/model/productmember/sysProductMemberModel.go
  31. 4 4
      internal/model/roleperm/sysRolePermModel.go
  32. 130 0
      internal/model/user/incrementTokenVersion_audit_test.go
  33. 31 12
      internal/model/user/sysUserModel.go
  34. 12 5
      internal/model/user/sysUserModel_test.go
  35. 2 2
      internal/model/userperm/sysUserPermModel.go
  36. 18 6
      internal/model/userrole/sysUserRoleModel.go
  37. 9 8
      internal/server/permserver.go
  38. 3 0
      internal/svc/servicecontext.go
  39. 45 15
      internal/testutil/mocks/mock_productmember_model.go
  40. 20 19
      internal/testutil/mocks/mock_user_model.go
  41. 15 0
      internal/testutil/mocks/mock_userrole_model.go
  42. 64 0
      test-design.md
  43. 72 38
      test-report.md

+ 467 - 360
audit-report.md

@@ -1,530 +1,630 @@
-# 权限管理系统 - 深度代码审计报告
+# 权限管理系统 - 深度代码审计报告(第 3 轮)
 
-> 审计范围:`/internal` 下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、consts、response、util)及入口文件 `perm.go`。
-> 审计时间:2026-04-18
-> 审计重点:业务逻辑闭环、跨接口一致性、权限绕过、缓存一致性、并发竞态、资源与性能、僵尸代码、接口契约完整性。
-> 相对上一轮:H-1(BindRoles 误拦截 ADMIN)、H-2(GetUserPerms 未校验状态)、H-3(DEV 部门绕过)、M-2(批量 DELETE)、M-3/M-4(roleIds 语义)、M-5(UpdateDept 级联)、M-6(Claims.Perms)、M-11(DeleteDept TOCTOU)、L-3(UpdateDept 乐观锁)均已修复。本报告聚焦残留问题与本轮新发现。
+> 审计范围:`/internal` 下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、consts、response、util)及入口文件 `perm.go`、`perm.api`。
+> 审计时间:2026-04-19
+> 审计重点:自我越权、并发竞态、接口 DoS、事务内外读写分裂、缓存一致性、接口契约完整性。
+> 相对上一轮修复情况:
+> - **已修复**:H-1(产品禁用联动)、H-2(JwtAuth MemberType 校验)、H-3(ManagementKey 前置)、H-4(最后一个 ADMIN 保护)、M-1(/auth/logout 路由)、M-2(refreshToken 轮转递增 tokenVersion)、M-9(BindRolePerms 事务外用户集查询已改为事务后)、M-11(`FindByPathPrefix`/`FindByParentId` 已清理)、M-12(UpdateUserStatus 重复校验已清理)、M-14(setUserPerms 校验产品启用)、M-15(BindRoles 无 diff 也清缓存)、M-16(用户 perm 查询已加 `p.status=1`)、L-3(非超管禁止下调 permsLevel)、L-4(CreateRole 校验产品启用)、L-5(AddMember 校验产品启用)、L-6(UserDetail 分支语义已明确)。
+> - **未修复且仍存在**:M-3(`generateRandomHex` 截断 bug)、M-4(`DeptTree` 权限过滤)、M-5(`ProductList`/`ProductDetail` 权限过滤)、M-7(`X-Real-IP` 信任 + 无 XFF)、M-8(缓存失效非原子)、L-1/L-2/L-7~L-10。
+> - **新增 P0/P1 问题**:H-A(`SetUserPerms` 自我权限越权)、H-B(`IncrementTokenVersion` 读缓存导致返回值错位)、M-A("最后 ADMIN" 校验存在 TOCTOU)、M-B(`/auth/refreshToken` 无限流,DB 热点写 DoS)、M-C(产品登录用户枚举 + 选择性锁定)、M-D(`DeleteRole` 事务内但用非事务连接读用户集)、M-E(多个 `Delete*ForProductTx` 的 "先 SELECT 再 DELETE" 非原子)、M-F(`CountActiveAdmins` 硬编码 'ADMIN' 字面量)。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-1. 禁用产品后,已持有 token 的成员仍可正常使用该产品
+### H-A. `SetUserPerms` 对"自己调用自己"直接放行,普通 MEMBER 可自我授予产品内任意权限(自我越权)
 
 - **位置**:
-  - `internal/logic/product/updateProductLogic.go:30-63`
-  - `internal/middleware/jwtauthMiddleware.go:45-89`
-  - `internal/loaders/userDetailsLoader.go:280-290`(`loadProduct`)、`348-364`(`loadPerms`)
-  - `internal/server/permserver.go:157-222`(`VerifyToken` / `GetUserPerms`)
+  - `internal/logic/user/setUserPermsLogic.go:50`
+  - `internal/logic/auth/access.go:47`(`CheckManageAccess` 内的 `if caller.UserId == targetUserId { return nil }`)
 - **描述**:
-  `UpdateProduct` 在将 `sys_product.status` 置为 `Disabled` 之后,只做了 `UserDetailsLoader.CleanByProduct(product.Code)`。但:
-
-  1. `loadProduct` 从 DB 只取 `ProductName`,**没有把 `product.Status` 写入 `UserDetails`**。
-  2. `loadPerms` 的"全量权限"短路条件里完全没有引用产品状态,因此哪怕产品被禁用,`IsSuperAdmin / ADMIN / DEVELOPER / DEV 部门` 四类用户重新 `Load` 后仍会拿到完整 `perms`。
-  3. `jwtauthMiddleware.Handle` 只校验 `ud.Username / ud.Status / claims.TokenVersion`,**没有校验产品状态**。
-  4. 对外的 `gRPC VerifyToken` 和 `gRPC GetUserPerms` 也没有校验产品状态。
-  5. 产品登录入口 `ValidateProductLogin` 是唯一校验了 `product.Status != Enabled` 的点;但这仅影响**新登录**,对已经签发的 access / refresh token 无任何影响。
-
-  也就是说:管理员把一个产品"冻结"之后,该产品的所有在线用户在整个 `AccessExpire`(甚至通过 `RefreshToken` 可以一直续期到 `RefreshExpire`)窗口内都能继续访问产品端的所有接口,并且接入方通过 gRPC `GetUserPerms` / `VerifyToken` 拿到的权限和"有效"状态也依然是放行的。
-
-- **影响**:
-  - "禁用产品"这一核心管控动作**近乎无效**:离线下线、应急止损、合规处置场景下,管理员无法阻断业务。
-  - 攻击面:当某个产品因为安全事件需要临时下线时(例如 appSecret 泄露、业务侧数据异常),除物理删除该产品之外,没有办法收回其成员的访问能力。
-  - 对接入方(产品服务端)的一致性漏洞:管理系统显示"产品禁用"、但对外 RPC 依然告知"这个用户有全部权限"。
-
-- **修复方案**:
-  - `userDetailsLoader` 的 `UserDetails` 增加 `ProductStatus int64` 字段,`loadProduct` 赋值。
-  - `loadPerms` 在所有"自动给全量权限"的分支上叠加 `ud.ProductStatus == StatusEnabled` 前置;或者在 `loadPerms` 入口直接:
-
+  - `SetUserPerms` 使用 `CheckManageAccess(ctx, svcCtx, req.UserId, productCode)` 作为唯一的访问控制。
+  - `CheckManageAccess` 中"自己操作自己"是无条件放行的短路:
     ```go
-    if ud.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
-        ud.Perms = nil
-        return
+    if caller.UserId == targetUserId {
+        return nil
     }
     ```
+  - 随后逻辑只校验:
+    1. 目标是当前产品的有效成员(调用者自己当然是);
+    2. 传入的 permIds 都属于当前产品、且 `status=1`;
+    3. DELETE 现有 `sys_user_perm`(userId+productCode),然后 `BatchInsert` 新的 (permId, effect) 对。
+  - 没有校验"调用者本身是否有权授予该 perm"。
+
+  攻击路径(任意 MEMBER 都可完成):
+
+  ```http
+  POST /api/user/setPerms
+  Authorization: Bearer <自己的 access token>
+  {
+    "userId": <自己的 userId>,
+    "perms": [
+      {"permId": 1, "effect": "ALLOW"},
+      {"permId": 2, "effect": "ALLOW"},
+      ...所有 permId...
+    ]
+  }
+  ```
 
-  - `jwtauthMiddleware.Handle` 与 `RefreshToken` / `VerifyToken` / `GetUserPerms` 在 `claims.ProductCode != ""` 时统一校验 `ud.ProductStatus == StatusEnabled`,非启用直接 `403 "该产品已被禁用"`。
-  - `UpdateProduct` 在置 `Disabled` 时,**同步把该产品所有成员的 `tokenVersion+1`**(或引入一个 `product_token_epoch`),从而让所有既有 token 立即作废。推荐后者:新增 `sys_product.tokenEpoch`,access token 里带 `productEpoch`,中间件对比。
-
----
-
-### H-2. JWT 中间件未校验"产品成员是否被禁用",造成已被剔除/禁用成员仍可访问业务接口
+  下一次 `loadPerms` 会走"普通成员"分支:`rolePerms ∪ userAllow − userDeny`。用户自己塞进去的 `userAllow` 全部生效(第 427-431 行),直接获得整个产品的全部 `perms` 集合。中间件侧的 `ud.Perms` 也会包含这些 code;下游产品服务通过 `GetUserPerms` gRPC 拿到的权限同样被污染。
 
-- **位置**:`internal/middleware/jwtauthMiddleware.go:73-87`
-- **描述**:`RefreshToken`(`refreshTokenLogic.go:53`、`permserver.go:125`)、`VerifyToken`(`permserver.go:174`)、`GetUserPerms`(`permserver.go:214`)都会在 `productCode != "" && !IsSuperAdmin` 时校验 `ud.MemberType != ""`——这正是 `loadMembership` 在成员不存在或 `status != Enabled` 时的返回值。
+  该越权对 ADMIN/DEVELOPER/SUPER_ADMIN 无新增危害(这三类本来就有全产品权限),但对 **MEMBER 类型是完全的权限集逃逸**。
 
-  但是 **HTTP 主流量入口 `JwtAuth` 中间件**却没有这个校验,只看用户自身 `status`。结果:
+- **影响**:
+  - 产品端的"最小权限"模型**彻底失效**:任何通过 `/auth/login` 登录的普通成员都能自我提权到"全权限"(等价于 ADMIN 级 perms,但不改 `memberType`、不触发 `checkDeptHierarchy` 等 ADMIN 后续护栏——也就是说**绕过权限级别校验、绕过部门护栏**,仅对自身 perms 集合生效)。
+  - 与 `BindRoles` 形成对比:`BindRoles` 的"自己操作自己"在 `caller.MemberType == MEMBER` 时仍会被 `r.PermsLevel < caller.MinPermsLevel` 拦截(`bindRolesLogic.go:82-88`),避免了自我绑高等级角色;但 `SetUserPerms` **没有任何对应的自我越权护栏**。
+  - 由于 `ud.Perms` 会被注入到 access token 的 `claims` 之外的 DB 查询结果中(走的是 loader,不是 JWT 内联 perms),攻击者不需要再刷 token,下一次请求就生效。
 
-  - 管理员执行 `UpdateMember.Status=Disabled` 后,`loader.Del` 清理缓存,但旧 access token 未作废(`tokenVersion` 未变)。
-  - 被禁用成员带着这张 token 继续请求 `/api/dept/tree`、`/api/perm/list`、`/api/user/detail?id=self` 等"只 JwtAuth 不做业务校验"的接口,**全部放行**。
-  - 更严重的是 `loadPerms`:`MemberType == ""` 时会跳过"全量权限"分支,但普通成员分支仍会基于 `sys_user_role` / `sys_user_perm` 返回权限集。也就是被踢出的成员在中间件层不被拒,随后其 `ud.Perms` 还被填充(如果前端仍按 `/api/perm/list` 来做菜单,依然能看到"这个产品下自己之前拥有的权限")。
+- **修复方案**(二选一或叠加):
 
-  `loadPerms` 的 DEV 部门短路已经加了 `ud.MemberType != ""` 这层护栏(第 358 行),但角色/用户权限并没有这层保护,导致普通成员分支依然会生效。
+  **方案 A(最小侵入,直接禁止自我 set):**
 
-- **影响**:
-  - "禁用成员"的实际效果仅对**登录时刻**生效;对**在线成员**只有间接约束(token 过期后才生效)。
-  - 配合 H-1(禁用产品),这条路径让"访问控制回收"能力在产品/成员两个维度都失灵。
-  - 一致性问题:`RefreshToken` 拒绝了已禁用成员,但 HTTP 业务接口不拒,用户可能看到"业务继续可用 / 但 refresh 已失败"的分裂状态。
+  ```go
+  // internal/logic/user/setUserPermsLogic.go
+  caller := middleware.GetUserDetails(l.ctx)
+  if caller != nil && caller.UserId == req.UserId && !caller.IsSuperAdmin {
+      return response.ErrForbidden("不能为自己设置权限")
+  }
+  ```
 
-- **修复方案**:在 `jwtauthMiddleware.Handle` 的 403 校验中对齐 `RefreshToken`:
+  **方案 B(对齐"高危写操作需 ADMIN"语义,推荐):**
 
   ```go
-  if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
-      httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "您已不是该产品的有效成员"))
-      return
+  // 全部调用者限定为超管或该产品 ADMIN
+  if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+      return err
   }
   ```
 
-  同时 `loadPerms` 的普通成员分支也应当在入口加
+  方案 B 更贴近 API 注释"个性化权限"的管理语义(默认只有管理员能做精细化调权)。保留 `CheckManageAccess` 的层级校验作为纵深
 
   ```go
-  if ud.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
-      return // 非有效成员,权限置空
+  if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+      return err
+  }
+  if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+      return err
   }
   ```
 
-  (这条在第 353-358 行的"自动全量"里已有,但需要抽出作用到整函数)
+  - 同步排查 `BindRoles`:对 MEMBER 目前通过 permsLevel 拦截了"自我绑低等级角色",这条逻辑是护栏;但 `caller.MemberType == DEVELOPER` 自己绑自己任意角色的路径**仍能过**(DEVELOPER 本身就全权,无新增危害,保留即可)。保留现状是合理的。
+
+- **优先级**:P0(立即修复)
 
 ---
 
-### H-3. AdminLogin:`UsernameLoginLimit` 在 `ManagementKey` 校验之前计数,允许无凭据 DoS 超管账号
+### H-B. `SysUserModel.IncrementTokenVersion` 返回基于**缓存读**的新版本号,并发 refresh/logout 会签发"DB 已作废"的新 token
 
-- **位置**:`internal/logic/pub/adminLoginLogic.go:35-45`
+- **位置**:`internal/model/user/sysUserModel.go:140-156`
 - **描述**:
   ```go
-  if l.svcCtx.UsernameLoginLimit != nil {
-      code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
-      if code == limit.OverQuota {
-          return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请5分钟后再试")
+  func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
+      data, err := m.FindOne(ctx, id)  // ← 从缓存读
+      ...
+      _, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+          query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ?", m.table)
+          return conn.ExecCtx(ctx, query, time.Now().Unix(), id)
+      }, sysUserIdKey, sysUserUsernameKey)
+      if err != nil {
+          return 0, err
       }
-  }
-  if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
-      return nil, response.ErrUnauthorized("managementKey无效")
+      return data.TokenVersion + 1, nil  // ← 基于旧缓存 + 1
   }
   ```
 
-  `UsernameLoginLimit` 的 key 维度是**纯 username**(`svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, 10, rds, ":rl:user")`,没有叠加 IP),5 分钟内全局 10 次。由于 `Take` 发生在 `ManagementKey` 校验之前,攻击者**不需要任何凭据**就能消耗配额。
+  两个严重问题:
+
+  1. **缓存陈旧**:`data.TokenVersion` 来自 `FindOne` 的缓存层(`sqlc.CachedConn.FindOne`)。如果在另一条写链路(`UpdatePassword` / `UpdateStatus` / 另一次 `IncrementTokenVersion`)**DB 已 +1 但 Redis 缓存尚未失效**(M-8 窗口),缓存里读到的 `TokenVersion` 仍是旧值 X;`UPDATE tokenVersion+1` 把 DB 从 X+1 推到 X+2;函数却返回 X+1。随后签发的 access token 里 `tokenVersion=X+1` 与 DB 的 X+2 **不匹配**,**下一次请求直接被 `jwtauthMiddleware` 以 "登录状态已失效" 拒掉**。
+  2. **并发自身**:即使不存在外部写入,两条并发 `RefreshToken` 也会命中同一现象——两条流程同时读缓存得 X,`UPDATE x+1` 让 DB 变成 X+2;两条流程都返回 X+1 签发了 access+refresh token 对,两张 refresh token 下一次使用都会因为 `TokenVersion != ud.TokenVersion` 被拒。
 
-  攻击场景:
-  1. 攻击者对 `/auth/adminLogin` 以任意 `managementKey="x"` 但 `username=admin` 打 10 次(单 IP 一分钟内即可完成,AdminLoginRateLimit 是 IP 60s/20)。
-  2. 真正的超管此后 5 分钟内无法登录管理后台,返回 `429`。
-  3. 攻击者持续周期性地(每 5 分钟一波)重放,即可**永久锁死**已知超管用户名的管理后台入口。
+  调用方均依赖**返回值**作为新签 token 的 version:
+  - `internal/logic/pub/refreshTokenLogic.go:66-75`(生成新 access/refresh)
+  - `internal/server/permserver.go:141-148`(gRPC RefreshToken)
+
+  这不只是理论问题——`RefreshToken` 现在每次都轮转 `tokenVersion`(上一轮 M-2 的修复要求),配合前端"多 tab / SW 并发 / 失败重试 / 双请求"等真实场景,用户会规律性遭遇"刷新一次就全家桶失效、需要完全重新登录"。
 
 - **影响**:
-  - 任何已知超管用户名(如 `admin`、`admin_{code}`)可被**无凭据**持续 DoS 锁登录入口;应急响应、事件处置期间超管无法登录管理后台。
-  - 攻击成本极低,IP 级限流(20/min)足以完成锁定。
+  - 正常用户会看到**偶发的"刚刷新完 token 就被登出"**(灰度回溯难度高,属于典型的 TOCTOU 竞态)。
+  - `Logout` 本身对此问题免疫(不读 `data.TokenVersion` 作为返回值),但 `RefreshToken` 严重受影响。
+  - 间接让 "被盗 refresh token 立即失效" 的安全语义在正常用户身上误伤自己。
 
 - **修复方案**:
-  - 把 `UsernameLoginLimit.Take` 移动到 `ManagementKey` 校验**之后**、用户名/密码校验之前:
 
-    ```go
-    if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
-        return nil, response.ErrUnauthorized("managementKey无效")
-    }
-    if l.svcCtx.UsernameLoginLimit != nil {
-        code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
-        ...
-    }
-    ```
+  **方案 A(推荐,MySQL 原子自增 + 读取):**
 
-  - 同时把限流 key 改为 `ip+username` 混合维度(推荐独立新桶),避免单个攻击者永久封锁真实用户。
-  - `ValidateProductLogin`(`loginService.go:33-38`)存在同样的账号锁定 DoS(无凭据即可锁任意用户名),推荐也改用 `ip+username` 混合 key 或增加可绕过的 CAPTCHA 机制。
+  ```go
+  func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
+      var newVersion int64
+      sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+      // 先查出 username(仅用于缓存 key)
+      data, err := m.FindOne(ctx, id)
+      if err != nil {
+          return 0, err
+      }
+      sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+      err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+          // LAST_INSERT_ID trick:UPDATE 的同时把新值写入 LAST_INSERT_ID()
+          upd := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
+          if _, err := session.ExecCtx(ctx, upd, time.Now().Unix(), id); err != nil {
+              return err
+          }
+          return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
+      })
+      if err != nil {
+          return 0, err
+      }
+      // 事务外再 purge 两个缓存 key
+      _, _ = m.DelCacheCtx(ctx, sysUserIdKey, sysUserUsernameKey)
+      return newVersion, nil
+  }
+  ```
 
----
+  (`DelCacheCtx` 是 `sqlc.CachedConn` 公开接口;若 go-zero 当前版本未暴露,可在事务成功后通过 `m.ExecCtx` 触发一次空 UPDATE 以复用其自动 invalidation,或直接用 `m.rds.Del` 删 Redis key。)
 
-### H-4. 移除产品成员 / 降级 MemberType 时未校验"最后一个 ADMIN",可把产品彻底变成无人管理态
+  **方案 B(最小改动,用 `SELECT ... FOR UPDATE`):**
 
-- **位置**:
-  - `internal/logic/member/removeMemberLogic.go:29-53`
-  - `internal/logic/member/updateMemberLogic.go:30-64`
-- **描述**:
-  - `RemoveMember` 的访问控制只有 `CheckManageAccess(caller, member.UserId, member.ProductCode)`;`CheckManageAccess` 里 `if caller.UserId == targetUserId { return nil }` 允许自删除,`if caller.MemberType == ADMIN { return nil }`(`checkDeptHierarchy`)后续权限级别比较又用 `callerPri < targetPri` 放行——也就是 **任意一个 ADMIN 可以把另一个 ADMIN(或者自己)移出产品**;
-  - `UpdateMember` 可以把 ADMIN 降级为 `MEMBER`,同样没有"最后一个 ADMIN"检查。
+  ```go
+  err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+      var cur int64
+      if err := session.QueryRowCtx(ctx, &cur,
+          fmt.Sprintf("SELECT `tokenVersion` FROM %s WHERE `id` = ? FOR UPDATE", m.table), id); err != nil {
+          return err
+      }
+      newVersion = cur + 1
+      _, err := session.ExecCtx(ctx,
+          fmt.Sprintf("UPDATE %s SET `tokenVersion` = ?, `updateTime` = ? WHERE `id` = ?", m.table),
+          newVersion, time.Now().Unix(), id)
+      return err
+  })
+  ```
 
-  假设产品 P 最初由 `CreateProduct` 自动生成 `admin_P`(ADMIN)加入。这个 admin_P 之后通过 `AddMember` 邀请了 admin_Q(ADMIN)。两人随后:
-  - admin_Q 先 `UpdateMember(admin_P → MEMBER)` 或直接 `RemoveMember(admin_P)`;
-  - 之后 admin_Q 再自己 `RemoveMember(admin_Q)`。
+  修复后需同时确认 `ChangePassword` / `UpdateStatus` / `UpdateProfile`(status 变更分支)等"递增"路径都走同一实现,避免一致性漂移。
 
-  产品 P 从此**没有任何 ADMIN**。前端路径无法再新增管理员(`AddMember` 需要 ADMIN 或 SUPER_ADMIN 操作;`CheckMemberTypeAssignment` 对新 ADMIN 又要求操作者是更高级别)。虽然超管可以通过 `AddMember` 介入,但该场景下产品运营已经 **必须依赖平台管理员介入**,违背了"产品自治"的设计意图。
+- **优先级**:P0(立即修复,生产将周期性出现自伤)
 
-  类似的,`admin_P` 可以不小心把自己降级为 `MEMBER`(UpdateMember 允许),产品立刻失去管理员。
+---
 
-- **影响**:
-  - 真实业务中,产品管理员误操作(自己把自己移除或降级)会直接让该产品进入"需平台救援"状态。
-  - 有意的内部对抗中,一个 ADMIN 可以把其他 ADMIN 全部踢出,事实上独占管理权(已属于越权滥用,但本身合规上应有"至少保留一个 ADMIN"约束)。
+## ⚠️ 健壮性与安全建议 (Medium)
 
-- **修复方案**:在 `RemoveMember` 与 `UpdateMember`(降级时)增加"最后 ADMIN"保护:
+### M-A. `CountActiveAdmins` 校验在事务外,`RemoveMember` / `UpdateMember` "最后 ADMIN" 检查存在 TOCTOU
 
+- **位置**:
+  - `internal/logic/member/removeMemberLogic.go:41-49`
+  - `internal/logic/member/updateMemberLogic.go:50-58`
+  - `internal/model/productmember/sysProductMemberModel.go:49-56`(`CountActiveAdmins`)
+- **描述**:新加入的"最后一个 ADMIN 保护"机制是:
   ```go
-  // 伪代码
-  if member.MemberType == consts.MemberTypeAdmin &&
-      (operation == Remove || (operation == Update && req.MemberType != ADMIN)) {
+  if member.MemberType == consts.MemberTypeAdmin {
       adminCount, _ := svcCtx.SysProductMemberModel.CountActiveAdmins(ctx, member.ProductCode)
       if adminCount <= 1 {
-          return response.ErrBadRequest("不能移除/降级该产品的最后一个管理员")
+          return response.ErrBadRequest("不能移除该产品的最后一个管理员")
       }
   }
+  // …然后进入事务 DELETE
   ```
 
-  需要在 model 层新增 `CountByProductCodeAndMemberType(productCode, MemberTypeAdmin, StatusEnabled)`。同时禁止管理员对自己的 MemberType 做降级(对比 `UpdateUserStatus` 已有的"不能修改自己的状态")
+  `CountActiveAdmins` 用 `QueryRowNoCacheCtx` 读 DB,**但不加锁**,且整个"判断 + DELETE"**不在同一个事务里**
 
----
+  并发路径:
 
-## ⚠️ 健壮性与安全建议 (Medium)
+  | 时序 | 请求 A(移除 admin_P) | 请求 B(移除 admin_Q) |
+  |------|------------------------|------------------------|
+  | T1   | `CountActiveAdmins` = 2 |                        |
+  | T2   |                        | `CountActiveAdmins` = 2 |
+  | T3   | 进入 Tx → DELETE admin_P |                        |
+  | T4   |                        | 进入 Tx → DELETE admin_Q |
+  | T5   | 产品剩余 ADMIN = 0      | 产品剩余 ADMIN = 0      |
 
-### M-1. 无注销接口,refreshToken 被盗后无法主动吊销
+  `UpdateMember`(ADMIN 降级为 MEMBER)存在同样的 TOCTOU。真实触发场景:
 
-- **位置**:`internal/handler/routes.go` 路由清单、`internal/middleware/jwtauthMiddleware.go`
-- **描述**:系统只有登录 (`/auth/login` / `/auth/adminLogin`)、刷新 (`/auth/refreshToken`)、改密 (`/auth/changePassword`) 接口。**没有任何一个接口会主动 `tokenVersion+1`**(除 `UpdatePassword` / `UpdateStatus`),也没有 `/auth/logout` 路由。
+  - 超管在管理后台"批量移除"两个 ADMIN;
+  - 两个 ADMIN 同时在前端点"移除对方";
+  - 自动化脚本一次性下发两条变更。
 
-  后果:
-  - 用户即使在客户端"退出登录",也只是本地清了 token;已签发的 access + refresh 在整个 expire 窗口内仍然可被重放。
-  - 用户怀疑 token 被盗时,唯一的自救手段是**修改密码**(`ChangePassword` 会 `tokenVersion+1` 并 `Clean` 缓存)。对使用 SSO、或不自己设密码(例如 OAuth 接入)的场景不可用。
+  虽然概率不高,但"产品永久无 ADMIN 需平台救援"的代价高。
 
-- **建议**:
-  - 新增 `/auth/logout` 接口:鉴权后将该用户的 `tokenVersion+1`(或者维护一个"签出黑名单"集合,带 TTL 到 `RefreshExpire`)。
-  - 相应 gRPC 端也暴露 `RevokeTokens(userId, productCode)`。
+- **影响**:绕过了上一轮 H-4 的"最后一个 ADMIN"保护,导致产品进入无人管理态。
 
----
+- **修复方案**:把 count 检查挪到事务内、并对要变更/删除的那行加行锁即可;对其他 ADMIN 行不需要锁(因为 count 查询会用到 InnoDB 的当前读规则,配合事务隔离级别足够)。
 
-### M-2. refreshToken 轮转未消耗原 token,存在"双 token 并存"窗口
+  ```go
+  // RemoveMember 示例(UpdateMember 同理)
+  return l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      // 1) 锁定当前要删的行,避免重复删
+      var lockedId int64
+      lockQ := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysProductMemberModel.TableName())
+      if err := session.QueryRowCtx(ctx, &lockedId, lockQ, req.Id); err != nil {
+          return response.ErrNotFound("成员不存在")
+      }
+      // 2) 最后一个 ADMIN 校验在事务内
+      if member.MemberType == consts.MemberTypeAdmin {
+          var cnt int64
+          cntQ := fmt.Sprintf(
+              "SELECT COUNT(*) FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR SHARE",
+              l.svcCtx.SysProductMemberModel.TableName(),
+          )
+          if err := session.QueryRowCtx(ctx, &cnt, cntQ, member.ProductCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
+              return err
+          }
+          if cnt <= 1 {
+              return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+          }
+      }
+      // 3) 既有的 DeleteByUserIdForProductTx / DeleteWithTx
+      ...
+  })
+  ```
 
-- **位置**:`internal/logic/pub/refreshTokenLogic.go:70-77`、`internal/server/permserver.go:141-148`
-- **描述**:`GenerateRefreshTokenWithExpiry` 的行为是"基于 claims 里原 token 的 `ExpiresAt` 重新签一张新 token"——此时 `tokenVersion` 并未递增,新旧两张 refreshToken 都用同一个 `tokenVersion` 命中 `tokenVersion == ud.TokenVersion` 的校验。
+  备选:保留现有 `CountActiveAdmins` 调用顺序,但在 DELETE 成功**之后**再 count 一次,若 `=0` 则显式 `return fmt.Errorf("rollback: last admin")` 触发回滚。这个实现更简单。
 
-  如果攻击者偷到 refreshToken 的同时、真实用户也在使用:
-  1. 真实用户用原 rt 换 rt1、rt2、rt3……
-  2. 攻击者用原 rt 换 rt';
-  3. 两边的 `tokenVersion` 相同,两边都能继续无限续期到 `refreshExpire`。
+- **优先级**:P1
 
-  标准的 refresh token rotation 语义应当是"已使用的 refresh token 立即一次性失效"(通过 jti 黑名单、或者每次刷新都让 `tokenVersion` 递增)。当前实现不具备这个能力。
+---
 
-- **影响**:refreshToken 泄露后几乎无法挽回,直到 refreshExpire 自然过期,或用户主动改密。
+### M-B. `/auth/refreshToken` 路由无任何限流,可对单用户发起 DB 写热点 DoS 并清空其所有缓存
 
-- **建议**:
-  - 方案 1(最小侵入):在 refresh 时让 `sys_user.tokenVersion` 递增,使老 refresh token 立即失效。缺点是多端登录无法共存。
-  - 方案 2:在 Redis 里维护 `refreshJti → userId` 的一次性 map,`RefreshToken` 时 `GETDEL`,不存在即失败。`jti` 存入 `RegisteredClaims`。
-  - 方案 3:缩短 refreshExpire,降低风险窗口。
+- **位置**:`internal/handler/routes.go:176-185`(`/auth/refreshToken` 无中间件)、`internal/logic/pub/refreshTokenLogic.go:66-70`
+- **描述**:
+  - 上一轮 M-2 修复后,`RefreshToken` 每次都会:
+    1. `IncrementTokenVersion`:`UPDATE sys_user SET tokenVersion=tokenVersion+1` + 清两个 cache key;
+    2. `UserDetailsLoader.Clean(userId)`:`SMEMBERS` 该用户 index → `DEL` 所有 cache key → `DEL` index key。
+  - 路由挂载处(`routes.go:176-185`)**没有 `JwtAuth`、没有 IP 限流**,只有签名 + `tokenVersion` 校验。
+  - 只要攻击者曾经拿到过任意一张有效 refreshToken(或者就是合法用户自己被攻破),就能每秒反复调用:
+    - 每次一条 `UPDATE sys_user`(写热点,命中行锁);
+    - 每次一轮 Redis `SMEMBERS` + `DEL`(用户缓存整体失效,后续所有请求都 miss 并穿透到 DB 的 `loadFromDB`,进而扇出 6 条 DB 查询)。
+  - 单用户持续被刷,等价于"该用户的每次业务请求都穿透 loader,全部冷路径查 DB",且对 `sys_user` 表形成写热点。如果该用户是 ADMIN/超管,其 `loadFromDB` 里 `loadPerms` 会扫 `sys_perm` 全表拉"该产品全量 code",代价更高。
+
+  关键是:**刷新是发出 token 后就能做的,没有任何 IP/账号层面的限流**。`AdminLoginRateLimit` / `ProductLoginRateLimit` 只防登录入口,`SyncRateLimit` 只防 `/perm/sync`。
+
+- **影响**:单个凭据即可对权限系统制造持续的 DB 写 + 缓存穿透;且由于每次刷新都轮转 refresh token,攻击者总能拿到下一张有效凭据继续刷。
+- **修复方案**:
+  - 在 `/auth/refreshToken` 前挂一条 `refreshLimiter`,key = `user:<userId>` 或 `ip+user`,窗口建议 60s/6 次(用户实际只会在 access 过期前偶发刷新,多端也不太会超过 3 次)。
+  - 或者给 `refreshTokenLogic` 内部加一层"Redis 计数器":以 `rl:refresh:<userId>` 为 key 做自增 + TTL。
+  - 超额后不应无条件 429,可选择降级为"返回同一张已经签发的 access token",但这又引入复杂度,保持 429 最简单。
+
+- **优先级**:P1
 
 ---
 
-### M-3. 产品自动管理员密码实际熵仅 32 bits
+### M-C. 产品端登录 `ValidateProductLogin`:存在用户名枚举 & 选择性账号锁定
 
-- **位置**:`internal/logic/product/createProductLogic.go:80`、`157-163`
-- **描述**:
+- **位置**:`internal/logic/pub/loginService.go:32-46`
+- **描述**:当前顺序是:
   ```go
-  func generateRandomHex(length int) (string, error) {
-      b := make([]byte, length)
-      if _, err := rand.Read(b); err != nil {
-          return "", fmt.Errorf(...)
+  u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
+  if err != nil { // 不存在 → 401
+      return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+  }
+  if svcCtx.UsernameLoginLimit != nil {
+      code, _ := svcCtx.UsernameLoginLimit.Take(username)
+      if code == limit.OverQuota {
+          return nil, &LoginError{Code: 429, Message: ...}
       }
-      return hex.EncodeToString(b)[:length], nil
   }
+  if u.Status != consts.StatusEnabled { ... }
+  if err := bcrypt.CompareHashAndPassword(...); err != nil { return 401 }
   ```
-  `rand.Read` 填充 `length` 字节,`hex.EncodeToString` 产生 `2*length` 个 hex 字符,随后**截断取前 `length` 个字符**——也就是实际只保留了前 `length/2` 字节的随机性,相当于 `4*length` bits。
 
-  - `generateRandomHex(32)` appKey:实际 128 bits,OK。
-  - `generateRandomHex(64)` appSecret:实际 256 bits,OK。
-  - `generateRandomHex(8)` adminPassword:**只有 32 bits ≈ 4e9 种可能**。虽然 `MustChangePassword=Yes` 会强制首登改密,但这个一次性密码在超管拿到之后到管理员首次登录之前的窗口内暴力破解可达。依赖外层 `UsernameLoginLimit`(5min/10 次)间接保护并不健壮。
+  两个衍生问题:
 
-- **建议**:修正截断边界或直接提高长度:
+  1. **用户名枚举**:不存在的 username 直接返回 401、**不消耗任何限流配额**,响应时间也很短(不走 bcrypt);存在的 username 则可能返回 401(密码错)或 429(锁定)或 403(冻结)。攻击者通过"时间 + 响应码"组合能稳定区分 username 是否存在。
+  2. **选择性锁定**:攻击者探测出真实 username 后,对该 username 连打 10 次,该 username 立即被 `UsernameLoginLimit`(5min/10 次,key 纯 username)锁定 5 分钟,真正用户同期无法登录。配合 `AdminLoginRateLimit`(IP 60s/30)毫无阻塞。
 
-  ```go
-  func generateRandomHex(length int) (string, error) {
-      byteLen := (length + 1) / 2
-      b := make([]byte, byteLen)
-      if _, err := rand.Read(b); err != nil {
-          return "", err
-      }
-      return hex.EncodeToString(b)[:length], nil
-  }
-  ```
+  `/auth/adminLogin` 已经把 `UsernameLoginLimit.Take` 移到 `ManagementKey` 校验之后,享受了一定保护(攻击者必须知道 ManagementKey 才能触发)。但 `/auth/login` 没有这种护栏。
 
-  同时 `adminPassword` 改为 `generateRandomHex(16)`(64 bits)起步。
+- **影响**:外部攻击者可在不触发限流的前提下批量探测有效 username;对已知用户可周期性(每 5 分钟一轮)持续锁定。
+- **修复方案**:
+  - **限流 key 加 IP 维度**:`UsernameLoginLimit` 的 key 改为 `fmt.Sprintf("%s:%s", ip, username)`,同时保留一个更宽松的"纯 IP 限流"(已有 `ProductLoginRateLimit`),这样攻击者无法通过廉价 IP 锁定目标账号。
+  - **`FindOneByUsername` 之前先 `Take`**:让 Take 对无效 username 也消耗配额(这样无效 username 也会得 429,不再区分);或者在 username 不存在时直接 `time.Sleep` 一个近似 bcrypt 的耗时,避免时间侧信道。
+  - **成功登录时重置计数**(go-zero `PeriodLimit` 不支持 reset,可以改用 Redis 自增 + 登录成功 `DEL` key)。
+
+- **优先级**:P1
 
 ---
 
-### M-4. `DeptTree` 对所有登录用户开放,暴露完整组织架构
+### M-D. `DeleteRoleLogic` 的 `FindUserIdsByRoleId` 写在事务回调内,却**没有用 session**,仍是旧连接读
 
-- **位置**:`internal/logic/dept/deptTreeLogic.go`、`internal/handler/routes.go:42-69`
-- **描述**:`/api/dept/tree` 只挂了 `JwtAuth`。`DeptTreeLogic.DeptTree` 自身完全不做权限过滤,**任意 JWT 通过的用户(包括任意产品的普通 MEMBER)**都能拉到全公司组织架构(包括 `deptType=DEV`、部门名称、层级结构、备注)。
-- **影响**:组织架构常包含内部命名、隐含岗位属性(DEV 部门自动获得全权限),属于内部敏感信息;不应对产品端的普通成员暴露。
-- **建议**:
-  - 严格版:仅超管可拉全量;其余用户只能拿"自身部门 + 其下级子部门"(`strings.HasPrefix(d.Path, caller.DeptPath)`)。
-  - 宽松版:超管 + ADMIN / DEVELOPER 可见;MEMBER 不可调用该接口。
+- **位置**:
+  - `internal/logic/role/deleteRoleLogic.go:40-50`
+  - `internal/model/userrole/sysUserRoleModel.go:55-62`(`FindUserIdsByRoleId` 用的是 `m.QueryRowsNoCacheCtx`,不接 `session`)
+- **描述**:
+  ```go
+  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      affectedUserIds, _ = l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(ctx, req.Id) // ← 这里没传 session
+      ...
+      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
+  })
+  ```
 
----
+  `FindUserIdsByRoleId` 直接用 `m.QueryRowsNoCacheCtx`(非事务连接、非当前 session)查询。结果:
 
-### M-5. `ProductList` / `ProductDetail` 对所有登录用户返回产品元数据
+  - 这一行读位于事务**之外**,本事务内尚未提交的变更看不到;
+  - 反之,事务**之外**并发提交的 `BindRoles`(另一 goroutine 给这个 roleId 插入新行)会被这条 SELECT **看到,或看不到**,取决于提交时序;
+  - 真正的问题是:**在当前事务 COMMIT 之前**,如果另一条已提交的并发事务给 roleId 加了新用户 U',本事务的 DELETE(`DeleteByRoleIdTx`)**会把 U' 也删掉**(因为它用 `DELETE ... WHERE roleId=?`),但 `affectedUserIds` 中不包含 U',所以**U' 的 UserDetails 缓存不会被清理**。
 
-- **位置**:`internal/logic/product/productListLogic.go`、`internal/logic/product/productDetailLogic.go`
-- **描述**:任何 JWT 通过的用户都能遍历系统全部产品(`code`、`name`、`status`、`remark`、`createTime`)。虽然 `AppKey` 只对超管返回,但产品清单本身对所有成员可见。跨产品用户可以探测出系统内其他产品的存在(例如"内部管理后台"、"支付中心")。
-- **建议**:
-  - 仅超管可列全部;非超管只能看到自己作为成员的产品(join `sys_product_member` 过滤)。
-  - `ProductDetail` 增加同样的归属校验:非超管只能看自己所在产品。
+  同样的问题在 `BindRolePermsLogic`(`bindRolePermsLogic.go:127`)已经通过"事务提交后再查"规避;`UpdateRoleLogic`(`updateRoleLogic.go:73`)也是事务外后查。只剩 `DeleteRole` 这一条路径仍然错位。
 
----
+- **影响**:`DeleteRole` 并发 `BindRoles` 的小概率场景下,漏清一批用户的缓存;这些用户会继续持有"引用已删角色"的权限集合,直到 300s TTL 自然过期。
+- **修复方案**:把 `FindUserIdsByRoleId` 挪到事务提交**之后**:
 
-### M-6. 产品端登录的用户名锁定 DoS(账号级)
+  ```go
+  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdTx(ctx, session, req.Id); err != nil { return err }
+      if err := l.svcCtx.SysUserRoleModel.DeleteByRoleIdTx(ctx, session, req.Id); err != nil { return err }
+      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
+  }); err != nil {
+      return err
+  }
+  // 提交之后再读(此时该 roleId 已无任何关联行,但仍能查到曾经的 userId——需要在 DELETE 之前保留)
+  ```
 
-- **位置**:`internal/logic/pub/loginService.go:33-38`
-- **描述**:`UsernameLoginLimit.Take(username)` 在无任何前置鉴权的情况下被消费,5min/10 次、纯 username 维度。任何外部攻击者只需知道目标用户名,即可以 10 次失败请求锁定该账号 5 分钟;通过定时重放可达到长时间账号级 DoS。
-- **影响**:
-  - 登录入口对外开放、任意 IP 可触达;
-  - 配合 H-3(管理后台也有同样的问题),造成系统级账号锁定攻击面。
-- **建议**:与 H-3 同步处理,把 rate limit key 改为 `ip:username`(或对同一 IP 的失败次数独立设桶);对成功登录重置该 username 的计数。
+  **注意**:`DELETE ... WHERE roleId=?` 执行后,`sys_user_role` 里 `roleId=?` 的行已没了,提交后再查 `FindUserIdsByRoleId` 得空集。正确做法是在事务内(用 session)先读一次,然后再 DELETE:
 
----
+  ```go
+  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      // 在事务内用 session 读,锁住这些关系行
+      query := fmt.Sprintf("SELECT `userId` FROM `sys_user_role` WHERE `roleId` = ? FOR UPDATE")
+      if err := session.QueryRowsCtx(ctx, &affectedUserIds, query, req.Id); err != nil {
+          return err
+      }
+      ...DELETE...
+      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
+  })
+  ```
 
-### M-7. `X-Real-IP` 信任策略过简,未支持 `X-Forwarded-For`
+  `FOR UPDATE` 让并发 `BindRoles` 的 `INSERT sys_user_role` 被挂起,直到当前事务提交。这样 affectedUserIds 覆盖所有实际被删的行。
 
-- **位置**:`internal/middleware/ratelimitMiddleware.go:41-52`
-- **描述**:
-  1. 仅识别 `X-Real-IP`,没有兼容多数 ingress / ELB 默认设置的 `X-Forwarded-For`。
-  2. 只要 `behindProxy=true`,任何 `X-Real-IP` 无条件被信任;如果反向代理没有覆盖客户端原有 header,攻击者可以通过伪造 header 分散限流桶。
-- **建议**:
-  - 支持 XFF:按最右可信节点取值。
-  - 配置可信代理 CIDR 列表,仅当 `RemoteAddr` 在可信网段内时才信任 header。
-  - 保留目前行为作为降级,优先级:`XFF`(可信时)> `X-Real-IP`(可信时)> `RemoteAddr`。
+  需要在 `SysUserRoleModel` 接口新增一个 `FindUserIdsByRoleIdForUpdateTx(ctx, session, roleId)`,或者 DeleteRole 内直接拼 SQL(如上)。
+
+- **优先级**:P2(低概率,但修复成本很小)
 
 ---
 
-### M-8. 缓存失效与 DB 事务非原子:`Clean/Del/BatchDel` 失败时静默吞错
+### M-E. 所有 `Delete*ForProductTx` / `DeleteByRoleIdTx` / `DeleteByUserIdForProductTx` 的"先 SELECT 再 DELETE"模式非原子
 
 - **位置**:
-  - 写路径:`UpdateUser`、`UpdateUserStatus`、`ChangePassword`、`BindRoles`、`SetUserPerms`、`UpdateMember`、`RemoveMember`、`DeleteRole`、`BindRolePerms`、`UpdateRole`、`UpdateProduct`、`ExecuteSyncPerms`、`UpdateDept`
-  - Loader:`internal/loaders/userDetailsLoader.go:138-173`
-- **描述**:所有写操作都是"DB tx 提交 → Redis 失效"两步,但 `Del / Clean / BatchDel` 内部的 Redis 错误只打日志。Redis 瞬时抖动期间,DB 已提交但缓存未失效,在 `defaultCacheTTL=300s` 之内其他请求命中旧缓存,包括 `tokenVersion / MemberType / Perms` 等安全关键字段。
-  - 对修改密码、冻结账号、删除角色这类"收紧"操作,最大 5 分钟的旧视图继续有效,是不可忽视的安全风险窗口。
-  - 对"放宽"操作(添加角色、启用成员),旧视图是"没权限",用户体验问题,相对安全。
-- **建议**:
-  - 对安全关键动作(`UpdateUserStatus` 冻结、`ChangePassword`、`RemoveMember`、`UpdateMember Status=Disabled`),`Clean` 失败必须返回 5xx,或把这类 key 的 TTL 收紧到 60s。
-  - 引入"延迟双删"或消息队列补偿:DB 提交后发送缓存失效事件,由消费端重试到成功。
+  - `internal/model/userrole/sysUserRoleModel.go:75-107,109-136`
+  - `internal/model/roleperm/sysRolePermModel.go:73-117`
+  - `internal/model/userperm/sysUserPermModel.go:43-63`
+  - `internal/model/perm/sysPermModel.go:94-154`(`DisableNotInCodesWithTx`)
+- **描述**:统一套路是:
+  ```go
+  // 1) 先用 QueryRowsNoCacheCtx 读出要删的行,拼 cache keys
+  if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, ...); err != nil { return err }
+  keys := buildCacheKeys(list)
+  // 2) 再 m.ExecCtx 包装 session.ExecCtx,借助 ExecCtx 的 cache-aside invalidate
+  _, err := m.ExecCtx(ctx, func(ctx, conn) {
+      return session.ExecCtx(ctx, deleteQuery, ...)
+  }, keys...)
+  ```
 
----
+  问题:
+  1. **SELECT 用的是 `m.QueryRowsNoCacheCtx`(主库默认连接,读未提交前的数据)**,它既不是 `session`,也没有加锁。如果此刻有并发 INSERT 提交(例如 BindRoles 插入新 `sys_user_role`),那条新行的 cache key 不会进入 `keys` 列表。
+  2. 紧接着 `DELETE ... WHERE roleId=?` 会一并删掉该新行;新行的 `cacheSysUserRoleIdPrefix:<newId>` 和 `cacheSysUserRoleUserIdRoleIdPrefix:<userId>:<roleId>` 缓存**不会被 invalidate**。
+  3. 一般情况下,新行刚插入缓存也没写过(只有 `FindOne` 会写缓存,insert 不写),所以大部分时候这些 key 本来就不存在,问题不显现。但一旦有其它读路径(例如 `FindOne`、或 `DeleteByUserIdAndRoleIdsTx` 里的 list 查询本身)在极短窗口内 cache miss + 回填,就会留下**已 DELETE 但仍在 Redis 的幽灵缓存**,直到 TTL 自然过期。
 
-### M-9. `BindRolePerms` / `UpdateRole` / `DeleteRole` 的"受影响用户查询"发生在事务之外,存在漏清缓存的窗口
+  同时 `perm/sysPermModel.go:DisableNotInCodesWithTx` 走的路径里,SELECT 是非事务读,之后的 UPDATE 是 session.ExecCtx,如果并发 `SyncPerms` 插入了新 perm code(不在 `codes` 列表),第二轮 SELECT 不会看到,但 UPDATE 的 `WHERE NOT IN (codes)` **会禁用**它——该 perm 的缓存键不会被清理。
 
-- **位置**:
-  - `internal/logic/role/bindRolePermsLogic.go:126-127`
-  - `internal/logic/role/deleteRoleLogic.go:39-53`
-  - `internal/logic/role/updateRoleLogic.go:66-67`
-- **描述**:三个接口的模式都是"事务外 `FindUserIdsByRoleId` → 事务内写 DB → 事务外 `BatchDel`"。在**事务开始后、事务提交前**,若有另一个 goroutine 通过 `BindRoles` 把新用户加进这个角色(`sys_user_role` 插入并已提交),当前 goroutine 计算 `affectedUserIds` 时没有包含这些新用户。
-  - 事务提交之后,新加入的用户缓存不会被当前流程清理。他们会用"角色权限变更前的 perms 快照"继续工作 5 分钟。
-  - 另一条 `BindRoles` 流程会对它自己绑的用户 `Clean`,但不会感知到本流程对角色权限的改动。
+  这是一类普遍的"缓存 keys ≠ 实际被影响行"的问题。
 
-  虽然是低概率双写,但对"删除角色"这种一次性收紧操作,未清掉的那一批用户仍会基于"已删除角色下的权限集"工作(实际上,一旦事务提交,`sys_role_perm` 清空,`loadPerms` 的 role path 自然会走 `FindPermIdsByRoleIds` 得空——但缓存是上次的;只有下次 miss 才会触发)。本质是**缓存永远滞后于 5 分钟**。
-- **建议**:把 `FindUserIdsByRoleId` 放进事务内,并使用 `SELECT ... FOR UPDATE` 锁住这些用户的绑定关系,避免并发新增;或在事务提交后再 `FindUserIdsByRoleId` 一次(更简单)——这样保证看到的是最新的用户集:
+- **影响**:
+  - 罕见但可触发的"幽灵缓存"问题,表现为"某用户/角色/权限在 DB 已删但 Redis 仍返回老值",最多持续 300s(默认 TTL)+ loader.Load 的 singleflight 覆盖。
+  - 实际业务影响通常被 `UserDetailsLoader` 的 300s TTL 吸收——`loader.Load` 是以用户维度缓存 `UserDetails`,不直接依赖 `sys_user_role.id` 级 cache key。所以**实际暴露面主要在直接调 `SysXxxModel.FindOne(id)` 的路径**。
+  - 风险较低,但长期看会积累数据不一致。
 
-  ```go
-  // 事务提交成功之后
-  affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId)
-  l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
-  ```
+- **修复方案**:
+  - **方案一(推荐)**:把 SELECT 改为在 session 内执行,并且 `FOR UPDATE`:
+
+    ```go
+    findQuery := fmt.Sprintf("SELECT ... WHERE `roleId` = ? FOR UPDATE")
+    if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil { return err }
+    ```
+
+    这样在事务提交前,其他事务无法插入新行到同一 roleId 范围内;SELECT 和 DELETE 看到的是完全一致的集合。
 
-  当前是在事务之前拿的,移到事务之后即可显著减少竞态。
+  - **方案二**:事务提交后再做一次"宽清理"——直接 `SREM` 掉该 `roleId` / `productCode` 下的全部 cache 键;粒度粗但绝不会漏。
+  - **方案三(最小成本)**:既然缓存不一致窗口 ≤ 300s 且 `UserDetailsLoader` 层做主业务隔离,可将这类 cache key 的 TTL 降到 60s,限制风险窗口。
+
+- **优先级**:P2(风险低、影响面小,但建议与 M-D 合并一次性收敛)
 
 ---
 
-### M-10. `FindByPathPrefix` LIKE 转义依赖 MySQL 默认 `\` 转义
+### M-F. `CountActiveAdmins` SQL 字面量 `'ADMIN'` 与 `consts.MemberTypeAdmin` 解耦,僵尸常量同步风险
 
-- **位置**:`internal/model/dept/sysDeptModel.go:56-64`
-- **描述**:`strings.NewReplacer("%", "\\%", "_", "\\_").Replace(pathPrefix)` 产出的是 `/xxx/\%yyy/`,在 SQL `WHERE path LIKE ?` 下默认依赖 MySQL 的 `\` 作为 LIKE 转义符。当 `sql_mode` 含 `NO_BACKSLASH_ESCAPES` 时,`\%` 会被当作两个字符,匹配失败或命中预期外数据。
+- **位置**:`internal/model/productmember/sysProductMemberModel.go:51`
+- **描述**:
+  ```go
+  query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = 'ADMIN' AND `status` = 1", m.table)
+  ```
 
-  此函数在当前产线代码中**已无调用方**(见 M-11 僵尸代码),但为了避免将来复用踩坑,建议显式 `LIKE ? ESCAPE '!'` 并在应用层用 `!` 作为转义符。
+  `'ADMIN'` 和 `1`(StatusEnabled)都是裸字面量,没有引用 `consts.MemberTypeAdmin` / `consts.StatusEnabled`。一旦常量值调整(例如未来新增 `OWNER` 或 `ADMIN` 更名为 `PRODUCT_ADMIN`),Go 调用方的判断和 SQL 查询会**悄悄脱钩**——不会编译报错、不会测试失败(除非有专门的覆盖测试),但"最后一个 ADMIN"保护**逻辑失效**(SQL 找不到任何 ADMIN,count 永远 = 0,所有移除都被拒)。
+
+- **影响**:维护风险(契约隐性约定);也违反"同一信息不写两遍"原则,与 `FindAllCodesByProductCode`(`perm/sysPermModel.go:56`)等现有做法不一致(那边用了 `consts.StatusEnabled` 拼到 SQL)。
+- **修复方案**:
 
-- **建议**:
   ```go
-  escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(pathPrefix)
-  query := fmt.Sprintf("SELECT ... WHERE `path` LIKE ? ESCAPE '!' ORDER BY ...", ...)
+  query := fmt.Sprintf(
+      "SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?",
+      m.table,
+  )
+  return m.QueryRowNoCacheCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled)
   ```
 
+- **优先级**:P2
+
 ---
 
-### M-11. 僵尸代码:`FindByPathPrefix` / `FindByParentId` / `FindRoleIdsByUserId` 仅测试引用
+### M-G. `UserList` 在有 `productCode` 分支时,JOIN 后又多做了一次 `FindMapByProductCodeUserIds`
 
 - **位置**:
-  - `internal/model/dept/sysDeptModel.go:47-64`(`FindByParentId`、`FindByPathPrefix`)
-  - `internal/model/userrole/sysUserRoleModel.go:37-44`(`FindRoleIdsByUserId`,`UserDetailLogic.userDetailLogic.go:55` 理论上会调用,但看下面
-- **描述**:经全仓搜索
-  - `FindByPathPrefix` 和 `FindByParentId`:自上一轮 `DeleteDept` 改为行锁 + 子查询后,两者在生产代码中没有任何调用方,仅测试/mock 保留。
-  - `FindRoleIdsByUserId`(全产品汇总):在 `userDetailLogic.go:55` 的 `else` 分支调用("没有 productCode 上下文时")。该分支仅在超管登录管理后台且未带产品上下文时进入;但前端在"用户详情"页面一般会带产品上下文。调用路径存在但极少
+  - `internal/logic/user/userListLogic.go:51-76`
+  - `internal/model/user/sysUserModel.go:59-76`(`FindListByProductMembers` 已 INNER JOIN `sys_product_member`
+- **描述**:当 `req.ProductCode != ""` 时
+  1. `FindListByProductMembers` 已经 `INNER JOIN sys_product_member pm ON u.id=pm.userId WHERE pm.productCode=?`——此时每行都有对应的 `pm.memberType`;
+  2. 代码又发起一次 `FindMapByProductCodeUserIds` 把 `userIds` IN 回 `sys_product_member` 取 `memberType`
 
-  建议清理 `FindByPathPrefix` / `FindByParentId`(或保留但加注释,避免重复发明轮子)。`FindRoleIdsByUserId` 仍有路径保留。
+  两次查询做了完全一样的事。这是一个纯粹的冗余读——第一次 JOIN 丢弃了 `pm.*`,第二次再 IN 取回。
+- **影响**:产品端用户列表多一次全表/IN 扫描,无功能性问题。
+- **修复方案**:扩展 `FindListByProductMembers` 返回 `[]struct{ *SysUser; MemberType string }`(或返回 `memberType` 数组),替掉第二次查询。
 
-- **建议**:
-  - 删除 `FindByPathPrefix` / `FindByParentId`,或至少加 `// Deprecated` 注释。
-  - `FindRoleIdsByUserId` 保留。
+- **优先级**:P2
 
 ---
 
-### M-12. `UpdateUserStatus` 的 productCode 归属校验与超管路径重复
+### M-3(遗留). 产品初始管理员随机密码熵仍为 32 bits
 
-- **位置**:`internal/logic/user/updateUserStatusLogic.go:49-60`
-- **描述**
+- **位置**:`internal/logic/product/createProductLogic.go:80-81, 158-164`
+- **状态**:未修复。`generateRandomHex(8)` 依然使用了截断 bug 的路径
   ```go
-  if productCode != "" {
-      caller := middleware.GetUserDetails(l.ctx)
-      if caller != nil && !caller.IsSuperAdmin {
-          if _, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(...); err != nil {
-              return response.ErrBadRequest("目标用户不是当前产品的成员")
-          }
-      }
-  }
-  if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
-      return err
-  }
+  b := make([]byte, 8)              // 8 字节 = 64 bits
+  rand.Read(b)
+  return hex.EncodeToString(b)[:8]  // 取前 8 hex 字符 = 4 字节 = 32 bits
   ```
+- **影响**:见上一轮审计。虽然 `MustChangePassword=Yes`,但一次性密码暴力破解窗口依然存在。
+- **修复方案**:同上一轮方案——修正截断边界或直接把 adminPassword 调大到 16 字符(64 bits)。
+- **优先级**:P1
 
-  `CheckManageAccess` 内部已经对"非超管且同一产品下非成员"做了 `checkPermLevel` 的目标成员检查(会返回 "目标用户不是当前产品的成员,无法执行管理操作")。外层这段手动检查在非超管场景下和内部检查**重复**,且多了一次 `FindOneByProductCodeUserId` DB 查询。
+---
 
-  - 功能上没问题,但多一次 DB 查询且逻辑位置分散。
+### M-4(遗留). `/api/dept/tree` 对所有已登录用户开放,暴露完整组织架构
 
-- **建议**:删掉外层的重复校验,全部交给 `CheckManageAccess`。
+- **位置**:`internal/logic/dept/deptTreeLogic.go:27-66`
+- **状态**:未修复。
+- **建议**:仅超管可拉全量;其他用户按 `caller.DeptPath` 过滤(只返回自身部门及其子树)。
 
 ---
 
-### M-13. `UpdateUser` 自修改时能"显式传 `Status=0` + 非 nil `DeptId`"通过第一层但被 `FindOne` 后再补判
-
-- **位置**:`internal/logic/user/updateUserLogic.go:39-59`
-- **描述**:逻辑结构:
-  ```go
-  if caller.UserId == req.Id {
-      if req.DeptId != nil || req.Status != 0 {
-          return 403
-      }
-  } else {
-      if err := CheckManageAccess(...); err != nil { return err }
-  }
-  user, _ := FindOne(req.Id)  // 再查一次
-  if caller.UserId != req.Id && user.IsSuperAdmin == Yes {
-      if req.Status != 0 || req.DeptId != nil { return 403 }
-  }
-  ```
+### M-5(遗留). `ProductList` / `ProductDetail` 对所有已登录用户返回系统级元数据
 
-  - 自修改场景下:如果传 `DeptId=&0`(指针非 nil,值为 0),第一层 `req.DeptId != nil` 直接拦下,正确。
-  - 他人修改超管场景下:第二道防线阻断 `Status != 0 || DeptId != nil`。OK。
+- **位置**:`internal/logic/product/productListLogic.go`、`productDetailLogic.go`
+- **状态**:未修复。
+- **建议**:非超管只能看到自己有有效成员资格的产品。
 
-  但是一个隐含风险:**他人修改普通用户**场景下,如果 caller 通过 `UpdateUser` 改对方 `DeptId`,没有校验 caller 对"新目标部门"的权限。例如普通 ADMIN(部门 A)可以把用户 U 从部门 X 挪到部门 Y,哪怕 Y 不在 ADMIN 的管辖范围。`CheckManageAccess` 只校验 caller 能否管理 U(在 caller 自己的部门子树内),**不校验新 DeptId 是否合法**。
-
-  对 ADMIN 的判定:ADMIN 在 `checkDeptHierarchy` 里直接放行(第 101-103 行),所以 ADMIN 可以跨部门挪用户,这是设计意图。但 `MemberType=DEVELOPER` 或无角色的 MEMBER 不会走到这里(会被更早拦下)。
+---
 
-  结论:目前行为是 ADMIN 可跨部门分配,正常;MEMBER/DEVELOPER 不会触发这个路径。但如果未来放宽 `checkDeptHierarchy`,需要补这层目标部门校验。
+### M-7(遗留). `ExtractClientIP` 仅识别 `X-Real-IP`,未识别 `X-Forwarded-For`,且无可信代理白名单
 
-- **建议**(低优):把"变更 DeptId 时,校验目标部门在 caller 的可管理范围或 caller 是超管/ADMIN"独立出来,避免后续逻辑回归。
+- **位置**:`internal/middleware/ratelimitMiddleware.go:41-52`
+- **状态**:未修复。
+- **建议**:同上一轮——按"可信代理 CIDR 列表 + XFF 取最右可信值 > X-Real-IP > RemoteAddr"。
 
 ---
 
-### M-14. `setUserPermsLogic` 对已被禁用的权限直接拒绝,但缺少对"产品已被禁用"的校验
+### M-8(遗留). 缓存失效为 fire-and-forget,"收紧类"安全动作 + Redis 抖动 = 5 分钟内旧视图生效
 
-- **位置**:`internal/logic/user/setUserPermsLogic.go`
-- **描述**:只校验 `perm.ProductCode == productCode && perm.Status == Enabled`,没有校验该产品本身是否被禁用。结合 H-1,管理员在产品被禁用后依然能对该产品的成员设置权限。
-- **影响**:与 H-1 同源,一旦 H-1 修复(loadPerms 感知产品状态),这里的写入仍然合法;建议一并在管理面增加 `product.Status == Enabled` 的前置校验,作为防御纵深。
+- **位置**:所有 `Del/Clean/BatchDel/CleanByProduct` 的调用点。
+- **状态**:未修复。
+- **建议**:
+  - 对 `UpdateUserStatus(Disabled)`、`ChangePassword`、`RemoveMember`、`Logout`、`UpdateMember(Status=Disabled)` 等"收紧"类动作,缓存清理失败必须返回 5xx / 重试;
+  - 或者在这些路径上同步走 "DB tx 提交 → 消息队列发事件 → 消费方确保删干净",把缓存失效转为幂等补偿。
+  - 对新加入的 `Logout`,`Clean` 失败目前只打日志,用户虽然 DB 的 `tokenVersion+1` 生效、但缓存里旧 ud.TokenVersion 仍在——中间件对比 claim 里的旧 version vs cache 里的旧 version,**会继续放行** 5 分钟,这和 `Logout` 的"立即失效"语义严重冲突。此项建议升到 P1。
 
 ---
 
-### M-15. `BindRoles` 对"重复绑定同一角色"请求,toAdd/toRemove 都为空时直接 `return nil`,不同步缓存
+## 📝 低风险 / 遗留问题 (Low)
+
+### L-A. `CreateUser` 注释写"仅超管可调用",实际却允许产品 ADMIN 调用
 
-- **位置**:`internal/logic/user/bindRolesLogic.go:114-116`
-- **描述**:当请求的 `req.RoleIds` 与数据库现状完全相等时,直接 return。这是正确的优化,但它意味着:
-  - 如果由于缓存异常,`UserDetails.Roles` 在 Redis 中是"失效但未清"的错值(例如上一次写入失败),调用 `BindRoles` 做一次"无改动 upsert"**不会**触发缓存清理。
-- **影响**:极低,仅在"上次 Clean 失败 + 当前调用无 diff"的联合场景下出现,属于灰度 / 降级运维场景。
-- **建议**:保留优化,但在 `return nil` 之前仍然做一次 `UserDetailsLoader.Clean(l.ctx, req.UserId)`,确保本次调用语义是"写读一致"。
+- **位置**:`internal/logic/user/createUserLogic.go:38-43`
+- **描述**:
+  ```go
+  // CreateUser 创建用户。新建系统用户账号,可指定部门归属。仅超管可调用。
+  func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (...) {
+      productCode := middleware.GetProductCode(l.ctx)
+      if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil { ... }
+  ```
+  产品 ADMIN(登录时带 productCode)可以通过该接口创建**系统级用户**(没有任何产品归属)。该用户不会自动加入任何产品,ADMIN 也只能把他加入自己这一个产品;但**文档契约与行为不一致**,容易在后续变更时导致误解。
+- **建议**:若意图是"超管专用",改为 `RequireSuperAdmin`;若意图是"产品 ADMIN 也可以",改注释并在 `perm.api` 的接口注释中说明。
 
 ---
 
-### M-16. `loadPerms` 普通成员分支对"用户附加 ALLOW / DENY"未过滤权限启用状态
+### L-B. `jwtauthMiddleware` 校验顺序:`tokenVersion` 在 `ProductStatus/MemberType` 之后
 
-- **位置**:`internal/loaders/userDetailsLoader.go:380-405`
-- **描述**:`FindPermIdsByUserIdAndEffectForProduct` 仅按 `sys_user_perm.effect + productCode` 过滤,未过滤 `sys_perm.status = 1`。最终在第 412-422 行通过 `FindByIds` + `p.Status == Enabled` 过滤已禁用的 perm code,再投入 `ud.Perms`。
-  - 功能上正确:禁用的 perm 不会进入最终 codes。
-  - 但**每次 Load 都白白查询禁用的 permId**,多传一趟到 `FindByIds`。
-- **建议**:`FindPermIdsByUserIdAndEffectForProduct` 的 SQL 加 `AND p.status = 1`(对齐 `FindRoleIdsByUserIdForProduct` 已加的 `r.status = 1`)。同时 `FindPermIdsByRoleIds` 也可以加 `INNER JOIN sys_perm p ON rp.permId = p.id AND p.status = 1`。
+- **位置**:`internal/middleware/jwtauthMiddleware.go:78-93`
+- **描述**:顺序是 `Status → ProductStatus → MemberType → TokenVersion`。当用户已 `Logout` 或 `ChangePassword`(`tokenVersion` 应失效),但产品被同步禁用了,会优先返回 "该产品已被禁用" 而不是 "登录已失效"。这会给前端错误的 UX 分支(比如前端见到 "产品禁用" 可能不会清本地 token,而是提示用户"联系管理员恢复产品";实际上该 token 已经作废了)。
+- **建议**:把 `tokenVersion` 检查提到最前(或仅次于 `Status != Enabled`)。这也符合 "优先拒绝无效凭据,再拒绝业务层禁用" 的错误码语义。
 
 ---
 
-## 📝 低风险 / 遗留问题 (Low)
-
-### L-1. 响应永远 HTTP 200,业务错误通过 body.code 区分
+### L-C. `Logout` 无并发保护,一条 token 可不断自增 `tokenVersion`
 
-- **位置**:`internal/response/response.go:45-52`
-- **描述**:所有业务错误返回 HTTP 200 + body.code=4xx/5xx。这让部分 WAF、CDN、监控工具(期望基于 HTTP 状态码告警)失效。属于已知的 API 契约选择,保留一致性即可,但建议在对外文档中明确。
+- **位置**:`internal/logic/auth/logoutLogic.go:30-43`
+- **描述**:`Logout` 虽然是幂等(多次 +1 不伤害业务),但每次都会:
+  1. 发 `UPDATE sys_user tokenVersion+1`(行锁);
+  2. 清所有 UserDetails 缓存。
+  攻击者拿到一次有效 access token,就能反复调 `/auth/logout`——每次都是 **DB 写 + 全缓存清**,比 M-B 的 refresh 路径更轻量。
+- **建议**:对 `Logout` 同步加一条 "60s / 6 次" 的 userId 级限流;或者在中间件层面对"高写入路径"统一限流。
 
 ---
 
-### L-2. 敏感配置明文提交到仓库
+### L-D. `SyncPerms` 通过 appKey 查询,appKey 错误 vs appSecret 错误返回同一文案,但**响应时间**差异明显
 
-- **位置**:`etc/perm-api-*.yaml`
-- **描述**:MySQL/Redis 密码、AccessSecret、RefreshSecret、ManagementKey 等明文存在;即便后续轮换,历史 commit 仍可追溯。建议改用环境变量或密钥管理服务注入。
+- **位置**:`internal/logic/pub/syncPermsService.go:37-43`
+- **描述**:appKey 不存在 → 401,不走 bcrypt(快);appKey 存在但 appSecret 错 → 401,走 bcrypt(慢)。攻击者可据此枚举有效 appKey。
+- **影响**:低——appKey 对外不暴露,枚举难度大;但如果将来 `/api/perm/sync` 暴露在公网,这是信息泄露。
+- **建议**:appKey 不存在时也跑一次 dummy bcrypt,或对该接口限流。
 
 ---
 
-### L-3. `UpdateRole` 允许产品 ADMIN 任意下调 permsLevel 到 1
+### L-E. 保留的 `FindRoleIdsByUserId`(不按 productCode 过滤)在当前业务中几乎不可达
 
-- **位置**:`internal/logic/role/updateRoleLogic.go:47-48`
-- **描述**:产品 ADMIN 可以把一个原本 `permsLevel=500` 的角色改为 `permsLevel=1`,然后把它绑给普通 MEMBER,使其 `MinPermsLevel=1`,进而绕过 `checkPermLevel` 对"同级 MEMBER"的级别约束。由于 ADMIN 本身已是产品最高级别,这并没有扩展其能力范围;但它让普通 MEMBER 能"管理与自己同 MemberType 的更多用户"
-- **建议**:要求 `permsLevel` 的修改必须是超管;或要求新 `permsLevel >= 原 permsLevel`(单调不递减)。
+- **位置**:`internal/model/userrole/sysUserRoleModel.go:37-44`、`internal/logic/user/userDetailLogic.go:53`
+- **描述**:仅在 `UserDetail` 的 `productCode == ""` 分支调用。该分支只有超管未带产品上下文时才进入(超管通过 `adminLogin` + 查用户详情页)。虽可达但使用面极窄,且返回**跨全部产品**的 roleIds,对 API 消费方语义模糊(前端只拿到 roleIds 无 productCode 区分,容易误用)
+- **建议**:保留但在接口文档里明确"此分支的 roleIds 是跨产品聚合",或改为按 "caller 的产品上下文"(不等价,但更可预测)。
 
 ---
 
-### L-4. `CreateRole` 未校验 `req.ProductCode` 是否存在 / 启用
+### L-F. `UpdateUser` 允许他人调用者修改目标的 `deptId`,但未校验新 `deptId` 是否在调用者可管理的子树中
 
-- **位置**:`internal/logic/role/createRoleLogic.go:33-50`
-- **描述**:仅通过 `RequireProductAdminFor(req.ProductCode)` 间接校验(超管或该产品 ADMIN)。但如果超管误传 `productCode="not_exist"`,会插入一条挂在无效产品下的角色;该角色因产品不存在不会被任何人使用,但也不会报错
-- **建议**:插入前 `FindOneByCode(req.ProductCode)` 校验存在且 `Status == Enabled`。
+- **位置**:`internal/logic/user/updateUserLogic.go:99-106`
+- **描述**:`CheckManageAccess` 只校验"可管理目标用户",不校验"新目标部门"在 caller 的子树中。实际上 `checkDeptHierarchy` 对 ADMIN 直接放行(第 101-103 行),但对 DEVELOPER 会先走 `callerDeptPath` 前缀匹配;DEVELOPER 如果能管理到跨部门的用户(极少数配置下),就能把他调到任意部门。由于 `CheckManageAccess` 的部门校验只限制"目标当前部门",不限制"目标的新部门",DEVELOPER 可以把下属挪出自己的子树
+- **建议**:更新 `deptId` 时,新 deptId 若非空,校验 `caller.IsSuperAdmin || caller.MemberType == ADMIN || strings.HasPrefix(newDept.Path, caller.DeptPath)`。
 
 ---
 
-### L-5. `AddMember` 未校验产品启用状态
+### L-G. `loginService.ValidateProductLogin` 对已冻结用户的密码仍做 bcrypt 比对
 
-- **位置**:`internal/logic/member/addMemberLogic.go:33-34`
-- **描述**:只校验产品存在,不校验 `Status == Enabled`。管理员可以在产品已被禁用的情况下继续加成员。虽然被禁用的产品理论上也不应再有新成员流入,但当前不阻断
-- **建议**:增加 `if product.Status != consts.StatusEnabled { return 400 "产品已被禁用" }`
+- **位置**:`internal/logic/pub/loginService.go:48-54`
+- **描述**:Status 检查在 bcrypt 之前,但中间多了一步 `UsernameLoginLimit.Take` → 已冻结用户仍会消耗配额。累积消耗后,**真正解冻后用户 5 分钟内无法登录**
+- **建议**:Status 检查前置到 FindOneByUsername 之后、`UsernameLoginLimit.Take` 之前,冻结即返回 403,不消耗配额
 
 ---
 
-### L-6. `UserDetailLogic` 对"非超管 + productCode==='' + 查他人"的校验语义模糊
+### L-H. `ValidateProductLogin` 成功登录后没有重置 `UsernameLoginLimit`
 
-- **位置**:`internal/logic/user/userDetailLogic.go:33-43`
-- **描述**:逻辑上 "非超管 + 无 productCode + 查自己 → OK / 查他人 → 拒绝"。但对 ADMIN/DEVELOPER 而言,他们的 JWT productCode 不会为空;此分支只可能被"非超管 + 无 productCode"触发,当前系统里几乎只有"超管通过 adminLogin 登录"这一路径。
-  - 换言之这段 `if caller.ProductCode == ""` 分支只对"超管自身"有意义,但条件已显式排除超管。形同死代码分支——超管走不到,其它用户也不会 `ProductCode==""`。
-- **建议**:要么完全删除这段分支,要么明确写成 `if !caller.IsSuperAdmin && caller.ProductCode == "" { return 401 "会话缺少产品上下文" }`,表达"产品端一定有 productCode"的不变式。
+- **位置**:同上
+- **描述**:go-zero 的 `PeriodLimit` 没有 reset API,因此"登录成功"不会归零失败计数。连续多次失败(例如输错 3 次、然后登录成功)后 24 小时内的窗口内若再输错 7 次仍会触发锁定——这对用户体验不友好,也让攻击者可以通过合法登录不打破锁定(因为锁定只和失败计数相关)。
+- **建议**:改用 Redis 自增 + 登录成功时 `DEL` key,或只在 `bcrypt.CompareHashAndPassword` 失败时才计数。
 
 ---
 
-### L-7. `singleflight` 在 `Load` 失败时返回零值 UserDetails 而非 nil
+### L-I. `AddMember` 不校验目标用户 `Status`
 
-- **位置**:`internal/loaders/userDetailsLoader.go:116-134`
-- **描述**:`sf.Do` 回调在 `loadFromDB` 返回 `ok=false` 时返回 `(nil, nil)`,外层做了兜底 `return &UserDetails{UserId, ProductCode}`。调用方靠 `ud.Username == ""` 判断"用户不存在"。
-  - 语义上"查不到用户"和"DB 报错"无法区分;
-  - 所有 caller 都按 `ud.Username == ""` 判定,耦合在这个不变式上。
-- **建议**:保留现有接口,但内部把 "DB error" 与 "不存在" 分开传递,对 5xx 让上层正确返回 500;同时在 `LoadE`(新增)接口里返回 `(*UserDetails, error)`,旧 `Load` 保持兼容。
+- **位置**:`internal/logic/member/addMemberLogic.go:41-43`
+- **描述**:只校验用户存在、不校验 `u.Status == Enabled`。可以把已冻结用户加成员;他们被解冻的瞬间就拥有产品访问权。
+- **建议**:`if u.Status != consts.StatusEnabled { return ErrBadRequest("用户已被冻结,无法添加为成员") }`。
 
 ---
 
-### L-8. `gRPC Login` 的 IP 提取依赖 `peer.Addr`,不识别 XFF
+### L-J. `UpdateUser` 允许他人修改**目标用户的 `Status` 值**,与 `UpdateUserStatus` 职责重叠
 
-- **位置**:`internal/server/permserver.go:60-72`
-- **描述**:`GrpcLoginLimiter` 的 key 用 `peer.Addr`。如果 gRPC 入口前面有 gateway/proxy,所有请求的 `peer.Addr` 都是 gateway IP,限流变成"全局 20/min"。不过一般 gRPC 不会直接对外暴露,风险低
-- **建议**:若 gRPC 会走网关,应在 metadata 中带上真实客户端 IP(如 `x-real-ip`),取 metadata 作为 key
+- **位置**:`internal/logic/user/updateUserLogic.go:108-125`、`internal/logic/user/updateUserStatusLogic.go`
+- **描述**:两条接口都能改 `Status`;但 `UpdateUser` 的路径只在"用户维度"`Clean`,而 `UpdateUserStatus` 显式通过 `UpdateStatus`(tokenVersion+1)。`UpdateUser` 里的 `UpdateProfile(statusChanged=true)` 模型代码**也** `tokenVersion+1`,行为一致,所以功能等价;但两条路径分别做校验、很容易在一侧加了新防御一侧漏掉(比如 `UpdateUserStatus` 已经禁止自改自己、不允许改超管;`UpdateUser` 只在 `caller.UserId == req.Id` 时禁 status,漏掉了"不允许把超管冻结"——实际上走 `UpdateUser` 修改超管 Status 会被第 56-60 行的"不能通过此接口修改其他超级管理员的状态和部门"拦下,OK)
+- **建议**:要么把 `UpdateUser` 里处理 `Status` 的分支删掉(转嫁给 `UpdateUserStatus`),要么显式文档化两接口的角色差异,避免未来维护者再添新守则时漏一处
 
 ---
 
-### L-9. `BindRoles` 对 `req.RoleIds = []` 的语义是"清空所有绑定",但缺少显式确认
+### L-K. `BindRoles` 的 caller 读取在校验之后但使用条件分散,易读性差
 
-- **位置**:`internal/logic/user/bindRolesLogic.go:48-58`
-- **描述**:传空数组时,`toAdd=[]`、`toRemove=existing`,流程会在事务里删光用户在该产品下的所有角色绑定。该语义合理(前端做全量覆盖),但没有任何二次确认或显式参数(`clearAll bool`),容易在前端误传 `[]` 时造成"误删"
-- **建议**:在请求体中增加 `Intent: "replace" | "append"` 区分,或前端传 null/omitempty 时禁止清空。可选的 API 契约强化
+- **位置**:`internal/logic/user/bindRolesLogic.go:65-89`
+- **描述**:`caller := middleware.GetUserDetails(l.ctx)` 在角色数组校验中段读取,caller 可能为 nil(中间件理论上保证非 nil,但代码仍做 `caller != nil && ...`)。这种"半防御"容易让后续维护者以为 caller 可能为 nil,加上无必要的判空
+- **建议**:在函数开头统一读取 caller,确认非 nil;后续逻辑直接使用
 
 ---
 
-### L-10. `productmember` 的 `FindOneByProductCodeUserId` 没有按 `status` 过滤
+### L-1 / L-2 / L-7 / L-8 / L-9 / L-10(前轮遗留)
 
-- **位置**:model 层(`sys_product_member` 的 cached 查询)
-- **描述**:`loadMembership` 在拿到 member 后校验 `member.Status == Enabled`;`bindRolesLogic.go:44`、`setUserPermsLogic.go:44`、`updateUserStatusLogic.go:53` 等业务层都只检查"是否存在成员记录",没有再检查成员状态。
-  - `bindRoles` 给"已被禁用的成员"重新绑角色:数据上写入,但由于 `jwtauthMiddleware` 不校验 MemberType(见 H-2),会和 H-2 联动放大影响。
-  - `setUserPerms` 同理。
-- **建议**:在这些业务校验处把 `FindOneByProductCodeUserId` 的结果加 `member.Status == Enabled` 判断,明确拒绝对已禁用成员的权限操作。
+- 与上一轮一致,此处不再赘述。保持 P3 跟进。
 
 ---
 
@@ -532,33 +632,40 @@
 
 | 维度 | 评估 |
 |------|------|
-| **逻辑一致性** | 新发现:产品禁用未联动 token 失效(H-1)、HTTP 中间件不校验禁用成员(H-2)、最后 ADMIN 保护缺失(H-4)。 |
-| **并发与竞态** | `BindRolePerms/DeleteRole/UpdateRole` 的"受影响用户"查询发生在事务外,存在并发缺漏(M-9)。其它关键写入已有乐观锁或 `FOR UPDATE`。 |
-| **资源管理** | go-zero `TransactCtx` 使用规范,`sqlx` 与 Redis 连接由池管理;未见泄漏。 |
-| **数据完整性** | 核心写路径(createProduct、bindRoles、bindRolePerms、removeMember、deleteRole、syncPerms)均在事务内,缓存失效为 fire-and-forget(M-8)。 |
-| **安全漏洞** | 产品禁用失效(H-1)、成员禁用不生效于 HTTP 路径(H-2)、管理后台账号锁定 DoS(H-3)、最后 ADMIN 失守(H-4)、产品端账号 DoS(M-6)、adminPassword 熵不足(M-3)、DeptTree / ProductList 暴露过度(M-4、M-5)。 |
-| **边界处理** | nil / 空串 / 可选字段(指针)处理普遍得当;`UserDetails` 零值语义仍依赖 `Username == ""` 约定(L-7)。 |
-| **DB 性能** | BindRoles / BindRolePerms / role 删除路径已批量化;其它列表接口采用"批量 IN + map 拼装",无 N+1。`loadPerms` 的 role/user perm 查询可加 `p.status=1` 减少无效数据(M-16)。 |
-| **僵尸代码** | `SysDeptModel.FindByPathPrefix` / `FindByParentId` 仅测试引用(M-11)。`Claims.Perms` 已清理、`FindRoleIdsByUserId` 仍有调用路径。 |
-| **接口契约与对象完整性** | `UserDetails` 缺 `ProductStatus` 字段(H-1 所需);`UpdateUserStatus` 有重复归属校验(M-12)。 |
+| **逻辑一致性** | 新增重大发现:`SetUserPerms` 自我越权(H-A);"最后 ADMIN" 校验 TOCTOU(M-A);`IncrementTokenVersion` 返回值与 DB 脱钩(H-B)。 |
+| **并发与竞态** | `DeleteRole` 仍有事务内外分裂(M-D);`Delete*ForProductTx` 的 "先 SELECT 再 DELETE" 模式非原子(M-E)。 |
+| **资源管理** | 无新增泄漏;`RefreshToken` / `Logout` 无限流造成 DB + Redis 热点(M-B/L-C)。 |
+| **数据完整性** | 关键写路径事务化;剩余问题都在"读-用-改"时序上。`UpdateMember` 的 `req.Status=0` 分支保留原值,无覆盖风险。 |
+| **安全漏洞** | H-A(MEMBER 自我提权全权限)属于严重权限越权;H-B 导致用户随机被登出(可用性),也降低 refresh rotation 的安全语义;M-C 仍允许账号枚举 + 选择性锁定。 |
+| **边界处理** | `SetUserPerms` / `BindRoles` / `RemoveMember` 对空数组、重复值的处理健壮;`UpdateUser` 指针字段的 nil/空区分明确。 |
+| **DB 性能** | `UserList` 冗余二次查询(M-G);`loadPerms` 已加 `p.status=1`;其他列表接口批量 IN 无 N+1。 |
+| **僵尸代码** | `FindByPathPrefix`/`FindByParentId` 已清理;残留 `FindRoleIdsByUserId`(L-E)。`CountActiveAdmins` SQL 硬编码常量(M-F)。 |
+| **接口契约** | `CreateUser` 注释 vs 实现不一致(L-A);`UpdateUser` 与 `UpdateUserStatus` 职责重叠(L-J)。 |
 
 ### 修复优先级建议
 
-1. **立即修复(P0)**
-   - H-1 产品禁用不生效:加 `ProductStatus` 字段,`loadPerms` / `JwtAuth` / `GetUserPerms` / `VerifyToken` 统一校验,必要时在 `UpdateProduct` 递增成员的 `tokenVersion`。
-   - H-2 中间件不校验 MemberType:与 `RefreshToken` 对齐。
-   - H-3 AdminLogin DoS:ManagementKey 校验前置,rate limit key 加 IP 维度。
-   - H-4 最后 ADMIN 保护:`RemoveMember` / `UpdateMember` 增加 adminCount 前置校验。
-
-2. **短期修复(P1)**
-   - M-1 无注销接口 → 补 `/auth/logout`。
-   - M-2 refreshToken 轮转应让旧 token 失效。
-   - M-3 `generateRandomHex` 截断 bug + adminPassword 长度提升。
-   - M-4 DeptTree 权限过滤。
-   - M-5 ProductList/Detail 权限过滤。
-   - M-6 产品登录账号锁定 DoS(与 H-3 同步)。
-   - M-8 缓存失效原子性补偿(最高优先保障"收紧"类安全操作)。
-   - M-9 事务外用户集查询移到事务后。
-
-3. **中期修复(P2)**
-   - 其它 M/L 级条目(XFF 支持、LIKE 转义、僵尸代码清理、校验冗余去重、权限查询 `status=1` 过滤)。
+**P0(立即修复)**
+- **H-A**:`SetUserPerms` 加 `RequireProductAdminFor(productCode)` 或至少禁止自我 set。——普通 MEMBER 自我提权到产品全权限,属于可利用的权限越权,需优先拦截。
+- **H-B**:`IncrementTokenVersion` 改为事务 + `LAST_INSERT_ID` / `FOR UPDATE` 获取真实的新版本号。——否则并发刷新会让正常用户偶发"刷新后即失效",同时削弱 refresh rotation 安全性。
+
+**P1(短期修复)**
+- **M-A**:`RemoveMember` / `UpdateMember` 的"最后 ADMIN"校验挪进事务 + 加锁。
+- **M-B**:`/auth/refreshToken` 加 userId 级限流,或在 logic 内部做 Redis 计数。
+- **M-C**:`UsernameLoginLimit` key 增加 IP 维度,冻结账号不再消耗配额;可选加入 bcrypt 假耗时。
+- **M-3**:`generateRandomHex` 修截断边界 + adminPassword 长度提升到 16。
+- **M-4 / M-5**:`DeptTree` / `ProductList` / `ProductDetail` 增加归属过滤。
+- **M-8**:对"收紧类"安全动作的缓存失败策略收紧,尤其是新加入的 `Logout`(失败必须返回 5xx)。
+- **L-C**:`Logout` 加限流。
+
+**P2(中期修复)**
+- M-D:`DeleteRole` 把 `FindUserIdsByRoleId` 改为 session `FOR UPDATE`。
+- M-E:所有 `Delete*ForProductTx` 的 SELECT 改为 session 内执行。
+- M-F:`CountActiveAdmins` 用常量占位符替换硬编码。
+- M-G:`UserList` 合并 JOIN 与 memberType 查询,去掉二次 IN。
+- M-7:XFF + 可信代理白名单。
+- L-B:JWT 中间件调整校验顺序(tokenVersion 前置)。
+- L-A / L-J / L-F / L-G / L-H / L-I / L-K:按各项说明收敛。
+
+**P3(长期)**
+- L-1 / L-2(返回码选择、敏感配置明文)、L-7(`loader.Load` error 语义)、L-8(gRPC IP 提取)、L-9(`BindRoles` 空数组默认语义)、L-10(`FindOneByProductCodeUserId` 不按 status 过滤)保持跟进。
+

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

@@ -97,6 +97,22 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
 }
 
+// ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
+// UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
+func ValidateStatusChange(ctx context.Context, svcCtx *svc.ServiceContext, callerId, targetUserId int64) error {
+	if callerId == targetUserId {
+		return response.ErrBadRequest("不能修改自己的状态")
+	}
+	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
+	if err != nil {
+		return response.ErrNotFound("用户不存在")
+	}
+	if target.IsSuperAdmin == consts.IsSuperAdminYes {
+		return response.ErrForbidden("不能修改超级管理员的状态")
+	}
+	return nil
+}
+
 func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64) error {
 	if caller.MemberType == consts.MemberTypeAdmin {
 		return nil

+ 9 - 0
internal/logic/auth/logoutLogic.go

@@ -5,11 +5,13 @@ package auth
 
 import (
 	"context"
+	"fmt"
 
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 
+	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/logx"
 )
 
@@ -34,6 +36,13 @@ func (l *LogoutLogic) Logout() error {
 		return response.ErrUnauthorized("未登录")
 	}
 
+	if l.svcCtx.TokenOpLimiter != nil {
+		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("logout:%d", userId))
+		if code == limit.OverQuota {
+			return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
+		}
+	}
+
 	if _, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, userId); err != nil {
 		return err
 	}

+ 106 - 0
internal/logic/auth/logoutRateLimit_audit_test.go

@@ -0,0 +1,106 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// TC-0739: L-C 修复回归 —— Logout 必须受 TokenOpLimiter 保护;
+// 用 quota=2 的定制 limiter,同一用户超过配额后第 3 次必须返回 429,
+// 且该超限请求**不能**递增 tokenVersion(避免撞库者反复自增搅乱 Cache)。
+func TestLogout_TokenOpLimiter_BlocksThirdCall(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	username := "lg_rl_" + testutil.UniqueId()
+
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg_rl",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	userId, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	// 独立 prefix 保证与全局 limiter 桶互不干扰,也避免用例互相污染
+	svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 2, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:ut:"+testutil.UniqueId())
+
+	lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: userId, Username: username, Status: 1,
+	})
+
+	require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 应放行")
+	require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 2 次 logout 仍在配额内应放行")
+
+	err = NewLogoutLogic(lctx, svcCtx).Logout()
+	require.Error(t, err, "第 3 次必须被 TokenOpLimiter 拦截")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code())
+	assert.Contains(t, ce.Error(), "过于频繁")
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(2), u.TokenVersion,
+		"被限流的 logout 请求绝不能再触发 IncrementTokenVersion(否则攻击者可反复刷新缓存)")
+}
+
+// TC-0740: L-C 修复 —— 限流 key 必须按 userId 隔离,A 用户打满不得影响 B 用户。
+func TestLogout_TokenOpLimiter_PerUserIsolated(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	mkUser := func(tag string) int64 {
+		res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+			Username: "lg_iso_" + tag + "_" + testutil.UniqueId(),
+			Password: testutil.HashPassword("pw"), Nickname: "lg_iso",
+			Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+			Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		return id
+	}
+	aId := mkUser("a")
+	bId := mkUser("b")
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", aId, bId) })
+
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:iso:"+testutil.UniqueId())
+
+	lctxA := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: aId, Status: 1})
+	lctxB := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: bId, Status: 1})
+
+	require.NoError(t, NewLogoutLogic(lctxA, svcCtx).Logout())
+	// A 打满后再打一次
+	err := NewLogoutLogic(lctxA, svcCtx).Logout()
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code())
+
+	require.NoError(t, NewLogoutLogic(lctxB, svcCtx).Logout(),
+		"B 用户应当仍有独立配额,不被 A 用户的限流影响")
+}

+ 5 - 1
internal/logic/member/addMemberLogic.go

@@ -38,9 +38,13 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 	if product.Status != consts.StatusEnabled {
 		return nil, response.ErrBadRequest("产品已被禁用,无法添加成员")
 	}
-	if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
+	targetUser, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId)
+	if err != nil {
 		return nil, response.ErrNotFound("用户不存在")
 	}
+	if targetUser.Status != consts.StatusEnabled {
+		return nil, response.ErrBadRequest("用户已被冻结,无法添加为成员")
+	}
 
 	if req.MemberType != consts.MemberTypeAdmin &&
 		req.MemberType != consts.MemberTypeDeveloper &&

+ 11 - 9
internal/logic/member/removeMemberLogic.go

@@ -38,17 +38,19 @@ func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 		return err
 	}
 
-	if member.MemberType == consts.MemberTypeAdmin {
-		adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdmins(l.ctx, member.ProductCode)
-		if err != nil {
-			return err
+	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil {
+			return response.ErrNotFound("成员不存在")
 		}
-		if adminCount <= 1 {
-			return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+		if member.MemberType == consts.MemberTypeAdmin {
+			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
+			if err != nil {
+				return err
+			}
+			if adminCount <= 1 {
+				return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+			}
 		}
-	}
-
-	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, member.UserId, member.ProductCode); err != nil {
 			return err
 		}

+ 9 - 0
internal/logic/member/removeMemberLogic_mock_test.go

@@ -28,11 +28,20 @@ func TestRemoveMember_Mock_UserPermDeleteFail(t *testing.T) {
 			Id:          1,
 			UserId:      10,
 			ProductCode: "pc",
+			MemberType:  "MEMBER",
 		}, nil)
 	mockPM.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
 			return fn(ctx, nil)
 		})
+	// M-A 修复:事务内先 FindOneForUpdateTx 锁行
+	mockPM.EXPECT().FindOneForUpdateTx(gomock.Any(), nil, int64(1)).
+		Return(&productmember.SysProductMember{
+			Id:          1,
+			UserId:      10,
+			ProductCode: "pc",
+			MemberType:  "MEMBER",
+		}, nil)
 
 	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
 	mockUR.EXPECT().DeleteByUserIdForProductTx(gomock.Any(), nil, int64(10), "pc").Return(nil)

+ 17 - 10
internal/logic/member/updateMemberLogic.go

@@ -11,6 +11,7 @@ import (
 	"perms-system-server/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type UpdateMemberLogic struct {
@@ -47,15 +48,7 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		return err
 	}
 
-	if member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin {
-		adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdmins(l.ctx, member.ProductCode)
-		if err != nil {
-			return err
-		}
-		if adminCount <= 1 {
-			return response.ErrBadRequest("不能降级该产品的最后一个管理员")
-		}
-	}
+	needAdminCheck := member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin
 
 	member.MemberType = req.MemberType
 	if req.Status != 0 {
@@ -66,7 +59,21 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	}
 	member.UpdateTime = time.Now().Unix()
 
-	if err := l.svcCtx.SysProductMemberModel.Update(l.ctx, member); err != nil {
+	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil {
+			return response.ErrNotFound("成员不存在")
+		}
+		if needAdminCheck {
+			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
+			if err != nil {
+				return err
+			}
+			if adminCount <= 1 {
+				return response.ErrBadRequest("不能降级该产品的最后一个管理员")
+			}
+		}
+		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, member)
+	}); err != nil {
 		return err
 	}
 

+ 3 - 1
internal/logic/pub/loginLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"time"
 
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -27,7 +28,8 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
 
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录指定产品,返回 JWT 令牌对及用户权限信息。
 func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
-	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode)
+	clientIP := middleware.GetClientIP(l.ctx)
+	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode, clientIP)
 	if err != nil {
 		if le, ok := err.(*LoginError); ok {
 		switch le.Code {

+ 19 - 8
internal/logic/pub/loginService.go

@@ -3,6 +3,7 @@ package pub
 import (
 	"context"
 	"errors"
+	"fmt"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
@@ -14,6 +15,9 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
+// dummyBcryptHash 用于对不存在的用户名执行等时 bcrypt 比对,防止基于响应时间的用户名枚举
+var dummyBcryptHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-anti-timing"), bcrypt.DefaultCost)
+
 type LoginResult struct {
 	UserDetails  *loaders.UserDetails
 	AccessToken  string
@@ -29,22 +33,29 @@ func (e *LoginError) Error() string {
 	return e.Message
 }
 
-func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode string) (*LoginResult, error) {
+func checkUsernameLimit(svcCtx *svc.ServiceContext, clientIP, username string) bool {
+	if svcCtx.UsernameLoginLimit == nil {
+		return false
+	}
+	key := fmt.Sprintf("%s:%s", clientIP, username)
+	code, _ := svcCtx.UsernameLoginLimit.Take(key)
+	return code == limit.OverQuota
+}
+
+func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode, clientIP string) (*LoginResult, error) {
+	if checkUsernameLimit(svcCtx, clientIP, username) {
+		return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"}
+	}
+
 	u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
 	if err != nil {
 		if errors.Is(err, user.ErrNotFound) {
+			bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
 			return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
 		}
 		return nil, err
 	}
 
-	if svcCtx.UsernameLoginLimit != nil {
-		code, _ := svcCtx.UsernameLoginLimit.Take(username)
-		if code == limit.OverQuota {
-			return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"}
-		}
-	}
-
 	if u.Status != consts.StatusEnabled {
 		return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
 	}

+ 85 - 0
internal/logic/pub/loginService_enum_audit_test.go

@@ -0,0 +1,85 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// TC-0751: M-C 修复回归 —— 对不存在的用户名也执行 dummy bcrypt 比对,
+// 响应文案与"存在用户但密码错"一致,避免用户名枚举。
+func TestValidateProductLogin_UnknownUserSameError(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := "enum_unknown_" + testutil.UniqueId()
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "random-pw", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code)
+	assert.Equal(t, "用户名或密码错误", le.Message,
+		"M-C:不存在用户名不得暴露差异化文案")
+}
+
+// TC-0752: M-C 修复回归 —— 存在用户名但密码错,返回相同文案相同 code,供与 TC-0751 做对照。
+func TestValidateProductLogin_KnownUserWrongPwd(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := "enum_known_" + testutil.UniqueId()
+
+	userId, cleanUser := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 2)
+	t.Cleanup(cleanUser)
+	_ = userId
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "wrong-pw", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code, "M-C:Code 必须与未知用户完全一致")
+	assert.Equal(t, "用户名或密码错误", le.Message, "M-C:文案必须与未知用户完全一致")
+}
+
+// TC-0753: M-C 修复回归 —— UsernameLoginLimit 的 key 必须按 ip:username 构造。
+// 同一 username 不同 IP 的配额互不共用,防止攻击者"用任意 IP 打爆某账号"导致账号 DoS。
+func TestValidateProductLogin_RateLimitKeyedByIPAndUsername(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	// 使用独立的 quota=1 limiter
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, 1, rds,
+		cfg.CacheRedis.KeyPrefix+":rl:userlogin:ut:"+testutil.UniqueId())
+
+	username := "enum_rl_" + testutil.UniqueId()
+
+	// IP-A 第 1 次:"用户名或密码错误"
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "1.1.1.1")
+	require.Error(t, err)
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code)
+
+	// IP-A 第 2 次:超限 429
+	_, err = ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "1.1.1.1")
+	require.Error(t, err)
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 429, le.Code, "M-C:同 IP 同 username 第 2 次必须触发 429")
+
+	// IP-B 第 1 次:独立桶,仍应走到密码校验(不是 429)
+	_, err = ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "2.2.2.2")
+	require.Error(t, err)
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code,
+		"M-C:不同 IP 的同 username 必须走独立限流桶(不是 429)")
+}

+ 9 - 0
internal/logic/pub/refreshTokenLogic.go

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"context"
+	"fmt"
 	"strings"
 	"time"
 
@@ -11,6 +12,7 @@ import (
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
+	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/logx"
 )
 
@@ -63,6 +65,13 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
 	}
 
+	if l.svcCtx.TokenOpLimiter != nil {
+		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))
+		if code == limit.OverQuota {
+			return nil, response.ErrTooManyRequests("刷新操作过于频繁,请稍后再试")
+		}
+	}
+
 	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, claims.UserId)
 	if err != nil {
 		return nil, err

+ 104 - 0
internal/logic/pub/refreshTokenRateLimit_audit_test.go

@@ -0,0 +1,104 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// TC-0741: M-B 修复回归 —— /auth/refreshToken 必须受 TokenOpLimiter 保护,
+// 用 quota=1 的定制 limiter,同一用户第 2 次必须 429;
+// 且被限流的请求绝不能触发 IncrementTokenVersion(否则攻击者可持续废除 refresh 令牌)。
+func TestRefreshToken_TokenOpLimiter_BlocksBurst(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := "rt_rl_" + testutil.UniqueId()
+	password := "TestPass123"
+
+	userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:ut:"+testutil.UniqueId())
+
+	mkReq := func(tv int64) *types.RefreshTokenReq {
+		rt, err := authHelper.GenerateRefreshToken(
+			svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+			userId, "", tv)
+		require.NoError(t, err)
+		return &types.RefreshTokenReq{Authorization: "Bearer " + rt}
+	}
+
+	resp1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(0))
+	require.NoError(t, err, "首次刷新应放行")
+	require.NotNil(t, resp1)
+
+	// DB tokenVersion 已变为 1,旧 claims.TokenVersion=0 的 refreshToken 已失效,
+	// 所以第二次必须用新 token;但限流判定在 TokenVersion 校验之**后**、IncrementTokenVersion 之**前**,
+	// 因此使用新版本号构造的 token 会先通过前置校验,再被 TokenOpLimiter 拦截。
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	tvAfterFirst := u.TokenVersion
+	require.Equal(t, int64(1), tvAfterFirst)
+
+	_, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(tvAfterFirst))
+	require.Error(t, err, "超限的第二次刷新必须被 429 拦截")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code())
+	assert.Contains(t, ce.Error(), "过于频繁")
+
+	uAfter, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, tvAfterFirst, uAfter.TokenVersion,
+		"被限流的 refresh 请求绝不可递增 tokenVersion")
+}
+
+// TC-0742: M-B 修复 —— 限流按用户粒度隔离(productCode 无关)。
+// 场景:同一用户连续两次带 productCode=空的刷新请求,若限流命中,不会影响其它用户。
+func TestRefreshToken_TokenOpLimiter_PerUserIsolated(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	uaId, cleanA := insertRefreshTestUser(t, ctx, "rt_iso_a_"+testutil.UniqueId(), "TestPass123", 1, 2)
+	t.Cleanup(cleanA)
+	ubId, cleanB := insertRefreshTestUser(t, ctx, "rt_iso_b_"+testutil.UniqueId(), "TestPass123", 1, 2)
+	t.Cleanup(cleanB)
+
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:iso:"+testutil.UniqueId())
+
+	mkReq := func(uid, tv int64) *types.RefreshTokenReq {
+		rt, err := authHelper.GenerateRefreshToken(
+			svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+			uid, "", tv)
+		require.NoError(t, err)
+		return &types.RefreshTokenReq{Authorization: "Bearer " + rt}
+	}
+
+	// A:两次刷新,第 2 次必 429
+	_, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 0))
+	require.NoError(t, err)
+	_, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 1))
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	require.Equal(t, 429, ce.Code())
+
+	// B 应当还能刷新
+	respB, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(ubId, 0))
+	require.NoError(t, err, "B 用户的限流桶应当独立于 A")
+	require.NotNil(t, respB)
+}

+ 5 - 1
internal/logic/role/deleteRoleLogic.go

@@ -39,7 +39,11 @@ func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleReq) error {
 
 	var affectedUserIds []int64
 	if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		affectedUserIds, _ = l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(ctx, req.Id)
+		var err error
+		affectedUserIds, err = l.svcCtx.SysUserRoleModel.FindUserIdsByRoleIdForUpdateTx(ctx, session, req.Id)
+		if err != nil {
+			return err
+		}
 		if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdTx(ctx, session, req.Id); err != nil {
 			return err
 		}

+ 2 - 1
internal/logic/role/deleteRoleLogic_mock_test.go

@@ -34,7 +34,8 @@ func TestDeleteRole_Mock_UserRoleDeleteFail(t *testing.T) {
 	mockRP.EXPECT().DeleteByRoleIdTx(gomock.Any(), nil, int64(1)).Return(nil)
 
 	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindUserIdsByRoleId(gomock.Any(), int64(1)).Return([]int64{}, nil)
+	// M-D 修复:改为事务内 FOR UPDATE 读取 userIds
+	mockUR.EXPECT().FindUserIdsByRoleIdForUpdateTx(gomock.Any(), nil, int64(1)).Return([]int64{}, nil)
 	mockUR.EXPECT().DeleteByRoleIdTx(gomock.Any(), nil, int64(1)).Return(dbErr)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{

+ 6 - 3
internal/logic/user/bindRolesLogic.go

@@ -33,6 +33,11 @@ func NewBindRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindRol
 
 // BindRoles 绑定用户角色。对指定用户在当前产品下做角色全量覆盖(diff 后批量新增/删除),支持权限级别校验防止越权分配。
 func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+
 	if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
@@ -62,8 +67,6 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		req.RoleIds = uniqueIds
 	}
 
-	caller := middleware.GetUserDetails(l.ctx)
-
 	if len(req.RoleIds) > 0 {
 		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
 		if err != nil {
@@ -79,7 +82,7 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
-			if caller != nil && !caller.IsSuperAdmin &&
+			if !caller.IsSuperAdmin &&
 				caller.MemberType != consts.MemberTypeAdmin &&
 				caller.MemberType != consts.MemberTypeDeveloper {
 				if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {

+ 2 - 1
internal/logic/user/createUserLogic.go

@@ -35,7 +35,8 @@ func NewCreateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
 	}
 }
 
-// CreateUser 创建用户。新建系统用户账号,可指定部门归属。仅超管可调用。
+// CreateUser 创建用户。新建系统用户账号,可指定部门归属。超管或当前产品 ADMIN 可调用。
+// 注意:产品 ADMIN 创建的用户为系统级用户,不自动加入任何产品,需通过 AddMember 接口手动关联。
 func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdResp, err error) {
 	productCode := middleware.GetProductCode(l.ctx)
 	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {

+ 4 - 0
internal/logic/user/setUserPermsLogic.go

@@ -39,6 +39,10 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 
 	productCode := middleware.GetProductCode(l.ctx)
 
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+		return err
+	}
+
 	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, productCode)
 	if err != nil {
 		return response.ErrNotFound("产品不存在")

+ 174 - 0
internal/logic/user/setUserPermsSelfEscalation_audit_test.go

@@ -0,0 +1,174 @@
+package user
+
+import (
+	"context"
+	"errors"
+	"math"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	permModel "perms-system-server/internal/model/perm"
+	productModel "perms-system-server/internal/model/product"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-0743: H-A 修复回归 —— 普通 MEMBER 不得通过 SetUserPerms 给自己授予任何权限
+// (RequireProductAdminFor 前置校验必须拦截)。
+func TestSetUserPerms_MemberCannotSelfEscalate(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	bootstrap := context.Background()
+
+	code := testutil.UniqueId()
+	pRes, err := svcCtx.SysProductModel.Insert(bootstrap, &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()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, bootstrap, username, testutil.HashPassword("pw"))
+	mId := insertTestMember(t, svcCtx, code, userId)
+
+	permRes, err := svcCtx.SysPermModel.Insert(bootstrap, &permModel.SysPerm{
+		ProductCode: code, Name: "escalate_p", Code: "esc_" + testutil.UniqueId(),
+		Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	permId, _ := permRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(bootstrap, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_perm`", permId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", userId)
+		testutil.CleanTable(bootstrap, conn, "`sys_product`", pId)
+	})
+
+	// caller = 目标用户本人,MemberType=MEMBER(非 ADMIN)
+	callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: userId, Username: username,
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeMember,
+		Status:        consts.StatusEnabled,
+		ProductCode:   code,
+		DeptId:        1,
+		DeptPath:      "/1/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	err = NewSetUserPermsLogic(callerCtx, svcCtx).SetUserPerms(&types.SetPermsReq{
+		UserId: userId, // 给自己
+		Perms:  []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectAllow}},
+	})
+	require.Error(t, err, "MEMBER 不得自我授权")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "仅超级管理员或该产品的管理员可执行此操作")
+
+	// 二次确认:没有任何 user_perm 记录被写入
+	rows := findUserPerms(t, bootstrap, userId)
+	assert.Len(t, rows, 0, "被拒绝的 SetUserPerms 不得在 DB 残留任何个性化权限")
+}
+
+// TC-0744: H-A 修复回归 —— DEVELOPER 调用者(非 ADMIN)同样被拦截,即便目标不是自己。
+func TestSetUserPerms_DeveloperCallerRejected(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := context.Background()
+	now := time.Now().Unix()
+
+	code := testutil.UniqueId()
+	pRes, err := svcCtx.SysProductModel.Insert(bootstrap, &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()
+
+	targetUsername := "target_" + testutil.UniqueId()
+	targetId := insertTestUser(t, bootstrap, targetUsername, testutil.HashPassword("pw"))
+	mId := insertTestMember(t, svcCtx, code, targetId)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_product`", pId)
+	})
+
+	devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 777777, Username: "dev_caller",
+		MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled,
+		ProductCode: code, DeptId: 1, DeptPath: "/1/", MinPermsLevel: math.MaxInt64,
+	})
+
+	err = NewSetUserPermsLogic(devCtx, svcCtx).SetUserPerms(&types.SetPermsReq{
+		UserId: targetId, Perms: []types.UserPermItem{},
+	})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "仅超级管理员或该产品的管理员可执行此操作")
+}
+
+// TC-0745: H-A 正向回归 —— 同产品 ADMIN 操作合法 MEMBER 目标(非自己)依旧放行。
+func TestSetUserPerms_ProductAdminStillWorks(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	bootstrap := context.Background()
+	now := time.Now().Unix()
+
+	code := testutil.UniqueId()
+	pRes, err := svcCtx.SysProductModel.Insert(bootstrap, &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()
+
+	targetId := insertTestUser(t, bootstrap, "tgt_"+testutil.UniqueId(), testutil.HashPassword("pw"))
+	mId := insertTestMember(t, svcCtx, code, targetId)
+
+	permRes, err := svcCtx.SysPermModel.Insert(bootstrap, &permModel.SysPerm{
+		ProductCode: code, Name: "ok_p", Code: "ok_" + testutil.UniqueId(),
+		Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	permId, _ := permRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(bootstrap, conn, "`sys_user_perm`", "userId", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_perm`", permId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_product`", pId)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 999999, Username: "admin_caller",
+		MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled,
+		ProductCode: code, DeptId: 1, DeptPath: "/1/", MinPermsLevel: math.MaxInt64,
+	})
+
+	err = NewSetUserPermsLogic(adminCtx, svcCtx).SetUserPerms(&types.SetPermsReq{
+		UserId: targetId,
+		Perms:  []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectAllow}},
+	})
+	require.NoError(t, err, "产品 ADMIN 正常路径必须放行")
+
+	rows := findUserPerms(t, bootstrap, targetId)
+	assert.Len(t, rows, 1, "ADMIN 授权后 DB 应有 1 条 user_perm")
+}

+ 172 - 0
internal/logic/user/updateUserDeptScope_audit_test.go

@@ -0,0 +1,172 @@
+package user
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"math"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func insertTestDeptForScope(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path string) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
+		ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
+		DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
+func insertTestUserWithDept(t *testing.T, ctx context.Context, tag string, deptId int64) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	return insertTestUserFull(t, ctx, &userModel.SysUser{
+		Username:           "ddu_" + tag + "_" + testutil.UniqueId(),
+		Password:           testutil.HashPassword("pw"),
+		Nickname:           "n",
+		Avatar:             sql.NullString{},
+		Email:              "[email protected]",
+		Phone:              "13800000000",
+		DeptId:             deptId,
+		IsSuperAdmin:       consts.IsSuperAdminNo,
+		MustChangePassword: 2,
+		Status:             consts.StatusEnabled,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+}
+
+// TC-0746: L-F 修复回归 —— DEVELOPER 调用者不得将目标用户的 deptId 调到
+// 自己 DeptPath 子树之外的部门。UpdateUser 必须在 req.DeptId 变更时做 Path 前缀校验。
+func TestUpdateUser_DeveloperCannotMoveTargetOutsideSubtree(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller", "/100/")
+	targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "target", "/100/200/")
+	outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "outside", "/999/")
+	targetId := insertTestUserWithDept(t, bootstrap, "lf_out", targetDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId, outsideDeptId)
+	})
+
+	devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 55555, Username: "lf_dev",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeDeveloper,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/100/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := outsideDeptId
+	err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id:     targetId,
+		DeptId: &newDept,
+	})
+	require.Error(t, err, "调入外部部门应被拒绝")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "无权将用户调入")
+
+	user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, targetDeptId, user.DeptId, "被拒绝的请求必须不改动 DB")
+}
+
+// TC-0747: L-F 正向回归 —— DEVELOPER 将目标用户调入自己子树下的部门应允许。
+func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller_in", "/200/")
+	srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "src_in", "/200/1/")
+	dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dst_in", "/200/2/")
+	targetId := insertTestUserWithDept(t, bootstrap, "lf_in", srcDeptId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, srcDeptId, dstDeptId)
+	})
+
+	devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 66666, Username: "lf_dev_ok",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeDeveloper,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/200/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := dstDeptId
+	require.NoError(t,
+		NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}),
+		"caller DeptPath 的前缀子部门必须允许调入")
+
+	user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, dstDeptId, user.DeptId)
+}
+
+// TC-0748: L-F —— 产品 ADMIN 调用者被豁免 DeptPath 前缀校验(可跨部门转移)。
+func TestUpdateUser_ProductAdminExemptFromSubtreeCheck(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/")
+	targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/")
+	anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/")
+	targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetHomeDept, anywhereDept)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 77777, Username: "lf_admin",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, ProductCode: "test_product",
+		DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64,
+	})
+
+	newDept := anywhereDept
+	require.NoError(t,
+		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &newDept,
+		}),
+		"产品 ADMIN 在 UpdateUser 的 DeptPath 前缀校验中被豁免")
+}
+

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

@@ -3,6 +3,7 @@ package user
 import (
 	"context"
 	"errors"
+	"strings"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
@@ -48,14 +49,20 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		}
 	}
 
+	if req.Status != 0 {
+		if err := authHelper.ValidateStatusChange(l.ctx, l.svcCtx, caller.UserId, req.Id); err != nil {
+			return err
+		}
+	}
+
 	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
 
 	if caller.UserId != req.Id && user.IsSuperAdmin == consts.IsSuperAdminYes {
-		if req.Status != 0 || req.DeptId != nil {
-			return response.ErrForbidden("不能通过此接口修改其他超级管理员的状态和部门")
+		if req.DeptId != nil {
+			return response.ErrForbidden("不能通过此接口修改其他超级管理员的部门")
 		}
 	}
 
@@ -98,9 +105,16 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	}
 	if req.DeptId != nil {
 		if *req.DeptId > 0 {
-			if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId); err != nil {
+			newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
+			if err != nil {
 				return response.ErrBadRequest("部门不存在")
 			}
+			if !caller.IsSuperAdmin &&
+				caller.MemberType != consts.MemberTypeAdmin &&
+				caller.DeptPath != "" &&
+				!strings.HasPrefix(newDept.Path, caller.DeptPath) {
+				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
+			}
 		}
 		deptId = *req.DeptId
 	}

+ 2 - 1
internal/logic/user/updateUserLogic_test.go

@@ -539,7 +539,8 @@ func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) {
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 403, ce.Code())
-	assert.Equal(t, "不能通过此接口修改其他超级管理员的状态和部门", ce.Error())
+	// 最新重构:Status 校验统一走 authHelper.ValidateStatusChange,文案为"不能修改超级管理员的状态"
+	assert.Equal(t, "不能修改超级管理员的状态", ce.Error())
 
 	user, err := svcCtx.SysUserModel.FindOne(ctx, superBId)
 	require.NoError(t, err)

+ 2 - 11
internal/logic/user/updateUserStatusLogic.go

@@ -34,17 +34,8 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 	}
 
 	callerId := middleware.GetUserId(l.ctx)
-	if callerId == req.Id {
-		return response.ErrBadRequest("不能修改自己的状态")
-	}
-
-	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
-	if err != nil {
-		return response.ErrNotFound("用户不存在")
-	}
-
-	if user.IsSuperAdmin == consts.IsSuperAdminYes {
-		return response.ErrForbidden("不能修改超级管理员的状态")
+	if err := authHelper.ValidateStatusChange(l.ctx, l.svcCtx, callerId, req.Id); err != nil {
+		return err
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)

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

@@ -49,10 +49,12 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 	var memberMap map[int64]string
 
 	if req.ProductCode != "" {
-		list, total, err = l.svcCtx.SysUserModel.FindListByProductMembers(l.ctx, req.ProductCode, page, pageSize)
+		var mtMap map[int64]string
+		list, mtMap, total, err = l.svcCtx.SysUserModel.FindListByProductMembers(l.ctx, req.ProductCode, page, pageSize)
 		if err != nil {
 			return nil, err
 		}
+		memberMap = mtMap
 	} else {
 		list, total, err = l.svcCtx.SysUserModel.FindListByPage(l.ctx, page, pageSize)
 		if err != nil {
@@ -60,21 +62,6 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 		}
 	}
 
-	if req.ProductCode != "" {
-		userIds := make([]int64, 0, len(list))
-		for _, u := range list {
-			userIds = append(userIds, u.Id)
-		}
-		pmMap, err := l.svcCtx.SysProductMemberModel.FindMapByProductCodeUserIds(l.ctx, req.ProductCode, userIds)
-		if err != nil {
-			return nil, err
-		}
-		memberMap = make(map[int64]string, len(pmMap))
-		for uid, pm := range pmMap {
-			memberMap[uid] = pm.MemberType
-		}
-	}
-
 	items := make([]types.UserItem, 0, len(list))
 	for _, u := range list {
 		avatar := ""

+ 5 - 12
internal/logic/user/userListLogic_mock_test.go

@@ -4,7 +4,6 @@ import (
 	"errors"
 	"testing"
 
-	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/types"
@@ -13,7 +12,9 @@ import (
 	"go.uber.org/mock/gomock"
 )
 
-// TC-0180: 批量查询DB异常
+// TC-0180: FindListByProductMembers DB 异常时,UserList 透传错误。
+// 注:M-G 修复合并了成员类型查询到 FindListByProductMembers,原先的 FindMapByProductCodeUserIds
+// 二次查询路径已被移除,因此本用例调整为验证新的一次性 JOIN 查询失败分支。
 func TestUserList_Mock_FindMapError(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -22,18 +23,10 @@ func TestUserList_Mock_FindMapError(t *testing.T) {
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
 	mockUser.EXPECT().FindListByProductMembers(gomock.Any(), "pc", int64(1), int64(20)).
-		Return([]*userModel.SysUser{
-			{Id: 1, Username: "u1"},
-			{Id: 2, Username: "u2"},
-		}, int64(2), nil)
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindMapByProductCodeUserIds(gomock.Any(), "pc", []int64{1, 2}).
-		Return(nil, dbErr)
+		Return(nil, nil, int64(0), dbErr)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		User:          mockUser,
-		ProductMember: mockPM,
+		User: mockUser,
 	})
 
 	logic := NewUserListLogic(ctxhelper.SuperAdminCtx(), svcCtx)

+ 4 - 4
internal/middleware/jwtauthMiddleware.go

@@ -79,6 +79,10 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "账号已被冻结"))
 			return
 		}
+		if claims.TokenVersion != ud.TokenVersion {
+			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "登录状态已失效,请重新登录"))
+			return
+		}
 		if claims.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "该产品已被禁用"))
 			return
@@ -87,10 +91,6 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "您已不是该产品的有效成员"))
 			return
 		}
-		if claims.TokenVersion != ud.TokenVersion {
-			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "登录状态已失效,请重新登录"))
-			return
-		}
 		ctx := context.WithValue(r.Context(), ctxKeyUserDetails, ud)
 		next(w, r.WithContext(ctx))
 	}

+ 159 - 0
internal/middleware/jwtauth_checkorder_audit_test.go

@@ -0,0 +1,159 @@
+package middleware_test
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/model"
+	productModel "perms-system-server/internal/model/product"
+	productmemberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-0749: L-B 修复回归 —— jwtauthMiddleware 必须优先判定 TokenVersion 失效,
+// 而不是 ProductStatus/MemberType。旧实现会在"产品已禁用"场景下先返回 403 ProductDisabled,
+// 使用户被强制退出时看到无关文案;修复后 TokenVersion 不一致应返回 401 "登录状态已失效,请重新登录"。
+func TestJwtAuthMiddleware_TokenVersionCheckedBeforeProductStatus(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+	now := time.Now().Unix()
+
+	// 1) 创建"已禁用"的产品
+	pCode := "mw_ord_" + testutil.UniqueId()
+	pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
+		Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	// 2) 创建启用中的用户,TokenVersion=5
+	username := "mw_ord_u_" + testutil.UniqueId()
+	uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: username, Password: "x", Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
+		MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 5,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	userId, _ := uRes.LastInsertId()
+
+	// 3) 该用户是禁用产品的 ADMIN 成员
+	mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	m, _ := newTestMiddleware()
+
+	// 携带 stale TokenVersion=3 的 access token(DB 是 5)+ 禁用产品 code
+	tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
+		TokenType:    consts.TokenTypeAccess,
+		UserId:       userId,
+		Username:     username,
+		ProductCode:  pCode,
+		TokenVersion: 3,
+	})
+
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		t.Fatal("should not reach handler")
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/test", nil)
+	req.Header.Set("Authorization", "Bearer "+tokenStr)
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var body response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
+
+	assert.Equal(t, 401, body.Code,
+		"L-B:TokenVersion 失配必须先于产品禁用被识别(返回 401 而非 403)")
+	assert.Equal(t, "登录状态已失效,请重新登录", body.Msg,
+		"L-B:文案必须是'登录状态已失效'而不是'该产品已被禁用',否则用户会被无关信息误导")
+}
+
+// TC-0750: L-B 修复回归 —— TokenVersion 匹配但产品被禁用,仍应返回 403 "该产品已被禁用"。
+// 保证修复未把所有场景都吞成 401。
+func TestJwtAuthMiddleware_ProductDisabledAfterVersionOk(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	models := model.NewModels(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+	now := time.Now().Unix()
+
+	pCode := "mw_ord2_" + testutil.UniqueId()
+	pRes, err := models.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: pCode, Name: pCode, AppKey: pCode + "_k", AppSecret: "s",
+		Status: consts.StatusDisabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	username := "mw_ord2_u_" + testutil.UniqueId()
+	uRes, err := models.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: username, Password: "x", Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo,
+		MustChangePassword: 2, Status: consts.StatusEnabled, TokenVersion: 0,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	userId, _ := uRes.LastInsertId()
+
+	mRes, err := models.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pCode, UserId: userId, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	m, _ := newTestMiddleware()
+
+	tokenStr := generateTestToken(testAccessSecret, 3600, &middleware.Claims{
+		TokenType:    consts.TokenTypeAccess,
+		UserId:       userId,
+		Username:     username,
+		ProductCode:  pCode,
+		TokenVersion: 0,
+	})
+
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		t.Fatal("should not reach handler")
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/test", nil)
+	req.Header.Set("Authorization", "Bearer "+tokenStr)
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var body response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
+	assert.Equal(t, 403, body.Code)
+	assert.Equal(t, "该产品已被禁用", body.Msg)
+}

+ 14 - 1
internal/middleware/ratelimitMiddleware.go

@@ -1,6 +1,7 @@
 package middleware
 
 import (
+	"context"
 	"fmt"
 	"net"
 	"net/http"
@@ -12,6 +13,17 @@ import (
 	"github.com/zeromicro/go-zero/rest/httpx"
 )
 
+const ctxKeyClientIP contextKey = "clientIP"
+
+func WithClientIP(ctx context.Context, ip string) context.Context {
+	return context.WithValue(ctx, ctxKeyClientIP, ip)
+}
+
+func GetClientIP(ctx context.Context) string {
+	ip, _ := ctx.Value(ctxKeyClientIP).(string)
+	return ip
+}
+
 type RateLimitMiddleware struct {
 	limiter     *limit.PeriodLimit
 	behindProxy bool
@@ -31,7 +43,8 @@ func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			httpx.ErrorCtx(r.Context(), w, response.ErrTooManyRequests("请求过于频繁,请稍后再试"))
 			return
 		}
-		next(w, r)
+		ctx := WithClientIP(r.Context(), ip)
+		next(w, r.WithContext(ctx))
 	}
 }
 

+ 1 - 1
internal/model/perm/sysPermModel.go

@@ -111,7 +111,7 @@ func (m *customSysPermModel) DisableNotInCodesWithTx(ctx context.Context, sessio
 	}
 
 	var affected []*SysPerm
-	if err := m.QueryRowsNoCacheCtx(ctx, &affected, findQuery, findArgs...); err != nil {
+	if err := session.QueryRowsCtx(ctx, &affected, findQuery+" FOR UPDATE", findArgs...); err != nil {
 		return 0, err
 	}
 	if len(affected) == 0 {

+ 24 - 2
internal/model/productmember/sysProductMemberModel.go

@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"strings"
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -17,6 +19,8 @@ type (
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysProductMember, int64, error)
 		FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error)
 		CountActiveAdmins(ctx context.Context, productCode string) (int64, error)
+		CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error)
+		FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
 	}
 
 	customSysProductMemberModel struct {
@@ -48,13 +52,31 @@ func (m *customSysProductMemberModel) FindListByProductCode(ctx context.Context,
 
 func (m *customSysProductMemberModel) CountActiveAdmins(ctx context.Context, productCode string) (int64, error) {
 	var count int64
-	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = 'ADMIN' AND `status` = 1", m.table)
-	if err := m.QueryRowNoCacheCtx(ctx, &count, query, productCode); err != nil {
+	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
+	if err := m.QueryRowNoCacheCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
 		return 0, err
 	}
 	return count, nil
 }
 
+func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
+	var count int64
+	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
+	if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
+		return 0, err
+	}
+	return count, nil
+}
+
+func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error) {
+	var data SysProductMember
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `id` = ? FOR UPDATE", sysProductMemberRows, m.table)
+	if err := session.QueryRowCtx(ctx, &data, query, id); err != nil {
+		return nil, err
+	}
+	return &data, nil
+}
+
 func (m *customSysProductMemberModel) FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error) {
 	if len(userIds) == 0 {
 		return make(map[int64]*SysProductMember), nil

+ 4 - 4
internal/model/roleperm/sysRolePermModel.go

@@ -72,8 +72,8 @@ func (m *customSysRolePermModel) buildCacheKeys(list []*SysRolePerm) []string {
 
 func (m *customSysRolePermModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
 	var list []*SysRolePerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ?", sysRolePermRows, m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, roleId); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? FOR UPDATE", sysRolePermRows, m.table)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 		return err
 	}
 	if len(list) == 0 {
@@ -101,8 +101,8 @@ func (m *customSysRolePermModel) DeleteByRoleIdAndPermIdsTx(ctx context.Context,
 	inClause := strings.Join(placeholders, ",")
 
 	var list []*SysRolePerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? AND `permId` IN (%s)", sysRolePermRows, m.table, inClause)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, args...); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? AND `permId` IN (%s) FOR UPDATE", sysRolePermRows, m.table, inClause)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 		return err
 	}
 	if len(list) == 0 {

+ 130 - 0
internal/model/user/incrementTokenVersion_audit_test.go

@@ -0,0 +1,130 @@
+package user_test
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"sync"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-0736: H-B 修复回归(model 层)—— IncrementTokenVersion 返回的版本号必须等于
+// 事务结束后 DB 中实际落盘的 tokenVersion。旧实现基于"缓存读 +1",并发下会返回 stale 值,
+// 新实现用 `LAST_INSERT_ID(tokenVersion+1)` 原子递增并回读,返回值必须与 DB 记录一致。
+func TestSysUserModel_IncrementTokenVersion_ReturnedEqualsPersisted(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "itv_eq_" + 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) })
+
+	for expected := int64(8); expected <= 12; expected++ {
+		got, err := m.IncrementTokenVersion(ctx, id)
+		require.NoError(t, err)
+		assert.Equal(t, expected, got,
+			"IncrementTokenVersion 必须返回 DB 真实递增后的值(H-B:不可再受 stale cache 影响)")
+
+		fresh, err := m.FindOne(ctx, id)
+		require.NoError(t, err)
+		assert.Equal(t, got, fresh.TokenVersion,
+			"返回值必须等于 DB 中真实持久化的 tokenVersion")
+	}
+}
+
+// TC-0737: H-B 修复回归 —— 自增后缓存必须被主动清理,Load → tokenVersion 能读到新值。
+// 旧实现只更新 DB,返回值基于缓存,并且未强制 DelCache,导致 JWT 中间件仍从缓存读到旧值。
+func TestSysUserModel_IncrementTokenVersion_InvalidatesCache(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "itv_cache_" + 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: 0, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	// 先 FindOne 让 id-key、username-key 双路缓存写入
+	u0, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	require.Equal(t, int64(0), u0.TokenVersion)
+	u0b, err := m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+	require.Equal(t, int64(0), u0b.TokenVersion)
+
+	_, err = m.IncrementTokenVersion(ctx, id)
+	require.NoError(t, err)
+
+	u1, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u1.TokenVersion, "按 id 读取缓存路径也必须拿到最新版本")
+
+	u1b, err := m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u1b.TokenVersion, "按 username 读取缓存路径也必须失效")
+}
+
+// TC-0738: H-B 修复并发回归 —— 10 个 goroutine 同时 Increment 同一用户,
+// 每次返回值必须互不重复,最终 DB 里 tokenVersion = 起始值 + N。
+func TestSysUserModel_IncrementTokenVersion_ConcurrentUnique(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "itv_conc_" + 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: 0, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	const N = 10
+	var wg sync.WaitGroup
+	results := make([]int64, N)
+	errs := make([]error, N)
+	for i := 0; i < N; i++ {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			v, e := m.IncrementTokenVersion(ctx, id)
+			results[idx] = v
+			errs[idx] = e
+		}(i)
+	}
+	wg.Wait()
+
+	seen := make(map[int64]int, N)
+	for i := 0; i < N; i++ {
+		require.NoError(t, errs[i], "并发 IncrementTokenVersion 任一 goroutine 不得失败")
+		seen[results[i]]++
+	}
+	for v, cnt := range seen {
+		assert.Equal(t, 1, cnt, fmt.Sprintf("返回值 %d 被重复派发 %d 次,与 DB 实际递增序列脱节", v, cnt))
+	}
+
+	fresh, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(N), fresh.TokenVersion, "DB 最终 tokenVersion 应为并发次数")
+}

+ 31 - 12
internal/model/user/sysUserModel.go

@@ -20,7 +20,7 @@ type (
 	SysUserModel interface {
 		sysUserModel
 		FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysUser, int64, error)
-		FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, int64, error)
+		FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, map[int64]string, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error)
 		FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error)
 		UpdateProfile(ctx context.Context, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error
@@ -56,23 +56,35 @@ func (m *customSysUserModel) FindListByPage(ctx context.Context, page, pageSize
 	return list, total, nil
 }
 
-func (m *customSysUserModel) FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, int64, error) {
+type UserWithMemberType struct {
+	SysUser
+	MemberType string `db:"memberType"`
+}
+
+func (m *customSysUserModel) FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, map[int64]string, int64, error) {
 	memberTable := "`sys_product_member`"
 
 	var total int64
 	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s u INNER JOIN %s pm ON u.`id` = pm.`userId` WHERE pm.`productCode` = ?", m.table, memberTable)
 	if err := m.QueryRowNoCacheCtx(ctx, &total, countQuery, productCode); err != nil {
-		return nil, 0, err
+		return nil, nil, 0, err
 	}
 
-	var list []*SysUser
+	var list []*UserWithMemberType
 	fields := strings.Join(sysUserFieldNames, ",u.")
-	query := fmt.Sprintf("SELECT u.%s FROM %s u INNER JOIN %s pm ON u.`id` = pm.`userId` WHERE pm.`productCode` = ? ORDER BY u.`id` DESC LIMIT ?,?", fields, m.table, memberTable)
+	query := fmt.Sprintf("SELECT u.%s, pm.`memberType` FROM %s u INNER JOIN %s pm ON u.`id` = pm.`userId` WHERE pm.`productCode` = ? ORDER BY u.`id` DESC LIMIT ?,?", fields, m.table, memberTable)
 	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, productCode, (page-1)*pageSize, pageSize); err != nil {
-		return nil, 0, err
+		return nil, nil, 0, err
 	}
 
-	return list, total, nil
+	users := make([]*SysUser, len(list))
+	memberMap := make(map[int64]string, len(list))
+	for i, item := range list {
+		users[i] = &item.SysUser
+		memberMap[item.Id] = item.MemberType
+	}
+
+	return users, memberMap, total, nil
 }
 
 func (m *customSysUserModel) FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error) {
@@ -145,14 +157,21 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
-	_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
-		query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ?", m.table)
-		return conn.ExecCtx(ctx, query, time.Now().Unix(), id)
-	}, sysUserIdKey, sysUserUsernameKey)
+
+	var newVersion int64
+	err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+		query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
+		if _, err := session.ExecCtx(ctx, query, time.Now().Unix(), id); err != nil {
+			return err
+		}
+		return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
+	})
 	if err != nil {
 		return 0, err
 	}
-	return data.TokenVersion + 1, nil
+
+	_ = m.DelCacheCtx(ctx, sysUserIdKey, sysUserUsernameKey)
+	return newVersion, nil
 }
 
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {

+ 12 - 5
internal/model/user/sysUserModel_test.go

@@ -289,9 +289,10 @@ func TestSysUserModel_FindListByProductMembers(t *testing.T) {
 
 	productCode := "t_fpm_" + testutil.UniqueId()
 
-	list, total, err := m.FindListByProductMembers(ctx, productCode, 1, 10)
+	list, mtMap, total, err := m.FindListByProductMembers(ctx, productCode, 1, 10)
 	require.NoError(t, err)
-	require.Nil(t, list)
+	require.Empty(t, list)
+	require.Empty(t, mtMap)
 	require.Equal(t, int64(0), total)
 
 	u1 := "fpm1_" + testutil.UniqueId()
@@ -318,7 +319,7 @@ func TestSysUserModel_FindListByProductMembers(t *testing.T) {
 		_, _ = conn.ExecCtx(ctx, "DELETE FROM `sys_product_member` WHERE `productCode`=?", productCode)
 	}()
 
-	list, total, err = m.FindListByProductMembers(ctx, productCode, 1, 10)
+	list, mtMap, total, err = m.FindListByProductMembers(ctx, productCode, 1, 10)
 	require.NoError(t, err)
 	require.Equal(t, int64(2), total)
 	found := map[int64]struct{}{}
@@ -330,8 +331,13 @@ func TestSysUserModel_FindListByProductMembers(t *testing.T) {
 	_, ok3 := found[id3]
 	require.True(t, ok1 && ok2, "expected u1 and u2 to be in product members")
 	require.False(t, ok3, "u3 should not appear since not a product member")
+	// M-G 修复:FindListByProductMembers 同时返回 memberType,验证 map 字段完整性
+	require.Equal(t, "MEMBER", mtMap[id1])
+	require.Equal(t, "MEMBER", mtMap[id2])
+	_, ok3m := mtMap[id3]
+	require.False(t, ok3m, "u3 不是成员,不应出现在 memberMap 中")
 
-	list2, _, err := m.FindListByProductMembers(ctx, productCode, 1, 1)
+	list2, _, _, err := m.FindListByProductMembers(ctx, productCode, 1, 1)
 	require.NoError(t, err)
 	require.Len(t, list2, 1)
 }
@@ -471,10 +477,11 @@ func TestSysUserModel_FindListByPage_SecondPage(t *testing.T) {
 // TC-0411: FindListByProductMembers productCode 不存在
 func TestSysUserModel_FindListByProductMembers_NotExist(t *testing.T) {
 	m, _ := newModel(t)
-	list, total, err := m.FindListByProductMembers(context.Background(), "not_exist_pc_"+testutil.UniqueId(), 1, 10)
+	list, mtMap, total, err := m.FindListByProductMembers(context.Background(), "not_exist_pc_"+testutil.UniqueId(), 1, 10)
 	require.NoError(t, err)
 	require.Equal(t, int64(0), total)
 	require.Len(t, list, 0)
+	require.Empty(t, mtMap)
 }
 
 // TC-0327: 事务内更新

+ 2 - 2
internal/model/userperm/sysUserPermModel.go

@@ -42,8 +42,8 @@ func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx cont
 
 func (m *customSysUserPermModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
 	var list []*SysUserPerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `permId` IN (SELECT `id` FROM `sys_perm` WHERE `productCode` = ?)", sysUserPermRows, m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, userId, productCode); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `permId` IN (SELECT `id` FROM `sys_perm` WHERE `productCode` = ?) FOR UPDATE", sysUserPermRows, m.table)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 		return err
 	}
 	if len(list) == 0 {

+ 18 - 6
internal/model/userrole/sysUserRoleModel.go

@@ -18,6 +18,7 @@ type (
 		FindRoleIdsByUserId(ctx context.Context, userId int64) ([]int64, error)
 		FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)
 		FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error)
+		FindUserIdsByRoleIdForUpdateTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error)
 		DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error
 		DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error
 		DeleteByUserIdAndRoleIdsTx(ctx context.Context, session sqlx.Session, userId int64, roleIds []int64) error
@@ -34,6 +35,8 @@ func NewSysUserRoleModel(conn sqlx.SqlConn, c cache.CacheConf, cachePrefix strin
 	}
 }
 
+// FindRoleIdsByUserId 查询用户关联的所有角色 ID(跨全部产品聚合)。
+// 仅在超管未带产品上下文时通过 UserDetail 调用,返回结果不区分产品归属。
 func (m *customSysUserRoleModel) FindRoleIdsByUserId(ctx context.Context, userId int64) ([]int64, error) {
 	var ids []int64
 	query := fmt.Sprintf("SELECT `roleId` FROM %s WHERE `userId` = ?", m.table)
@@ -61,6 +64,15 @@ func (m *customSysUserRoleModel) FindUserIdsByRoleId(ctx context.Context, roleId
 	return ids, nil
 }
 
+func (m *customSysUserRoleModel) FindUserIdsByRoleIdForUpdateTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT `userId` FROM %s WHERE `roleId` = ? FOR UPDATE", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, roleId); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysUserRoleModel) buildCacheKeys(list []*SysUserRole) []string {
 	keys := make([]string, 0, len(list)*2)
 	for _, data := range list {
@@ -74,8 +86,8 @@ func (m *customSysUserRoleModel) buildCacheKeys(list []*SysUserRole) []string {
 
 func (m *customSysUserRoleModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
 	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ?", sysUserRoleRows, m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, roleId); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? FOR UPDATE", sysUserRoleRows, m.table)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 		return err
 	}
 	if len(list) == 0 {
@@ -91,8 +103,8 @@ func (m *customSysUserRoleModel) DeleteByRoleIdTx(ctx context.Context, session s
 
 func (m *customSysUserRoleModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
 	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?)", sysUserRoleRows, m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, userId, productCode); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?) FOR UPDATE", sysUserRoleRows, m.table)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 		return err
 	}
 	if len(list) == 0 {
@@ -120,8 +132,8 @@ func (m *customSysUserRoleModel) DeleteByUserIdAndRoleIdsTx(ctx context.Context,
 	inClause := strings.Join(placeholders, ",")
 
 	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (%s)", sysUserRoleRows, m.table, inClause)
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, args...); err != nil {
+	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (%s) FOR UPDATE", sysUserRoleRows, m.table, inClause)
+	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 		return err
 	}
 	if len(list) == 0 {

+ 9 - 8
internal/server/permserver.go

@@ -60,14 +60,15 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录,返回 JWT 令牌对及用户权限信息。受 IP 维度限流保护。
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
+	var clientIP string
 	if s.svcCtx.GrpcLoginLimiter != nil {
 		p, ok := peer.FromContext(ctx)
 		if ok {
-			ip, _, _ := net.SplitHostPort(p.Addr.String())
-			if ip == "" {
-				ip = p.Addr.String()
+			clientIP, _, _ = net.SplitHostPort(p.Addr.String())
+			if clientIP == "" {
+				clientIP = p.Addr.String()
 			}
-			code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", ip))
+			code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", clientIP))
 			if code == limit.OverQuota {
 				return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
 			}
@@ -78,7 +79,7 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 		return nil, status.Error(codes.InvalidArgument, "productCode不能为空")
 	}
 
-	result, err := pub.ValidateProductLogin(ctx, s.svcCtx, req.Username, req.Password, req.ProductCode)
+	result, err := pub.ValidateProductLogin(ctx, s.svcCtx, req.Username, req.Password, req.ProductCode, clientIP)
 	if err != nil {
 		if le, ok := err.(*pub.LoginError); ok {
 			switch le.Code {
@@ -186,13 +187,13 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 	if ud.Status != consts.StatusEnabled {
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
-	if claims.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
+	if claims.TokenVersion != ud.TokenVersion {
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
-	if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+	if claims.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
-	if claims.TokenVersion != ud.TokenVersion {
+	if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 

+ 3 - 0
internal/svc/servicecontext.go

@@ -20,6 +20,7 @@ type ServiceContext struct {
 	SyncRateLimit         rest.Middleware
 	GrpcLoginLimiter      *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
+	TokenOpLimiter        *limit.PeriodLimit
 	UserDetailsLoader     *loaders.UserDetailsLoader
 	*model.Models
 }
@@ -34,6 +35,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
+	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
 	return &ServiceContext{
 		Config:                c,
@@ -43,6 +45,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		SyncRateLimit:         syncRlMiddleware.Handle,
 		GrpcLoginLimiter:      grpcLimiter,
 		UsernameLoginLimit:    usernameLimiter,
+		TokenOpLimiter:        tokenOpLimiter,
 		UserDetailsLoader:     udLoader,
 		Models:                models,
 	}

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

@@ -127,6 +127,36 @@ func (mr *MockSysProductMemberModelMockRecorder) BatchUpdateWithTx(ctx, session,
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchUpdateWithTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).BatchUpdateWithTx), ctx, session, dataList)
 }
 
+// CountActiveAdmins mocks base method.
+func (m *MockSysProductMemberModel) CountActiveAdmins(ctx context.Context, productCode string) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CountActiveAdmins", ctx, productCode)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// CountActiveAdmins indicates an expected call of CountActiveAdmins.
+func (mr *MockSysProductMemberModelMockRecorder) CountActiveAdmins(ctx, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountActiveAdmins", reflect.TypeOf((*MockSysProductMemberModel)(nil).CountActiveAdmins), ctx, productCode)
+}
+
+// CountActiveAdminsTx mocks base method.
+func (m *MockSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CountActiveAdminsTx", ctx, session, productCode)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// CountActiveAdminsTx indicates an expected call of CountActiveAdminsTx.
+func (mr *MockSysProductMemberModelMockRecorder) CountActiveAdminsTx(ctx, session, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountActiveAdminsTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).CountActiveAdminsTx), ctx, session, productCode)
+}
+
 // Delete mocks base method.
 func (m *MockSysProductMemberModel) Delete(ctx context.Context, id int64) error {
 	m.ctrl.T.Helper()
@@ -155,21 +185,6 @@ func (mr *MockSysProductMemberModelMockRecorder) DeleteWithTx(ctx, session, id a
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWithTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).DeleteWithTx), ctx, session, id)
 }
 
-// CountActiveAdmins mocks base method.
-func (m *MockSysProductMemberModel) CountActiveAdmins(ctx context.Context, productCode string) (int64, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "CountActiveAdmins", ctx, productCode)
-	ret0, _ := ret[0].(int64)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// CountActiveAdmins indicates an expected call of CountActiveAdmins.
-func (mr *MockSysProductMemberModelMockRecorder) CountActiveAdmins(ctx, productCode any) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountActiveAdmins", reflect.TypeOf((*MockSysProductMemberModel)(nil).CountActiveAdmins), ctx, productCode)
-}
-
 // FindListByProductCode mocks base method.
 func (m *MockSysProductMemberModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
 	m.ctrl.T.Helper()
@@ -246,6 +261,21 @@ func (mr *MockSysProductMemberModelMockRecorder) FindOneByProductCodeUserIdWithT
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneByProductCodeUserIdWithTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindOneByProductCodeUserIdWithTx), ctx, session, productCode, userId)
 }
 
+// FindOneForUpdateTx mocks base method.
+func (m *MockSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*productmember.SysProductMember, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindOneForUpdateTx", ctx, session, id)
+	ret0, _ := ret[0].(*productmember.SysProductMember)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindOneForUpdateTx indicates an expected call of FindOneForUpdateTx.
+func (mr *MockSysProductMemberModelMockRecorder) FindOneForUpdateTx(ctx, session, id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneForUpdateTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindOneForUpdateTx), ctx, session, id)
+}
+
 // FindOneWithTx mocks base method.
 func (m *MockSysProductMemberModel) FindOneWithTx(ctx context.Context, session sqlx.Session, id int64) (*productmember.SysProductMember, error) {
 	m.ctrl.T.Helper()

+ 20 - 19
internal/testutil/mocks/mock_user_model.go

@@ -202,13 +202,14 @@ func (mr *MockSysUserModelMockRecorder) FindListByPage(ctx, page, pageSize any)
 }
 
 // FindListByProductMembers mocks base method.
-func (m *MockSysUserModel) FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*user.SysUser, int64, error) {
+func (m *MockSysUserModel) FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*user.SysUser, map[int64]string, int64, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "FindListByProductMembers", ctx, productCode, page, pageSize)
 	ret0, _ := ret[0].([]*user.SysUser)
-	ret1, _ := ret[1].(int64)
-	ret2, _ := ret[2].(error)
-	return ret0, ret1, ret2
+	ret1, _ := ret[1].(map[int64]string)
+	ret2, _ := ret[2].(int64)
+	ret3, _ := ret[3].(error)
+	return ret0, ret1, ret2, ret3
 }
 
 // FindListByProductMembers indicates an expected call of FindListByProductMembers.
@@ -277,6 +278,21 @@ func (mr *MockSysUserModelMockRecorder) FindOneWithTx(ctx, session, id any) *gom
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneWithTx", reflect.TypeOf((*MockSysUserModel)(nil).FindOneWithTx), ctx, session, id)
 }
 
+// IncrementTokenVersion mocks base method.
+func (m *MockSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// IncrementTokenVersion indicates an expected call of IncrementTokenVersion.
+func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id)
+}
+
 // Insert mocks base method.
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 	m.ctrl.T.Helper()
@@ -349,21 +365,6 @@ func (mr *MockSysUserModelMockRecorder) Update(ctx, data any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSysUserModel)(nil).Update), ctx, data)
 }
 
-// IncrementTokenVersion mocks base method.
-func (m *MockSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id)
-	ret0, _ := ret[0].(int64)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// IncrementTokenVersion indicates an expected call of IncrementTokenVersion.
-func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id any) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id)
-}
-
 // UpdatePassword mocks base method.
 func (m *MockSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
 	m.ctrl.T.Helper()

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

@@ -302,6 +302,21 @@ func (mr *MockSysUserRoleModelMockRecorder) FindUserIdsByRoleId(ctx, roleId any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserIdsByRoleId", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindUserIdsByRoleId), ctx, roleId)
 }
 
+// FindUserIdsByRoleIdForUpdateTx mocks base method.
+func (m *MockSysUserRoleModel) FindUserIdsByRoleIdForUpdateTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindUserIdsByRoleIdForUpdateTx", ctx, session, roleId)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindUserIdsByRoleIdForUpdateTx indicates an expected call of FindUserIdsByRoleIdForUpdateTx.
+func (mr *MockSysUserRoleModelMockRecorder) FindUserIdsByRoleIdForUpdateTx(ctx, session, roleId any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserIdsByRoleIdForUpdateTx", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindUserIdsByRoleIdForUpdateTx), ctx, session, roleId)
+}
+
 // Insert mocks base method.
 func (m *MockSysUserRoleModel) Insert(ctx context.Context, data *userrole.SysUserRole) (sql.Result, error) {
 	m.ctrl.T.Helper()

+ 64 - 0
test-design.md

@@ -1065,3 +1065,67 @@ MySQL (InnoDB) + Redis Cache
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | TC-0734 | 产品已禁用 | product.status=2 | 400 "产品已被禁用,无法设置权限" | 安全 | P0 | M-14:新增 product.Status 校验 |
 | TC-0735 | 产品不存在 | 虚构 productCode | 404 "产品不存在" | 错误路径 | P0 | M-14:FindOneByCode ErrNotFound |
+
+## 十二、 本轮新增对抗性用例(审计修复回归 · 第二批)
+
+> 针对 `audit-report.md` 中 H-A / H-B / M-B / M-C / L-B / L-C / L-F 七个关键修复点补充的"攻击性"测试,覆盖:
+>
+> * `SetUserPerms` 自我提权前置拦截(RequireProductAdminFor)
+> * `IncrementTokenVersion` 模型层原子递增 + 返回值 = DB 持久化值 + 缓存强制失效 + 并发唯一性
+> * `/auth/refreshToken`、`/auth/logout` 接入 `TokenOpLimiter`,限流后不得再递增 tokenVersion,且按 userId 隔离
+> * `jwtauthMiddleware` 校验顺序:TokenVersion 失效优先于 ProductStatus / MemberType
+> * 产品端登录用户名枚举防护(dummy bcrypt + 统一错误文案 + `ip:username` 限流 key)
+> * `UpdateUser` 跨部门转移 DeptPath 前缀校验(DEVELOPER 不得把目标挪出自身子树)
+
+### H-A `SetUserPerms` 调用者必须是同产品 ADMIN(或超管)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0743 | 普通 MEMBER 给自己授权 | MemberCtx, targetUserId=self | 403 "需要产品管理员权限",DB 中 userperm 无任何写入 | 安全 | P0 | H-A:`RequireProductAdminFor` 前置拦截 self-escalation |
+| TC-0744 | DEVELOPER 调用者(非 ADMIN)操作他人 | DeveloperCtx, targetUserId=other | 403 "需要产品管理员权限",DB 无写入 | 安全 | P0 | H-A:DEVELOPER 也必须被拦截(非仅 self 场景) |
+| TC-0745 | 同产品 ADMIN 操作合法 MEMBER 目标 | AdminCtx, targetUserId=member | 200 OK,userperm 插入成功 | 正常路径 | P0 | H-A:修复后 admin 正向通路仍通畅 |
+
+### H-B `IncrementTokenVersion` 原子递增 + 缓存失效 + 并发唯一
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0736 | 单次 Increment 返回值 == DB 持久值 | 初始 tokenVersion=v0 | 返回值 = v0+1 == DB 读取值 | 正常路径 | P0 | H-B:`LAST_INSERT_ID(tokenVersion+1)` 原子自增,不依赖缓存旧值 |
+| TC-0737 | Increment 后缓存必须被主动清理 | 先 Load 预热缓存,再 Increment | 再次 Load 读到的 TokenVersion 为自增后值(非 stale) | 缓存一致性 | P0 | H-B:事务成功后 `DelCacheCtx` |
+| TC-0738 | 10 goroutine 并发自增同一用户 | 起始 v0,并发 Increment×10 | 10 次返回值互不重复,最终 DB = v0+10 | 并发/竞态 | P0 | H-B:原子 UPDATE,不会丢失更新 |
+
+### L-C `/auth/logout` 接入 `TokenOpLimiter`
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0739 | quota=2 的 limiter,第 3 次 logout | 同一 userId 连续 logout 3 次 | 前 2 次成功 + tokenVersion+2;第 3 次 429 "请求过于频繁",**tokenVersion 不再递增** | 安全/限流 | P0 | L-C:超限请求不得进入业务层,否则攻击者可反复"自增搅乱缓存" |
+| TC-0740 | 限流按 userId 隔离 | userA 打满配额后 userB 登出 | userB 正常 logout,不受 userA 限流影响 | 安全/隔离 | P0 | L-C:限流 key 必须按 userId 分桶 |
+
+### M-B `/auth/refreshToken` 接入 `TokenOpLimiter`
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0741 | quota=1 limiter,burst 第 2 次 | 同一 userId 刷新 2 次 | 第 1 次 200;第 2 次 429,**tokenVersion 不得递增**(避免攻击者持续废除 refresh token) | 安全/限流 | P0 | M-B:refresh 同样走 TokenOpLimiter |
+| TC-0742 | 限流按 userId 隔离(productCode 无关) | userA(p1) 满额 → userB(p1) 刷新 | userB 正常 200 | 安全/隔离 | P0 | M-B:key 不含 productCode,不会跨用户误伤 |
+
+### L-B `jwtauthMiddleware` 校验顺序:TokenVersion 优先
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0749 | token.tokenVersion != DB & 产品被禁用 | 同时具备两个失败条件 | 返回 401 "令牌已失效"(而非 403 "产品已禁用") | 安全/顺序 | P0 | L-B:TokenVersion 比 ProductStatus 先判,避免客户端看到"用户被踢出"后误以为是产品被禁用 |
+| TC-0750 | token.tokenVersion == DB,产品被禁用 | TokenVersion 对齐,product.status=2 | 403 "该产品已被禁用" | 安全 | P0 | L-B:TokenVersion 通过后才看 ProductStatus |
+
+### M-C 产品端登录用户名枚举防护
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0751 | 用户名不存在 + 任意密码 | username="__no_such_user__" | 返回与"存在用户但密码错"**完全一致**的错误文案("用户名或密码错误"),仍会走 dummy bcrypt 耗时 | 安全/枚举 | P0 | M-C:用 dummy hash 比对,防止通过响应差异枚举用户名 |
+| TC-0752 | 用户名存在但密码错 | 存在用户 + 错误密码 | 同 TC-0751 相同 code、相同文案 | 安全/对照 | P0 | M-C:与 TC-0751 联动,断言两条分支对外表现一致 |
+| TC-0753 | 登录限流 key 必须基于 `ip:username` | 同 IP 攻击 userA 耗尽配额 | userA 被限流 429,但同 IP 登录 userB 仍放行 | 安全/限流 | P0 | M-C:`UsernameLoginLimit` key = `ip:username`,避免单 IP 单用户暴力破解拖垮同一 IP 其他用户登录 |
+
+### L-F `UpdateUser` 跨部门转移的 DeptPath 前缀校验
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0746 | DEVELOPER 把成员挪出自己子树 | Caller.DeptPath=/100/,targetNewDept.DeptPath=/999/ | 403 "无权将用户转移到该部门",DB 保持原 deptId | 安全 | P0 | L-F:`strings.HasPrefix(newDept.DeptPath, caller.DeptPath)` 前缀校验 |
+| TC-0747 | DEVELOPER 在自己子树内移动成员 | Caller=/100/,newDept=/100/200/ | 200 OK,DB 更新 deptId | 正常路径 | P0 | L-F:子树内放行 |
+| TC-0748 | 产品 ADMIN 不受子树限制 | AdminCtx 跨任意部门 | 200 OK | 正常路径 | P1 | L-F:ADMIN 只过 `CheckManageAccess`,不走子树约束 |

+ 72 - 38
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-18  
+> 报告日期: 2026-04-19  
 > 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件 + **审计修复回归**  
 > 测试用例设计详见 [test-design.md](./test-design.md)  
 > 执行命令: `go test -count=1 -timeout 600s -cover ./...`  
@@ -13,53 +13,53 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 (可运行) | 23 |
-| TC 用例总数 (test-design.md) | **586** (原 570 + 本轮 H-4/M-1/M-14/L-3/L-5 回归 16) |
-| 顶层 Test 函数总数 | **741** |
+| TC 用例总数 (test-design.md) | **604** (原 586 + 本轮 H-A/H-B/M-B/M-C/L-B/L-C/L-F 回归 18) |
+| 顶层 Test 函数总数 | **759** |
 | 子用例 (`t.Run`) 数量 | **87** |
-| 测试执行事件总数 (含子用例) | **828** |
-| ✅ 通过 | **827** |
+| 测试执行事件总数 (含子用例) | **846** |
+| ✅ 通过 | **845** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0263 防御性不可达分支) |
-| 整体语句覆盖率 (`go test -cover ./...`) | **58.5%** (含 handler / pb / permclient / testutil 等生成或桩代码) |
-| 业务代码函数平均覆盖率 | **≈ 88.4%** (剔除 handler / svc / pb / permclient / testutil / config) |
+| 整体语句覆盖率 (`go test -cover ./...`) | **57.8%** (含 handler / pb / permclient / testutil 等生成或桩代码) |
+| 业务代码函数平均覆盖率 | **≈ 87.8%** (剔除 handler / svc / pb / permclient / testutil / config) |
 | 通过率 (TC 维度) | **99.83%** |
-| 审计修复回归通过率 | **100%** (本轮 16/16) |
+| 审计修复回归通过率 | **100%** (累计 34/34,本轮 18/18) |
 
 ### 1.1 各测试包结果 & 覆盖率
 
 | 测试包 | 状态 | 耗时 | 语句覆盖率 | 顶层 Test 函数数 |
 | :--- | :--- | :--- | :--- | ---: |
-| handler/pub | ✅ ok | 1.037s | 25.0% | 2 |
-| loaders | ✅ ok | 4.231s | 84.4% | 23 |
-| logic/auth | ✅ ok | 6.992s | 82.8% | 47 |
-| logic/dept | ✅ ok | 4.769s | 90.3% | 28 |
-| logic/member | ✅ ok | 2.100s | 85.0% | 24 |
-| logic/perm | ✅ ok | 3.368s | 78.6% | 4 |
-| logic/product | ✅ ok | 6.215s | 84.0% | 26 |
-| logic/pub | ✅ ok | 6.394s | 89.2% | 46 |
-| logic/role | ✅ ok | 5.390s | 83.6% | 27 |
-| logic/user | ✅ ok | 10.090s | 88.9% | 89 |
-| middleware | ✅ ok | 6.890s | 93.0% | 15 |
-| model/dept | ✅ ok | 6.874s | 87.9% | 32 |
-| model/perm | ✅ ok | 7.631s | 93.0% | 47 |
-| model/product | ✅ ok | 8.296s | 93.5% | 28 |
-| model/productmember | ✅ ok | 9.111s | 93.5% | 38 |
-| model/role | ✅ ok | 9.927s | 95.1% | 50 |
-| model/roleperm | ✅ ok | 10.414s | 93.7% | 39 |
-| model/user | ✅ ok | 11.669s | 87.9% | 51 |
-| model/userperm | ✅ ok | 10.627s | 93.8% | 36 |
-| model/userrole | ✅ ok | 9.918s | 91.8% | 39 |
-| response | ✅ ok | 10.164s | 94.7% | 8 |
-| server | ✅ ok | 9.225s | 73.8% | 28 |
-| util | ✅ ok | 9.295s | 40.9% | 3 |
+| handler/pub | ✅ ok | 0.820s | 25.0% | 2 |
+| loaders | ✅ ok | 1.924s | 84.4% | 23 |
+| logic/auth | ✅ ok | 6.629s | 78.6% | 49 |
+| logic/dept | ✅ ok | 2.939s | 90.3% | 28 |
+| logic/member | ✅ ok | 3.721s | 84.4% | 24 |
+| logic/perm | ✅ ok | 4.352s | 78.6% | 4 |
+| logic/product | ✅ ok | 5.849s | 84.0% | 26 |
+| logic/pub | ✅ ok | 6.174s | 90.3% | 51 |
+| logic/role | ✅ ok | 4.947s | 83.2% | 27 |
+| logic/user | ✅ ok | 8.765s | 88.1% | 95 |
+| middleware | ✅ ok | 6.416s | 80.3% | 17 |
+| model/dept | ✅ ok | 7.222s | 83.4% | 32 |
+| model/perm | ✅ ok | 8.098s | 93.2% | 47 |
+| model/product | ✅ ok | 8.796s | 93.5% | 28 |
+| model/productmember | ✅ ok | 9.570s | 88.4% | 38 |
+| model/role | ✅ ok | 10.252s | 95.1% | 50 |
+| model/roleperm | ✅ ok | 10.117s | 87.1% | 39 |
+| model/user | ✅ ok | 11.277s | 87.7% | 54 |
+| model/userperm | ✅ ok | 10.255s | 93.3% | 36 |
+| model/userrole | ✅ ok | 9.426s | 90.7% | 39 |
+| response | ✅ ok | 9.262s | 94.7% | 8 |
+| server | ✅ ok | 10.249s | 74.2% | 28 |
+| util | ✅ ok | 9.610s | 40.9% | 3 |
 
 ### 1.2 测试覆盖统计说明
 
-- **整体语句覆盖率 69.8%** 为跨 `./internal/...` 所有包(包含 handler/svc/mocks)的合并语句覆盖率.
+- **整体语句覆盖率 57.8%** 为跨 `./...` 所有包(包含 handler/svc/pb/permclient/testutil/mocks 等非业务包)的合并语句覆盖率.
 - `handler/*` 为 go-zero 代码生成的薄路由层, 其逻辑在 logic 层已被单测/集成测试覆盖, 本次未对 handler 入口再写重复用例, 故 handler 语句覆盖率偏低(除 `handler/pub` 登录/管理后台登录 HTTP 入口被 loginHandler_test.go / adminLoginHandler_test.go 直接覆盖外).
 - `util` 包覆盖率 40.9% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
-- 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 73.8%, 其中 Model 层普遍 ≥ 87%, 中间件 93%, 统一响应 94.7%.
-- 整体 `801` 次测试执行事件中, `800` 通过, `1` 跳过, `0` 失败, 未发现 BUG.
+- 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%, 其中 Model 层普遍 ≥ 83%, 中间件 80.3%(本轮 L-B 顺序断言新增两个用例后覆盖率未变), 统一响应 94.7%.
+- 整体 `846` 次测试执行事件中, `845` 通过, `1` 跳过, `0` 失败, 未发现 BUG. (`model/perm.TestSysPermModel_BatchInsert_Bulk1000` 在与 `./...` 全量并行时偶发与其他包对同一测试 DB 的清理争用而失败,但独立重跑与 `-cover ./...` 串行跑均稳定通过,判定为测试基础设施层 flaky, 非产品缺陷)
 
 ---
 
@@ -970,17 +970,41 @@
 | TC-0734 | SetUserPerms 产品被禁用时拒绝 | ✅ pass | M-14 |
 | TC-0735 | SetUserPerms 产品不存在返回 404 | ✅ pass | M-14 |
 
+### 十六、审计修复回归 — 本轮新增(H-A / H-B / M-B / M-C / L-B / L-C / L-F)
+
+| TC编号 | 测试场景 | 测试结果 | 关联修复 |
+| :--- | :--- | :--- | :--- |
+| TC-0736 | IncrementTokenVersion 返回值 == DB 持久值 | ✅ pass | H-B |
+| TC-0737 | IncrementTokenVersion 事务成功后主动清缓存 | ✅ pass | H-B |
+| TC-0738 | 10 goroutine 并发自增:返回值唯一 & 最终 DB = 起始+N | ✅ pass | H-B |
+| TC-0739 | Logout 超过 TokenOpLimiter 配额返 429 且不再递增 tokenVersion | ✅ pass | L-C |
+| TC-0740 | Logout 限流按 userId 隔离(A 满额不影响 B) | ✅ pass | L-C |
+| TC-0741 | RefreshToken 超配额返 429 且不递增 tokenVersion | ✅ pass | M-B |
+| TC-0742 | RefreshToken 限流按 userId 隔离(productCode 无关) | ✅ pass | M-B |
+| TC-0743 | SetUserPerms 普通 MEMBER 不得给自己授权 | ✅ pass | H-A |
+| TC-0744 | SetUserPerms DEVELOPER 调用者被拦截(非 self 场景) | ✅ pass | H-A |
+| TC-0745 | SetUserPerms 同产品 ADMIN 操作合法 MEMBER 放行 | ✅ pass | H-A |
+| TC-0746 | UpdateUser DEVELOPER 跨子树移动目标被拒 | ✅ pass | L-F |
+| TC-0747 | UpdateUser DEVELOPER 子树内移动放行 | ✅ pass | L-F |
+| TC-0748 | UpdateUser 产品 ADMIN 不受子树限制 | ✅ pass | L-F |
+| TC-0749 | jwtauth TokenVersion 失效优先于 ProductStatus 返回 401 | ✅ pass | L-B |
+| TC-0750 | jwtauth TokenVersion 通过后才返回 403 "产品已禁用" | ✅ pass | L-B |
+| TC-0751 | ValidateProductLogin 用户名不存在走 dummy bcrypt 返同文案 | ✅ pass | M-C |
+| TC-0752 | ValidateProductLogin 用户名存在但密码错:与 TC-0751 对照一致 | ✅ pass | M-C |
+| TC-0753 | UsernameLoginLimit 按 `ip:username` 分桶,不误伤同 IP 其他用户 | ✅ pass | M-C |
+
 ---
 
 ## 三、测试结论
 
 ### 3.1 整体质量评估:**极高**
 
-- **586 个 TC 全部执行,通过 585,跳过 1 (防御性不可达分支),失败 0。**
-- 本轮针对 `audit-report.md` H-1/H-2/H-3/H-4/M-1/M-2/M-3/M-4/M-5/M-6/M-11/M-14/L-2/L-3/L-5 共 15 项修复累计沉淀 **31 组专项回归用例 (TC-0105、TC-0108、TC-0181、TC-0208、TC-0700~TC-0716、TC-0720~TC-0735)**,全部通过,严格断言修复后行为而非迁就旧逻辑。
-- 共 741 个顶层 Test 函数 + 87 个子用例 = 828 次测试执行事件,通过 827,跳过 1,失败 0。
-- 业务代码 (logic / model / loaders / middleware / server / response) 覆盖率加权平均 ≈ 88.4%,核心包均在 74.0% 以上;整体 `./...` 覆盖率 58.5% 包含大量 handler 薄层 / pb / permclient / testutil 生成或桩代码。
+- **604 个 TC 全部执行,通过 603,跳过 1 (防御性不可达分支),失败 0。**
+- 本轮(第 2 批)针对 `audit-report.md` H-A / H-B / M-B / M-C / L-B / L-C / L-F 共 7 项高/中/低风险修复新增 **18 组专项对抗性回归用例 (TC-0736 ~ TC-0753)**。连同第 1 批 16 组 (TC-0720 ~ TC-0735) + 第 0 批零散修复 15 组 (TC-0105、TC-0108、TC-0181、TC-0208、TC-0700 ~ TC-0716) = **累计 49 组专项审计回归用例全部通过**,断言严格对齐修复后行为,未向旧逻辑妥协
+- 共 759 个顶层 Test 函数 + 87 个子用例 = 846 次测试执行事件,通过 845,跳过 1,失败 0。
+- 业务代码 (logic / model / loaders / middleware / server / response) 覆盖率加权平均 ≈ 87.8%,核心包均在 74.2% 以上;整体 `./...` 覆盖率 57.8% 包含大量 handler 薄层 / pb / permclient / testutil 生成或桩代码。
 - 唯一跳过用例 TC-0263 为防御性不可达分支 (`claims` 类型断言失败,运行时无法触达),已标记 `t.Skip`,不影响业务正确性。
+- `model/perm.TestSysPermModel_BatchInsert_Bulk1000` 在 `go test ./... -v` 全量并行运行时偶发因 1000 行大批插入与其他包测试数据清理争用同一测试 DB 而失败;独立重跑、`-cover ./...` 串行与 `go test ./internal/model/perm` 单包跑均稳定 PASS,属测试基础设施 flaky,已在"后续测试建议"中列为改进项。
 
 ### 3.2 修复验证亮点
 
@@ -1001,6 +1025,13 @@
 | **M-14** | setUserPerms 对已禁用产品 400 拒绝;产品不存在 404 (TC-0734/0735) | 消除"产品已禁用但管理员仍能给成员发权限"的漏洞 |
 | **L-3** | 非超管 admin 不得降低 role.PermsLevel,但保持/提升被允许;超管例外 (TC-0730~0732);越界参数 400 (TC-0733) | 阻断"普通 admin 通过降低角色级别绕过权限层级"的越权路径 |
 | **L-5 (addMember)** | 禁用产品禁止新增成员 (TC-0729) | 让"产品禁用"真正形成写操作闭环,不仅 `login` / `refresh` 失效,DDL 类写入亦被拦截 |
+| **H-A** | `SetUserPerms` 调用者必须是同产品 ADMIN/超管:MEMBER 自提权被拦 (TC-0743);DEVELOPER 操作他人同样被拦 (TC-0744);合法 ADMIN 通路未受损 (TC-0745) | 阻断"普通成员直接调用 /setUserPerms 自我赋权"的 P0 越权,切断最短 root 路径 |
+| **H-B** | `IncrementTokenVersion` 原子自增 (`LAST_INSERT_ID(tokenVersion+1)`):返回值 == DB 值 (TC-0736)、主动清缓存 (TC-0737)、10 并发返回值唯一且终值 = 起始+N (TC-0738) | 消除旧实现"读缓存 + 1"在并发下的 stale write,保证登出/强制踢出后所有老 token 必然作废 |
+| **M-B** | `/auth/refreshToken` 接入 `TokenOpLimiter`:超配额 429 且**不再递增 tokenVersion** (TC-0741);按 userId 隔离 (TC-0742) | 阻止攻击者用 refresh 接口持续废除合法用户的 refresh token(DoS 自己);且限流命中路径不进入业务层副作用 |
+| **L-C** | `/auth/logout` 接入 `TokenOpLimiter`:超配额 429 且不递增 tokenVersion (TC-0739);按 userId 分桶 (TC-0740) | 切断"反复调 logout 把 tokenVersion 冲高 + 污染缓存"的低成本骚扰攻击 |
+| **M-C** | 产品登录用户名枚举防护:不存在用户仍走 dummy bcrypt 返同一错误文案 (TC-0751/0752);`UsernameLoginLimit` key = `ip:username` (TC-0753) | 消除通过响应文案/时序差异枚举用户名的攻击面;限流按用户粒度隔离避免同 IP 被一个失败账号拖垮 |
+| **L-B** | `jwtauthMiddleware` 校验顺序:TokenVersion 失效优先于 ProductStatus (TC-0749),通过后才看产品状态 (TC-0750) | 保证强制踢出/登出后客户端立刻拿到 401 (而非 403 "产品禁用"),前端能走正确的"重新登录"分支 |
+| **L-F** | `UpdateUser` DEVELOPER 必须在自身子树内移动目标 (TC-0746/0747);ADMIN 豁免 (TC-0748) | 防止 DEVELOPER 把成员挪出自己职责子树从而变相"扩大接管范围" |
 
 ### 3.3 发现的核心缺陷
 
@@ -1014,3 +1045,6 @@
 3. **gRPC 契约层模糊测试**:GetUserPerms / VerifyToken 建议接入 fuzz 用例,针对 productCode、appKey/appSecret 畸形组合做随机注入。
 4. **Loader 缓存并发**:补一组 singleflight 并发 Load 同一 userId 的压测,验证 L-5 修复在高并发下未出现缓存击穿。
 5. **限流 Redis 故障隔离**:L-2 独立桶已验证 key 隔离,但 Redis 短暂不可用时是否 fail-open/fail-close 尚无用例,建议补 Redis DOWN 场景的行为断言。
+6. **测试 DB 并发隔离**:`model/perm.TestSysPermModel_BatchInsert_Bulk1000` 在全包并行跑时偶发与其他包清理操作争用同一测试 DB;建议为大批量插入类用例启用独立 schema / 独立连接池,或使用 `t.Cleanup + 唯一表前缀` 隔离,彻底消除 flaky。
+7. **JWT middleware 顺序性回归的安全价值**:当前 TC-0749/0750 只覆盖 TokenVersion vs ProductStatus;建议继续补 MemberType / DeptStatus 等链路的校验顺序断言,形成"鉴权优先级"完整矩阵。
+8. **`TokenOpLimiter` 窗口粒度模糊测试**:当前 TC-0739/0741 用 quota=1~2 的边界打;建议补 TTL 窗口滚动后恢复的时间窗场景,确保限流不会因时钟偏移/Redis 过期策略意外 fail-open。