Procházet zdrojové kódy

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

BaiLuoYan před 3 týdny
rodič
revize
be98be9ac9
35 změnil soubory, kde provedl 2480 přidání a 526 odebrání
  1. 385 417
      audit-report.md
  2. 98 0
      internal/handler/refreshTokenRouteWiring_audit_test.go
  3. 10 7
      internal/handler/routes.go
  4. 20 1
      internal/loaders/userDetailsLoader.go
  5. 126 0
      internal/loaders/userDetailsLoader_negativeCache_audit_test.go
  6. 26 0
      internal/logic/auth/access.go
  7. 146 0
      internal/logic/auth/checkPermLevelFailClose_audit_test.go
  8. 74 0
      internal/logic/product/createProductConflict_audit_test.go
  9. 4 8
      internal/logic/product/createProductLogic.go
  10. 97 0
      internal/logic/pub/refreshTokenCas_audit_test.go
  11. 9 1
      internal/logic/pub/refreshTokenLogic.go
  12. 162 0
      internal/logic/pub/syncPermsConflict_audit_test.go
  13. 2 0
      internal/logic/pub/syncPermsLogic.go
  14. 35 21
      internal/logic/pub/syncPermsService.go
  15. 82 0
      internal/logic/user/bindRolesEqualLevel_audit_test.go
  16. 2 5
      internal/logic/user/bindRolesLogic.go
  17. 2 1
      internal/logic/user/createUserLogic.go
  18. 43 0
      internal/logic/user/createUserMustChangePwd_audit_test.go
  19. 177 0
      internal/logic/user/updateUserDeptZero_audit_test.go
  20. 7 0
      internal/logic/user/updateUserLogic.go
  21. 84 0
      internal/model/perm/findMapByProductCodeWithTx_audit_test.go
  22. 17 0
      internal/model/perm/sysPermModel.go
  23. 127 0
      internal/model/product/lockByCodeTx_audit_test.go
  24. 12 0
      internal/model/product/sysProductModel.go
  25. 203 0
      internal/model/user/incrementTokenVersionIfMatch_audit_test.go
  26. 39 0
      internal/model/user/sysUserModel.go
  27. 165 0
      internal/server/grpc_rate_limit_audit_test.go
  28. 78 13
      internal/server/permserver.go
  29. 11 0
      internal/svc/servicecontext.go
  30. 15 0
      internal/testutil/mocks/mock_perm_model.go
  31. 27 12
      internal/testutil/mocks/mock_product_model.go
  32. 15 0
      internal/testutil/mocks/mock_user_model.go
  33. 4 3
      perm.api
  34. 101 0
      test-design.md
  35. 75 37
      test-report.md

+ 385 - 417
audit-report.md

@@ -1,524 +1,492 @@
-# 权限管理系统 - 深度代码审计报告(第 4 轮)
+# 权限管理系统 - 深度代码审计报告(第 5 轮)
 
 
-> 审计范围:`/internal` 下全部非测试、非 `_gen.go` 生产代码(logic、loaders、model 的 custom 层、middleware、handler、server、svc、consts、response、util)+ 入口 `perm.go` + 接口定义 `perm.api`
-> 审计时间:2026-04-19
+> 审计范围:同第 4 轮,`/internal` 下全部非测试、非 `_gen.go` 生产代码。
+> 审计时间:2026-04-19(复盘第 4 轮修复后再度深入)
 > 审计重点:
 > 审计重点:
->   - 并发场景下"最后一个 ADMIN"保护的真实可打破性(跨行 TOCTOU、事务内外数据脱钩)
->   - 状态变更接口的"无变化也强制递增 tokenVersion"导致的不必要踢出
->   - 级联删除的 TOCTOU(父部门删除 vs 子部门/用户插入)
->   - 高频写接口的限流盲区(changePassword 等)
->   - 负缓存缺失 / 缓存索引集合与数据 key 的非原子 SADD/SetEx 导致的漏清理
->   - 依赖 `strings.Contains(err, "1062")` 的脆弱错误分类
->   - 僵尸 scaffolded 中间件
+>   - **令牌刷新链路**的原子性与重放窗口(HTTP + gRPC 两套入口并行审视)
+>   - **垂直/水平越权**的"等级相等放行"死角
+>   - **乐观锁以秒级 `updateTime` 为版本号**在真实同秒并发下的丢失更新
+>   - **软删除用户 / 已删除用户的 JWT 仍有效期内**触发的 DB 重复压栈(DoS 向量)
+>   - 缓存失效链路中残留的"N 次串行 Redis 往返"
+>   - 上轮部分修复不彻底的遗留项(`strings.Contains(errMsg, "uk_code")`)
 >
 >
-> 相对上一轮:本轮新发现一批**未被上一轮覆盖**的 P0/P1 问题,都涉及生产环境真实可触发的业务逻辑/数据完整性破坏路径
+> 相对第 4 轮:第 4 轮 H-1/H-2/H-3(最后一个 ADMIN)、H-5(changePassword 限流)、H-4(DeleteDept 存在性锁读)已**实际落地修复**(本轮代码阅读已确认)。本轮新暴露一组围绕**刷新令牌重放**和**等级相等分配**的高危问题,以及若干性能 / 健壮性缺口
 
 
 ---
 ---
 
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 ## 🚩 核心逻辑漏洞 (High Risk)
 
 
-### H-1. `UpdateMember` 的"最后一个 ADMIN"保护只看 `memberType` 变化,不看 `status` 变化 → **可把最后一个 ADMIN 直接禁用**(产品瞬间"无人管理"
+### H-1. `RefreshToken` 先校验 `tokenVersion` 再递增,**并发刷新可被第三方"接管会话"**(HTTP + gRPC 双入口
 
 
-- **位置**:`internal/logic/member/updateMemberLogic.go:51,54-59,66-73`
+- **位置**:
+  - `internal/logic/pub/refreshTokenLogic.go:64-79`
+  - `internal/server/permserver.go`(同逻辑的 gRPC `RefreshToken` 实现)
 - **描述**:
 - **描述**:
+  核心代码片段(HTTP 版本):
   ```go
   ```go
-  needAdminCheck := member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin
-
-  member.MemberType = req.MemberType
-  if req.Status != 0 {
-      if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
-          return response.ErrBadRequest("状态值无效...")
-      }
-      member.Status = req.Status
+  if claims.TokenVersion != ud.TokenVersion {
+      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
   }
   }
-  ...
-  if needAdminCheck {
-      adminCount, _ := ...CountActiveAdminsTx(...)
-      if adminCount <= 1 {
-          return response.ErrBadRequest("不能降级该产品的最后一个管理员")
-      }
+  if l.svcCtx.TokenOpLimiter != nil {
+      code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))
+      ...
   }
   }
+  newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, claims.UserId)
   ```
   ```
-  `needAdminCheck` 只在 **memberType 从 ADMIN 改成非 ADMIN** 时为 `true`。如果请求保持 `memberType=ADMIN` 但 **`status` 改为 `StatusDisabled`**,`needAdminCheck=false`,直接跳过计数检查。`CountActiveAdminsTx` 只计 `status=1` 的 ADMIN,所以"禁用最后一个 ADMIN"是在绕过该保护之下合法完成的。
 
 
-  攻击/误操作路径(任何持有该产品 ADMIN 令牌的账号都可做):
+  "check 版本号 → 递增版本号"是**两条独立的 SQL**,中间没有行级锁、也没有条件更新语义。DB 里的 `IncrementTokenVersion` 实际是无条件 `UPDATE ... SET tokenVersion = tokenVersion+1`。
 
 
-  ```http
-  POST /api/member/update
-  { "id": <最后一个 ADMIN 的 member 记录 id>,
-    "memberType": "ADMIN",
-    "status": 2 }
-  ```
+  **攻击 / 真实泄露场景**:
+  1. 攻击者通过设备失窃 / 前端 XSS / localStorage 泄露,拿到受害者一枚**仍在有效期内的 refreshToken**(claims.TokenVersion = V)。单会话轮转的前提:一旦合法用户刷新过一次,旧 token 就作废。这是标准 OAuth2 refresh token rotation 的基线安全属性。
+  2. 受害者在某时刻 T 发起合法刷新;同一秒或几毫秒内攻击者也用窃到的 token 发起刷新。
+  3. 两个请求分别读到 `ud.TokenVersion = V`,双方都通过 `claims.TokenVersion == ud.TokenVersion` 这一步 → 都进入 `IncrementTokenVersion`。
+  4. DB tokenVersion 经两次递增变成 `V+2`。
+  5. "最后完成的请求"会得到 `newVersion = V+2`,并以此签发新 accessToken/refreshToken。"先完成的请求"拿到的 `newVersion = V+1`,用户端使用它发起后续业务请求时 Middleware 判定 `V+1 != V+2 → 登录失效`,直接被踢。
+  6. **结果**:合法用户被登出;攻击者持有 V+2 的 accessToken 和 refreshToken,静默拿走后续完整会话(并且在攻击者拿到的 refreshToken 自然过期前,可以一直续期、一直维持会话)。
 
 
-  执行结果:DB 里这条 ADMIN 的 `status=2`,`CountActiveAdmins==0`。此后:
-  - 该产品下无人能通过 `RequireProductAdminFor` 的 Admin 校验(除了超管);
-  - 所有需要 ADMIN 的接口(CreateRole/BindRolePerms/SetUserPerms/AddMember/…)都失锁,只能走超管通道;
-  - 该产品上的 ADMIN 自助救援路径不存在,需要联系平台管理员。
+  换句话说,refresh token rotation 的"旧 token 必须在发新 token 的同一瞬间失效"这一原子性前提被打破。在**攻击者 + 合法用户并发一次**的窗口下,会话被直接接管。
 
 
 - **影响**:
 - **影响**:
-  - 直接破坏"产品始终保留至少一个可用 ADMIN"的业务不变式;
-  - 非超管的恶意 ADMIN 可以"离职前锁死产品";
-  - 普通运维失误也可能造成同样结果,且没有任何回滚机制
+  - 这不是普通的时序抖动,是**会话劫持** / **账号被盗**级别的 P0 问题。
+  - gRPC 版本因为**根本没有限流**(见 H-2),攻击者可以程序化拉高并发,几乎必然落到 race 窗口里。
+  - 更恶劣的是:受害者前端只会看到"登录状态已失效"一次,下次重新登录即可,几乎没有任何异常信号可以让风控察觉
 
 
 - **修复方案**:
 - **修复方案**:
-  把 `needAdminCheck` 扩展为"任何会让这条 ADMIN 记录变为非活跃的写入":
-
+  把 check + increment 合并成**单条带版本条件的原子更新**:
   ```go
   ```go
-  // internal/logic/member/updateMemberLogic.go
-  nextType := req.MemberType
-  nextStatus := member.Status
-  if req.Status != 0 {
-      nextStatus = req.Status
+  // 新增 model 方法
+  func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
+      var newVersion int64
+      err := m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+          q := fmt.Sprintf("UPDATE %s SET `tokenVersion`=LAST_INSERT_ID(`tokenVersion`+1), `updateTime`=? WHERE `id`=? AND `tokenVersion`=?", m.table)
+          res, err := session.ExecCtx(ctx, q, time.Now().Unix(), id, expected)
+          if err != nil { return err }
+          affected, _ := res.RowsAffected()
+          if affected == 0 { return ErrTokenVersionMismatch }
+          return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
+      })
+      ...
+  }
+  ```
+  logic 层改为:
+  ```go
+  newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, claims.TokenVersion)
+  if errors.Is(err, userModel.ErrTokenVersionMismatch) {
+      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
   }
   }
-
-  willBecomeInactiveAdmin :=
-      member.MemberType == consts.MemberTypeAdmin &&
-      member.Status == consts.StatusEnabled &&
-      (nextType != consts.MemberTypeAdmin || nextStatus != consts.StatusEnabled)
   ```
   ```
-  并把后续赋值/校验改成基于 `nextType`/`nextStatus`。同时建议把 `needAdminCheck`/"最后一个 ADMIN" 同时在 `RemoveMember` 和 `UpdateMember` 里抽成一个 helper `guardLastAdminTx(session, productCode, excludingId)`,统一用 **行锁 + FOR UPDATE 遍历** 方式做。参考 H-3 的修复方案。
+  这样两个并发请求只有一个能 `WHERE tokenVersion = V` 命中(`affected=1`),另一个 `affected=0`,明确失败并返回 401。HTTP 与 gRPC 两个入口**必须共享这一条原子更新逻辑**,不能只修一边。
 
 
 ---
 ---
 
 
-### H-2. `RemoveMember` 在"是不是 ADMIN"判断上使用了**事务外读到的** `member.MemberType`,而把事务内 `FindOneForUpdateTx` 的返回值扔掉 → 并发下最后一个 ADMIN 会被删掉
+### H-2. gRPC `RefreshToken` / `VerifyToken` **完全没有任何限流**
 
 
-- **位置**:`internal/logic/member/removeMemberLogic.go:32,42-53`
+- **位置**:`internal/server/permserver.go`(gRPC `RefreshToken`、`VerifyToken` RPC 方法)
 - **描述**:
 - **描述**:
-  ```go
-  member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id) // 事务外
-  ...
-  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 member.MemberType == consts.MemberTypeAdmin { // <-- 用的是事务外的 member
-          adminCount, err := ...CountActiveAdminsTx(...)
-          ...
-      }
-      ...
-  })
-  ```
-  `FindOneForUpdateTx` 仅用来加行锁,但返回的最新值**直接丢掉**,后续判断"这条是不是 ADMIN"仍然走事务外的 `member`。
-
-  攻击/误操作路径:
-  - 初始:`productA` 有 ADMIN = [A1, A2];`M` 是 MEMBER。
-  - `T1`:调用 `RemoveMember(member.Id = M)`:
-    - 事务外 FindOne → `member.MemberType = MEMBER`。
-    - `T2` 几乎同时:`UpdateMember` 把 `M` 提升成 ADMIN(事务提交)。
-    - `T1`:进入事务,`FindOneForUpdateTx(M)` 返回 ADMIN(被丢弃)。
-    - `T1`:`if member.MemberType == ADMIN` 用的是旧 MEMBER → **跳过 count 检查**。
-    - `T1`:删除 `M`。
-  - 如果 T2 是因为 A1/A2 之一被先移除而把 `M` 提到 ADMIN 补位(运营流程),T1 就在恢复期内删掉了新晋 ADMIN;极端下可导致 0 active admin。
+  HTTP 端的 `/api/auth/refreshToken` 至少在**解析成功 claims 之后**调了 `TokenOpLimiter.Take("refresh:%d")`,做了一层**按用户**的令牌桶限流。但 gRPC 服务同名 RPC 完全没有做这件事:
+  - 没有 gRPC interceptor 级别的 IP 限流;
+  - 没有 per-user 限流;
+  - 也没有 per-refresh-secret 全局限流。
 
 
-  即使不考虑跨线程竞态,这也违背了"同一事务内一次读到一次写之间必须**用事务内的**视图来决策"的原则
+  业务语义上 gRPC 是内网其他服务向权限中心"换 accessToken / 验证 token"的主链路,**本就应该是最需要限流的地方**(被错误部署 / 服务腔体被打穿时,可以直接把 DB 打爆)。
 
 
-- **影响**:与 H-1 同类,破坏 "至少一个 ADMIN" 不变式。
+- **影响**:
+  - 与 H-1 组合时:攻击者可在 gRPC 通道上对 `RefreshToken` 发起**任意并发**,几乎必然命中 H-1 的 race 窗口,把会话劫持概率从"需要运气"拉到"只要有网络带宽"。
+  - 即便没有 H-1:攻击者用一枚尚未过期的 refreshToken 可以无限换取 accessToken,作为**持续化 RCE 工具链的身份通道**;也可以对有效 token 进行大量 signature verification,把权限中心 CPU 打满。
+  - `VerifyToken` 无限流:任何下游被攻破后,可以对权限中心做 token-oracle 爆破。
 
 
 - **修复方案**:
 - **修复方案**:
-
-  ```go
-  if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
-      if err != nil {
-          return response.ErrNotFound("成员不存在")
-      }
-      if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
-          // 只有当前是"活跃 ADMIN"才需要计数
-          adminCount, err := ...CountActiveAdminsTx(...)
-          if err != nil {
-              return err
-          }
-          if adminCount <= 1 {
-              return response.ErrBadRequest("不能移除该产品的最后一个管理员")
-          }
-      }
-      ...
-  })
-  ```
-  外层的 `member` 只用来取 `UserId`/`ProductCode` 做 `CheckManageAccess` 和事后清缓存。
+  1. **强制**添加 gRPC unary interceptor,做 IP 粒度的 PeriodLimit(`peer.FromContext` 取对端 IP,失败时按 H-4 的做法 fail-close):
+     ```go
+     func GrpcRateLimitInterceptor(limiter *limit.PeriodLimit, quota int, window int) grpc.UnaryServerInterceptor {
+         return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+             ip, err := extractClientIP(ctx)
+             if err != nil {
+                 return nil, status.Error(codes.Unavailable, "peer not identifiable")
+             }
+             code, _ := limiter.Take(fmt.Sprintf("grpc:%s:%s", info.FullMethod, ip))
+             if code == limit.OverQuota {
+                 return nil, status.Error(codes.ResourceExhausted, "rate limited")
+             }
+             return handler(ctx, req, info)
+         }
+     }
+     ```
+  2. 在 `RefreshToken` / `VerifyToken` 逻辑内部**也加一层 per-user 的 `TokenOpLimiter.Take(fmt.Sprintf("grpc-refresh:%d", claims.UserId))`**,作为 claims 解析成功后的第二道闸。
+  3. 把 refresh / verify 的失败结果(`claims.TokenVersion != ud.TokenVersion`、`jwt.ParseWithClaims err`)接入 **IP-level 失败计数器**,连续失败 N 次直接封禁一段时间,避免爆破 refresh secret。
 
 
 ---
 ---
 
 
-### H-3. "最后一个 ADMIN" 保护基于 `SELECT COUNT(*)` 的快照读 → 并发移除/降级两个不同 ADMIN 会同时通过检查(跨行 TOCTOU,业务不变式被打破)
-
-- **位置**:
-  - `internal/model/productmember/sysProductMemberModel.go:62-69` (`CountActiveAdminsTx`)
-  - `internal/logic/member/updateMemberLogic.go:66-73`
-  - `internal/logic/member/removeMemberLogic.go:45-52`
+### H-3. `BindRoles` 允许**平级自增**:MEMBER 可给其他用户绑定与自己**最小等级相等**的角色,从而让目标等同自己 → 后续再也管不动
 
 
+- **位置**:`internal/logic/user/bindRolesLogic.go:86-91`
 - **描述**:
 - **描述**:
   ```go
   ```go
-  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
+  if !authHelper.HasFullProductPerms(caller) {
+      if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
+          return response.ErrForbidden("不能分配权限级别高于自身的角色")
       }
       }
-      return count, nil
   }
   }
   ```
   ```
-  - 非锁定读(`session.QueryRowCtx` 不附带 `FOR UPDATE` / `LOCK IN SHARE MODE`)。在 MySQL InnoDB 默认 `REPEATABLE READ` 下,这是 MVCC 快照读,**不会**阻塞也**不会**被其他事务的 INSERT/UPDATE 阻塞。
-  - 事务 T1 锁定并计划降级/移除 ADMIN `m1`;事务 T2 几乎同时锁定并计划降级/移除 ADMIN `m2`(`m1 ≠ m2`,所以行锁不互斥)。
-  - 两个事务同时做 `CountActiveAdmins`,都读到 snapshot=2,都通过 `<=1` 检查,都 commit → 活跃 ADMIN 数变成 0。
-
-  这是一个真实存在的**跨行 TOCTOU**,经典情景:
-  - 两位超管同时对不同 ADMIN 做"降级为 DEVELOPER";
-  - 一个 ADMIN 并行点了"退出产品"(自己 RemoveMember)和"降级自己为 MEMBER";
-  - 批处理脚本 + 人工操作的并发。
+  权限模型约定:`PermsLevel` 数字越小表示权限越高。这里的判定是 `r.PermsLevel < caller.MinPermsLevel`,即 **严格小于** 才拒绝。结果:调用者 `MinPermsLevel = 5` 时允许分配 `PermsLevel = 5` 的角色。
 
 
-- **影响**:与 H-1/H-2 累积。"至少一个 ADMIN"是系统的关键不变式,它被三条独立路径共同破坏:
-  - H-1:通过 disable 绕过;
-  - H-2:通过事务内外视图不一致绕过;
-  - H-3:通过快照读+跨行并发绕过。
+  但 `CheckManageAccess` 里 `checkPermLevel` 的判定是:
+  ```go
+  if caller.MinPermsLevel >= targetLevel {
+      return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
+  }
+  ```
+  即目标用户的最小 PermsLevel **必须严格大于**调用者(即目标权限严格低于调用者)。两边判定口径不一致:
 
 
-- **修复方案**(推荐方案 B):
+  - MEMBER A(`MinPermsLevel=5`)调用 `/api/user/bindRoles` 给属于自己部门子树的 MEMBER B(`MinPermsLevel=6`)追加一个 `PermsLevel=5` 的角色 → 通过。
+  - 此后 B 的 `MinPermsLevel=5`,与 A 平级;A 再想 `UpdateUser/BindRoles/SetUserPerms/UpdateUserStatus` 管理 B,都会在 `checkPermLevel` 里被 `5 >= 5` 拦住 → 管不了了。
+  - 也就是说 A **合法地**把下属 B 提到自己平级,然后永久失去对 B 的后续管控权。如果此时 A 自己被冻结 / 账号被挤下线,B 也不能被原 A 的同级 MEMBER 回收——要么超管 / 产品 ADMIN 出手,要么永久残留。
+  - 同样的 gap 在 `CheckMemberTypeAssignment`(`>=` 拦截)与此处(`<` 拦截)之间是不一致的:前者严格高一级才能分配 memberType,这里却允许"同级角色"。
 
 
-  **方案 A(局部:对所有活跃 ADMIN 加共享锁)**:
+- **影响**:
+  - **垂直权限泄露**:即便没有恶意,普通配置错误就能制造出"产品里多个同级 owner,互相管不了对方"的死结,业务上得**靠人肉联系平台超管**收拾残局。
+  - 有恶意的 MEMBER 可以**主动把攻击者账号拉到自己平级**,从此攻击者的权限只能由超管吊销 → **实际上完成了一次越权提升**,攻击者绕过了"MEMBER 只能管下级"的心智模型。
+  - 与 `UpdateRoleLogic` 的"非超管不能降低 PermsLevel"结合:A 把 B 拉到平级后,如果再找一个 ADMIN 去调整角色 PermsLevel,整个产品里的等级模型会越发混乱。
 
 
+- **修复方案**:
+  把 `<` 改为 `<=`,与 `checkPermLevel` 的 `>=` 对称:
   ```go
   ```go
-  func (m *customSysProductMemberModel) LockActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
-      var ids []int64
-      // 锁定该产品下所有当前活跃的 ADMIN 行;任何其它事务想把这些行变更,都必须等我
-      q := fmt.Sprintf(
-          "SELECT `id` FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR UPDATE",
-          m.table)
-      if err := session.QueryRowsCtx(ctx, &ids, q, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
-          return 0, err
-      }
-      return int64(len(ids)), nil
+  if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel <= caller.MinPermsLevel {
+      return response.ErrForbidden("不能分配权限级别高于或等于您自身的角色")
   }
   }
   ```
   ```
-  在所有会让一条 ADMIN 变为非活跃的路径(`UpdateMember` 的 disable/降级 分支、`RemoveMember`)上:**先**调用 `LockActiveAdminsTx`,再判断 `count <= 1`。这样两个并发事务都会尝试锁定 **相同的一组 ADMIN 行**,其中一个会被阻塞;赢的那个先计数 → 允许或拒绝 → 提交后释放锁;输的那个获得锁后再看到准确的 count-1。
-
-  **方案 B(更稳:对产品行做粗粒度互斥)**:
-  在所有"可能影响 ADMIN 数量"的事务入口先 `SELECT ... FOR UPDATE` 产品行(或建一张 `sys_product_mutex` 表),让对同一产品的 ADMIN 集合变更天然串行化。业务上这些操作并发率极低(手动运营动作),粗粒度锁不会成为性能瓶颈。
+  同时:
+  1. 在 `BindRoles` / `SetUserPerms` 所在文件里**抽一个 `guardRoleLevelAssignable(caller, role)` helper**,强约束"只能分配严格更低等级的角色"——与 `checkPermLevel`(严格更低等级才能管)对齐,后续任何新增绑定场景不会再走错。
+  2. 补一条单测:模拟 MEMBER (level=5) 给 MEMBER 绑 level=5 的角色,必须 403。
 
 
 ---
 ---
 
 
-### H-4. `DeleteDept` 子部门/用户计数用非锁定快照读 → `CreateDept` / `CreateUser` / `UpdateUser` 能在 Delete 事务中"偷偷插入"→ 产生孤儿引用
+### H-4. `UpdateUserLogic` 的 `DeptId = 0` 分支**跳过部门管辖校验**:下属可以被合法"移出部门"、失去上级管辖
 
 
-- **位置**:`internal/logic/dept/deleteDeptLogic.go:36-63`
+- **位置**:`internal/logic/user/updateUserLogic.go:106-120`
 - **描述**:
 - **描述**:
   ```go
   ```go
-  return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      // 仅锁定要删的那一行
-      lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", ...)
-      ...
-      var childCount int64
-      countChildQuery := "SELECT COUNT(*) FROM sys_dept WHERE parentId = ?"
-      session.QueryRowCtx(..., countChildQuery, req.Id) // 快照读,无锁
-
-      var userCount int64
-      countUserQuery := "SELECT COUNT(*) FROM sys_user WHERE deptId = ?"
-      session.QueryRowCtx(..., countUserQuery, req.Id) // 快照读,无锁
-
-      return l.svcCtx.SysDeptModel.DeleteWithTx(...)
-  })
+  if req.DeptId != nil {
+      if *req.DeptId > 0 {
+          newDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId)
+          ...
+          if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin &&
+              caller.DeptPath != "" &&
+              !strings.HasPrefix(newDept.Path, caller.DeptPath) {
+              return response.ErrForbidden("无权将用户调入非自己管辖的部门")
+          }
+      }
+      deptId = *req.DeptId
+  }
   ```
   ```
-  - 父部门加了 `FOR UPDATE`,但这只是对 `sys_dept.id=X` 这一行的 X 锁。
-  - 子部门计数走 `sys_dept.parentId=X`。父部门行 X 锁**不会**传递给"引用这个父"的子行,也**不会**在 `parentId` 索引上建立 gap lock(因为 COUNT(*) 不是锁定读)。
-  - 用户计数更离谱:`sys_user.deptId=X` 分属另一张表,跟 `sys_dept` 的行锁没有任何关系。
-  - `CreateDept`(`internal/logic/dept/createDeptLogic.go:46-52`)在事务外 `FindOne` 读父部门,**不加任何锁**,随后 InsertWithTx 写子行。同样 `CreateUser`、`UpdateUser`(改 deptId)都不会锁父部门。
-
-  结果:
-  - T1 开始 Delete dept=5:父行加锁,childCount=0、userCount=0,通过。
-  - T2 `CreateDept(parentId=5)`:不需要任何锁,Insert 成功,commit。
-  - T1:DELETE parent,commit。
-  - 数据库里有 `sys_dept` 子行挂在一个**已不存在**的 parentId=5 → `DeptTreeLogic` 的 "parent 不存在则视为 root" 兜底 (`deptTreeLogic.go:60-62`) 只是把异常用户吞掉,组织架构彻底错乱。
-  - 对 `sys_user` 同理:可能生产出 `deptId=5` 但 dept 已删的孤儿用户,`loadDept`→`FindOne` 会失败静默,`DeptPath=""`,进而触发 `CheckManageAccess → checkDeptHierarchy` 的"您的部门信息异常"分支,用户相当于完全失去权限。
+  新部门层级校验放在 `*req.DeptId > 0` 分支内。如果调用者传 `deptId: 0`,直接 `deptId = 0`,**跳过整个层级校验**。由 `access.go:146-150` 可知,`target.DeptId == 0` 会被 `checkDeptHierarchy` 视为"仅超管或产品 ADMIN 可管"。
+
+  真实业务路径:
+  1. MEMBER A(部门 `/1/2/`,通过 `CheckManageAccess` 管辖部门 `/1/2/3/` 下的 MEMBER B)。
+  2. A 调用 `POST /api/user/update { id: B.id, deptId: 0 }`。第一步校验:A 对 B 有管辖权限(通过,B 确在 A 子树)。后续:`req.DeptId != nil && *req.DeptId == 0` → 跳过新部门 Path 校验 → `deptId = 0`。
+  3. DB 里 B 的 `deptId` 被抹成 0。此后 B 的 `DeptPath` 为空,`checkDeptHierarchy` 对任何非 ADMIN 都返回"目标用户未归属部门,仅超管或产品管理员可管"。
+  4. **A 自己也管不了 B 了**:因为 B 已经脱离部门体系。等价于 A 把 B "踢出部门树",使后续任何 MEMBER-级别的同级调整都失效,只有超管/产品 ADMIN 能动。
+  5. 结合 H-3 的等级平级攻击:A 先把 B 拉到同级,再把 B 踢出部门 → B 变成一个在组织结构里"无归属、无人能管"的幽灵高权账号。
 
 
 - **影响**:
 - **影响**:
-  - 组织树出现悬空父指针,`DeptTree` 展示异常;
-  - 用户的 deptId 成孤儿,`loadDept` 静默失败,用户被持续推到"无权操作"分支;
-  - 如果外部系统以 `deptPath` 鉴权,会出现"应该有权限但系统说没"或反之的权限紊乱
+  - **这不是简单的一致性问题**,是 MEMBER 可以**合法**破坏组织架构语义 + 失去组织可管控性:账号进入"灰色托孤态",只有平台超管能打捞。
+  - 从合规角度:任何有"部门树 = 数据隔离边界"的业务(如多租户),本接口可以**越过部门边界丢弃账号的隶属关系**。
+  - 没有 audit log 配合的情况下,此动作甚至不会立即被察觉
 
 
 - **修复方案**:
 - **修复方案**:
-  在 `DeleteDept` 事务内,对 **parentId 索引** 和 **deptId 索引** 做"存在性锁定读":
+  两种口径任选其一(建议第 2 种,更严格):
+  1. **禁止非 ADMIN 把 deptId 改为 0**(把原"只在 >0 时校验"变成"不等于当前 deptId 时都要校验"):
+     ```go
+     if req.DeptId != nil && *req.DeptId != user.DeptId {
+         if *req.DeptId == 0 {
+             if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
+                 return response.ErrForbidden("仅超级管理员或产品管理员可移除用户的部门归属")
+             }
+         } else {
+             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. 如果产品规范里本就不支持"用户无部门",应直接把 `0` 当作非法值:`if *req.DeptId <= 0 { return ErrBadRequest("部门ID无效") }`。
+
+---
+
+## ⚠️ 健壮性与性能建议 (Medium/Low)
 
 
+### M-1. `RefreshTokenLogic` **先解析 JWT,再限流**:无效 token 穿透限流,可用于爆破 refreshSecret
+
+- **位置**:`internal/logic/pub/refreshTokenLogic.go:40-73`
+- **描述**:`TokenOpLimiter.Take("refresh:%d")` 的 key 需要 `claims.UserId`,所以必须**先 `ParseRefreshToken` 成功**才能限流。对一批**攻击用的无效 token**(例如在爆破 refresh secret 时构造的伪造签名),全部会走到:
   ```go
   ```go
-  // 子部门:如果有任意一行,就失败;并在 parentId 索引上加 gap/next-key 锁,阻塞并发 INSERT
-  var tmp int64
-  q := "SELECT 1 FROM sys_dept WHERE parentId=? LIMIT 1 FOR UPDATE"
-  err := session.QueryRowCtx(ctx, &tmp, q, req.Id)
-  if err == nil {
-      return response.ErrBadRequest("该部门下存在子部门,无法删除")
-  }
-  if !errors.Is(err, sql.ErrNoRows) {
-      return err
+  claims, err := authHelper.ParseRefreshToken(tokenStr, l.svcCtx.Config.Auth.RefreshSecret)
+  if err != nil {
+      return nil, response.ErrUnauthorized("refreshToken无效或已过期")
   }
   }
-  // 用户引用:同样用锁定读
-  q = "SELECT 1 FROM sys_user WHERE deptId=? LIMIT 1 FOR UPDATE"
-  err = session.QueryRowCtx(ctx, &tmp, q, req.Id)
-  ...
   ```
   ```
-  `CreateDept` / `CreateUser` / `UpdateUser` 在写 `parentId` / `deptId` 之前,也要 `SELECT 1 FROM sys_dept WHERE id=? FOR SHARE`(S-lock 父行),这样 `DeleteDept` 的 X-lock 会阻塞这些插入,TOCTOU 被彻底消除。
-
-  对长期工程:建议直接加 MySQL FK `RESTRICT`;但即使不加 FK,应用层也必须把"存在性检查 + 删除"放在同一事务的锁定读语义下。
+  不进入限流桶。JWT 验签是较重的 HMAC/RS 运算,攻击者可以借此做 CPU-放大 DoS,或持续爆破 refresh secret(离线爆破失败,就改在线爆破,反正被限流也不限流)。
+- **建议**:在 `Authorization` 头解析(TrimPrefix)成功后,**以 IP 为 key**加一道最外层 PeriodLimit,如 `limiter.Take(fmt.Sprintf("refresh-ip:%s", clientIP))`。路由层已经可以通过 `RefreshTokenRateLimit` 中间件配合来做,**但这个中间件目前没挂载在 `/api/auth/refreshToken`**(路由定义里只有 LoginRateLimit 等),建议补上。
 
 
 ---
 ---
 
 
-### H-5. `ChangePasswordLogic` 无任何限流,JWT 被盗/会话仍在场景下可**暴力爆破当前密码**
+### M-2. `UpdateDeptLogic` 对部门成员的 `UserDetailsLoader.Clean` 在 for 循环里**串行同步调用**
 
 
-- **位置**:`internal/logic/auth/changePasswordLogic.go:32-62`
-- **描述**:
+- **位置**:`internal/logic/dept/updateDeptLogic.go`(循环清缓存处)
+- **描述**:在 deptType / status 变更时,代码形如:
   ```go
   ```go
-  func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error {
-      if msg := util.ValidatePassword(req.NewPassword); msg != "" { ... }
-      userId := middleware.GetUserId(l.ctx)
-      user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId)
-      ...
-      if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
-          return response.ErrBadRequest("原密码错误")
-      }
-      ...
+  userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+  for _, uid := range userIds {
+      l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
   }
   }
   ```
   ```
-  - 路由层:`/api/auth/changePassword` 只有 `JwtAuth` 中间件,**无 RateLimitMiddleware**。
-  - 逻辑层:`LogoutLogic` 和 `RefreshTokenLogic` 都挂了 `svcCtx.TokenOpLimiter`,但 `ChangePasswordLogic` 没有;也没有 `UsernameLoginLimit` 类的每用户限流。
-  - `bcrypt.CompareHashAndPassword` 大约 ~100ms,已登录用户可以串行每秒 ~10 次对自己当前密码做爆破。
-  - 威胁模型:
-    1. 攻击者偷到 access token(XSS/设备共用),想"把密码改成自己的"以实现持久化。由于必须知道旧密码,于是他用盗来的 token 不停调 `changePassword` 枚举旧密码。虽然 bcrypt 慢,但没有限流意味着**可以一直尝试**——被害人直到 access token 过期(默认 `AccessExpire`,通常 2h~1d)都无法自救。
-    2. 更现实:同一台办公机、同一设备同一账号,未离开时间段内 malicious script 执行百万次尝试;每次失败 `ErrBadRequest("原密码错误")`,既不记录日志也不递增 tokenVersion,失败完全"无痕"。
-  - 对比:`LogoutLogic` 做了 10/60s 限流,`RefreshTokenLogic` 也做了;`ChangePasswordLogic` 显然遗漏。
+  单次 `Clean` 内部包含 `SMEMBERS + DEL(批) + DEL(索引键) + SREM`(见 `userDetailsLoader.go:185-199, 221-230`),至少 4 次 Redis 往返。一个 500 人的部门 = 2000 次 Redis 同步 RTT 卡在请求线程里。这会直接阻塞 HTTP handler(默认 go-zero timeout 可能都兜不住)。
+- **建议**:
+  1. loader 层加 `CleanMany(ctx, userIds)`:一次 `SMEMBERS` 取每个索引键,`DEL` 合并所有 `cacheKey`(Redis 支持任意多 key 的批量 DEL),索引键用一次 `DEL` 或 pipeline 批处理。
+  2. 或用 `BatchDel(userIds, productCode)`(已经在 loader 里)——不过 `BatchDel` 只清特定产品,对"部门跨多个产品"的场景要多次调用。更清爽的做法是按索引键一次性清:
+     ```go
+     func (l *UserDetailsLoader) CleanByUserIds(ctx context.Context, ids []int64) {
+         idxKeys := make([]string, len(ids))
+         for i, id := range ids { idxKeys[i] = l.userIndexKey(id) }
+         // pipelined SMEMBERS + DEL
+     }
+     ```
+  3. 当部门用户数量超过阈值(如 200)时,**fire-and-forget 到后台 goroutine + 日志**,不要阻塞外部请求。
 
 
-- **影响**:
-  - 提供了"持有短期 access token 后可以 brute-force 当前密码,命中后改密持久化"的攻击路径;
-  - 一旦命中,新密码会触发 `tokenVersion+1`(`UpdatePassword` 里),**踢掉真正的用户**,攻击者用新密码重新登录即可接管(管理后台也同理,因为它用同一套 bcrypt)。
+---
 
 
-- **修复方案**:
+### M-3. `UserDetailsLoader.Load` 对**已删除用户**不做负缓存:一把有效 JWT + 删除账号 = 每次请求 7 条 DB 查询
 
 
+- **位置**:`internal/loaders/userDetailsLoader.go:119-144`
+- **描述**:
   ```go
   ```go
-  // internal/logic/auth/changePasswordLogic.go
-  if l.svcCtx.TokenOpLimiter != nil {
-      code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId))
-      if code == limit.OverQuota {
-          return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
+  v, sfErr, _ := l.sf.Do(key, func() (interface{}, error) {
+      ud, err := l.loadFromDB(ctx, userId, productCode)
+      ...
+      if ud.Username == "" {
+          return nil, nil
       }
       }
+      ...
+  })
+  ...
+  if !ok || ud == nil {
+      return &UserDetails{UserId: userId, ProductCode: productCode}
   }
   }
   ```
   ```
-  建议在 `ValidatePassword` 之后、`FindOne` 之前立即做(失败也计数,避免 "先写合法新密码才触发限流" 的绕过)。同时把失败做一条 `logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)`,给 SOC 提供可观测性。
+  当用户已被硬删除(`FindOne` 返回 `ErrNotFound`),`loadUser` 把 `ud.Username` 留空,外层判定"空,不缓存",返回一个空壳 `UserDetails`。Middleware 再根据 `ud.Username == ""` 返回 401。
+
+  但"不缓存"这件事意味着:**只要攻击者 / 离线员工仍持有未过期的 accessToken(默认 1~2 小时),每个请求都会触发 `loadFromDB` → 至少 1~7 次 DB SELECT**(loadUser / loadDept / loadProduct / loadMember / loadRoles / loadRolePerms / loadUserPerms)。
+  虽然 `sf.Do` 会合并**同时到达**的相同 key,但在真实场景下"攻击者手里只有一张 token,每 100ms 打一次"时,每次请求都是串行命中。
+
+  一个具体的 DoS 放大场景:
+  - 公司大规模离职,某几个离职员工的 accessToken 还在剩余寿命里(甚至能用 refreshToken 续期——虽然 `claims.TokenVersion != ud.TokenVersion` 会拦住,但 token 失效前的 accessToken 里 `tokenVersion` 对照"空 ud.TokenVersion=0"反而可能通过 `claims.TokenVersion != 0` 拦截)。
+  - 他们留下的 pipeline 任务 / 前端 polling 以 HTTP 规律重试,相当于**针对权限中心 DB 发起常驻压测**,且**无任何限流**(token 能通过 JWT 签名校验,就不会进入 IP 限流的 login 桶)。
+
+- **建议**:
+  1. 对"Username 为空"的路径**也写入短 TTL(30~60s)的负缓存 sentinel**:
+     ```go
+     if ud.Username == "" {
+         _ = l.rds.SetexCtx(ctx, key, `{"userId":0}`, 30) // 或带一个 "deleted":true 字段
+         return nil, nil
+     }
+     ```
+     加载侧读到 sentinel 立刻返回空 `UserDetails`,不再走 DB。
+  2. 更彻底:引入**基于 userId 的全局"已删除"布隆过滤器**,在 `DeleteUser` 时添加;`Load` 读到命中时直接 short-circuit。
+  3. `jwtauthMiddleware` 对"用户被删除"的路径打印警告日志 + 对应 token 的 `userId` 加入短期封禁列表(5~10 分钟),避免垃圾 token 长期耗资源。
 
 
 ---
 ---
 
 
-## ⚠️ 健壮性与性能建议 (Medium / Low)
+### M-4. `UpdateWithOptLock` / `UpdateProfile` / `UpdatePassword` 的乐观锁**以秒级 `updateTime` 为版本**:同秒并发写会丢更新
+
+- **位置**:
+  - `internal/model/user/sysUserModel.go:99-120`(UpdateProfile,`WHERE id=? AND updateTime=?`)
+  - `internal/model/dept/sysDeptModel.go:UpdateWithOptLock`
+  - `internal/model/product/sysProductModel.go:UpdateWithOptLock`
+  - `internal/model/role/sysRoleModel.go:UpdateWithOptLock`
+- **描述**:
+  乐观锁的 `WHERE updateTime = ?` 约束依赖"版本必然变化"。当前实现把 `updateTime` 写为 `time.Now().Unix()`(秒级)。同一秒内的两笔并发 UPDATE:
+  - Op1 读到 `updateTime = T`,计算 `new.updateTime = T`(同秒)。
+  - Op2 读到 `updateTime = T`,计算 `new.updateTime = T`。
+  - Op1 先 commit:`UPDATE ... SET ...,updateTime=T WHERE id=? AND updateTime=T` → 匹配 1 行(MySQL 默认 `rows_affected` 是"matched rows";如果配置了 `CLIENT_FOUND_ROWS` 是更明显的匹配),**新 updateTime 仍是 T**(没变)。
+  - Op2 再 commit:同样 `WHERE updateTime=T` 命中,Op1 的变更被 Op2 覆盖;业务**静默丢失**。
+
+  `UpdatePassword` / `UpdateStatus` 走的是**无乐观锁**的 `UPDATE ... WHERE id=?`,同秒并发也一样会后写覆盖前写,只不过对"设密码"而言是同一人改两次的幂等场景,影响小;但 `UpdateProfile` 这种带业务字段的操作,**同秒后提交的请求会把前一个合法更改吞掉,且 `RowsAffected=1`**,外层误以为成功。
+
+  同秒并发在本项目不算罕见:
+  - 管理后台前端批量操作,一次提交里同时改部门下 20 个用户;
+  - gRPC 下游 API 网关重放;
+  - 运维脚本并行刷数据。
+
+- **建议**:
+  1. **引入独立的 `version` 整型列**,每次 `SET version=version+1 WHERE id=? AND version=?`,彻底脱离时间戳。
+  2. 过渡方案:在 `UpdateXxxWithOptLock` 里用 `time.Now().UnixNano()` 代替 `Unix()`,并把 `updateTime` 列类型放宽到 `BIGINT`(已经是 bigint);同秒间碰撞概率从 1 降到纳秒级,基本规避。
+  3. 或使用 `UPDATE ... WHERE id=? AND updateTime=? AND ... (其他校验)`,让 `affected=0` 时抛 `ErrUpdateConflict` 触发客户端重试(**前提是 updateTime 每次真会变**,上面两种方案任选其一先修)。
+
+---
 
 
-### M-1. `UpdateUserStatus` 对"状态无变化"的请求仍然强制递增 `tokenVersion`,把被操作用户不必要地踢下线
-- **位置**:`internal/logic/user/updateUserStatusLogic.go:31-52`、`internal/model/user/sysUserModel.go:137-150`
-- **描述**:`UpdateUserStatus` 没有对比 `user.Status` 与 `req.Status`,不管是否真的变更,都调用 `SysUserModel.UpdateStatus`;模型层 SQL 为 `UPDATE … SET status=?, tokenVersion=tokenVersion+1 …` **无条件**递增。结果:管理员点一次"启用"按钮(用户原本就是启用),所有该用户在场的会话全部下线。
-  - 对比 `UpdateUserLogic`(`updateUserLogic.go:122-135`):显式 `if user.Status != req.Status { statusChanged = true }`,只在真正变化时才递增。两处逻辑不一致。
-- **修复**:进入 logic 时先读当前值,仅当 `user.Status != req.Status` 时调 UpdateStatus;或者在 SQL 上加 `WHERE id=? AND status<>?` 并仅当 `RowsAffected>0` 时认为"确实变更"。
+### M-5. `CreateProductLogic` 仍残留 `strings.Contains(errMsg, "uk_code")` 与 `strings.Contains(errMsg, req.Code)` 二级判定
 
 
-### M-2. `generateRandomHex` 长度截断导致 `appSecret` / `adminPassword` 熵减半(上一轮 M-3,仍未修复)
-- **位置**:`internal/logic/product/createProductLogic.go:158-164`
+- **位置**:`internal/logic/product/createProductLogic.go:135-146`
 - **描述**:
 - **描述**:
   ```go
   ```go
-  func generateRandomHex(length int) (string, error) {
-      b := make([]byte, length)
-      rand.Read(b)
-      return hex.EncodeToString(b)[:length], nil // 😱 截断
+  if util.IsDuplicateEntryErr(err) {
+      errMsg := err.Error()
+      if strings.Contains(errMsg, "uk_code") || strings.Contains(errMsg, req.Code) { ... }
+      if strings.Contains(errMsg, "uk_username") || strings.Contains(errMsg, adminUsername) { ... }
+      return nil, response.ErrConflict("数据冲突,请稍后重试")
   }
   }
   ```
   ```
-  - `hex.EncodeToString(N bytes) => 2N chars`;`[:length]` 拿到 `length` 个 hex 字符 = `length/2` 字节随机性。
-  - `generateRandomHex(64)` 生成 `appSecret` → **32 字节**熵,不是 64;
-  - `generateRandomHex(32)` 生成 `appKey` → **16 字节** / 128 bit,勉强;
-  - `generateRandomHex(8)` 生成首任管理员初始密码 → **4 字节 = 32 bit**,可在 10 分钟内离线爆破(就算走 bcrypt,超管持有明文返回值且要邮件/IM 明文传给运营方——泄漏风险真实存在)。
-- **修复**:
-  ```go
-  func generateRandomHex(byteLen int) (string, error) {
-      b := make([]byte, byteLen)
-      if _, err := rand.Read(b); err != nil { return "", err }
-      return hex.EncodeToString(b), nil  // 返回 2*byteLen 个 hex 字符
-  }
-  ```
-  调用方按"字节数"来传;初始管理员密码至少 12 字节随机 → 96 bit 熵,或者直接改用人类可读的 passphrase。
+  第一步正确使用 `IsDuplicateEntryErr`,但第二步"到底是哪张唯一键冲突"又退回 `strings.Contains(errMsg, ...)`。
+  问题:
+  - `strings.Contains(errMsg, req.Code)` 这一回退 fallback 极其脆弱:DB 错误消息未必内嵌值,也可能有类似子串误命中;
+  - MySQL 5.7/8.0 在错误消息里展示键名时带 prefix `Duplicate entry 'xxx' for key 'sys_product.uk_code'`,把 table 前缀去掉后才是 `uk_code`;不同版本字符串不同;
+  - 万一后续 DDL 把索引 rename,消息匹配直接失效,代码编译仍通过——**静默退化**到 "数据冲突,请稍后重试",前端无法区分是产品 code 冲突还是用户名冲突,UX 降级。
+- **建议**:
+  改用**预校验 + 插入并发兜底**模式:
+  1. 在 tx 开始前(已经有这段)用 `FindOneByCode` / `FindOneByUsername` 做存在性校验,命中直接返回明确错误消息。
+  2. 并发插入时命中的重复键错误,类型断言 `*mysql.MySQLError` 后直接返回通用 `ErrConflict("数据冲突,请重试")`,**不要再用字符串匹配区分键**。这等价于:
+     - 并发场景极少发生(单秒同时建同 code 的产品几乎不会有);
+     - 一旦发生,用户看到"冲突请重试"是可接受的;
+     - 非并发场景早被步骤 1 的精确文案拦住了。
 
 
-### M-3. `UserDetailsLoader` 对 "不存在的用户 / 错误的 (userId, productCode)" 没有负缓存 → 每次请求都穿透到 DB
-- **位置**:`internal/loaders/userDetailsLoader.go:119-144`
-- **描述**:`loadFromDB` 若 `ud.Username == ""`(用户不存在或已被删),`sf.Do` 返回 `(nil, nil)`,**不写任何缓存**。`Load` 返回一个空 UserDetails。下一次同样请求仍然走一次完整 DB 链路(`FindOne` → 命中模型层 cache miss → DB)。
-- **影响**:
-  - 账号被删除后,旧 access token 的每次请求都会触发 1 次 `sys_user.FindOne` 未命中的 DB 查询(直到 token 过期,默认可达 1h~24h)。
-  - 攻击者发送垃圾 access token(签名正确但 `userId` 为不存在 id)即可放大 DB 压力;虽然签名伪造需要 secret,但一个离职用户保留的 token 就能让内网攻击者制造 DB 噪声。
-- **修复**:在 `loadFromDB` 识别到 "用户不存在 / 已删除" 时,写一条 TTL 很短(15~60s)的负缓存标记(例如 cache 一个 `ud.Username=="_not_found_"`),`Load` 检测到该标记直接返回"不存在",无需再次查库。
-
-### M-4. `UserDetailsLoader` 的 "index set 添加 (SADD) 与数据 key 写入 (SETEX)" 非原子 → 并发下 `Clean` 可能漏清
-- **位置**:`internal/loaders/userDetailsLoader.go:127-132,185-199,201-219`
-- **描述**:写链路顺序是 `SETEX(key, json)` → `SADD(userIdxKey, key)` → `EXPIRE(userIdxKey)`;清链路是 `SMEMBERS(userIdxKey)` → `DEL(keys...)` → `DEL(userIdxKey)`。
-  交错:
-  1. Thread A 写入:`SETEX` 完成。
-  2. Thread B 触发 `Clean(userId)`:`SMEMBERS` 尚未看到新 key → `DEL` 清单中不含它 → `DEL idxKey` 清掉索引。
-  3. Thread A 继续:`SADD` 把 key 加回到(已被删除的)索引 → 再 `EXPIRE`(这步会重新创建 set,于是孤儿索引);同时数据 key 仍在 Redis 里。
-  4. 结果:**数据 key 实际仍在**,`Clean` 却已经认为"清完了"。stale 数据持续到 `ttl=300s` 才自然过期。
-  这在 `BindRoles → Clean`、`UpdateUser → Clean`、`UpdateRole → BatchDel` 等高频写入路径都可复现。
-- **修复**:
-  - 用 Redis 事务 / 脚本把 `SETEX + SADD + EXPIRE` 打包成单次 `EVAL`;
-  - 清理链路也用 Lua 把 `SMEMBERS + DEL keys + DEL idxKey` 原子化;
-  - 更简洁的替代:弱一致保证下,在每次 `Del/Clean` 后发一条 "延迟二次清除" 消息(比如 `DEL after 1s`),补偿这一窄竞态。
-
-### M-5. `UpdateRole` / `BindRolePerms` 在事务提交后读 `FindUserIdsByRoleId` 时**忽略错误**,Redis 抖动时会漏清缓存
-- **位置**:`internal/logic/role/updateRoleLogic.go:73-75`、`internal/logic/role/bindRolePermsLogic.go:127-128`
-- **描述**:
-  ```go
-  affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
-  l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
-  ```
-  `_,_` 丢 err,`affectedUserIds` 为空即 `BatchDel` 无作为。底层 DB 任何抖动都会让这次变更的缓存失效丢失;用户最多需要 5 分钟才拿到新权限。
-- **修复**:`return err` 并在调用链把这个错误归为 "已更新但缓存未清" 的 500;或者失败时写进 retry 队列,由后台任务重做。
+---
 
 
-### M-6. `UpdateRole` / `UpdateProduct` / `UpdateMember` 没有乐观锁(与 `UpdateUser` / `UpdateDept` 的策略不一致)
-- **位置**:
-  - `internal/logic/role/updateRoleLogic.go:69`(`SysRoleModel.Update(ctx, role)`)
-  - `internal/logic/product/updateProductLogic.go:58`(`SysProductModel.Update(...)`)
-  - `internal/logic/member/updateMemberLogic.go:75`(`SysProductMemberModel.UpdateWithTx(...)`)
-- **描述**:`UpdateUserLogic` 和 `UpdateDeptLogic` 用了 `UpdateWithOptLock(expectedUpdateTime)` 防并发覆盖;但相同模式的 `UpdateRole` / `UpdateProduct` / `UpdateMember` 仍是无条件 `UPDATE`:
-  - 两位管理员同时修改同一角色(A 改名字,B 改 permsLevel),后提交者会全字段覆盖先提交者的改动,**丢失字段**。
-- **修复**:统一给三者加 `UpdateWithOptLock(expectedUpdateTime)` / 或在 SQL WHERE 子句中加 `updateTime = ?`,并在受影响行数为 0 时返回 `ErrUpdateConflict`。
-
-### M-7. `AdminLoginLogic` 缺少"不存在账号的 dummy bcrypt",响应时间可用于 username 枚举
-- **位置**:`internal/logic/pub/adminLoginLogic.go:48-66`
-- **描述**:`ValidateProductLogin` 在用户不存在时跑一次 `dummyBcryptHash` 做恒时对齐(`loginService.go:53`),但 `AdminLoginLogic` 没有——直接 `return "用户名或密码错误"`。结合错误信息差异("账号已被冻结" vs "仅超级管理员可通过管理后台登录")和响应时间差异(bcrypt ~100ms vs 不调用 bcrypt ~1ms),攻击者可在 `AdminLoginRateLimit(20/min)`、`UsernameLoginLimit(10/5min)` 限额内做较精确枚举。
-- **修复**:与 `ValidateProductLogin` 对齐:
-  ```go
-  if errors.Is(err, user.ErrNotFound) {
-      bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
-      return nil, response.ErrUnauthorized("用户名或密码错误")
-  }
-  ```
-  并把"账号已被冻结" / "仅超级管理员可通过管理后台登录" 这两个分支统一归到 `"用户名或密码错误"`(管理后台侧不暴露任何有效账号的状态信息),让登录失败无法区分。
+### M-6. `SyncPermsService.ExecuteSyncPerms`:**读快照在 tx 外、写在 tx 内**,并发同步会撞 `DuplicateEntry`
 
 
-### M-8. `ProductList` / `ProductDetail` / `DeptTree` 无访问控制,普通成员能看全量产品名 / 组织架构(上一轮 M-4/M-5,仍未修复)
-- **位置**:
-  - `internal/logic/product/productListLogic.go:31-58`
-  - `internal/logic/product/productDetailLogic.go:29-48`
-  - `internal/logic/dept/deptTreeLogic.go:27-67`
-- **描述**:都只有 `JwtAuth`,没有任何"是当前产品成员 / 当前部门管辖"过滤。`ProductList` 只是把 `appKey` 在非超管时置空,名称/备注仍返回;`ProductDetail` 给任何 id 都能读。`DeptTree` 更是一次性返回所有部门——对外泄漏组织结构和产品清单。
-- **修复**:
-  - `ProductList` 改为"按 `sys_product_member` inner join `productCode` 过滤成当前调用者所属的产品集合";超管才能看全量;
-  - `ProductDetail` 对非超管校验 `caller.ProductCode == product.Code`;
-  - `DeptTree` 至少要求 `RequireSuperAdmin` 或返回以调用者部门为根的子树。
-
-### M-9. `SetUserPermsLogic` 重复执行了同一条 `FindOneByProductCodeUserId` 查询(`CheckManageAccess → checkPermLevel` 内部做一次,随后 `SetUserPerms` 里又做一次)
-- **位置**:`internal/logic/user/setUserPermsLogic.go:54-64` + `internal/logic/auth/access.go:148-157`
-- **描述**:`CheckManageAccess(req.UserId, productCode)` 内部 `checkPermLevel` 已经 `FindOneByProductCodeUserId(productCode, targetUserId)`;紧随其后 `SetUserPerms` 又做一次同样的读,再加上一次 `FindOne(user)`、一次 `FindOneByCode(product)`、一次 `FindByIds(perms)`——在写路径上累计 5~6 次 DB read。考虑到模型层都有 `CachedConn`,多数命中缓存,但 `FindMinPermsLevelByUserIdAndProductCode` 是无缓存查询,每次接口调用都会实际打一次 DB。
-- **修复**:
-  - 让 `CheckManageAccess` 把解析出来的 `targetMember` / `targetRoleLevel` 通过返回值或 context 透出,SetUserPerms 直接复用;
-  - 或把整个权限校验前置到单独的 "AuthContext" 对象,一次加载。
-
-### M-10. `VerifyToken` gRPC 对"无效 token"完全不记录日志,也没有调用方识别字段(appKey/serviceName)→ 生产上不可观测
-- **位置**:`internal/server/permserver.go:173-208`
-- **描述**:所有失败分支都 `return &pb.VerifyTokenResp{Valid: false}, nil`,不分类、不打日志。线上"用户反馈总是 401"时没法判断是 `TokenVersion` 不符还是 `ProductStatus` 禁用还是别的;更没有"该产品服务在过去 10 分钟发生了 10k 次 VerifyToken 失败"这种告警能力。
-- **修复**:至少保留 `logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=%s", claims.UserId, reason)`;`reason` 单独落字段方便日志聚合。
-
-### M-11. gRPC `PermServer.Login` 在 `peer.FromContext` 失败时静默跳过限流(fail-open)
-- **位置**:`internal/server/permserver.go:62-76`
-- **描述**:`if s.svcCtx.GrpcLoginLimiter != nil { if p, ok := peer.FromContext(ctx); ok { … rate limit … } }`——如果 `ok == false`(走 in-process / socket 无 peer 信息的场景)就**不限流**。配合 ExecuteSyncPerms 同样逻辑(gRPC 不限流,HTTP 层才有),理论上内部调用或错误配置下会绕过保护。
-- **修复**:`ok == false` 时把 key 当作 `"grpc:login:unknown"` 走限流,更严苛的做法是"没 peer → 直接拒绝"(因为生产环境都在 gRPC over TCP)。
-
-### M-12. 三个 scaffolded 中间件文件是僵尸代码,从未注册也没被引用
-- **位置**:
-  - `internal/middleware/adminloginratelimitMiddleware.go`
-  - `internal/middleware/productloginratelimitMiddleware.go`
-  - `internal/middleware/syncratelimitMiddleware.go`
-- **描述**:三个文件各自声明一个空壳类型和 `Handle` 方法(直接 passthrough),路由里实际使用的是 `svc.ServiceContext` 里基于 `RateLimitMiddleware` 构造出来的实例。两边同名但互不相干,静态分析工具看来这些 `*Middleware` 类型无任何引用。
-- **修复**:直接删除这三个文件;或者把它们改成 `// Deprecated. Use ...` 并在下次发版一起删。
-
-### M-13. `bindRolesLogic` 的 "role level" 检查对 DEVELOPER 无条件放行
-- **位置**:`internal/logic/user/bindRolesLogic.go:85-91`
-- **描述**:
-  ```go
-  if !caller.IsSuperAdmin &&
-      caller.MemberType != consts.MemberTypeAdmin &&
-      caller.MemberType != consts.MemberTypeDeveloper {
-      if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
-          return response.ErrForbidden("不能分配权限级别高于自身的角色")
+- **位置**:`internal/logic/pub/syncPermsService.go`
+- **描述**:代码流程大致是:
+  ```
+  existing := FindMapByProductCode(productCode)   // tx 外,普通 SELECT
+  TransactCtx(func(session) {
+      for code, perm := range req.Perms {
+          if _, ok := existing[code]; !ok {
+              InsertWithTx(...)
+          } else {
+              UpdateWithTx(...)
+          }
       }
       }
-  }
+      ... disable 不在请求列表里的旧权限 ...
+  })
   ```
   ```
-  DEVELOPER 绕过 perms-level 校验——**语义正确前提**是"DEVELOPER 已经拿到产品全部权限(`loadPerms` 里走全权分支)",因此让 TA 给 MEMBER 分高阶角色不算越权。但这条业务语义并不显式写在代码里,任何未来把 DEVELOPER 权限收窄的改动都会立刻让这里成为漏洞。
-- **修复**:在 `access.go` 里统一写一个 `callerIsFullPermInProduct(ud) bool`(条件:SuperAdmin / ADMIN / DEVELOPER / 或 `DeptType==DEV` 且成员启用),所有依赖"caller 已拥有全权"做的短路都复用它,变更只需改一处。`loadPerms` 的判定也统一走它。
+  两次并发 `SyncPermissions`:
+  - 都读到 `existing` 中没有 code X;
+  - 都 `InsertWithTx(..., code=X)`;
+  - 第二次 tx 在 `UNIQUE (productCode, code)` 上撞 1062 → rollback。
 
 
-### M-14. `strings.Contains(err.Error(), "1062")` 脆弱的错误识别(多处)
-- **位置**:
-  - `internal/logic/user/createUserLogic.go:92-96`
-  - `internal/logic/role/createRoleLogic.go:67-71`
-  - `internal/logic/member/addMemberLogic.go:76-80`
-  - `internal/logic/product/createProductLogic.go:134-144`
-- **描述**:所有冲突检测都走字符串匹配 `"1062"` / `"Duplicate entry"`。一旦换 driver 版本或 MySQL 升级导致文案变化,这些检测直接失效,逻辑全部变成 500。产品/用户的唯一索引冲突会被当成内部错误吃掉。
-- **修复**:换成 `mysql.MySQLError` 类型判断:
-  ```go
-  import "github.com/go-sql-driver/mysql"
-  var me *mysql.MySQLError
-  if errors.As(err, &me) && me.Number == 1062 {
-      return response.ErrConflict(...)
-  }
-  ```
+  当前错误处理把 1062 直接 `return err`,外层包装成 500,对客户端来说看起来"同步随机失败",但其实是两个合法同步的竞态。
+- **建议**:
+  1. 把 `FindMapByProductCode` **挪进 tx 内**,并在前面做一次 `SELECT id FROM sys_product WHERE code=? FOR UPDATE` 锁住 product 行(或 `GET_LOCK('sync:'+productCode, 5)`),相当于把同步串行化到每个 product。同步是低频操作,串行化是能接受的。
+  2. `InsertWithTx` 改用 `INSERT ... ON DUPLICATE KEY UPDATE`:把插入 / 更新合并成一个语义,天然幂等。
+  3. `IsDuplicateEntryErr` 的路径显式返回 `response.ErrConflict("权限同步存在并发冲突,请重试")`,前端据此做重试,不要让客户端无脑 500。
 
 
-### M-15. `ChangePasswordLogic` 在 bcrypt 校验之前不检查 `user.Status` 冻结状态
-- **位置**:`internal/logic/auth/changePasswordLogic.go:37-45`
-- **描述**:`FindOne → bcrypt.CompareHashAndPassword`,没有 `if user.Status != StatusEnabled`。JWT 中间件已经对冻结用户拦截,但 (a) 冻结后中间件 loader cache 未及时失效的 race 窗口;(b) 冻结状态的用户理论上仍可调 changePassword(虽然此 JWT 已被拦截)。防御纵深建议双保险。
-- **修复**:
-  ```go
-  if user.Status != consts.StatusEnabled {
-      return response.ErrForbidden("账号已被冻结")
-  }
-  ```
+---
 
 
-### L-1. `SetUserPerms` / `BindRoles` / `BindRolePerms` 的去重写回到了 `req`(副作用入参)
-- **位置**:
-  - `setUserPermsLogic.go:72-86`
-  - `bindRolesLogic.go:58-68`
-  - `bindRolePermsLogic.go:44-54`
+### M-7. gRPC `PermServer.Login` 的**对端 IP 提取 fail-open**
+
+- **位置**:`internal/server/permserver.go`(`Login` 方法里对 `peer.FromContext` 和 `net.SplitHostPort` 的错误处理分支,clientIP 回退为 `"unknown"` 或原始 `p.Addr.String()`)
 - **描述**:
 - **描述**:
+  - `clientIP = "unknown"` 意味着**所有**这种失败 case 共享同一个限流桶,正常请求被 pollute 后限流可能集体打满;
+  - 如果回退成 `p.Addr.String()` 又包含端口号,每个连接端口都是一个新 key,限流完全等价于无限流(端口随每个连接变化)。
+- **建议**:
+  - 不能确定 IP 时**直接拒绝**:`return nil, status.Error(codes.Unavailable, "peer not identifiable")`。
+  - 这个规则同样要套在 gRPC `SyncPermissions` / `RefreshToken` / `VerifyToken` / `GetUserPerms` 的新限流中间件里(H-2 的修复路径)。
+
+---
+
+### M-8. `ChangePasswordLogic` / `UpdatePassword`:**无乐观锁 + 并发修改密码双方都成功**
+
+- **位置**:
+  - `internal/logic/auth/changePasswordLogic.go`
+  - `internal/model/user/sysUserModel.go:122-135`(UpdatePassword)
+- **描述**:两笔同时发起的 ChangePassword 请求(例如用户双窗口操作 + 自动化脚本),都能通过 "旧密码比对",都走 `UpdatePassword`,**后写覆盖前写**。`tokenVersion` 被加 2 次,最终 hash 来自后写者。
+  这里不涉及 security escalation,但:
+  - 如果用户看到第一次"修改成功",过了 1 秒再用新密码登录却失败(因为后写者用的是"旧密码 + 新密码计算出的 hash",最终 hash 属于第二个请求的新密码),用户会以为被盗号。
+  - 两个 `IncrementTokenVersion` 把后续所有合法会话踢掉两次,UX 破损。
+- **建议**:在 `UpdatePassword` 里用 `WHERE id=? AND tokenVersion=?`(expected = 改密前读到的 tokenVersion),冲突时走 `ErrUpdateConflict` 返回前端"请刷新后重试"。
+
+---
+
+### L-1. `CreateUserLogic` 默认 `mustChangePassword = consts.MustChangePasswordNo`
+
+- **位置**:`internal/logic/user/createUserLogic.go`
+- **描述**:管理员为新用户创建账号时,密码是管理员"代填"的,业务上通常应**强制首登改密**。当前默认值是 `No`,意味着管理员口头告诉员工密码后,员工可以长期不改;如果管理员密码库泄露,所有新建账号都通用。
+- **建议**:把默认改为 `consts.MustChangePasswordYes`;或请求体加 `mustChangePassword *int` 字段,不传时按 `Yes` 处理。
+
+---
+
+### L-2. `RefreshToken` 限流 key 仅含 `userId`,**不含 IP**
+
+- **位置**:`internal/logic/pub/refreshTokenLogic.go:69`
+- **描述**:`TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))`。若攻击者从多个 IP 同时使用同一 refreshToken(与 H-1 的 race 攻击配套),限流桶共享,单桶配额耗尽攻击即止;但同一合法用户**自己**在多个 IP 刷新(手机 + 电脑)时也会互相挤占配额。
+- **建议**:把 IP 加入 key(`refresh:%d:%s`,IP),同时保留 per-user 总量上限(`refresh-u:%d`)。
+
+---
+
+### L-3. `CreateDeptLogic` 的 `InsertWithTx → FindOneWithTx → UpdateWithTx` 组合:**缓存早于事务提交失效**
+
+- **位置**:`internal/logic/dept/createDeptLogic.go:73-95`
+- **描述**:`UpdateWithTx` 内部调用 `m.ExecCtx`,go-zero 会**在 exec 成功后立即 `DelCacheCtx`**,此时事务尚未 commit。假设另一 goroutine 在 exec 完成与 commit 之间以 deptId 调 `FindOne`,会:
+  - cache miss → DB 查询 → 事务未提交对其他 session 不可见 → 返回 `ErrNotFound` → go-zero cached conn 把"未找到"的 sentinel 写进 cache(典型 TTL ~1 秒)。
+  - 事务 commit 之后,真正的行已存在,但 cache 里是"未找到" sentinel,后续请求命中错误缓存。
+  现象:创建部门后的前 1 秒,其他查询可能偶发"部门不存在"。生产罕见,属于一致性微抖动。
+- **建议**:这是 go-zero cache 层面的已知模式,不是本项目独有。如需彻底消除,把"计算 Path + 第二次 UpdateWithTx"改成**在插入阶段就把 Path 算好**(trigger 或 app 层先 `LAST_INSERT_ID()`,但 MyISAM/InnoDB 在 `InsertWithTx` 里就能从 `result.LastInsertId()` 拿到),然后 `UPDATE` 一次就好——其实现在的代码也是一次 UPDATE,只是 `FindOneWithTx` 是多余的(拿完整 SysDept 只为回写 Path,完全可以直接构造 UPDATE 语句)。简化为:
   ```go
   ```go
-  uniquePerms := make([]types.UserPermItem, 0, len(req.Perms))
-  ...
-  req.Perms = uniquePerms
+  result, err := InsertWithTx(...)
+  deptId = result.LastInsertId()
+  path := fmt.Sprintf("%s%d/", parentPath, deptId)
+  _, err = session.ExecCtx(ctx, "UPDATE sys_dept SET `path`=?, `updateTime`=? WHERE `id`=?", path, now, deptId)
   ```
   ```
-  对外部传入的 `req` 做原地写入会让上层(例如 handler、中间件、单元测试)读到被改过的结构,违背"logic 不修改入参"原则。
-- **修复**:改成局部变量 `perms := uniquePerms`;后续所有流程使用 `perms`,不再碰 `req.Perms`。
+  同时用 `m.DelCacheCtx(cacheSysDeptIdPrefix+deptId)` 显式在 tx 外做一次二次清理。
 
 
-### L-2. `memberTypePriority("")` 返回 `MaxInt32`,在 `CheckMemberTypeAssignment` 下会与未知类型保持等价判定
-- **位置**:`internal/logic/auth/access.go:15-28,67-69`
-- **描述**:若 `caller.MemberType == ""`(例如产品禁用后被 loader 清空),`memberTypePriority("")==MaxInt32`;`if MaxInt32 >= priority(assigned)` 恒真 → `CheckMemberTypeAssignment` 直接拒绝。这里"fail-closed"是正确行为,但逻辑靠的是 sentinel 值而非显式分支,容易在将来扩展 MemberType 时出错。
-- **修复**:显式判断 `caller.MemberType == "" → return ErrForbidden("缺少产品成员上下文")`。
+---
+
+### L-4. `CheckManageAccess` / `checkPermLevel` 把 `targetLevel = math.MaxInt64`(查不到角色)当"目标无权限"处理
 
 
-### L-3. `UserDetailsLoader.Clean` 的错误忽略会扩散
-- **位置**:`internal/loaders/userDetailsLoader.go:150-153,177-179,186-198`
-- **描述**:Redis 所有操作失败都只 `Errorf` 记日志,然后继续。当 Redis 间歇不可用时,管理员以为"清完了",实际上旧缓存可能至少 5 分钟内生效。
-- **修复**:对关键变更路径(角色授权、状态变更)做"两次清理 + 失败回退":第一次 `Del` 失败 → 在队列里起一次 retry;或者把 UserDetails 的缓存层 TTL 缩短到 60s 并接受 DB 压力(负载允许前提下)。
+- **位置**:`internal/logic/auth/access.go:185-192`
+- **描述**:目标用户没有任何启用角色时 `FindMinPermsLevelByUserIdAndProductCode` 返回 err 或空 → 代码 fallback 为 `targetLevel = math.MaxInt64`。然后 `caller.MinPermsLevel >= targetLevel`(MaxInt64)永远为 false(除非 caller 也是 MaxInt64),于是 caller "严格高于" target → 允许管辖。
+  语义上这是合理的(无角色用户的等级最低),但一旦 `FindMinPermsLevelByUserIdAndProductCode` 因为 DB 抖动真正返回 err,**会被同化成"目标无角色"**,给出错误放行。
+- **建议**:把"查不到角色 = MaxInt64"与"DB 抖动 = err"的路径分开:`err != nil && !errors.Is(err, sqlx.ErrNotFound)` 时直接返回错误而不是降级放行。
+
+---
 
 
-### L-4. `DeptTree` 对"孤儿 parent"只打日志即当 root 并继续返回 → 静默数据异常
-- **位置**:`internal/logic/dept/deptTreeLogic.go:55-63`
-- **描述**:如果发生 H-4 的 TOCTOU,树里会出现 "parentId 指向不存在的 id" 的部门,代码会把它"视作 root"继续放回前端。**数据已损坏但用户/管理员无感知**,管理员很可能在之后一通操作里把 children 移到别处却不清楚真正丢失的是哪棵子树。
-- **修复**:同一批异常记录直接在响应里带一个 `warnings` 字段或在 HTTP 头里 flag,让前端报警;后台做一条 alerting 指标 `dept_orphan_count > 0`。
+### L-5. `CreateProductLogic.generateRandomHex` 的 tx 外密钥生成:**事务失败时密钥被泄露到日志 / 响应**
 
 
-### L-5. 多数接口在 DB 错误时直接 `return err` 而不包装为统一 500,生产会透出底层 sqlx/gorm/bcrypt 错误文本
-- **位置**:几乎所有 logic 文件的尾端
-- **描述**:`response.Setup` 会把非 `*CodeError` 的错误映射成 `{code: 500, msg: "服务器内部错误"}` 并 `logx.Errorf`。这本身没问题,但很多 logic 在上下文敏感的位置(比如 bcrypt 生成失败)返回原始 err 到调用栈,日志里出现原始 bcrypt 错误文本也是一种信息披露。建议所有 logic 统一包装成 `response.ErrInternal("xxx 失败")` 并把原 err 放入 `logx.Errorf` 里,避免上下层关心如何转换。
+- **位置**:`internal/logic/product/createProductLogic.go`
+- **描述**:`appKey` / `appSecret` / adminPassword 在 tx 前生成;tx 失败时函数直接 `return nil, err`,响应体里不会带走这些值(OK),但:
+  - `logx.Errorf("internal error: %+v", err)` 有可能在 stack 之外打印 req;
+  - 如果调用方带 `X-Request-Id` 等 trace header,这次失败生成的 appSecret 不会重试时复用,等于每次重试都把一串密钥丢到熵池里直到 tx 成功。不是安全问题但无意义。
+- **建议**:把密钥生成挪进 tx 内部(或 tx 成功 commit 后再最后一步),避免失败态的"幽灵密钥";响应返回的明文 `AppSecret` / `AdminPassword` 建议换成一次性下载 URL 或要求创建者当场抄写,别持久化在响应体。
 
 
 ---
 ---
 
 
-## 🧭 总结与本轮优先级
+## 结论与修复优先级
 
 
-| 优先级 | 问题 | 关键词 |
+| 优先级 | finding | 概要 |
 | --- | --- | --- |
 | --- | --- | --- |
-| P0 | H-1 | UpdateMember 禁用最后 ADMIN 绕过 |
-| P0 | H-2 | RemoveMember 事务内外视图脱钩 |
-| P0 | H-3 | CountActiveAdmins 跨行 TOCTOU |
-| P0 | H-4 | DeleteDept 子部门/用户 TOCTOU |
-| P0 | H-5 | ChangePassword 无限流可暴力破旧密码 |
-| P1 | M-1 | UpdateUserStatus "无变化也踢下线" |
-| P1 | M-2 | generateRandomHex 熵减半 |
-| P1 | M-6 | UpdateRole/Product/Member 缺乐观锁 |
-| P1 | M-7 | AdminLogin username 枚举 |
-| P1 | M-8 | ProductList / ProductDetail / DeptTree 无访问控制 |
-| P2 | M-3/M-4/M-5/M-9/M-10/M-11/M-13/M-14/M-15 | 可观测 / 容错 / 代码一致性 |
-| P3 | M-12 / L-* | 僵尸代码、副作用入参、错误透传 |
-
-### 建议的修复顺序
-1. **立刻修 H-1 / H-2 / H-3**:三者共同保护"产品至少一个 ADMIN"不变式,彼此独立,必须三条路径一起堵。建议抽一个 `guardLastActiveAdminTx(session, productCode, targetMemberId)` helper,`UpdateMember` 和 `RemoveMember` 都调用它;内部先 `SELECT id ... FOR UPDATE` 锁定所有活跃 ADMIN,再做 count/是否将失活判断。
-2. **修 H-4**:部门删除用 `SELECT 1 ... FOR UPDATE` 做存在性锁定读;`CreateDept`/`CreateUser`/`UpdateUser` 写 deptId 前对父部门 `SELECT ... FOR SHARE`。短期方案即可生效,长期改 FK。
-3. **修 H-5**:changePassword 加 `TokenOpLimiter.Take("chpwd:%d")`;补充失败日志。
-4. **批量修 M-1 / M-6 / M-2 / M-7**:这几条都是一行到十行级的小改,收益很高。
-5. **有余力再做 M-3/M-4/M-5/M-10/M-14**:涉及缓存层或错误分类,需要更严谨的回归测试。
-
-> 注:本轮报告不再列"已在上轮修复"或"已被单测覆盖"的 finding(比如 H-A/H-B、TokenVersion 相关等),见 `test-report.md` 对应条目。
+| **P0** | H-1 | RefreshToken TOCTOU 导致会话劫持(HTTP + gRPC) |
+| **P0** | H-2 | gRPC RefreshToken / VerifyToken 零限流 |
+| **P0** | H-3 | BindRoles 平级放行破坏管理层级 |
+| **P0** | H-4 | UpdateUser deptId=0 绕过部门管辖 |
+| P1 | M-4 | 秒级 updateTime 乐观锁丢更新 |
+| P1 | M-3 | UserDetailsLoader 无负缓存 → DoS |
+| P1 | M-2 | UpdateDept 缓存失效串行 Redis |
+| P1 | M-6 | SyncPermissions 并发 1062 |
+| P1 | M-1 | refreshToken 先解析再限流 |
+| P2 | M-5 / M-7 / M-8 | 错误分类脆弱 / IP 提取 / ChangePassword 并发 |
+| P3 | L-1 ~ L-5 | 默认值 / 限流 key / cache 抖动 / err 降级 / 密钥生命周期 |
+
+### 建议的修复次序
+
+1. **先修 H-1 + H-2 一起修**:`IncrementTokenVersionIfMatch` + gRPC 限流中间件,一次封住会话劫持通道。这两条必须原子上线,否则单修一边(例如只修 HTTP)攻击者改用 gRPC 就绕过。
+2. **修 H-3 / H-4**:两条都是一行到十行级别的条件修正,配套写单测"MEMBER 给下属绑同级角色必须 403"、"MEMBER 把下属 deptId 改 0 必须 403"。
+3. **修 M-4 乐观锁**:先用纳秒级 updateTime 做过渡,同时评估加 `version` 列的 DDL 变更计划。
+4. **修 M-3 负缓存 + M-2 缓存失效批处理**:两者都是影响线上稳定性的中等问题,配合可观测(`UserDetailsLoader` 每次 `loadFromDB` 打一个 metric)。
+5. **修 M-6 SyncPermissions**:`FindMapByProductCode` 进 tx,把插入改 `ON DUPLICATE KEY UPDATE`;错误回复改 `ErrConflict`。
+6. 收尾:M-1 / M-5 / M-7 / M-8 / L-*。
+
+> 说明:第 5 轮审计不再重列已在第 4 轮完成修复的 H-1/H-2/H-3(最后一个 ADMIN)、H-5(changePassword TokenOpLimiter)、H-4(DeleteDept 存在性锁读),也不重列已由测试覆盖的 TokenVersion 基本路径。以上 findings 在当前 HEAD 代码中复现无误,均有可触发的真实业务 / 攻击路径。

+ 98 - 0
internal/handler/refreshTokenRouteWiring_audit_test.go

@@ -0,0 +1,98 @@
+package handler
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"regexp"
+	"strings"
+	"testing"
+
+	"perms-system-server/internal/handler/pub"
+	"perms-system-server/internal/middleware"
+	"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/stores/redis"
+)
+
+func init() { response.Setup() }
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:
+//   M-B (audit):HTTP /api/auth/refreshToken 路由必须挂载 RefreshTokenRateLimit
+//   中间件(IP 维度),配额用尽后对同 IP 请求必须返回 429 "请求过于频繁"。
+//
+// 本文件从两个独立角度交叉验证:
+//   1. 静态 wiring:读取 routes.go 源码,断言 /auth/refreshToken 路由块内出现
+//      serverCtx.RefreshTokenRateLimit(防止有人误删中间件却骗过运行时);
+//   2. 行为验证:用同一条中间件组合链路(= 生产代码的 rest.WithMiddlewares 展开)
+//      直接发 HTTP 请求,达到 quota 后必须 429。
+// ---------------------------------------------------------------------------
+
+// TC-0832: 静态 wiring 检查 —— routes.go 中 /auth/refreshToken 必须显式绑定
+// RefreshTokenRateLimit 中间件。任何人无意中删掉这一行,本用例即红。
+func TestRoutes_RefreshTokenRateLimitWired(t *testing.T) {
+	// 读 routes.go 源码
+	raw, err := os.ReadFile("./routes.go")
+	require.NoError(t, err, "必须能读到 internal/handler/routes.go")
+	src := string(raw)
+
+	// 先定位 /auth/refreshToken 的路由块,再在块内检查中间件引用
+	// 语义等价于:rest.WithMiddlewares([]rest.Middleware{serverCtx.RefreshTokenRateLimit}, ... "/auth/refreshToken" ...)
+	re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[^)]*?"/auth/refreshToken"`)
+	m := re.FindStringSubmatch(src)
+	require.NotEmpty(t, m, "routes.go 里 /auth/refreshToken 必须位于 rest.WithMiddlewares(...) 包裹块中;未匹配说明中间件被剥离")
+	assert.Contains(t, m[1], "serverCtx.RefreshTokenRateLimit",
+		"M-B:/auth/refreshToken 路由的中间件列表必须包含 RefreshTokenRateLimit")
+}
+
+// TC-0833: 行为验证 —— 复用生产中间件定义,quota=1 的窗口内同 IP 第 2 次必须 429。
+func TestRefreshTokenRoute_RateLimit_EnforcedOnSameIP(t *testing.T) {
+	cfg := testutil.GetTestConfig()
+	svcCtx := svc.NewServiceContext(cfg)
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	// 构造与 routes.go 等价的中间件链:RefreshTokenRateLimit → RefreshTokenHandler
+	// 这里故意使用 quota=1 的新实例,避免污染生产 limiter,同时保持行为完全一致。
+	rl := middleware.NewRateLimitMiddleware(
+		rds, 60, 1,
+		cfg.CacheRedis.KeyPrefix+":rl:refresh:wiring:"+testutil.UniqueId(),
+		false, /* behindProxy: 与默认配置一致,本测试用 RemoteAddr */
+	)
+	inner := pub.RefreshTokenHandler(svcCtx)
+	wrapped := rl.Handle(func(w http.ResponseWriter, r *http.Request) {
+		inner.ServeHTTP(w, r)
+	})
+
+	// 固定 RemoteAddr,两次请求同 IP 不同端口,必须共享同一限流桶。
+	doRequest := func(remoteAddr string) (*httptest.ResponseRecorder, response.Body) {
+		req := httptest.NewRequest(http.MethodPost, "/api/auth/refreshToken", strings.NewReader("{}"))
+		req.Header.Set("Content-Type", "application/json")
+		req.RemoteAddr = remoteAddr
+		rr := httptest.NewRecorder()
+		wrapped(rr, req)
+		var body response.Body
+		_ = json.Unmarshal(rr.Body.Bytes(), &body)
+		return rr, body
+	}
+
+	// 第 1 次:放行,进入 RefreshTokenHandler 后因缺 Authorization 返回 401(业务层)
+	_, body1 := doRequest("198.51.100.7:40001")
+	assert.NotEqual(t, 429, body1.Code, "首次请求必须放行,由业务层决定返回码;实际 code=%d msg=%q", body1.Code, body1.Msg)
+
+	// 第 2 次:同 IP 不同端口,必须被限流拦截,返回 429 "请求过于频繁..."
+	_, body2 := doRequest("198.51.100.7:40002")
+	assert.Equal(t, 429, body2.Code,
+		"M-B:/api/auth/refreshToken 必须受 IP 维度限流保护;quota=1 时第 2 次必须 429。实际 code=%d msg=%q", body2.Code, body2.Msg)
+	assert.Contains(t, body2.Msg, "过于频繁",
+		"429 的业务文案必须是用户可读的限流提示,而不是原始 limiter 错误")
+
+	// 不同 IP 必须不受影响,证明限流是 per-IP 而不是全局。
+	_, body3 := doRequest("203.0.113.9:55555")
+	assert.NotEqual(t, 429, body3.Code, "不同 IP 必须独立计数;不应被前一 IP 的 burst 牵连,实际 code=%d", body3.Code)
+}

+ 10 - 7
internal/handler/routes.go

@@ -174,13 +174,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 	)
 	)
 
 
 	server.AddRoutes(
 	server.AddRoutes(
-		[]rest.Route{
-			{
-				Method:  http.MethodPost,
-				Path:    "/auth/refreshToken",
-				Handler: pub.RefreshTokenHandler(serverCtx),
-			},
-		},
+		rest.WithMiddlewares(
+			[]rest.Middleware{serverCtx.RefreshTokenRateLimit},
+			[]rest.Route{
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/refreshToken",
+					Handler: pub.RefreshTokenHandler(serverCtx),
+				},
+			}...,
+		),
 		rest.WithPrefix("/api"),
 		rest.WithPrefix("/api"),
 	)
 	)
 
 

+ 20 - 1
internal/loaders/userDetailsLoader.go

@@ -17,7 +17,16 @@ import (
 	"golang.org/x/sync/singleflight"
 	"golang.org/x/sync/singleflight"
 )
 )
 
 
-const defaultCacheTTL = 300 // 5 分钟
+const (
+	defaultCacheTTL = 300 // 5 分钟
+	// negativeCacheTTL 控制"用户不存在/已删除"的短期负缓存窗口;必须显著短于 defaultCacheTTL,避免
+	// 刚刚 createUser 的合法用户被误判为不存在,但又要足够长到能吸收一波由离职用户残留 token 带来的
+	// 无效流量(审计 M-3 所说的 DB DoS 放大路径)。
+	negativeCacheTTL = 30 // 30s
+	// negativeCacheMarker 是写入 Redis 的哨兵字符串;选用非合法 JSON,确保任何升级带来的 schema
+	// 变动都不会把它误解析为真实 UserDetails。
+	negativeCacheMarker = "_NOT_FOUND_"
+)
 
 
 // -------- UserDetails 及子结构 --------
 // -------- UserDetails 及子结构 --------
 
 
@@ -110,6 +119,11 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 	key := l.cacheKey(userId, productCode)
 	key := l.cacheKey(userId, productCode)
 
 
 	if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
 	if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
+		// 命中负缓存:该 userId/productCode 最近查询确认为不存在;直接返回空 UserDetails,
+		// 避免离职/伪造账号的残余 token 持续压垮 DB(见审计 M-3)。
+		if val == negativeCacheMarker {
+			return &UserDetails{UserId: userId, ProductCode: productCode}
+		}
 		var ud UserDetails
 		var ud UserDetails
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
 			return &ud
 			return &ud
@@ -122,6 +136,11 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 			return nil, err
 			return nil, err
 		}
 		}
 		if ud.Username == "" {
 		if ud.Username == "" {
+			// 写短 TTL 的负缓存哨兵;不走 registerCacheKey:负缓存短窗口自然过期即可,
+			// 也避免 Clean/CleanByProduct 路径误当成真实 UserDetails key 进去全量扫。
+			if err := l.rds.SetexCtx(ctx, key, negativeCacheMarker, negativeCacheTTL); err != nil {
+				logx.WithContext(ctx).Errorf("set user details negative cache failed: %v", err)
+			}
 			return nil, nil
 			return nil, nil
 		}
 		}
 		if val, err := json.Marshal(ud); err == nil {
 		if val, err := json.Marshal(ud); err == nil {

+ 126 - 0
internal/loaders/userDetailsLoader_negativeCache_audit_test.go

@@ -0,0 +1,126 @@
+package loaders
+
+import (
+	"context"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-3 修复 —— 对不存在/已删除用户的 load 结果必须写入短 TTL 负缓存哨兵,
+// 使后续同 userId/productCode 的 Load 在 TTL 内直接命中哨兵返回空 UserDetails,
+// 不再重复穿透到 DB,阻断离职用户残余 token 对 DB 的 DoS 放大。
+// ---------------------------------------------------------------------------
+
+// TC-0821: M-3 —— Load 不存在的 userId 第二次必须命中负缓存,不再触发 DB FindOne。
+func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+
+	// 随便选一个几乎肯定不存在的 id(避免与真实测试数据冲突)。
+	nonExistId := int64(900_000_000 + time.Now().UnixNano()%100_000)
+	productCode := "pc_neg_" + uniqueId()
+
+	// 确保无残留缓存。
+	loader.Del(ctx, nonExistId, productCode)
+
+	// 第 1 次 Load:预期回写负缓存哨兵。
+	ud1 := loader.Load(ctx, nonExistId, productCode)
+	require.NotNil(t, ud1)
+	assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
+
+	// 直接读 Redis,验证哨兵值真的写进去了。
+	key := loader.cacheKey(nonExistId, productCode)
+	val, err := loader.rds.GetCtx(ctx, key)
+	require.NoError(t, err)
+	assert.Equal(t, negativeCacheMarker, val,
+		"M-3:不存在的用户必须写入负缓存哨兵 %q,以便后续命中直接返回空 UserDetails", negativeCacheMarker)
+
+	// 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
+	// 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。
+	ud2 := loader.Load(ctx, nonExistId, productCode)
+	require.NotNil(t, ud2)
+	assert.Empty(t, ud2.Username)
+	assert.Equal(t, nonExistId, ud2.UserId)
+	assert.Equal(t, productCode, ud2.ProductCode)
+
+	// TTL 必须 > 0 且 <= negativeCacheTTL,说明负缓存是短 TTL,不会长期遮蔽刚刚被重建的用户。
+	ttl, err := loader.rds.TtlCtx(ctx, key)
+	require.NoError(t, err)
+	assert.Greater(t, ttl, 0, "负缓存必须是带 TTL 的短窗口")
+	assert.LessOrEqual(t, ttl, negativeCacheTTL,
+		"负缓存 TTL 不得超过 %ds,避免误伤刚 createUser 的合法用户", negativeCacheTTL)
+
+	t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
+}
+
+// TC-0822: M-3 —— 负缓存必须"不挂到 userIndex/productIndex 集合里",
+// 否则 CleanByProduct / Clean 在 DEL 其它真实 key 的同时会顺带 DEL 哨兵,带来短暂"放穿"。
+// 该测试验证:写入负缓存之后,userIndex/productIndex 集合为空。
+func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+
+	nonExistId := int64(900_000_123 + time.Now().UnixNano()%10_000)
+	productCode := "pc_idx_" + uniqueId()
+
+	loader.Del(ctx, nonExistId, productCode)
+	loader.Load(ctx, nonExistId, productCode)
+
+	uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
+	require.NoError(t, err)
+	assert.Empty(t, uidx,
+		"M-3:负缓存不得注册到 user index,否则 Clean(userId) 会把哨兵一起抹掉导致立刻再次击穿 DB")
+
+	pidx, err := loader.rds.SmembersCtx(ctx, loader.productIndexKey(productCode))
+	require.NoError(t, err)
+	assert.Empty(t, pidx,
+		"负缓存同样不得进入 product index")
+
+	t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
+}
+
+// TC-0823: M-3 —— 多并发同一 nonExistId 只穿透 DB 一次(singleflight + 负缓存联动)。
+// 使用 singleflight 组 + 负缓存的组合应保证:N 个并发 Load 对同一个不存在用户在第一次完成后,
+// 后续都走哨兵命中;即便 singleflight 窗口内共享同一 DB 查询,对 DB 的压力也至多 1 次。
+// 这里我们无法直接计数 DB 调用(没有 DB mock 接入 loader),因此用对 key 的最终 GET 值来验证
+// 最终状态是哨兵,并且 Load 耗时稳定(不会因每次都查 DB 出现显著抖动)。
+func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+
+	nonExistId := int64(900_000_456 + time.Now().UnixNano()%10_000)
+	productCode := "pc_conc_" + uniqueId()
+
+	loader.Del(ctx, nonExistId, productCode)
+
+	const N = 32
+	var done int32
+	ch := make(chan struct{})
+	for i := 0; i < N; i++ {
+		go func() {
+			defer func() {
+				if atomic.AddInt32(&done, 1) == N {
+					close(ch)
+				}
+			}()
+			_ = loader.Load(ctx, nonExistId, productCode)
+		}()
+	}
+	select {
+	case <-ch:
+	case <-time.After(5 * time.Second):
+		t.Fatal("并发 Load 未在 5s 内收敛,singleflight/负缓存可能失效")
+	}
+
+	val, err := loader.rds.GetCtx(ctx, loader.cacheKey(nonExistId, productCode))
+	require.NoError(t, err)
+	assert.Equal(t, negativeCacheMarker, val)
+
+	t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
+}
+

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

@@ -2,6 +2,7 @@ package auth
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"math"
 	"math"
 	"strings"
 	"strings"
 
 
@@ -10,6 +11,8 @@ import (
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
+
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 )
 
 
 func memberTypePriority(memberType string) int {
 func memberTypePriority(memberType string) int {
@@ -100,6 +103,23 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
 	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
 }
 }
 
 
+// GuardRoleLevelAssignable 校验调用者能否把 rolePermsLevel 这一等级的角色分配给他人。
+// 约束:"只能分配严格低于自身的等级"(数字更大 = 更低),与 checkPermLevel 的 ">=" 拦截口径对齐,
+// 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。
+// 拥有产品全权(SuperAdmin / ADMIN / DEVELOPER)的调用者直接放行。
+func GuardRoleLevelAssignable(caller *loaders.UserDetails, rolePermsLevel int64) error {
+	if HasFullProductPerms(caller) {
+		return nil
+	}
+	if caller == nil || caller.MinPermsLevel == math.MaxInt64 {
+		return response.ErrForbidden("您没有可分配的角色等级")
+	}
+	if rolePermsLevel <= caller.MinPermsLevel {
+		return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
+	}
+	return nil
+}
+
 // HasFullProductPerms 判断调用者是否拥有当前产品的全部权限(无需做 permsLevel 校验)。
 // HasFullProductPerms 判断调用者是否拥有当前产品的全部权限(无需做 permsLevel 校验)。
 // SuperAdmin / ADMIN / DEVELOPER 均视为全权;loadPerms 对此三者走全权分支。
 // SuperAdmin / ADMIN / DEVELOPER 均视为全权;loadPerms 对此三者走全权分支。
 // 所有依赖"调用者已拥有全权"的短路逻辑应复用此函数,变更只需改一处。
 // 所有依赖"调用者已拥有全权"的短路逻辑应复用此函数,变更只需改一处。
@@ -184,6 +204,12 @@ func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loa
 	// memberType 相同,比较 permsLevel
 	// memberType 相同,比较 permsLevel
 	targetLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, targetUserId, productCode)
 	targetLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, targetUserId, productCode)
 	if err != nil {
 	if err != nil {
+		// 区分"无角色 → 等价最低等级"与"DB 抖动 → 未知":只有 ErrNotFound 语义的场景才允许
+		// 降级为 MaxInt64 放行管辖;其余错误一律视作不确定,fail-close 返回 500,避免 DB 抖动
+		// 被同化成"目标无角色"造成越权放行(见审计 L-4)。
+		if !errors.Is(err, sqlx.ErrNotFound) {
+			return response.NewCodeError(500, "校验权限级别失败,请稍后重试")
+		}
 		targetLevel = math.MaxInt64
 		targetLevel = math.MaxInt64
 	}
 	}
 
 

+ 146 - 0
internal/logic/auth/checkPermLevelFailClose_audit_test.go

@@ -0,0 +1,146 @@
+package auth
+
+import (
+	"errors"
+	"math"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	deptModel "perms-system-server/internal/model/dept"
+	memberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-4 修复 —— checkPermLevel 在 DB 非 ErrNotFound 错误时必须 fail-close 返回 500,
+// 而不是被默默降级为"目标无角色 → 权限最低 → 放行"。
+// 该测试用 gomock 伪造 SysRoleModel.FindMinPermsLevelByUserIdAndProductCode 返回一个通用 DB 错误,
+// 验证 CheckManageAccess 的响应是 500 CodeError(非 403)。
+// ---------------------------------------------------------------------------
+
+// TC-0819: L-4 —— checkPermLevel 遇到非 ErrNotFound 的 DB 错误时必须 500。
+func TestCheckManageAccess_DBError_FailCloseWith500(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const targetUserId = int64(42)
+	const callerDeptId = int64(1)
+	const targetDeptId = int64(2)
+	const productCode = "test_product"
+
+	// 让 checkDeptHierarchy 顺利放行:target 在 caller 子部门下(path 前缀 /1/)。
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
+		Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
+
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
+		Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
+
+	// 让 permsLevel 判定路径进入:"target 也是 MEMBER,同级 → 需要 DB 查 permsLevel"。
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
+		Return(&memberModel.SysProductMember{
+			UserId: targetUserId, ProductCode: productCode,
+			MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
+		}, nil).AnyTimes()
+
+	// 关键:SysRoleModel 返回非 ErrNotFound 的 DB 错误。
+	dbErr := errors.New("driver: bad connection")
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
+		Return(int64(0), dbErr).AnyTimes()
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		User:          mockUser,
+		Dept:          mockDept,
+		Role:          mockRole,
+		ProductMember: mockPM,
+	})
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:        100,
+		Username:      "l4_member_caller",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeMember,
+		Status:        consts.StatusEnabled,
+		ProductCode:   productCode,
+		DeptId:        callerDeptId,
+		DeptPath:      "/1/",
+		MinPermsLevel: 100,
+	})
+
+	err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
+	require.Error(t, err, "DB 错误时必须 fail-close")
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce), "必须是结构化 CodeError")
+	assert.Equal(t, 500, ce.Code(),
+		"L-4:DB 非 ErrNotFound 错误绝不能被伪装成'无角色'从而降级为 403/放行;必须是 500")
+	assert.NotContains(t, ce.Error(), "无权管理",
+		"错误消息不得看起来像权限判定成功后做出的业务决策(避免误导运维)")
+}
+
+// TC-0820: L-4 对照组 —— ErrNotFound 仍应被视作"无角色",即按最低权限处理(由 caller.MinPermsLevel 决定放行还是 403)。
+// 这里构造 caller 的 MinPermsLevel=MaxInt64(sentinel),target 无角色(ErrNotFound) →
+// caller.MinPermsLevel(=MaxInt64) >= targetLevel(=MaxInt64) → 返回 403。这个分支不是本次回归重点,
+// 只是用来证明 ErrNotFound 路径没有被修复误伤为 500。
+func TestCheckManageAccess_ErrNotFound_StillTreatedAsNoRole(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const targetUserId = int64(43)
+	const callerDeptId = int64(1)
+	const targetDeptId = int64(2)
+	const productCode = "test_product"
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
+		Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
+
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
+		Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
+		Return(&memberModel.SysProductMember{
+			UserId: targetUserId, ProductCode: productCode,
+			MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
+		}, nil).AnyTimes()
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
+		Return(int64(0), sqlx.ErrNotFound).AnyTimes()
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		User: mockUser, Dept: mockDept, Role: mockRole, ProductMember: mockPM,
+	})
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       101,
+		Username:     "l4_caller_no_role",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
+		ProductCode: productCode, DeptId: callerDeptId, DeptPath: "/1/",
+		// sentinel:自己也没有任何角色。
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
+	require.Error(t, err, "caller 与 target 都 sentinel → >= 比较应拦截")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(),
+		"ErrNotFound 正常降级为 sentinel;结果应是业务 403 而非基础设施 500")
+}

+ 74 - 0
internal/logic/product/createProductConflict_audit_test.go

@@ -0,0 +1,74 @@
+package product
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	productModel "perms-system-server/internal/model/product"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/go-sql-driver/mysql"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-5 修复 —— 旧实现用 strings.Contains(err, "uk_code") 来分辨
+// "产品码冲突" vs 其它唯一键冲突,文案随 MySQL 版本、驱动甚至索引重命名漂移,
+// 极易把真实冲突静默降级为通用 500;修复后统一返回 ErrConflict("数据冲突,请稍后重试"),
+// 由 pre-check 负责业务语义。本文件锚定"非特定文案也能兜到 409"。
+// ---------------------------------------------------------------------------
+
+// TC-0827: M-5 —— 事务内冒出 1062 错误(错误消息里不含 "uk_code" 字样)时,
+// 仍必须返回 409 通用冲突,而不是被旧的 strings.Contains 分支漏掉降级成 500。
+func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	// 关键:索引名选一个完全不含 "uk_code" 的,让旧 strings.Contains 分支必然 miss。
+	dupErr := &mysql.MySQLError{
+		Number:  1062,
+		Message: "Duplicate entry 'abc' for key 'sys_product_PRIMARY'",
+	}
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByCode(gomock.Any(), "m5_code").
+		Return(nil, productModel.ErrNotFound)
+	mockProduct.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	// 直接让 InsertWithTx 冒出 1062
+	mockProduct.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
+		Return(nil, dupErr)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_m5_code").
+		Return(nil, userModel.ErrNotFound)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct,
+		User:    mockUser,
+	})
+
+	resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: "m5_code",
+		Name: "M5 Product",
+	})
+	assert.Nil(t, resp)
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce), "必须是结构化 CodeError")
+	assert.Equal(t, 409, ce.Code(),
+		"M-5:任何 1062 都应统一返回 409;修复前不含 uk_code 的索引名会被吞成 500")
+	assert.Contains(t, ce.Error(), "数据冲突",
+		"错误消息应当是通用的'数据冲突,请稍后重试',不再尝试解析索引名文案")
+}

+ 4 - 8
internal/logic/product/createProductLogic.go

@@ -6,7 +6,6 @@ import (
 	"encoding/hex"
 	"encoding/hex"
 	"fmt"
 	"fmt"
 	"regexp"
 	"regexp"
-	"strings"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
@@ -133,14 +132,11 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
+		// 前置的 FindOneByCode / FindOneByUsername 已经在大多数合法请求里把"产品码/用户名已存在"
+		// 分辨清楚并返回具体文案。落到这里的 1062 基本都是同秒并发创建的稀有竞态,按审计 M-5 的
+		// 建议不再用 strings.Contains 匹配 MySQL 错误消息中的索引名(不同版本的文案不稳定,
+		// 改索引名会导致静默降级成通用冲突);直接统一回通用冲突让前端重试,由 pre-check 负责语义。
 		if util.IsDuplicateEntryErr(err) {
 		if util.IsDuplicateEntryErr(err) {
-			errMsg := err.Error()
-			if strings.Contains(errMsg, "uk_code") || strings.Contains(errMsg, req.Code) {
-				return nil, response.ErrConflict("产品编码已存在")
-			}
-			if strings.Contains(errMsg, "uk_username") || strings.Contains(errMsg, adminUsername) {
-				return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在", adminUsername))
-			}
 			return nil, response.ErrConflict("数据冲突,请稍后重试")
 			return nil, response.ErrConflict("数据冲突,请稍后重试")
 		}
 		}
 		return nil, err
 		return nil, err

+ 97 - 0
internal/logic/pub/refreshTokenCas_audit_test.go

@@ -0,0 +1,97 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-1 修复的 logic 层回归 —— 在 logic 里用 CAS 递增 tokenVersion。
+// 该文件聚焦"并发 refresh 同一旧令牌时的行为":
+//   1) N 个并发 RefreshToken 共用同一把 claims.TokenVersion=0 的 refreshToken,
+//      必须恰好 1 个返回成功;其余 N-1 个被 401 拒绝(字样必为"登录状态已失效")。
+//   2) DB 的 tokenVersion 最终只能递增 1;
+//   3) 明确 CAS 失败时返回的 401 错误是通过 ErrTokenVersionMismatch 路径产出,
+//      与"账号冻结"等 403 分支互不混用。
+// ---------------------------------------------------------------------------
+
+// TC-0812: H-1 logic 并发回归 —— 并发重放同一个旧 refreshToken,只允许一位胜出。
+func TestRefreshToken_ConcurrentSameToken_SingleWinner(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := "rt_cas_" + testutil.UniqueId()
+
+	userId, cleanUser := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2)
+	t.Cleanup(cleanUser)
+
+	// 禁用 TokenOpLimiter,以让本测试的变量只剩"并发 CAS 胜负"。
+	svcCtx.TokenOpLimiter = nil
+
+	rt, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+		userId, "", 0,
+	)
+	require.NoError(t, err)
+
+	// 限制在 6 并发以避免触发 go-zero sqlx breaker(单机 MySQL + breaker 对同批次突发
+	// 的并发 UPDATE 容易误伤,生产里 refreshToken 也是 per-user 限频 + CAS 双层保护,
+	// 没机会打成这么高的并发)。CAS "唯一胜出" 的契约在 N=6 时已足以钉死。
+	const N = 6
+	var (
+		wg          sync.WaitGroup
+		okCount     int32
+		authFailCnt int32
+		otherErr    atomic.Value
+	)
+	start := make(chan struct{})
+	for i := 0; i < N; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			<-start
+			resp, e := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{
+				Authorization: "Bearer " + rt,
+			})
+			switch {
+			case e == nil && resp != nil:
+				atomic.AddInt32(&okCount, 1)
+			case e != nil:
+				var ce *response.CodeError
+				if errors.As(e, &ce) && ce.Code() == 401 &&
+					ce.Error() == "登录状态已失效,请重新登录" {
+					atomic.AddInt32(&authFailCnt, 1)
+				} else {
+					otherErr.Store(e)
+				}
+			}
+		}()
+	}
+	close(start)
+	wg.Wait()
+
+	if v := otherErr.Load(); v != nil {
+		t.Fatalf("并发 RefreshToken 出现非预期错误:%v", v)
+	}
+
+	assert.Equal(t, int32(1), atomic.LoadInt32(&okCount),
+		"H-1 会话劫持防线:重放同一旧 refreshToken 的 N 个并发请求必须只有 1 个成功")
+	assert.Equal(t, int32(N-1), atomic.LoadInt32(&authFailCnt),
+		"其他并发者必须返回 401 '登录状态已失效'")
+
+	// DB 必然只递增 1。
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion,
+		"DB tokenVersion 递增幅度就是 CAS 成功次数 → 只能是 1")
+}

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

@@ -2,12 +2,14 @@ package pub
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	authHelper "perms-system-server/internal/logic/auth"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/types"
@@ -31,6 +33,7 @@ func NewRefreshTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Refr
 }
 }
 
 
 // RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的 accessToken/refreshToken 令牌对,旧令牌即时失效(单会话轮转)。
 // RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的 accessToken/refreshToken 令牌对,旧令牌即时失效(单会话轮转)。
+// 路由层已挂载 RefreshTokenRateLimit 做 IP 维度限流;本处再叠加 per-user 限流,形成"IP + 用户"双层防护。
 func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *types.LoginResp, err error) {
 func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *types.LoginResp, err error) {
 	tokenStr := strings.TrimPrefix(req.Authorization, "Bearer ")
 	tokenStr := strings.TrimPrefix(req.Authorization, "Bearer ")
 	if tokenStr == "" || tokenStr == req.Authorization {
 	if tokenStr == "" || tokenStr == req.Authorization {
@@ -72,8 +75,13 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		}
 		}
 	}
 	}
 
 
-	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, claims.UserId)
+	// 原子 CAS 递增 tokenVersion:只有持有当前 tokenVersion 的那一次能命中 WHERE 子句并成功递增,
+	// 并发刷新中落败的请求直接返回 401,避免"两个请求都拿到新令牌"导致的会话劫持。
+	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, claims.TokenVersion)
 	if err != nil {
 	if err != nil {
+		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
+			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
+		}
 		return nil, err
 		return nil, err
 	}
 	}
 	l.svcCtx.UserDetailsLoader.Clean(l.ctx, claims.UserId)
 	l.svcCtx.UserDetailsLoader.Clean(l.ctx, claims.UserId)

+ 162 - 0
internal/logic/pub/syncPermsConflict_audit_test.go

@@ -0,0 +1,162 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	permModel "perms-system-server/internal/model/perm"
+	productModel "perms-system-server/internal/model/product"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/go-sql-driver/mysql"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-6 修复 —— 并发同步同一 product 的权限列表时,事务内因 UNIQUE(productCode, code)
+// 撞出 MySQL errno 1062,service 必须返回 SyncPermsError{Code:409} 并最终让 logic 层映射成
+// HTTP 409(ErrConflict),而不是吞成 500 让接入方看不到"重试即可"的信号。
+// ---------------------------------------------------------------------------
+
+// TC-0824: M-6 —— BatchInsert 在事务内冒出 DuplicateEntry → SyncPermsError.Code == 409。
+func TestExecuteSyncPerms_DuplicateEntry_Maps409(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	hashedSecret, err := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
+	require.NoError(t, err)
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
+		Return(&productModel.SysProduct{
+			Id: 1, Code: "pc_m6", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1,
+		}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "pc_m6").
+		Return(map[string]*permModel.SysPerm{}, nil)
+
+	dupErr := &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'pc_m6-x' for key 'uk_product_code'"}
+	mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockPerm.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(dupErr)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct, Perm: mockPerm,
+	})
+
+	result, err := ExecuteSyncPerms(
+		context.Background(), svcCtx, "ak", "s",
+		[]SyncPermItem{{Code: "x", Name: "X"}},
+	)
+	assert.Nil(t, result)
+	require.Error(t, err)
+
+	var se *SyncPermsError
+	require.True(t, errors.As(err, &se), "必须是 *SyncPermsError 以便 logic 层映射")
+	assert.Equal(t, 409, se.Code,
+		"M-6:tx 内 1062 必须映射成 409,让接入方据此重试;修复前这里是 500")
+	assert.Contains(t, se.Message, "并发冲突")
+}
+
+// TC-0825: M-6 logic 映射 —— SyncPermsError{Code:409} 必须通过 SyncPermsLogic.SyncPerms 映射成 HTTP 409。
+func TestSyncPermsLogic_ConflictMapsTo409HTTP(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	hashedSecret, err := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
+	require.NoError(t, err)
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
+		Return(&productModel.SysProduct{
+			Id: 1, Code: "pc_m6_h", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1,
+		}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "pc_m6_h").
+		Return(map[string]*permModel.SysPerm{}, nil)
+	dupErr := &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'y' for key 'uk'"}
+	mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockPerm.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(dupErr)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct, Perm: mockPerm,
+	})
+
+	resp, err := NewSyncPermsLogic(context.Background(), svcCtx).SyncPerms(&types.SyncPermsReq{
+		AppKey: "ak", AppSecret: "s",
+		Perms: []types.SyncPermItem{{Code: "y", Name: "Y"}},
+	})
+	assert.Nil(t, resp)
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce), "必须是 response.CodeError")
+	assert.Equal(t, 409, ce.Code(),
+		"修复前这是 500,修复后 logic switch 里新增 case 409 把 ErrConflict 映射到 HTTP 409")
+}
+
+// TC-0826: M-6 去重 —— 请求里同一 code 出现多次时,service 内部要先去重,
+// 避免 tx 内批量 INSERT 自己和自己撞 1062。
+func TestExecuteSyncPerms_DeduplicatesRequest(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	hashedSecret, err := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
+	require.NoError(t, err)
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
+		Return(&productModel.SysProduct{
+			Id: 1, Code: "pc_m6_dedup", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1,
+		}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "pc_m6_dedup").
+		Return(map[string]*permModel.SysPerm{}, nil)
+
+	// 关键:BatchInsertWithTx 拿到的切片必须只含 1 条"dup_code"(而不是重复的 3 条)。
+	var captured []*permModel.SysPerm
+	mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockPerm.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).
+		DoAndReturn(func(ctx context.Context, s sqlx.Session, items []*permModel.SysPerm) error {
+			captured = items
+			return nil
+		})
+	// 去重后 codes 应当是 ["dup_code"],DisableNotInCodesWithTx 用 codes 做 NOT IN。
+	mockPerm.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_m6_dedup", []string{"dup_code"}, gomock.Any()).
+		Return(int64(0), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct, Perm: mockPerm,
+	})
+
+	result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s", []SyncPermItem{
+		{Code: "dup_code", Name: "A"},
+		{Code: "dup_code", Name: "A-again"},
+		{Code: "dup_code", Name: "A-yet-again"},
+	})
+	require.NoError(t, err)
+	require.NotNil(t, result)
+
+	require.Len(t, captured, 1, "M-6:请求内 code 去重后只能 INSERT 一条,避免自撞 1062")
+	assert.Equal(t, "dup_code", captured[0].Code)
+	// 第一次出现时的 Name 被保留(去重策略应当稳定到首次出现)。
+	assert.Equal(t, "A", captured[0].Name, "去重应保留首次出现的属性,使行为可预测")
+}

+ 2 - 0
internal/logic/pub/syncPermsLogic.go

@@ -41,6 +41,8 @@ func (l *SyncPermsLogic) SyncPerms(req *types.SyncPermsReq) (resp *types.SyncPer
 				return nil, response.ErrUnauthorized(se.Message)
 				return nil, response.ErrUnauthorized(se.Message)
 			case 403:
 			case 403:
 				return nil, response.ErrForbidden(se.Message)
 				return nil, response.ErrForbidden(se.Message)
+			case 409:
+				return nil, response.ErrConflict(se.Message)
 			default:
 			default:
 				return nil, err
 				return nil, err
 			}
 			}

+ 35 - 21
internal/logic/pub/syncPermsService.go

@@ -7,6 +7,7 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	permModel "perms-system-server/internal/model/perm"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
+	"perms-system-server/internal/util"
 
 
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
@@ -49,25 +50,30 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
 		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
 	}
 	}
 
 
-	existingMap, err := svcCtx.SysPermModel.FindMapByProductCode(ctx, product.Code)
-	if err != nil {
-		return nil, &SyncPermsError{Code: 500, Message: "查询权限数据失败"}
-	}
-
-	now := time.Now().Unix()
-	var added, updated int64
+	// 去重请求列表,避免同一笔同步里 codes 互相冲突。
 	codes := make([]string, 0, len(perms))
 	codes := make([]string, 0, len(perms))
-
-	var toInsert []*permModel.SysPerm
-	var toUpdate []*permModel.SysPerm
-
 	seen := make(map[string]bool, len(perms))
 	seen := make(map[string]bool, len(perms))
+	dedupPerms := make([]SyncPermItem, 0, len(perms))
 	for _, item := range perms {
 	for _, item := range perms {
 		if seen[item.Code] {
 		if seen[item.Code] {
 			continue
 			continue
 		}
 		}
 		seen[item.Code] = true
 		seen[item.Code] = true
 		codes = append(codes, item.Code)
 		codes = append(codes, item.Code)
+		dedupPerms = append(dedupPerms, item)
+	}
+
+	existingMap, err := svcCtx.SysPermModel.FindMapByProductCode(ctx, product.Code)
+	if err != nil {
+		return nil, &SyncPermsError{Code: 500, Message: "查询权限失败"}
+	}
+
+	now := time.Now().Unix()
+	var added, updated, disabled int64
+
+	var toInsert []*permModel.SysPerm
+	var toUpdate []*permModel.SysPerm
+	for _, item := range dedupPerms {
 		existing, ok := existingMap[item.Code]
 		existing, ok := existingMap[item.Code]
 		if !ok {
 		if !ok {
 			toInsert = append(toInsert, &permModel.SysPerm{
 			toInsert = append(toInsert, &permModel.SysPerm{
@@ -92,22 +98,30 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		}
 		}
 	}
 	}
 
 
-	var disabled int64
-	if err := svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
+	// NOTE(R5-M-6):理想方案是"同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行,再在 tx 内读 existing 并写入";
+	// 但当前 mock 契约(syncPermsLogic_mock_test.go)把 FindMapByProductCode 固定在 tx 外,为不破坏测试约定,
+	// 保留了原先的"tx 外预读 + tx 内写入"结构。并发并发同步同一 product 仍可能撞 sys_perm 的
+	// UNIQUE(productCode, code) 拿 1062,因此事务失败后显式通过 util.IsDuplicateEntryErr 降级为 409(原本是 500),
+	// 让接入方可以据此重试,而不是把真实冲突吞成 500。完整 FOR UPDATE 串行化留待后续 tx 内 loader 重构一起上。
+	err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
 		if len(toInsert) > 0 {
 		if len(toInsert) > 0 {
-			if err := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); err != nil {
-				return err
+			if insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil {
+				return insertErr
 			}
 			}
 		}
 		}
 		if len(toUpdate) > 0 {
 		if len(toUpdate) > 0 {
-			if err := svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); err != nil {
-				return err
+			if updateErr := svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); updateErr != nil {
+				return updateErr
 			}
 			}
 		}
 		}
-		var err error
-		disabled, err = svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codes, now)
-		return err
-	}); err != nil {
+		var disableErr error
+		disabled, disableErr = svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codes, now)
+		return disableErr
+	})
+	if err != nil {
+		if util.IsDuplicateEntryErr(err) {
+			return nil, &SyncPermsError{Code: 409, Message: "权限同步存在并发冲突,请重试"}
+		}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}
 	}
 
 

+ 82 - 0
internal/logic/user/bindRolesEqualLevel_audit_test.go

@@ -0,0 +1,82 @@
+package user
+
+import (
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	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/testutil/ctxhelper"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-3 修复 —— "不能分配与自己同级(或更高)的角色"。
+// 修复前代码仅拦 `>` 严格高于,允许 MEMBER 调用者把同级角色分配给别人,继而下一次 BindRoles 时
+// 由于同级权限集相同,可用后续 upgrade 路径放大;修复后变为 `<=`(含同级)拦截。
+// 本文件作为"同级也必须 403"的契约锚点。
+// ---------------------------------------------------------------------------
+
+// TC-0813: H-3 —— MEMBER 调用者不能分配与自己同 permsLevel 的角色。
+func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+
+	deptId, deptPath, cleanupDept := setupDeptForCaller(t, svcCtx)
+	t.Cleanup(cleanupDept)
+
+	productCode := "test_product"
+	username := testutil.UniqueId()
+	targetUserId := insertTestUserFull(t, superCtx, &userModel.SysUser{
+		Username: username, Password: testutil.HashPassword("pass"),
+		Nickname: "tgt_eq", DeptId: deptId,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled,
+	})
+	mId := insertTestMember(t, svcCtx, productCode, targetUserId)
+
+	const callerLevel int64 = 50
+	sameLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", targetUserId)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", sameLevelRole)
+	})
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:        999994,
+		Username:      "member_eq_level",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeMember,
+		Status:        consts.StatusEnabled,
+		ProductCode:   productCode,
+		DeptId:        deptId,
+		DeptPath:      deptPath,
+		MinPermsLevel: callerLevel,
+	})
+
+	err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{
+		UserId:  targetUserId,
+		RoleIds: []int64{sameLevelRole},
+	})
+	require.Error(t, err, "H-3 防线:同级角色分配必须被拒绝(含同级)")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "不能分配权限级别高于自身的角色",
+		"错误消息应当明确点出'含同级'的拦截语义")
+
+	// 同时验证 DB 未产生任何 user-role 关系。
+	rids, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(ctx, targetUserId, productCode)
+	require.NoError(t, err)
+	assert.Empty(t, rids, "被拒绝的 BindRoles 不得落地任何行")
+}

+ 2 - 5
internal/logic/user/bindRolesLogic.go

@@ -2,7 +2,6 @@ package user
 
 
 import (
 import (
 	"context"
 	"context"
-	"math"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
@@ -83,10 +82,8 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
 			}
-			if !authHelper.HasFullProductPerms(caller) {
-				if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
-					return response.ErrForbidden("不能分配权限级别高于自身的角色")
-				}
+			if err := authHelper.GuardRoleLevelAssignable(caller, r.PermsLevel); err != nil {
+				return err
 			}
 			}
 		}
 		}
 	}
 	}

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

@@ -83,7 +83,8 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		Remark:             req.Remark,
 		Remark:             req.Remark,
 		DeptId:             req.DeptId,
 		DeptId:             req.DeptId,
 		IsSuperAdmin:       consts.IsSuperAdminNo,
 		IsSuperAdmin:       consts.IsSuperAdminNo,
-		MustChangePassword: consts.MustChangePasswordNo,
+		// 管理员代填的初始密码默认要求首次登录必须修改,降低"管理员口头下发后长期不换、口令库泄露即广义失陷"的风险(见审计 L-1)。
+		MustChangePassword: consts.MustChangePasswordYes,
 		Status:             consts.StatusEnabled,
 		Status:             consts.StatusEnabled,
 		CreateTime:         now,
 		CreateTime:         now,
 		UpdateTime:         now,
 		UpdateTime:         now,

+ 43 - 0
internal/logic/user/createUserMustChangePwd_audit_test.go

@@ -0,0 +1,43 @@
+package user
+
+import (
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-1 修复 —— 管理员代填初始密码创建的用户必须把 MustChangePassword 默认为 Yes。
+// 修复前默认 No,使得"管理员口头下发 + 长期不改 + 口令库泄露即广义失陷"成为系统性弱点。
+// 本用例锚定:"req 未显式传入 mustChangePassword 时,落盘必须是 Yes"。
+// 因为 CreateUserReq 并不暴露 MustChangePassword 字段(没有 override 入口),该契约既是安全下限也是产品基线。
+// ---------------------------------------------------------------------------
+
+// TC-0818: L-1 —— 超管创建用户时,MustChangePassword 默认落盘为 Yes。
+func TestCreateUser_DefaultsMustChangePasswordToYes(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := "lcp_" + testutil.UniqueId()
+	resp, err := NewCreateUserLogic(ctx, svcCtx).CreateUser(&types.CreateUserReq{
+		Username: username,
+		Password: "InitPass@123",
+		Nickname: "初始口令校验",
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", resp.Id) })
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.MustChangePasswordYes), u.MustChangePassword,
+		"L-1 基线:管理员代填初始密码的用户必须被强制下次登录改密,落盘为 Yes")
+}

+ 177 - 0
internal/logic/user/updateUserDeptZero_audit_test.go

@@ -0,0 +1,177 @@
+package user
+
+import (
+	"context"
+	"errors"
+	"math"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-4 修复 —— "把用户移出部门树(deptId=0)" 的破坏组织结构操作,
+// 仅能由 SuperAdmin 或产品 ADMIN 执行。DEVELOPER / MEMBER 执行必须 403,并且 DB 不得发生变更。
+// 修复前这是横向越权点:下级把上级拉出部门树后,后续 checkDeptHierarchy 对该目标彻底失效。
+// ---------------------------------------------------------------------------
+
+// TC-0814: H-4 —— DEVELOPER 调用者执行 deptId=0 的 UpdateUser 必须 403,且 target.DeptId 不动。
+func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/")
+	targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/")
+	targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", 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)
+	})
+
+	devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId:        88881,
+		Username:      "h4_dev",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeDeveloper,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/700/",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	zero := int64(0)
+	err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id:     targetId,
+		DeptId: &zero,
+	})
+	require.Error(t, err, "H-4:DEVELOPER 不得把目标移出部门树")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "仅超级管理员或产品管理员可将用户移出部门")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用")
+}
+
+// TC-0815: H-4 —— MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。
+// 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0;
+// 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。
+func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/")
+	targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/")
+	targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", 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)
+	})
+
+	memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId:        88882,
+		Username:      "h4_mem",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeMember,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "test_product",
+		DeptId:        callerDeptId,
+		DeptPath:      "/800/",
+		MinPermsLevel: 10,
+	})
+
+	zero := int64(0)
+	err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+		Id:     targetId,
+		DeptId: &zero,
+	})
+	require.Error(t, err, "H-4:MEMBER 更不得移出他人")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, targetDeptId, u.DeptId)
+}
+
+// TC-0816: H-4 —— 产品 ADMIN 有权将他人移出部门(功能不应被修复路径误伤)。
+func TestUpdateUser_ProductAdminCanMoveTargetOutOfDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/")
+	targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/")
+	targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", 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`", adminDeptId, targetDeptId)
+	})
+
+	adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 88883, Username: "h4_admin",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		Status: consts.StatusEnabled, ProductCode: "test_product",
+		DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64,
+	})
+
+	zero := int64(0)
+	require.NoError(t,
+		NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &zero,
+		}),
+		"产品 ADMIN 必须仍能执行 deptId=0 的合法运维操作")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), u.DeptId, "ADMIN 的合法 deptId=0 操作必须落盘")
+}
+
+// TC-0817: H-4 —— SuperAdmin 有权将他人移出部门(豁免路径)。
+func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) {
+	bootstrap := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/")
+	targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", 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`", targetDeptId)
+	})
+
+	zero := int64(0)
+	require.NoError(t,
+		NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, DeptId: &zero,
+		}),
+		"SuperAdmin 的 deptId=0 操作是合法的顶层运维")
+
+	u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), u.DeptId)
+}

+ 7 - 0
internal/logic/user/updateUserLogic.go

@@ -115,6 +115,13 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 				!strings.HasPrefix(newDept.Path, caller.DeptPath) {
 				!strings.HasPrefix(newDept.Path, caller.DeptPath) {
 				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
 				return response.ErrForbidden("无权将用户调入非自己管辖的部门")
 			}
 			}
+		} else {
+			// deptId=0 意味着"把用户移出部门树";一旦生效目标将失去 DeptPath,此后 MEMBER / DEVELOPER
+			// 级别的调用者都通不过 checkDeptHierarchy 对"目标必须归属部门"的强校验,无法再被管辖。
+			// 因此仅超管和产品 ADMIN 有权执行该破坏组织结构语义的操作(见审计 H-4)。
+			if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin {
+				return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门")
+			}
 		}
 		}
 		deptId = *req.DeptId
 		deptId = *req.DeptId
 	}
 	}

+ 84 - 0
internal/model/perm/findMapByProductCodeWithTx_audit_test.go

@@ -0,0 +1,84 @@
+package perm_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/perm"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-6 修复的预留基础设施 —— FindMapByProductCodeWithTx。
+// 它目前尚未在 SyncPermsService 的路径里被连起来使用(service.go 的 NOTE 也说明了这一点),
+// 但作为供后续 SELECT FOR UPDATE 串行化用的基础方法,必须满足两个契约:
+//   1) 必须能在 tx 内跑通,并且返回结果与 tx 外的 FindMapByProductCode 完全一致;
+//   2) 当 productCode 没有任何行时,返回 empty map 而不是 nil(便于上层 `_, ok := map[x]` 安全查询)。
+// 如果未来有人改这个方法让它忘记填充 map 或返回 nil,这些测试会立即失败,避免 M-6 的串行化路径被悄悄破坏。
+// ---------------------------------------------------------------------------
+
+// TC-0807: FindMapByProductCodeWithTx 与 tx 外 FindMapByProductCode 数据一致。
+func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
+	ctx := context.Background()
+	m := newTestSysPermModel(t)
+	conn := testutil.GetTestSqlConn()
+	productCode := "pc_fmwtx_" + testutil.UniqueId()
+	now := time.Now().Unix()
+
+	res1, err := m.Insert(ctx, &perm.SysPerm{
+		ProductCode: productCode, Name: "a", Code: "a_" + testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id1, _ := res1.LastInsertId()
+	res2, err := m.Insert(ctx, &perm.SysPerm{
+		ProductCode: productCode, Name: "b", Code: "b_" + testutil.UniqueId(),
+		Status: 2, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id2, _ := res2.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", id1, id2) })
+
+	nonTx, err := m.FindMapByProductCode(ctx, productCode)
+	require.NoError(t, err)
+	require.Len(t, nonTx, 2)
+
+	var withTx map[string]*perm.SysPerm
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		var err error
+		withTx, err = m.FindMapByProductCodeWithTx(c, session, productCode)
+		return err
+	}))
+	require.Len(t, withTx, 2)
+
+	for code, p := range nonTx {
+		gotWithTx, ok := withTx[code]
+		require.True(t, ok, "tx 版本必须返回与非 tx 版本相同的 code 集合")
+		assert.Equal(t, p.Id, gotWithTx.Id)
+		assert.Equal(t, p.Status, gotWithTx.Status, "status(尤其是禁用态)必须真实透传,不能被过滤")
+		assert.Equal(t, p.Name, gotWithTx.Name)
+	}
+}
+
+// TC-0808: 空 productCode 下 FindMapByProductCodeWithTx 返回非 nil 的空 map。
+// 上层同步逻辑里会对 map 直接做 `_, ok := existingMap[item.Code]`;如果是 nil 依然安全,
+// 但若不慎写成 `existingMap[item.Code] = ...` 就会炸,因此约定为"空 map"更稳。
+func TestSysPermModel_FindMapByProductCodeWithTx_EmptyIsNonNil(t *testing.T) {
+	ctx := context.Background()
+	m := newTestSysPermModel(t)
+	productCode := "pc_empty_" + testutil.UniqueId()
+
+	var withTx map[string]*perm.SysPerm
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		var err error
+		withTx, err = m.FindMapByProductCodeWithTx(c, session, productCode)
+		return err
+	}))
+	require.NotNil(t, withTx, "空集必须是 empty map,而不是 nil(避免上层误用 map 赋值时 panic)")
+	assert.Empty(t, withTx)
+}

+ 17 - 0
internal/model/perm/sysPermModel.go

@@ -21,6 +21,10 @@ type (
 		FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error)
 		FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
 		FindMapByProductCode(ctx context.Context, productCode string) (map[string]*SysPerm, error)
 		FindMapByProductCode(ctx context.Context, productCode string) (map[string]*SysPerm, error)
+		// FindMapByProductCodeWithTx 在事务内查询权限快照;配合 SysProductModel.LockByCodeTx 锁住
+		// product 行,可把"读取现有权限 → 增/改/禁用"这段与其他 SyncPermissions 串行化,
+		// 避免两次并发同步都认为 code X 不存在并并发 INSERT 导致 1062(见审计 M-6)。
+		FindMapByProductCodeWithTx(ctx context.Context, session sqlx.Session, productCode string) (map[string]*SysPerm, error)
 		DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error)
 		DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error)
 	}
 	}
 
 
@@ -91,6 +95,19 @@ func (m *customSysPermModel) FindMapByProductCode(ctx context.Context, productCo
 	return result, nil
 	return result, nil
 }
 }
 
 
+func (m *customSysPermModel) FindMapByProductCodeWithTx(ctx context.Context, session sqlx.Session, productCode string) (map[string]*SysPerm, error) {
+	var list []*SysPerm
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ?", sysPermRows, m.table)
+	if err := session.QueryRowsCtx(ctx, &list, query, productCode); err != nil {
+		return nil, err
+	}
+	result := make(map[string]*SysPerm, len(list))
+	for _, p := range list {
+		result[p.Code] = p
+	}
+	return result, nil
+}
+
 func (m *customSysPermModel) DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error) {
 func (m *customSysPermModel) DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error) {
 	// 先查出将被禁用的行,构建缓存 key
 	// 先查出将被禁用的行,构建缓存 key
 	var findQuery string
 	var findQuery string

+ 127 - 0
internal/model/product/lockByCodeTx_audit_test.go

@@ -0,0 +1,127 @@
+package product
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-6 的新增 LockByCodeTx —— "在当前事务里 SELECT ... FOR UPDATE 锁住 product 行"。
+// 本方法是后续将 SyncPermissions 串行化到每个 product 的基础设施。契约:
+//   1) 存在的 code 必须正常返回全字段;
+//   2) 不存在的 code 必须返回 sqlx.ErrNotFound 以便调用方 fail-close;
+//   3) 行锁必须真实 —— 两个事务对同一 code 并发 FOR UPDATE,
+//      先拿到锁的那个必须让后者阻塞到前者 commit/rollback 为止。
+// ---------------------------------------------------------------------------
+
+// TC-0809: LockByCodeTx 对存在的 code 返回完整数据。
+func TestSysProductModel_LockByCodeTx_Found(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	var got *SysProduct
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		got, err = m.LockByCodeTx(c, session, p.Code)
+		return err
+	}))
+	require.NotNil(t, got)
+	assert.Equal(t, id, got.Id)
+	assert.Equal(t, p.Code, got.Code)
+	assert.Equal(t, p.AppKey, got.AppKey)
+	assert.Equal(t, p.Status, got.Status, "锁行时不得过滤禁用态,否则 SyncPermissions 无法为禁用产品正确 fail-close")
+}
+
+// TC-0810: LockByCodeTx 对不存在的 code 返回 sqlx.ErrNotFound。
+func TestSysProductModel_LockByCodeTx_NotFound(t *testing.T) {
+	ctx := context.Background()
+	m := newTestModel(t)
+
+	err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		_, e := m.LockByCodeTx(c, session, "definitely_no_such_code_"+testutil.UniqueId())
+		return e
+	})
+	require.Error(t, err)
+	assert.True(t, errors.Is(err, sqlx.ErrNotFound),
+		"LockByCodeTx 对不存在的 code 必须返回 ErrNotFound,便于上层 fail-close 返回 401/404")
+}
+
+// TC-0811: FOR UPDATE 行锁真实生效 —— 两个事务同时尝试锁同一行时,
+// 后进者必须被阻塞直到先进者结束事务。
+// 测量方式:goroutine A 在 tx 内 Lock 住后 sleep 500ms 再 commit;
+//          goroutine B 等 100ms 后也尝试 Lock 同一行,记录耗时。B 的耗时必须≥400ms。
+func TestSysProductModel_LockByCodeTx_BlocksConcurrentWriter(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := newTestModel(t)
+
+	p := newSysProduct()
+	res, err := m.Insert(ctx, p)
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	var (
+		wg             sync.WaitGroup
+		aHoldMs        = int64(500)
+		bStartDelayMs  = int64(100)
+		bElapsedNanos  int64
+		aFinishedNanos int64
+		aErr, bErr     error
+	)
+
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		aErr = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			if _, e := m.LockByCodeTx(c, session, p.Code); e != nil {
+				return e
+			}
+			// A 拿到锁后故意延时,模拟一段业务处理期。期间 B 必须被阻塞。
+			time.Sleep(time.Duration(aHoldMs) * time.Millisecond)
+			atomic.StoreInt64(&aFinishedNanos, time.Now().UnixNano())
+			return nil
+		})
+	}()
+
+	go func() {
+		defer wg.Done()
+		time.Sleep(time.Duration(bStartDelayMs) * time.Millisecond)
+		start := time.Now()
+		bErr = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			_, e := m.LockByCodeTx(c, session, p.Code)
+			return e
+		})
+		atomic.StoreInt64(&bElapsedNanos, time.Since(start).Nanoseconds())
+	}()
+
+	wg.Wait()
+	require.NoError(t, aErr)
+	require.NoError(t, bErr)
+
+	// B 的耗时 ≥ A 的剩余持锁时间。A 持 500ms,B 延 100ms 后入场,
+	// 因此 B 被阻塞的时间至少 (500-100)=400ms。给 DB 一点抖动放到 300ms。
+	elapsedMs := atomic.LoadInt64(&bElapsedNanos) / int64(time.Millisecond)
+	minBlockedMs := int64(300)
+	assert.GreaterOrEqualf(t, elapsedMs, minBlockedMs, fmt.Sprintf(
+		"B 的 LockByCodeTx 总耗时 %dms 明显低于预期最小阻塞 %dms —— "+
+			"意味着 FOR UPDATE 行锁失效,M-6 声称的'按 product 串行化'不成立",
+		elapsedMs, minBlockedMs))
+}

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

@@ -19,6 +19,9 @@ type (
 		sysProductModel
 		sysProductModel
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
 		UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime int64) error
 		UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime int64) error
+		// LockByCodeTx 在当前事务里锁定 product 行(SELECT ... FOR UPDATE),用于把跨表写入(如权限同步)
+		// 按 product 串行化,避免两次并发 SyncPermissions 在 sys_perm UNIQUE(productCode, code) 上撞 1062。
+		LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error)
 	}
 	}
 
 
 	customSysProductModel struct {
 	customSysProductModel struct {
@@ -50,6 +53,15 @@ func (m *customSysProductModel) UpdateWithOptLock(ctx context.Context, data *Sys
 	return nil
 	return nil
 }
 }
 
 
+func (m *customSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*SysProduct, error) {
+	var resp SysProduct
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `code` = ? LIMIT 1 FOR UPDATE", sysProductRows, m.table)
+	if err := session.QueryRowCtx(ctx, &resp, query, code); err != nil {
+		return nil, err
+	}
+	return &resp, nil
+}
+
 func (m *customSysProductModel) FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error) {
 func (m *customSysProductModel) FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error) {
 	var total int64
 	var total int64
 	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)
 	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)

+ 203 - 0
internal/model/user/incrementTokenVersionIfMatch_audit_test.go

@@ -0,0 +1,203 @@
+package user_test
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-1 修复 —— IncrementTokenVersionIfMatch 必须是"当 DB.tokenVersion == expected
+// 时才原子递增"的 CAS,否则 refreshToken rotation 在并发刷新时会放出"两枚都合法的新令牌"导致会话劫持。
+// 以下用例把这一契约显式钉死:
+//   - 匹配 → 成功并返回递增后的新版本
+//   - 不匹配 → ErrTokenVersionMismatch(不能返回成功,不能悄悄递增)
+//   - 并发竞态 → N 个 goroutine 用同一个 expected 打入,必须只有 1 个成功
+//   - 成功后必须清掉 id-key / username-key 双路缓存
+// ---------------------------------------------------------------------------
+
+// TC-0802: H-1 —— expected 与 DB 当前 tokenVersion 一致时返回递增后的新版本。
+func TestSysUserModel_IncrementTokenVersionIfMatch_Match(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "cas_match_" + 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: 5, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 5)
+	require.NoError(t, err)
+	assert.Equal(t, int64(6), got, "expected 命中时返回 DB 真实递增后的新版本")
+
+	fresh, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(6), fresh.TokenVersion, "DB 落盘值必须也是 6")
+}
+
+// TC-0803: H-1 —— expected 与 DB 不一致时返回 ErrTokenVersionMismatch 且 DB 不得发生任何变更。
+// 这是会话劫持窗口的关键拦截:攻击者的 token 里 TokenVersion = V,但合法用户已刷新到 V+1,
+// 攻击者再来刷新时 expected=V 打不中 WHERE 子句 → 必须失败。
+func TestSysUserModel_IncrementTokenVersionIfMatch_Mismatch_NoSideEffect(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "cas_mismatch_" + 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: 10, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 9)
+	require.Error(t, err, "expected 未命中时必须返回错误")
+	assert.True(t, errors.Is(err, user.ErrTokenVersionMismatch), "错误必须是 ErrTokenVersionMismatch 以供 logic 层分辨")
+	assert.Equal(t, int64(0), got)
+
+	fresh, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(10), fresh.TokenVersion, "CAS 失败必须对 DB 零副作用")
+}
+
+// TC-0804: H-1 —— user 不存在时必须返回原生 NotFound 错误(不得被 ErrTokenVersionMismatch 掩盖)。
+// 这个边界保证 logic 层能区分"用户被删"(应走 UserDetailsLoader 的 status 分支)和"令牌被接管"。
+func TestSysUserModel_IncrementTokenVersionIfMatch_UserNotFound(t *testing.T) {
+	m, _ := newModel(t)
+	ctx := context.Background()
+
+	got, err := m.IncrementTokenVersionIfMatch(ctx, 999999998, 0)
+	require.Error(t, err)
+	assert.False(t, errors.Is(err, user.ErrTokenVersionMismatch),
+		"用户不存在的错误不得伪装成 TokenVersionMismatch,避免混淆 logic 层的分支")
+	assert.Equal(t, int64(0), got)
+}
+
+// TC-0805: H-1 并发回归 —— N 个 goroutine 用同一个 expected 去 CAS,
+// 必须恰好只有 1 个返回 success,其余全部 ErrTokenVersionMismatch;
+// 最终 DB 的 tokenVersion 必须只递增 1(攻击者无法劫持第二枚令牌)。
+func TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "cas_race_" + 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: 20, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
+
+	// 限制在 8 并发以避免触发 go-zero sqlx breaker(单机 MySQL + breaker 对同批次突发
+	// 的并发 UPDATE 容易误伤;CAS 契约在 N=8 时已足以验证"唯一胜出")。
+	const N = 8
+	var (
+		wg          sync.WaitGroup
+		successCnt  int32
+		mismatchCnt int32
+		otherErr    atomic.Value
+		winners     sync.Map
+	)
+
+	start := make(chan struct{})
+	for i := 0; i < N; i++ {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			<-start // 最大程度对齐并发起跑线
+			v, e := m.IncrementTokenVersionIfMatch(ctx, id, 20)
+			switch {
+			case e == nil:
+				atomic.AddInt32(&successCnt, 1)
+				winners.Store(idx, v)
+			case errors.Is(e, user.ErrTokenVersionMismatch):
+				atomic.AddInt32(&mismatchCnt, 1)
+			default:
+				otherErr.Store(e)
+			}
+		}(i)
+	}
+	close(start)
+	wg.Wait()
+
+	if v := otherErr.Load(); v != nil {
+		t.Fatalf("并发 CAS 出现非预期错误:%v", v)
+	}
+	assert.Equal(t, int32(1), atomic.LoadInt32(&successCnt),
+		"会话劫持防线:N=16 的竞态中必须有且仅有 1 个 CAS 胜出")
+	assert.Equal(t, int32(N-1), atomic.LoadInt32(&mismatchCnt),
+		"其他并发者必须全部返回 ErrTokenVersionMismatch,即攻击者会被 401 下线")
+
+	// 唯一胜出者的返回值必须等于 21(起点 20 → +1)
+	winners.Range(func(_, v any) bool {
+		assert.Equal(t, int64(21), v.(int64), "唯一胜出的 CAS 应返回 expected+1")
+		return true
+	})
+
+	fresh, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(21), fresh.TokenVersion, "DB 最终只能递增 1(CAS 原子性的外部可观察证据)")
+}
+
+// TC-0806: H-1 —— 成功后必须使 id-key / username-key 双路缓存失效,
+// 否则 middleware 读缓存拿到的 tokenVersion 与 DB 不一致,依然存在"旧令牌合法误放"的旁路。
+func TestSysUserModel_IncrementTokenVersionIfMatch_InvalidatesCaches(t *testing.T) {
+	m, conn := newModel(t)
+	ctx := context.Background()
+	now := time.Now().Unix()
+	username := "cas_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) })
+
+	u0a, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	require.Equal(t, int64(0), u0a.TokenVersion)
+	u0b, err := m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+	require.Equal(t, int64(0), u0b.TokenVersion)
+
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 0)
+	require.NoError(t, err)
+	require.Equal(t, int64(1), got)
+
+	// 再次读两路缓存,必须看到递增后的 1(而非 stale 0)
+	u1a, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u1a.TokenVersion, fmt.Sprintf(
+		"id-key 缓存未被清理,stale tokenVersion=%d(审计 H-1 的缓存一致性防线)", u1a.TokenVersion))
+
+	u1b, err := m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u1b.TokenVersion, fmt.Sprintf(
+		"username-key 缓存未被清理,stale tokenVersion=%d", u1b.TokenVersion))
+}

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

@@ -14,6 +14,11 @@ import (
 
 
 var ErrUpdateConflict = errors.New("update conflict: data has been modified by another operation")
 var ErrUpdateConflict = errors.New("update conflict: data has been modified by another operation")
 
 
+// ErrTokenVersionMismatch 表示令牌版本与数据库当前版本不一致,刷新令牌失败。
+// 典型场景:refreshToken rotation 并发到达 —— 只有持有当前 tokenVersion 的那一次能原子递增成功,
+// 其余全部返回该错误,防止两个请求都"换到"新令牌(导致会话劫持)。
+var ErrTokenVersionMismatch = errors.New("token version mismatch")
+
 var _ SysUserModel = (*customSysUserModel)(nil)
 var _ SysUserModel = (*customSysUserModel)(nil)
 
 
 type (
 type (
@@ -27,6 +32,7 @@ type (
 		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
 		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
 		UpdateStatus(ctx context.Context, id int64, status int64) error
 		UpdateStatus(ctx context.Context, id int64, status int64) error
 		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
 		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
+		IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error)
 	}
 	}
 
 
 	customSysUserModel struct {
 	customSysUserModel struct {
@@ -174,6 +180,39 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 	return newVersion, nil
 	return newVersion, nil
 }
 }
 
 
+// IncrementTokenVersionIfMatch 原子递增 tokenVersion;仅当 DB 里当前 tokenVersion == expected 时才会生效。
+// 这是 refreshToken rotation 的原子 CAS:两个并发的刷新请求只有一个能命中 WHERE tokenVersion=expected,
+// 另一个 affected=0 返回 ErrTokenVersionMismatch,从而避免"两边都换到新令牌"的会话劫持窗口。
+func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return 0, err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+
+	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` = ? AND `tokenVersion` = ?", m.table)
+		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id, expected)
+		if err != nil {
+			return err
+		}
+		affected, _ := res.RowsAffected()
+		if affected == 0 {
+			return ErrTokenVersionMismatch
+		}
+		return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
+	})
+	if err != nil {
+		return 0, err
+	}
+
+	_ = m.DelCacheCtx(ctx, sysUserIdKey, sysUserUsernameKey)
+	return newVersion, nil
+}
+
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {
 	if len(ids) == 0 {
 	if len(ids) == 0 {
 		return nil, nil
 		return nil, nil

+ 165 - 0
internal/server/grpc_rate_limit_audit_test.go

@@ -0,0 +1,165 @@
+package server
+
+import (
+	"context"
+	"database/sql"
+	"net"
+	"testing"
+	"time"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/pb"
+
+	"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"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/peer"
+	"google.golang.org/grpc/status"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:
+//   H-2:gRPC RefreshToken / VerifyToken 必须受 IP 维度限流保护;
+//   M-7:extractClientIP 必须剥离端口,同一 IP 的不同 TCP 端口共享同一限流桶;
+//   H-1:gRPC RefreshToken 里走的是 IncrementTokenVersionIfMatch,
+//        一次成功后旧 refreshToken 立刻失效;重放必须返回 Unauthenticated。
+// ---------------------------------------------------------------------------
+
+// withPeerIP 往 ctx 注入指定 "host:port" 的 peer,模拟 gRPC 上游的 PeerAddr。
+func withPeerIP(ctx context.Context, hostPort string) context.Context {
+	addr, err := net.ResolveTCPAddr("tcp", hostPort)
+	if err != nil {
+		panic(err)
+	}
+	return peer.NewContext(ctx, &peer.Peer{Addr: addr})
+}
+
+// TC-0828: H-2 —— GrpcRefreshLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
+func TestGrpcRefreshToken_RateLimit_OverIP(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	// quota=1 的定制 limiter,让第 2 次必然 429/ResourceExhausted。
+	svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:ut:"+testutil.UniqueId())
+	svcCtx.TokenOpLimiter = nil
+
+	srv := NewPermServer(svcCtx)
+
+	// 第 1 次:故意用个无效 token,让 limiter 放行、业务层兜底返回 Unauthenticated。
+	// 这里只关心 limiter 是否"吃掉 1 个配额"。
+	ctx1 := withPeerIP(ctx, "10.1.2.3:11111")
+	_, err1 := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: "invalid"})
+	require.Error(t, err1)
+	st1, _ := status.FromError(err1)
+	assert.Equal(t, codes.Unauthenticated, st1.Code(),
+		"首次放行,业务层应返回 Unauthenticated(token 无效),不应是 ResourceExhausted")
+
+	// 第 2 次:同 IP 但端口不同(模拟新 TCP 连接),必须被同一限流桶拦住。
+	ctx2 := withPeerIP(ctx, "10.1.2.3:22222")
+	_, err2 := srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: "anything"})
+	require.Error(t, err2)
+	st2, _ := status.FromError(err2)
+	assert.Equal(t, codes.ResourceExhausted, st2.Code(),
+		"H-2 + M-7:同 IP 第 2 次刷新必须 429;端口变化不得绕过限流(extractClientIP 剥端口)")
+	assert.Contains(t, st2.Message(), "过于频繁")
+}
+
+// TC-0829: H-2 —— GrpcVerifyLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。
+// VerifyToken 契约是"非法 token 返回 Valid=false 而不是 error",因此限流是唯一能让接口返回 gRPC error 的路径。
+func TestGrpcVerifyToken_RateLimit_OverIP(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx.GrpcVerifyLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:verify:ut:"+testutil.UniqueId())
+	srv := NewPermServer(svcCtx)
+
+	ctx1 := withPeerIP(ctx, "10.9.8.7:30001")
+	resp1, err1 := srv.VerifyToken(ctx1, &pb.VerifyTokenReq{AccessToken: "invalid"})
+	require.NoError(t, err1, "首次放行:VerifyToken 对非法 token 只返回 Valid=false,不 error")
+	require.NotNil(t, resp1)
+	assert.False(t, resp1.Valid)
+
+	// 同 IP 不同端口 → 必须被限流拦住。
+	ctx2 := withPeerIP(ctx, "10.9.8.7:30002")
+	_, err2 := srv.VerifyToken(ctx2, &pb.VerifyTokenReq{AccessToken: "whatever"})
+	require.Error(t, err2)
+	st2, _ := status.FromError(err2)
+	assert.Equal(t, codes.ResourceExhausted, st2.Code(),
+		"H-2:gRPC VerifyToken 必须受 IP 级限流保护,防止下游被当 token oracle 爆破")
+}
+
+// TC-0830: M-7 —— extractClientIP 对 "host:port" 必须剥成 host;
+// 缺失 peer 时返回 error,由上层决定降级到 unknown 桶。
+func TestExtractClientIP_StripsPort(t *testing.T) {
+	addr, err := net.ResolveTCPAddr("tcp", "192.168.0.1:54321")
+	require.NoError(t, err)
+	ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: addr})
+
+	ip, err := extractClientIP(ctx)
+	require.NoError(t, err)
+	assert.Equal(t, "192.168.0.1", ip,
+		"M-7:gRPC peer.Addr 必须剥成纯 host;保留端口会导致限流形同虚设")
+
+	// 无 peer 的 context
+	_, err2 := extractClientIP(context.Background())
+	assert.Error(t, err2, "无 peer 时必须返回 error,让上层选择 fail-close 或降级到 unknown 桶")
+}
+
+// TC-0831: H-1 + M-7 —— gRPC RefreshToken 成功一次后,旧 refreshToken 立刻失效;
+// 换用同 IP 重放旧 token 必须返回 Unauthenticated("登录状态已失效"),
+// 而不是因端口变化绕过限流或因 CAS 失败被伪装成 500。
+func TestGrpcRefreshToken_CASInvalidatesOldToken(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	// 放开限流以聚焦 CAS 正确性(quota 大)。
+	svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit(
+		60, 100, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:cas:"+testutil.UniqueId())
+	svcCtx.TokenOpLimiter = nil
+
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "n",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	userId, _ := uRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	rt, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, userId, "", 0)
+	require.NoError(t, err)
+
+	srv := NewPermServer(svcCtx)
+
+	// 第一次成功刷新。
+	ctx1 := withPeerIP(ctx, "172.16.0.1:11001")
+	resp, err := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: rt})
+	require.NoError(t, err)
+	require.NotEmpty(t, resp.RefreshToken)
+
+	// 用同一个旧 rt 重放,应当 Unauthenticated;
+	// 注意:旧 token 里 tokenVersion=0,DB 已被 CAS 推到 1,所以 "claims.TokenVersion != ud.TokenVersion" 这一步就会拦住。
+	// 端口换掉以确保不是限流在帮我们挡。
+	ctx2 := withPeerIP(ctx, "172.16.0.1:11002")
+	_, err = srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: rt})
+	require.Error(t, err, "H-1:旧 refreshToken 成功刷新一次后必须失效")
+	st, _ := status.FromError(err)
+	assert.Equal(t, codes.Unauthenticated, st.Code(),
+		"旧 token 重放必须返回 Unauthenticated,不能是 Internal/ResourceExhausted")
+	assert.Contains(t, st.Message(), "登录状态已失效")
+}

+ 78 - 13
internal/server/permserver.go

@@ -2,6 +2,7 @@ package server
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"time"
 	"time"
@@ -10,6 +11,7 @@ import (
 	authHelper "perms-system-server/internal/logic/auth"
 	authHelper "perms-system-server/internal/logic/auth"
 	pub "perms-system-server/internal/logic/pub"
 	pub "perms-system-server/internal/logic/pub"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/pb"
 	"perms-system-server/pb"
 
 
@@ -22,6 +24,29 @@ import (
 	"google.golang.org/grpc/status"
 	"google.golang.org/grpc/status"
 )
 )
 
 
+// unknownPeerBucket 当无法解析对端 IP 时共享的限流桶 key。
+// 生产环境 gRPC-over-TCP 必然有 peer.Addr,正常流量不会落到这里;此常量仅为 in-process/socket
+// 等边缘路径兜底,避免 M-7 审计指出的"按 p.Addr.String() 取完整 host:port 导致限流形同虚设"的
+// 随端口漂移问题。共享同一个 key 会放大 DoS 面(所有未知 peer 共用一个计数器),但在此类路径
+// 不走真实业务流量的前提下收益足够。
+const unknownPeerBucket = "unknown"
+
+// extractClientIP 从 gRPC context 中提取对端 IP。显式剥离端口号(M-7):gRPC 的 p.Addr.String()
+// 形如 "1.2.3.4:54321",端口每次连接都变,若直接作为限流 key 相当于没限流。
+// 解析失败返回 error,由上层按场景决定是 fail-close(RefreshToken 敏感路径)还是降级到 unknown 桶
+// (VerifyToken 契约层约束不允许返回 error)。
+func extractClientIP(ctx context.Context) (string, error) {
+	p, ok := peer.FromContext(ctx)
+	if !ok || p == nil || p.Addr == nil {
+		return "", errors.New("peer not identifiable")
+	}
+	host, _, err := net.SplitHostPort(p.Addr.String())
+	if err != nil || host == "" {
+		return "", errors.New("peer address invalid")
+	}
+	return host, nil
+}
+
 // PermServer 权限管理系统 gRPC 服务实现,供接入产品的服务端调用。
 // PermServer 权限管理系统 gRPC 服务实现,供接入产品的服务端调用。
 type PermServer struct {
 type PermServer struct {
 	svcCtx *svc.ServiceContext
 	svcCtx *svc.ServiceContext
@@ -49,6 +74,8 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 				return nil, status.Error(codes.Unauthenticated, se.Message)
 				return nil, status.Error(codes.Unauthenticated, se.Message)
 			case 403:
 			case 403:
 				return nil, status.Error(codes.PermissionDenied, se.Message)
 				return nil, status.Error(codes.PermissionDenied, se.Message)
+			case 409:
+				return nil, status.Error(codes.Aborted, se.Message)
 			default:
 			default:
 				return nil, status.Error(codes.Internal, se.Message)
 				return nil, status.Error(codes.Internal, se.Message)
 			}
 			}
@@ -61,17 +88,14 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 
 
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录,返回 JWT 令牌对及用户权限信息。受 IP 维度限流保护。
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录,返回 JWT 令牌对及用户权限信息。受 IP 维度限流保护。
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
-	var clientIP string
+	clientIP, ipErr := extractClientIP(ctx)
+	if ipErr != nil {
+		// 审计 M-7 的核心修复是"把 host:port 剥成 host,避免端口漂移让限流失效";
+		// 生产环境 gRPC 必有 peer,这里走不到;in-process/单测等边缘路径回落到共享 unknown 桶,
+		// 上层仍会继续执行用户名级的 UsernameLoginLimit,不会造成防护真空。
+		clientIP = unknownPeerBucket
+	}
 	if s.svcCtx.GrpcLoginLimiter != nil {
 	if s.svcCtx.GrpcLoginLimiter != nil {
-		clientIP = "unknown"
-		if p, ok := peer.FromContext(ctx); ok {
-			host, _, err := net.SplitHostPort(p.Addr.String())
-			if err == nil && host != "" {
-				clientIP = host
-			} else {
-				clientIP = p.Addr.String()
-			}
-		}
 		code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", clientIP))
 		code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", clientIP))
 		if code == limit.OverQuota {
 		if code == limit.OverQuota {
 			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
 			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
@@ -112,8 +136,22 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 	}, nil
 	}, nil
 }
 }
 
 
-// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的令牌对,同时递增 tokenVersion 使旧令牌即时失效(单会话轮转)。
+// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的令牌对,同时原子 CAS 递增 tokenVersion
+// 使旧令牌即时失效(单会话轮转)。受 IP 维度限流保护,防止签名爆破和并发刷新被用于会话劫持。
 func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq) (*pb.RefreshTokenResp, error) {
 func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq) (*pb.RefreshTokenResp, error) {
+	clientIP, ipErr := extractClientIP(ctx)
+	if ipErr != nil {
+		// 和 Login 相同,IP 解析失败走共享 unknown 桶;后续 CAS(IncrementTokenVersionIfMatch)
+		// 和 per-user TokenOpLimiter 仍然兜底 session 劫持路径。
+		clientIP = unknownPeerBucket
+	}
+	if s.svcCtx.GrpcRefreshLimiter != nil {
+		code, _ := s.svcCtx.GrpcRefreshLimiter.Take(fmt.Sprintf("grpc:refresh:%s", clientIP))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+		}
+	}
+
 	claims, err := authHelper.ParseRefreshToken(req.RefreshToken, s.svcCtx.Config.Auth.RefreshSecret)
 	claims, err := authHelper.ParseRefreshToken(req.RefreshToken, s.svcCtx.Config.Auth.RefreshSecret)
 	if err != nil {
 	if err != nil {
 		return nil, status.Error(codes.Unauthenticated, "refreshToken无效或已过期")
 		return nil, status.Error(codes.Unauthenticated, "refreshToken无效或已过期")
@@ -142,8 +180,19 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 		return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 	}
 	}
 
 
-	newVersion, err := s.svcCtx.SysUserModel.IncrementTokenVersion(ctx, claims.UserId)
+	if s.svcCtx.TokenOpLimiter != nil {
+		code, _ := s.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("grpc-refresh-u:%d", claims.UserId))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "刷新操作过于频繁,请稍后再试")
+		}
+	}
+
+	// 原子 CAS 递增 tokenVersion,避免并发刷新时两个请求都通过 check 并各自拿到"新令牌"导致会话劫持。
+	newVersion, err := s.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, claims.TokenVersion)
 	if err != nil {
 	if err != nil {
+		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
+			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
+		}
 		return nil, status.Error(codes.Internal, "刷新token失败")
 		return nil, status.Error(codes.Internal, "刷新token失败")
 	}
 	}
 	s.svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
 	s.svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
@@ -172,8 +221,24 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 	}, nil
 	}, nil
 }
 }
 
 
-// VerifyToken 验证令牌。校验 accessToken 的有效性(签名、过期、用户状态、产品状态、成员资格、tokenVersion),有效时返回用户身份和权限信息。
+// VerifyToken 验证令牌。校验 accessToken 的有效性(签名、过期、用户状态、产品状态、成员资格、tokenVersion),
+// 有效时返回用户身份和权限信息。受 IP 维度限流保护,防止下游被攻破后把权限中心当作 token oracle 做爆破。
+//
+// 注意:本方法对外契约是"任何畸形/非法 token 都只返回 Valid=false,不返回 gRPC 错误"(见 fuzz 契约测试),
+// 因此 IP 解析失败时不能走 fail-close,改为降级到共享 "unknown" 限流桶——仍然有限速,但不破坏上游产品网关
+// 的稳定错误分类;真正过载时用 ResourceExhausted 响应。
 func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
 func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*pb.VerifyTokenResp, error) {
+	clientIP, ipErr := extractClientIP(ctx)
+	if ipErr != nil {
+		clientIP = "unknown"
+	}
+	if s.svcCtx.GrpcVerifyLimiter != nil {
+		code, _ := s.svcCtx.GrpcVerifyLimiter.Take(fmt.Sprintf("grpc:verify:%s", clientIP))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+		}
+	}
+
 	token, err := jwt.ParseWithClaims(req.AccessToken, &middleware.Claims{}, func(token *jwt.Token) (interface{}, error) {
 	token, err := jwt.ParseWithClaims(req.AccessToken, &middleware.Claims{}, func(token *jwt.Token) (interface{}, error) {
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 	})
 	})

+ 11 - 0
internal/svc/servicecontext.go

@@ -18,7 +18,10 @@ type ServiceContext struct {
 	ProductLoginRateLimit rest.Middleware
 	ProductLoginRateLimit rest.Middleware
 	AdminLoginRateLimit   rest.Middleware
 	AdminLoginRateLimit   rest.Middleware
 	SyncRateLimit         rest.Middleware
 	SyncRateLimit         rest.Middleware
+	RefreshTokenRateLimit rest.Middleware
 	GrpcLoginLimiter      *limit.PeriodLimit
 	GrpcLoginLimiter      *limit.PeriodLimit
+	GrpcRefreshLimiter    *limit.PeriodLimit
+	GrpcVerifyLimiter     *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	UserDetailsLoader     *loaders.UserDetailsLoader
 	UserDetailsLoader     *loaders.UserDetailsLoader
@@ -33,7 +36,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	productLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 30, c.CacheRedis.KeyPrefix+":rl:login:product", c.BehindProxy)
 	productLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 30, c.CacheRedis.KeyPrefix+":rl:login:product", c.BehindProxy)
 	adminLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login:admin", c.BehindProxy)
 	adminLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login:admin", c.BehindProxy)
 	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
 	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
+	refreshTokenRL := middleware.NewRateLimitMiddleware(rds, 60, 30, c.CacheRedis.KeyPrefix+":rl:refresh", c.BehindProxy)
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
+	// gRPC refreshToken 一般低频操作(分钟级),限紧一点可以同时防签名爆破与并发刷新被用作会话劫持的放大器。
+	grpcRefreshLimiter := limit.NewPeriodLimit(60, 30, rds, c.CacheRedis.KeyPrefix+":rl:grpc:refresh")
+	// gRPC verifyToken 是下游每请求都会调用的热路径,阈值必须足够高;这里的作用是兜底防止下游被攻破后把权限中心当 token oracle 爆破。
+	grpcVerifyLimiter := limit.NewPeriodLimit(60, 6000, rds, c.CacheRedis.KeyPrefix+":rl:grpc:verify")
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
 
@@ -43,7 +51,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		ProductLoginRateLimit: productLoginRL.Handle,
 		ProductLoginRateLimit: productLoginRL.Handle,
 		AdminLoginRateLimit:   adminLoginRL.Handle,
 		AdminLoginRateLimit:   adminLoginRL.Handle,
 		SyncRateLimit:         syncRlMiddleware.Handle,
 		SyncRateLimit:         syncRlMiddleware.Handle,
+		RefreshTokenRateLimit: refreshTokenRL.Handle,
 		GrpcLoginLimiter:      grpcLimiter,
 		GrpcLoginLimiter:      grpcLimiter,
+		GrpcRefreshLimiter:    grpcRefreshLimiter,
+		GrpcVerifyLimiter:     grpcVerifyLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		UserDetailsLoader:     udLoader,
 		UserDetailsLoader:     udLoader,

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

@@ -231,6 +231,21 @@ func (mr *MockSysPermModelMockRecorder) FindMapByProductCode(ctx, productCode an
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMapByProductCode", reflect.TypeOf((*MockSysPermModel)(nil).FindMapByProductCode), ctx, productCode)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMapByProductCode", reflect.TypeOf((*MockSysPermModel)(nil).FindMapByProductCode), ctx, productCode)
 }
 }
 
 
+// FindMapByProductCodeWithTx mocks base method.
+func (m *MockSysPermModel) FindMapByProductCodeWithTx(ctx context.Context, session sqlx.Session, productCode string) (map[string]*perm.SysPerm, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindMapByProductCodeWithTx", ctx, session, productCode)
+	ret0, _ := ret[0].(map[string]*perm.SysPerm)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindMapByProductCodeWithTx indicates an expected call of FindMapByProductCodeWithTx.
+func (mr *MockSysPermModelMockRecorder) FindMapByProductCodeWithTx(ctx, session, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMapByProductCodeWithTx", reflect.TypeOf((*MockSysPermModel)(nil).FindMapByProductCodeWithTx), ctx, session, productCode)
+}
+
 // FindOne mocks base method.
 // FindOne mocks base method.
 func (m *MockSysPermModel) FindOne(ctx context.Context, id int64) (*perm.SysPerm, error) {
 func (m *MockSysPermModel) FindOne(ctx context.Context, id int64) (*perm.SysPerm, error) {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()

+ 27 - 12
internal/testutil/mocks/mock_product_model.go

@@ -291,6 +291,21 @@ func (mr *MockSysProductModelMockRecorder) InsertWithTx(ctx, session, data any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWithTx", reflect.TypeOf((*MockSysProductModel)(nil).InsertWithTx), ctx, session, data)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWithTx", reflect.TypeOf((*MockSysProductModel)(nil).InsertWithTx), ctx, session, data)
 }
 }
 
 
+// LockByCodeTx mocks base method.
+func (m *MockSysProductModel) LockByCodeTx(ctx context.Context, session sqlx.Session, code string) (*product.SysProduct, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "LockByCodeTx", ctx, session, code)
+	ret0, _ := ret[0].(*product.SysProduct)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// LockByCodeTx indicates an expected call of LockByCodeTx.
+func (mr *MockSysProductModelMockRecorder) LockByCodeTx(ctx, session, code any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockByCodeTx", reflect.TypeOf((*MockSysProductModel)(nil).LockByCodeTx), ctx, session, code)
+}
+
 // TableName mocks base method.
 // TableName mocks base method.
 func (m *MockSysProductModel) TableName() string {
 func (m *MockSysProductModel) TableName() string {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
@@ -319,32 +334,32 @@ func (mr *MockSysProductModelMockRecorder) TransactCtx(ctx, fn any) *gomock.Call
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactCtx", reflect.TypeOf((*MockSysProductModel)(nil).TransactCtx), ctx, fn)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactCtx", reflect.TypeOf((*MockSysProductModel)(nil).TransactCtx), ctx, fn)
 }
 }
 
 
-// UpdateWithOptLock mocks base method.
-func (m *MockSysProductModel) UpdateWithOptLock(ctx context.Context, data *product.SysProduct, expectedUpdateTime int64) error {
+// Update mocks base method.
+func (m *MockSysProductModel) Update(ctx context.Context, data *product.SysProduct) error {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
+	ret := m.ctrl.Call(m, "Update", ctx, data)
 	ret0, _ := ret[0].(error)
 	ret0, _ := ret[0].(error)
 	return ret0
 	return ret0
 }
 }
 
 
-// UpdateWithOptLock indicates an expected call of UpdateWithOptLock.
-func (mr *MockSysProductModelMockRecorder) UpdateWithOptLock(ctx, data, expectedUpdateTime any) *gomock.Call {
+// Update indicates an expected call of Update.
+func (mr *MockSysProductModelMockRecorder) Update(ctx, data any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysProductModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSysProductModel)(nil).Update), ctx, data)
 }
 }
 
 
-// Update mocks base method.
-func (m *MockSysProductModel) Update(ctx context.Context, data *product.SysProduct) error {
+// UpdateWithOptLock mocks base method.
+func (m *MockSysProductModel) UpdateWithOptLock(ctx context.Context, data *product.SysProduct, expectedUpdateTime int64) error {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Update", ctx, data)
+	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
 	ret0, _ := ret[0].(error)
 	ret0, _ := ret[0].(error)
 	return ret0
 	return ret0
 }
 }
 
 
-// Update indicates an expected call of Update.
-func (mr *MockSysProductModelMockRecorder) Update(ctx, data any) *gomock.Call {
+// UpdateWithOptLock indicates an expected call of UpdateWithOptLock.
+func (mr *MockSysProductModelMockRecorder) UpdateWithOptLock(ctx, data, expectedUpdateTime any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSysProductModel)(nil).Update), ctx, data)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysProductModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
 }
 }
 
 
 // UpdateWithTx mocks base method.
 // UpdateWithTx mocks base method.

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

@@ -293,6 +293,21 @@ func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id any) *gomo
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id)
 }
 }
 
 
+// IncrementTokenVersionIfMatch mocks base method.
+func (m *MockSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IncrementTokenVersionIfMatch", ctx, id, expected)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// IncrementTokenVersionIfMatch indicates an expected call of IncrementTokenVersionIfMatch.
+func (mr *MockSysUserModelMockRecorder) IncrementTokenVersionIfMatch(ctx, id, expected any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersionIfMatch", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersionIfMatch), ctx, id, expected)
+}
+
 // Insert mocks base method.
 // Insert mocks base method.
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()

+ 4 - 3
perm.api

@@ -326,10 +326,11 @@ service perm-api {
 	post /auth/login (LoginReq) returns (LoginResp)
 	post /auth/login (LoginReq) returns (LoginResp)
 }
 }
 
 
-// 令牌刷新,不需要鉴权中间件,自行验证 refreshToken 有效性
+// 令牌刷新,不需要鉴权中间件,自行验证 refreshToken 有效性;受 IP 维度限流保护,防止签名爆破/CPU 放大 DoS
 @server (
 @server (
-	prefix: /api
-	group:  pub
+	prefix:     /api
+	group:      pub
+	middleware: RefreshTokenRateLimit
 )
 )
 service perm-api {
 service perm-api {
 	// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的 accessToken/refreshToken 令牌对,旧令牌即时失效(单会话轮转)
 	// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的 accessToken/refreshToken 令牌对,旧令牌即时失效(单会话轮转)

+ 101 - 0
test-design.md

@@ -1299,3 +1299,104 @@ MySQL (InnoDB) + Redis Cache
 | TC-0799 | ChangePasswordHandler 缺必填字段 | `{}` | 400, 文案点名 `oldPassword`/`newPassword` | 契约 | P0 | goctl required/optional 标注防退化 |
 | TC-0799 | ChangePasswordHandler 缺必填字段 | `{}` | 400, 文案点名 `oldPassword`/`newPassword` | 契约 | P0 | goctl required/optional 标注防退化 |
 | TC-0800 | RefreshTokenHandler 缺 Authorization | 无 header | 401 或 400, 文案不含 `sql`/`redis` | 契约 | P0 | handler 错误文案不得泄露实现细节 |
 | TC-0800 | RefreshTokenHandler 缺 Authorization | 无 header | 401 或 400, 文案不含 `sql`/`redis` | 契约 | P0 | handler 错误文案不得泄露实现细节 |
 | TC-0801 | RefreshTokenHandler 非法 bearer | `Bearer garbage.token.value` | 401(绝不 500/200/panic) | 契约 | P0 | refresh token 畸形时等价于未登录 |
 | TC-0801 | RefreshTokenHandler 非法 bearer | `Bearer garbage.token.value` | 401(绝不 500/200/panic) | 契约 | P0 | refresh token 畸形时等价于未登录 |
+
+## 十四、 本轮新增对抗性用例(QA 主动补齐 · 第四批 / 审计报告修复回归)
+
+> 本批与最新的 `audit-report.md` 逐项对齐。每条修复的高/中/低风险点都挂一条或一组独立 TC,
+> 断言 **修复后的预期行为**(不是源码当前的观测),一旦未来有人把修复改回旧路径,本批用例立刻红。
+>
+> 覆盖域:
+>
+> * **H-1** `IncrementTokenVersionIfMatch` 原子 CAS + logic 层并发刷新胜负 + 缓存一致性
+> * **H-2 / M-7** gRPC Refresh / Verify IP 级限流 + `extractClientIP` 端口剥离契约
+> * **H-3** `BindRoles` 等级 `>=` 护栏(等级平行时亦拒绝赋权)
+> * **H-4** `UpdateUser` 将 `deptId` 设为 0 需 ADMIN / 超管权限
+> * **L-1** `CreateUser` 未显式指定时 `mustChangePassword` 默认为 1(强制首次改密)
+> * **L-4** `CheckManageAccess` / `checkPermLevel` 面对 DB 瞬时错误必须 fail-close → 500,而非 "没有角色 → 403"
+> * **M-3** `UserDetailsLoader` 负缓存 sentinel,保证大量携带"已删除用户 token"的请求不再反复击穿 DB
+> * **M-5** `CreateProduct` 并发冲突(1062 唯一键)必须映射为 409,而非 500 + 脆弱字符串匹配
+> * **M-6** `SyncPerms` 事务内锁产品 + 事务内读 perm map + 入参去重 + 1062 → 409
+> * **M-B** HTTP 路由 `/api/auth/refreshToken` 必须挂载 `RefreshTokenRateLimit`(路由静态 wiring + 行为双重验证)
+
+### H-1 CAS 会话劫持防线(`internal/model/user/incrementTokenVersionIfMatch_audit_test.go` + `internal/logic/pub/refreshTokenCas_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0802 | `expected == DB.tokenVersion` | expected=5, DB=5 | 返回 6;DB 落盘 6 | 正常路径 | P0 | CAS 成功分支 |
+| TC-0803 | `expected != DB.tokenVersion` | expected=9, DB=10 | `ErrTokenVersionMismatch`;DB 零副作用 | 安全/并发 | P0 | 会话劫持窗口拦截 |
+| TC-0804 | 用户不存在 | id=999999998 | `ErrNotFound`(不能伪装成 Mismatch) | 分支区分 | P0 | logic 层据此分流"被删"/"被劫持" |
+| TC-0805 | 8 goroutine 同时 CAS 同 expected | N=8 | 恰好 1 成功 + 7 `ErrTokenVersionMismatch`;DB `tokenVersion` 只递增 1 | 并发/竞态 | P0 | 原子性外部可观察证据 |
+| TC-0806 | 成功后 id-key / username-key 缓存一致性 | CAS→1 | 再读两路都看到 1(非 stale 0) | 缓存 | P0 | 防 middleware 读 stale tokenVersion 放行旧 token |
+| TC-0812 | logic 层 6 goroutine 并发 RefreshToken 同一旧 rt | N=6 | 1 成功 + 5 × 401 "登录状态已失效";DB 递增 1 | 并发/协议 | P0 | H-1 纵深,覆盖 logic 层分支到 CodeError |
+
+### H-2 + M-7 gRPC 限流 + client IP 剥端口(`internal/server/grpc_rate_limit_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0828 | 同 IP 两次 gRPC RefreshToken,quota=1 | peer `10.1.2.3:11111` → `10.1.2.3:22222` | 第 1 次 Unauthenticated(业务放行);第 2 次 ResourceExhausted + "过于频繁" | 安全/限流 | P0 | H-2:端口变化不得绕过限流 |
+| TC-0829 | 同 IP 两次 gRPC VerifyToken,quota=1 | peer `10.9.8.7:30001` → `10.9.8.7:30002` | 第 1 次 `Valid=false` + nil err;第 2 次 ResourceExhausted | 安全/限流 | P0 | VerifyToken 作为 token oracle 必须受限流保护 |
+| TC-0830 | `extractClientIP` 对 "host:port" 剥离 | `192.168.0.1:54321` | 返回 `192.168.0.1`;无 peer 时 error | 契约 | P0 | M-7:剥端口契约不得回退 |
+| TC-0831 | gRPC refresh 成功后重放旧 rt(换端口) | quota 宽松 | 第 2 次 Unauthenticated + "登录状态已失效" | 安全/并发 | P0 | H-1 + M-7 纵深交叉 |
+
+### H-3 BindRoles 等级 `>=` 护栏(`internal/logic/user/bindRolesEqualLevel_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0813 | MEMBER 调用者给他人赋予 "与自己 permsLevel 相同" 的角色 | caller level=50, role level=50 | 403(等级不允许);DB 的 sys_user_role 关系无变化 | 安全/越权 | P0 | `GuardRoleLevelAssignable` 的 `>=` 防自等升权 |
+
+### H-4 UpdateUser deptId=0 门禁(`internal/logic/user/updateUserDeptZero_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0814 | DEVELOPER 将他人 deptId 置 0 | caller=DEVELOPER | 403;目标 deptId 不变 | 安全/越权 | P0 | 防"把用户挪出部门树以逃出管理视野" |
+| TC-0815 | MEMBER 将他人 deptId 置 0 | caller=MEMBER | 403;目标 deptId 不变 | 安全/越权 | P0 | 同上 |
+| TC-0816 | 产品 ADMIN 将他人 deptId 置 0 | caller=ADMIN | 200;目标 deptId=0 | 正常路径 | P1 | 合法操作不被误伤 |
+| TC-0817 | SuperAdmin 将他人 deptId 置 0 | caller=SuperAdmin | 200;目标 deptId=0 | 正常路径 | P1 | 顶级权限链路通畅 |
+
+### L-1 CreateUser 默认强制首次改密(`internal/logic/user/createUserMustChangePwd_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0818 | SuperAdmin 创建用户未显式指定 `mustChangePassword` | req 缺字段 | DB 落盘 `MustChangePassword=1` | 默认值/安全 | P1 | 默认 Yes 才能保证账号发出后立刻被改密 |
+
+### L-4 checkPermLevel fail-close(`internal/logic/auth/checkPermLevelFailClose_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0819 | DB 层瞬时错误(mock `errors.New("boom")`) | mock 抛通用 err | `CodeError.Code == 500`,不是 403 | 鲁棒性/安全 | P0 | 禁止把 DB 故障曲解为"没角色 → 403" |
+| TC-0820 | DB 返回 `sqlx.ErrNotFound` | mock `sqlx.ErrNotFound` | 403(保留原业务语义) | 分支区分 | P0 | 只有真正"无角色"才 403,保证可审计 |
+
+### M-3 UserDetailsLoader 负缓存(`internal/loaders/userDetailsLoader_negativeCache_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0821 | 同一"不存在 userId" 第 2 次 Load | 首次写入 sentinel 后再 Load | 第 2 次 0 次 DB 调用;sentinel 持有 `negativeCacheTTL` | 防 DoS/缓存 | P0 | 携带已删除用户 token 的请求不再反复击穿 DB |
+| TC-0822 | 负缓存不登记到 `userIndex`/`productIndex` | 查不存在用户后观察集合 | sentinel 不进 Clean 索引,避免误伤 | 缓存一致性 | P0 | 防 Clean 误删合法 key 或污染统计 |
+| TC-0823 | 50 并发 Load 同一不存在 userId | singleflight + 负缓存协同 | 最终 Redis key = 负缓存 sentinel;无 panic | 并发/缓存 | P0 | 防并发惊群 + 负缓存收敛 |
+
+### M-5 CreateProduct 并发唯一键冲突(`internal/logic/product/createProductConflict_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0827 | mock `InsertWithTx` 返回 mysql error 1062,message 不含 "uk_code" | `&mysql.MySQLError{Number:1062,Message:"generic"}` | 返回 `response.ErrConflict`(409) | 错误映射 | P0 | 去掉脆弱 `strings.Contains` 依赖,靠 `mysql.MySQLError.Number` 判定 |
+
+### M-6 SyncPerms 事务一致性 + 409(`internal/logic/pub/syncPermsConflict_audit_test.go` + 基础设施 `internal/model/perm/findMapByProductCodeWithTx_audit_test.go` + `internal/model/product/lockByCodeTx_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0807 | `FindMapByProductCodeWithTx` 与非事务版返回等价 | Tx + 产品有启/禁用权限 | 两次调用数据一致;仅 `status=1` 被返回 | 一致性 | P0 | 基础设施:事务内读 perm map |
+| TC-0808 | 产品无权限时返回 | 空集 | 非 nil 空 map(防 upstream NPE) | 健壮性 | P1 | 空产品语义固定 |
+| TC-0809 | `LockByCodeTx` 对存在 code 返回完整行 | Tx 内 `SELECT ... FOR UPDATE` | 行数据完整 | 正常路径 | P0 | 基础设施:事务内锁产品行 |
+| TC-0810 | 对不存在 code | Tx | `sqlx.ErrNotFound` | 分支 | P0 | 让 logic 层分辨"产品不存在" vs "DB 错误" |
+| TC-0811 | 两个事务同时锁同一行 | 并发 `LockByCodeTx` | 后者被阻塞,前者 commit 后才继续 | 并发/锁 | P0 | 实证 FOR UPDATE 的行级锁语义 |
+| TC-0824 | mock `BatchInsertWithTx` 返回 1062 | Tx 内抛 DuplicateEntry | `SyncPermsError{Code:409}`(可重试) | 错误映射 | P0 | 并发同步必须是 409 而不是 500 |
+| TC-0825 | logic 映射 SyncPermsError.Code=409 | ExecuteSyncPerms 返回 409 | HTTP `response.ErrConflict`(409) | 协议映射 | P0 | 让调用方做"指数退避重试"而不是告警 |
+| TC-0826 | 同一 perm code 在 req 中重复 | `perms = [A, A]` | 落盘仅 1 条(入参内部去重) | 防自伤 | P0 | 并发同步引发 1062 的主因之一 |
+
+### M-B HTTP /refreshToken 中间件挂载(`internal/handler/refreshTokenRouteWiring_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0832 | 静态 wiring 检查:routes.go 中 `/auth/refreshToken` 所在 `rest.WithMiddlewares(...)` 块必须包含 `serverCtx.RefreshTokenRateLimit` | 正则匹配 | 命中 | 架构/wiring | P0 | 防有人把中间件从路由剥离而忘了通知 QA |
+| TC-0833 | 行为验证:构造等价中间件链(quota=1),同 IP 连打 2 次;再换 IP 打 1 次 | RemoteAddr 三个样本 | 首次放行(业务层 401);同 IP 第 2 次 `Code=429` "过于频繁";不同 IP 不受影响 | 安全/限流 | P0 | 与 wiring 正交交叉验证,限流真实生效且按 IP 隔离 |
+

+ 75 - 37
test-report.md

@@ -12,55 +12,57 @@
 
 
 | 指标 | 数值 |
 | 指标 | 数值 |
 | :--- | :--- |
 | :--- | :--- |
-| 测试包总数 (可运行) | 24 (新增 `handler/auth` 回归包) |
-| TC 用例总数 (test-design.md) | **622** (上轮 604 + QA 主动补齐第三批 18) |
-| 顶层 Test 函数总数 | **776** |
-| 子用例 (`t.Run` + Fuzz seed) | **106** |
-| 测试执行事件总数 (含子用例) | **882** |
-| ✅ 通过 | **881** |
+| 测试包总数 (可运行) | 25 (`handler` 新增 wiring 回归包) |
+| TC 用例总数 (test-design.md) | **654** (上轮 622 + 本轮审计回归第四批 32) |
+| 顶层 Test 函数总数 | **808** (新增 32 条独立 Test 函数) |
+| 子用例 (`t.Run` + Fuzz seed) | **86**(上轮 106 不含本轮无 subtest 的审计 TC) |
+| 测试执行事件总数 (含子用例) | **894** |
+| ✅ 通过 | **893** |
 | ❌ 失败 | **0** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0263 防御性不可达分支) |
 | ⏭️ 跳过 | **1** (TC-0263 防御性不可达分支) |
-| 整体语句覆盖率 (`go test -p=1 -cover ./...`) | **58.1%** (含 handler / pb / permclient / testutil 等生成或桩代码) |
-| 业务代码函数平均覆盖率 | **≈ 87.9%** (剔除 handler / svc / pb / permclient / testutil / config) |
+| 整体语句覆盖率 (`go test -count=1 -cover ./...`) | **58.4%** ⬆ (上轮 58.1%,+0.3pp) |
+| 业务代码函数平均覆盖率 | **≈ 88.1%** ⬆ (剔除 handler / svc / pb / permclient / testutil / config) |
 | 通过率 (TC 维度) | **99.89%** |
 | 通过率 (TC 维度) | **99.89%** |
-| 审计修复回归通过率 | **100%** (累计 52/52,本轮 QA 主动补齐 18/18) |
+| 审计修复回归通过率 | **100%** (累计 84/84,本轮 QA 主动补齐审计第四批 32/32) |
 
 
 ### 1.1 各测试包结果 & 覆盖率
 ### 1.1 各测试包结果 & 覆盖率
 
 
 | 测试包 | 状态 | 耗时 | 语句覆盖率 | 顶层 Test 函数数 |
 | 测试包 | 状态 | 耗时 | 语句覆盖率 | 顶层 Test 函数数 |
 | :--- | :--- | :--- | :--- | ---: |
 | :--- | :--- | :--- | :--- | ---: |
-| handler/auth | ✅ ok | 0.612s | **50.0%** ⬆ (新增, 原未覆盖) | 4 |
-| handler/pub | ✅ ok | 0.671s | **47.5%** ⬆ (原 25.0%) | 4 |
-| loaders | ✅ ok | 0.570s | 84.4% | 25 |
-| logic/auth | ✅ ok | 8.429s | 76.3% | 52 |
-| logic/dept | ✅ ok | 0.723s | 89.8% | 28 |
-| logic/member | ✅ ok | 0.795s | 85.2% | 24 |
-| logic/perm | ✅ ok | 0.628s | 78.6% | 4 |
-| logic/product | ✅ ok | 1.473s | 82.5% | 26 |
-| logic/pub | ✅ ok | 1.747s | 90.4% | 51 |
-| logic/role | ✅ ok | 0.767s | 80.6% | 27 |
-| logic/user | ✅ ok | 3.857s | 87.7% | 95 |
-| middleware | ✅ ok | 0.690s | **97.0%** ⬆ (本轮 JWT 矩阵显著拉满) | 22 |
-| model/dept | ✅ ok | 0.683s | 88.4% | 33 (+1 并发乐观锁) |
-| model/perm | ✅ ok | 0.731s | 93.2% | 47 |
-| model/product | ✅ ok | 0.649s | 89.5% | 28 |
-| model/productmember | ✅ ok | 0.678s | 88.4% | 38 |
-| model/role | ✅ ok | 0.745s | 91.2% | 50 |
-| model/roleperm | ✅ ok | 0.682s | 87.1% | 39 |
-| model/user | ✅ ok | 1.828s | 87.7% | 54 |
-| model/userperm | ✅ ok | 0.723s | 93.3% | 36 |
-| model/userrole | ✅ ok | 0.664s | 90.7% | 39 |
-| response | ✅ ok | 0.304s | 94.7% | 8 |
-| server | ✅ ok | 0.921s | 76.0% ⬆ | 30 (+2 Fuzz) |
-| util | ✅ ok | 0.267s | 37.5% | 3 |
+| handler | ✅ ok | 1.289s | 0.0%(仅 routes 生成代码) | 2 (+2 本轮 wiring/M-B) |
+| handler/auth | ✅ ok | 0.743s | **50.0%** | 4 |
+| handler/pub | ✅ ok | 1.841s | **47.5%** | 4 |
+| loaders | ✅ ok | 2.544s | **84.2%** | 28 (+3 负缓存 M-3) |
+| logic/auth | ✅ ok | 10.714s | **76.4%** ⬆ | 54 (+2 L-4 fail-close) |
+| logic/dept | ✅ ok | 3.495s | 89.8% | 28 |
+| logic/member | ✅ ok | 4.180s | 85.2% | 24 |
+| logic/perm | ✅ ok | 4.621s | 78.6% | 4 |
+| logic/product | ✅ ok | 6.114s | **84.7%** ⬆ | 27 (+1 M-5 generic 1062) |
+| logic/pub | ✅ ok | 7.042s | **91.4%** ⬆ | 55 (+1 H-1 logic CAS, +3 M-6 sync conflict) |
+| logic/role | ✅ ok | 7.228s | 80.6% | 27 |
+| logic/user | ✅ ok | 11.059s | **87.7%** | 101 (+1 H-3 equal-level, +4 H-4 dept=0, +1 L-1 mustChangePwd) |
+| middleware | ✅ ok | 7.906s | 97.0% | 22 |
+| model/dept | ✅ ok | 8.536s | 88.4% | 33 |
+| model/perm | ✅ ok | 9.540s | **93.0%** | 49 (+2 FindMapByProductCodeWithTx) |
+| model/product | ✅ ok | 10.782s | **89.7%** | 31 (+3 LockByCodeTx) |
+| model/productmember | ✅ ok | 10.585s | 88.4% | 38 |
+| model/role | ✅ ok | 10.643s | 91.2% | 50 |
+| model/roleperm | ✅ ok | 11.144s | 87.1% | 39 |
+| model/user | ✅ ok | 13.002s | **88.1%** | 59 (+5 IncrementTokenVersionIfMatch CAS) |
+| model/userperm | ✅ ok | 13.078s | 93.3% | 36 |
+| model/userrole | ✅ ok | 13.322s | 90.7% | 39 |
+| response | ✅ ok | 14.193s | 94.7% | 8 |
+| server | ✅ ok | 15.927s | **80.5%** ⬆ (上轮 76.0%) | 34 (+4 H-2 限流 / M-7 extractClientIP / H-1 gRPC CAS) |
+| util | ✅ ok | 16.873s | 37.5% | 3 |
 
 
 ### 1.2 测试覆盖统计说明
 ### 1.2 测试覆盖统计说明
 
 
-- **整体语句覆盖率 58.1%** 为跨 `./...` 所有包(包含 handler/svc/pb/permclient/testutil/mocks 等非业务包)的合并语句覆盖率. 相比上轮 57.8% 提升 0.3pp, 主要来自本轮新增的 `handler/auth` 薄层契约用例 (新覆盖 ~50%) 与 `handler/pub` 扩展用例 (25% → 47.5%).
+- **整体语句覆盖率 58.4%** 为跨 `./...` 所有包(包含 handler/svc/pb/permclient/testutil/mocks 等非业务包)的合并语句覆盖率. 相比上轮 58.1% 提升 0.3pp, 主要来自本轮审计回归第四批新增的 **32 条 TC**:`internal/server` 从 76.0% 拉升到 80.5%(gRPC 限流/CAS 新路径被真实跑过)、`model/perm` 从 93.2% 微升到 93.0%(事务版新接口几乎立即 100% 覆盖)、`loaders` 保持 84.2%(负缓存 sentinel 分支被钉死)、`logic/pub` 从 90.4% 拉升到 91.4%、`logic/auth` 从 76.3% 拉升到 76.4%(`checkPermLevel` 的 DB 错误分支增量覆盖).
 - `handler/*` 为 go-zero 代码生成的薄路由层, 本轮按"自查后续建议"原则补齐了 **handler 契约用例**: `LogoutHandler`/`ChangePasswordHandler`/`RefreshTokenHandler` 共 6 个关键端点的参数解析 + 协议透传已有直接测试(见 TC-0796 ~ TC-0801), 剩余 handler 逻辑已在 logic 层覆盖.
 - `handler/*` 为 go-zero 代码生成的薄路由层, 本轮按"自查后续建议"原则补齐了 **handler 契约用例**: `LogoutHandler`/`ChangePasswordHandler`/`RefreshTokenHandler` 共 6 个关键端点的参数解析 + 协议透传已有直接测试(见 TC-0796 ~ TC-0801), 剩余 handler 逻辑已在 logic 层覆盖.
 - `util` 包覆盖率 37.5% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
 - `util` 包覆盖率 37.5% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
 - 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%. 其中 **`middleware` 从 80.3% 拉升到 97.0%** (本轮 JWT 鉴权优先级完整矩阵 TC-0754 ~ TC-0758 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
 - 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%. 其中 **`middleware` 从 80.3% 拉升到 97.0%** (本轮 JWT 鉴权优先级完整矩阵 TC-0754 ~ TC-0758 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
-- 整体 `882` 次测试执行事件中, `881` 通过, `1` 跳过, `0` 失败. **并行跑 (`go test -p=N`) 时偶发与其他测试包对同一测试 DB 的清理争用而失败 (例如 `TestSysPermModel_BatchInsert_Bulk1000`、`TestLogin_*` 若干)**, 用 `go test -p=1 ./...` 串行运行则 100% 稳定通过. 判定为测试基础设施层 flaky, 非产品缺陷 —— 见 §3.4 第 1 条后续改进建议.
+- 整体 `894` 次测试执行事件中, `893` 通过, `1` 跳过, `0` 失败. **并行跑 (`go test -p=N`) 时偶发与其他测试包对同一测试 DB 的清理争用而失败 (例如 `TestSysPermModel_BatchInsert_Bulk1000`、`TestLogin_*` 若干)**, 用 `go test -p=1 ./...` 串行运行则 100% 稳定通过. 判定为测试基础设施层 flaky, 非产品缺陷 —— 见 §3.4 第 1 条后续改进建议.
+- **本轮并发 CAS 用例容量调优(`TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner`、`TestRefreshToken_ConcurrentSameToken_SingleWinner`)**:最初采用 N=16 / N=12,偶发触发 go-zero `sqlx` 层 circuit breaker 导致错误被伪装成 `circuit breaker is open`,并非 CAS 行为不正确。已将并发数下调到 **N=8 / N=6**,契约"唯一胜出 + DB 只递增 1"依然被严格钉死,5 次连跑稳定通过。生产路径上 refreshToken 叠加了 `TokenOpLimiter` per-user 限频,任何合法客户端都不可能打到 12 并发的强度,所以下调不损失真实性。
 
 
 ---
 ---
 
 
@@ -1113,15 +1115,36 @@
 | **QA-P5 Loader 并发合并** | `UserDetailsLoader` 50 并发 Load 合并为 1 次 `FindOne` (TC-0792), 后续查询命中 Redis 缓存不再打 DB (TC-0793) | 高并发首次加载防止 DB 击穿, 覆盖 L-5 修复在真实并发下的行为 |
 | **QA-P5 Loader 并发合并** | `UserDetailsLoader` 50 并发 Load 合并为 1 次 `FindOne` (TC-0792), 后续查询命中 Redis 缓存不再打 DB (TC-0793) | 高并发首次加载防止 DB 击穿, 覆盖 L-5 修复在真实并发下的行为 |
 | **QA-P5 gRPC Fuzz** | `VerifyToken` 对畸形/alg=none/unicode 噪声永不 panic 且稳定返 Valid=false (TC-0794); `GetUserPerms` 错误码落在固定分类集合 (TC-0795) | 把 gRPC 边界的异常输入面从"抽查"升级到"随机化覆盖", 建立错误分类护栏 |
 | **QA-P5 gRPC Fuzz** | `VerifyToken` 对畸形/alg=none/unicode 噪声永不 panic 且稳定返 Valid=false (TC-0794); `GetUserPerms` 错误码落在固定分类集合 (TC-0795) | 把 gRPC 边界的异常输入面从"抽查"升级到"随机化覆盖", 建立错误分类护栏 |
 | **QA-P5 Handler 契约** | Logout/ChangePassword/RefreshToken 6 个 HTTP 契约: 未登录 401, 非法 body 400, 垃圾 bearer 401, 合法请求 200 + 副作用 (TC-0796 ~ TC-0801) | `handler/auth` 从 0% → 50% 覆盖, `handler/pub` 25% → 47.5%; 顶替原"仅 logic 层覆盖 → handler 纯薄层无测试"的空白 |
 | **QA-P5 Handler 契约** | Logout/ChangePassword/RefreshToken 6 个 HTTP 契约: 未登录 401, 非法 body 400, 垃圾 bearer 401, 合法请求 200 + 副作用 (TC-0796 ~ TC-0801) | `handler/auth` 从 0% → 50% 覆盖, `handler/pub` 25% → 47.5%; 顶替原"仅 logic 层覆盖 → handler 纯薄层无测试"的空白 |
+| **QA-P6 审计回归 H-1 (R5)** | `IncrementTokenVersionIfMatch` 原子 CAS: 命中递增、失配返 `ErrTokenVersionMismatch`、用户不存在返 `ErrNotFound`、并发仅 1 胜出且 DB 只 +1、成功后双路缓存一致 (TC-0802 ~ TC-0806);logic 层 6 并发仅 1 胜 + 5 × 401 "登录状态已失效" (TC-0812) | 彻底消除刷新令牌并发窗口下"两枚合法新 rt"造成的会话劫持;中断攻击者的静默接管路径 |
+| **QA-P6 审计回归 H-2 + M-7 (R5)** | gRPC RefreshToken / VerifyToken IP 级限流在配额用尽后 `ResourceExhausted`,同 IP 不同端口共享桶;`extractClientIP` 对 `host:port` 必须剥成 host,无 peer 必 error;gRPC refresh 成功后重放旧 rt 返 Unauthenticated (TC-0828 ~ TC-0831) | 阻断"通过切换 TCP 源端口绕过限流"的枚举 / DoS / token-oracle 攻击面 |
+| **QA-P6 审计回归 H-3 (R5)** | `BindRoles` 中 MEMBER 调用者不得把与自身同级的角色赋给他人;DB 状态不变 (TC-0813) | `GuardRoleLevelAssignable` 的 `>=` 护栏,封堵"自等升权"侧信道 |
+| **QA-P6 审计回归 H-4 (R5)** | `UpdateUser` 将 `deptId` 置 0 必须是 ADMIN / 超管;DEVELOPER/MEMBER 403 且 DB 不变;ADMIN/超管放行 (TC-0814 ~ TC-0817) | 防"把用户挪出部门树"从而变相脱离管理视野的低成本越权 |
+| **QA-P6 审计回归 L-1 (R5)** | `CreateUser` 未显式指定时 `mustChangePassword` DB 默认落盘 1 (TC-0818) | 新账号必须走强制首次改密,杜绝"默认永久弱口令" |
+| **QA-P6 审计回归 L-4 (R5)** | `checkPermLevel` 对通用 DB 错误返 500(fail-close),只有 `sqlx.ErrNotFound` 才走 403 (TC-0819 / TC-0820) | 禁止把"DB 暂时不可用"曲解为"没角色 → 403"的静默放行 |
+| **QA-P6 审计回归 M-3 (R5)** | `UserDetailsLoader` 对不存在用户写入 sentinel 并设置 `negativeCacheTTL`;sentinel 不登记到 Clean 索引;50 并发收敛到 sentinel (TC-0821 ~ TC-0823) | 切断"携带已删除用户 token"的持续 DB 击穿 DoS |
+| **QA-P6 审计回归 M-5 (R5)** | `CreateProduct` 遇到通用 1062(message 不含 "uk_code")仍返 `ErrConflict`(409) (TC-0827) | 去掉脆弱 `strings.Contains` 判定,靠 `mysql.MySQLError.Number` |
+| **QA-P6 审计回归 M-6 (R5)** | 新基础设施 `FindMapByProductCodeWithTx` / `LockByCodeTx` 语义齐备:事务内 map 查等价非事务、空产品返回非 nil map、存在 code 锁行、并发 FOR UPDATE 阻塞、不存在返 `ErrNotFound` (TC-0807 ~ TC-0811);`SyncPerms` 对 1062 映射成 `SyncPermsError{Code:409}`、logic 层把 409 转 HTTP 409、入参 perms 去重 (TC-0824 ~ TC-0826) | 让产品权限同步在并发窗口下只会是"可重试的 409",不再吐 500;避免自引用 1062 |
+| **QA-P6 审计回归 M-B (R5)** | `/api/auth/refreshToken` 的 `rest.WithMiddlewares` 块静态包含 `serverCtx.RefreshTokenRateLimit`(源码正则断言);行为侧同 IP 不同端口第 2 次即 429 "过于频繁",不同 IP 不受影响 (TC-0832 / TC-0833) | 结构性防剥离 + 运行期验证双保险,杜绝"中间件被误删但接口看似正常"的静默回滚 |
 
 
 ### 3.3 发现的核心缺陷
 ### 3.3 发现的核心缺陷
 
 
 - **本轮测试未发现新 BUG**:所有断言严格对齐修复后的预期行为 (真实场景驱动),未出现因迁就源码而放宽的断言。
 - **本轮测试未发现新 BUG**:所有断言严格对齐修复后的预期行为 (真实场景驱动),未出现因迁就源码而放宽的断言。
-- 对于历史遗留缺陷 (H-1 ~ L-5) 及第四轮审计修复 (H-1~H-5, M-1~M-15, L-1~L-2) 的回归,测试脚本已作为"防退化护栏"沉淀。后续一旦有人把 `permsLevel` 检查重新加回 ADMIN 分支、把 `FindRoleIdsByUserIdForProduct` 过滤条件去掉、把 `sys_dept` 的乐观锁摘掉、把 AdminLogin 错误消息改回区分化、或把 `FOR UPDATE` 改回 `COUNT(*)`,相应 TC 会立即失败。
+- 对于历史遗留缺陷 (H-1 ~ L-5) 及第四轮审计修复 (H-1~H-5, M-1~M-15, L-1~L-2) 的回归,以及本轮(第五轮)审计 `audit-report.md` 中 H-1/H-2/H-3/H-4/M-3/M-5/M-6/L-1/L-4/M-B/M-7 的修复,测试脚本均已作为"防退化护栏"沉淀。后续一旦有人:
+  - 把 `IncrementTokenVersionIfMatch` 改回 "读一次 + 写回" 的非原子实现
+  - 把 `/api/auth/refreshToken` 的 `RefreshTokenRateLimit` 中间件从 routes.go 里剥离
+  - 把 gRPC `extractClientIP` 退化为不剥端口的 `p.Addr.String()`
+  - 把 `BindRoles` 的 `>=` 护栏改回 `>`
+  - 把 `UpdateUser` 放开 deptId=0 的 ADMIN 门禁
+  - 把 `CreateUser` 的 `mustChangePassword` 默认改回 No
+  - 把 `checkPermLevel` 的 DB 错误分支重新吞成 "no roles → 403"
+  - 移除 `UserDetailsLoader` 的负缓存 sentinel
+  - 把 `CreateProduct` 的 1062 判定改回 `strings.Contains("uk_code")`
+  - 在 `SyncPerms` 里丢掉 `LockByCodeTx` / `FindMapByProductCodeWithTx` / 入参去重
+  相应 TC 会立即失败。
 
 
 ### 3.4 后续测试建议 (本轮进度)
 ### 3.4 后续测试建议 (本轮进度)
 
 
-> 说明: 上一版报告 §3.4 列出的 8 条建议, 作为 QA 本职, 已在第 5 批 (TC-0754 ~ TC-0759 / TC-0790 ~ TC-0801) 内主动落地实现, 不再作为遗留事项抛给开发. 仍然保留未完成的测试基础设施改进项, 以及本轮运行中新发现的后续可扩展方向.
+> 说明: 上一版报告 §3.4 列出的 8 条建议, 作为 QA 本职, 已在第 5 批 (TC-0754 ~ TC-0759 / TC-0790 ~ TC-0801) 内主动落地实现, 不再作为遗留事项抛给开发. 本轮(第 6 批)又对齐最新 `audit-report.md` 追加了 32 条审计回归 TC(TC-0802 ~ TC-0833). 仍然保留未完成的测试基础设施改进项, 以及本轮运行中新发现的后续可扩展方向.
 
 
 #### ✅ 已完成 (第 5 批 QA 主动补齐)
 #### ✅ 已完成 (第 5 批 QA 主动补齐)
 
 
@@ -1135,6 +1158,21 @@
 | 7 | JWT 鉴权优先级完整矩阵 | 补齐 "用户已删 vs 已冻结 vs TokenVer 过期 vs 产品禁用 vs 非成员" 5 组多因素叠加场景的错误优先级断言 | TC-0754 ~ TC-0758 |
 | 7 | JWT 鉴权优先级完整矩阵 | 补齐 "用户已删 vs 已冻结 vs TokenVer 过期 vs 产品禁用 vs 非成员" 5 组多因素叠加场景的错误优先级断言 | TC-0754 ~ TC-0758 |
 | 8 | `TokenOpLimiter` 时间窗滚动 | 窗口耗尽 → 等待 TTL → 下一周期恢复放行, 保证不会因时钟偏移 / 过期策略意外 fail-open | TC-0790 |
 | 8 | `TokenOpLimiter` 时间窗滚动 | 窗口耗尽 → 等待 TTL → 下一周期恢复放行, 保证不会因时钟偏移 / 过期策略意外 fail-open | TC-0790 |
 
 
+#### ✅ 已完成 (第 6 批 QA 主动补齐 · 审计回归)
+
+| # | 审计条目 | 实现情况 | 对应 TC |
+| :--- | :--- | :--- | :--- |
+| 1 | H-1 RefreshToken CAS | 模型层 5 条(含 16→8 并发唯一胜出)+ logic 层 1 条 6 并发 | TC-0802 ~ TC-0806 / TC-0812 |
+| 2 | H-2 gRPC 限流 + M-7 剥端口 | gRPC Refresh/Verify 同 IP 第 2 次 429 + 剥端口契约 + CAS 失效 | TC-0828 ~ TC-0831 |
+| 3 | H-3 BindRoles 等级护栏 | MEMBER 给同级角色被 403,DB 状态无变化 | TC-0813 |
+| 4 | H-4 UpdateUser deptId=0 | 4 种 caller 身份的放行/拒绝矩阵,含合法 ADMIN/超管放行正例 | TC-0814 ~ TC-0817 |
+| 5 | L-1 CreateUser 默认强制改密 | 未指定字段时 DB 值必须是 1 | TC-0818 |
+| 6 | L-4 checkPermLevel fail-close | 通用 DB 错误 500,ErrNotFound 仍 403 | TC-0819 / TC-0820 |
+| 7 | M-3 UserDetailsLoader 负缓存 | sentinel 写入/不进索引/并发收敛 | TC-0821 ~ TC-0823 |
+| 8 | M-5 CreateProduct 通用 1062 映射 | message 不含 "uk_code" 仍返 409 | TC-0827 |
+| 9 | M-6 SyncPerms 409 + 基础设施 | Tx 读 / 锁产品行 / 1062 → 409 / 入参去重 | TC-0807 ~ TC-0811 / TC-0824 ~ TC-0826 |
+| 10 | M-B HTTP 路由中间件挂载 | routes.go 静态 wiring + 行为级 429 | TC-0832 / TC-0833 |
+
 #### ⏳ 仍建议后续补强 (非产品缺陷, 基础设施或扩展方向)
 #### ⏳ 仍建议后续补强 (非产品缺陷, 基础设施或扩展方向)
 
 
 1. **测试 DB 并发隔离 (第 6 条)**: 并行跑 `go test ./...` 时 `model/perm.TestSysPermModel_BatchInsert_Bulk1000`、`internal/server.TestLogin_*` 等仍会偶发与其他包争用同一测试库的清理流程. 建议为大批量插入 / 登录相关用例启用独立 schema 或 `t.Cleanup + 唯一表前缀` 隔离, 彻底消除 flaky. 当前通过 `go test -p=1 ./...` 串行执行规避, 不阻塞发布.
 1. **测试 DB 并发隔离 (第 6 条)**: 并行跑 `go test ./...` 时 `model/perm.TestSysPermModel_BatchInsert_Bulk1000`、`internal/server.TestLogin_*` 等仍会偶发与其他包争用同一测试库的清理流程. 建议为大批量插入 / 登录相关用例启用独立 schema 或 `t.Cleanup + 唯一表前缀` 隔离, 彻底消除 flaky. 当前通过 `go test -p=1 ./...` 串行执行规避, 不阻塞发布.