소스 검색

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

BaiLuoYan 3 주 전
부모
커밋
be98be9ac9
35개의 변경된 파일2480개의 추가작업 그리고 526개의 파일을 삭제
  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)
 
-### 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
-  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
-  // 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
-  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
-  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
-  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
-  // 子部门:如果有任意一行,就失败;并在 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
-  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
-  // 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
-  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
-  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(
-		[]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"),
 	)
 

+ 20 - 1
internal/loaders/userDetailsLoader.go

@@ -17,7 +17,16 @@ import (
 	"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 及子结构 --------
 
@@ -110,6 +119,11 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 	key := l.cacheKey(userId, productCode)
 
 	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
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
 			return &ud
@@ -122,6 +136,11 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 			return nil, err
 		}
 		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
 		}
 		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 (
 	"context"
+	"errors"
 	"math"
 	"strings"
 
@@ -10,6 +11,8 @@ import (
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
+
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 func memberTypePriority(memberType string) int {
@@ -100,6 +103,23 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 	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 校验)。
 // SuperAdmin / ADMIN / DEVELOPER 均视为全权;loadPerms 对此三者走全权分支。
 // 所有依赖"调用者已拥有全权"的短路逻辑应复用此函数,变更只需改一处。
@@ -184,6 +204,12 @@ func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loa
 	// memberType 相同,比较 permsLevel
 	targetLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, targetUserId, productCode)
 	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
 	}
 

+ 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"
 	"fmt"
 	"regexp"
-	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -133,14 +132,11 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	})
 
 	if err != nil {
+		// 前置的 FindOneByCode / FindOneByUsername 已经在大多数合法请求里把"产品码/用户名已存在"
+		// 分辨清楚并返回具体文案。落到这里的 1062 基本都是同秒并发创建的稀有竞态,按审计 M-5 的
+		// 建议不再用 strings.Contains 匹配 MySQL 错误消息中的索引名(不同版本的文案不稳定,
+		// 改索引名会导致静默降级成通用冲突);直接统一回通用冲突让前端重试,由 pre-check 负责语义。
 		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, 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 (
 	"context"
+	"errors"
 	"fmt"
 	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -31,6 +33,7 @@ func NewRefreshTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Refr
 }
 
 // RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的 accessToken/refreshToken 令牌对,旧令牌即时失效(单会话轮转)。
+// 路由层已挂载 RefreshTokenRateLimit 做 IP 维度限流;本处再叠加 per-user 限流,形成"IP + 用户"双层防护。
 func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *types.LoginResp, err error) {
 	tokenStr := strings.TrimPrefix(req.Authorization, "Bearer ")
 	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 errors.Is(err, userModel.ErrTokenVersionMismatch) {
+			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
+		}
 		return nil, err
 	}
 	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)
 			case 403:
 				return nil, response.ErrForbidden(se.Message)
+			case 409:
+				return nil, response.ErrConflict(se.Message)
 			default:
 				return nil, err
 			}

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

@@ -7,6 +7,7 @@ import (
 	"perms-system-server/internal/consts"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
+	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"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: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
 	}
 
-	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))
-
-	var toInsert []*permModel.SysPerm
-	var toUpdate []*permModel.SysPerm
-
 	seen := make(map[string]bool, len(perms))
+	dedupPerms := make([]SyncPermItem, 0, len(perms))
 	for _, item := range perms {
 		if seen[item.Code] {
 			continue
 		}
 		seen[item.Code] = true
 		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]
 		if !ok {
 			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 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 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: "同步权限事务失败"}
 	}
 

+ 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 (
 	"context"
-	"math"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -83,10 +82,8 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				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,
 		DeptId:             req.DeptId,
 		IsSuperAdmin:       consts.IsSuperAdminNo,
-		MustChangePassword: consts.MustChangePasswordNo,
+		// 管理员代填的初始密码默认要求首次登录必须修改,降低"管理员口头下发后长期不换、口令库泄露即广义失陷"的风险(见审计 L-1)。
+		MustChangePassword: consts.MustChangePasswordYes,
 		Status:             consts.StatusEnabled,
 		CreateTime:         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) {
 				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
 	}

+ 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)
 		FindByIds(ctx context.Context, ids []int64) ([]*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)
 	}
 
@@ -91,6 +95,19 @@ func (m *customSysPermModel) FindMapByProductCode(ctx context.Context, productCo
 	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) {
 	// 先查出将被禁用的行,构建缓存 key
 	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
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, 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 {
@@ -50,6 +53,15 @@ func (m *customSysProductModel) UpdateWithOptLock(ctx context.Context, data *Sys
 	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) {
 	var total int64
 	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")
 
+// ErrTokenVersionMismatch 表示令牌版本与数据库当前版本不一致,刷新令牌失败。
+// 典型场景:refreshToken rotation 并发到达 —— 只有持有当前 tokenVersion 的那一次能原子递增成功,
+// 其余全部返回该错误,防止两个请求都"换到"新令牌(导致会话劫持)。
+var ErrTokenVersionMismatch = errors.New("token version mismatch")
+
 var _ SysUserModel = (*customSysUserModel)(nil)
 
 type (
@@ -27,6 +32,7 @@ type (
 		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
 		UpdateStatus(ctx context.Context, id int64, status int64) error
 		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
+		IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error)
 	}
 
 	customSysUserModel struct {
@@ -174,6 +180,39 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 	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) {
 	if len(ids) == 0 {
 		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 (
 	"context"
+	"errors"
 	"fmt"
 	"net"
 	"time"
@@ -10,6 +11,7 @@ import (
 	authHelper "perms-system-server/internal/logic/auth"
 	pub "perms-system-server/internal/logic/pub"
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/svc"
 	"perms-system-server/pb"
 
@@ -22,6 +24,29 @@ import (
 	"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 服务实现,供接入产品的服务端调用。
 type PermServer struct {
 	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)
 			case 403:
 				return nil, status.Error(codes.PermissionDenied, se.Message)
+			case 409:
+				return nil, status.Error(codes.Aborted, se.Message)
 			default:
 				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 维度限流保护。
 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 {
-		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))
 		if code == limit.OverQuota {
 			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
@@ -112,8 +136,22 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 	}, nil
 }
 
-// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的令牌对,同时递增 tokenVersion 使旧令牌即时失效(单会话轮转)。
+// RefreshToken 刷新令牌。使用有效的 refreshToken 换取新的令牌对,同时原子 CAS 递增 tokenVersion
+// 使旧令牌即时失效(单会话轮转)。受 IP 维度限流保护,防止签名爆破和并发刷新被用于会话劫持。
 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)
 	if err != nil {
 		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, "登录状态已失效,请重新登录")
 	}
 
-	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 errors.Is(err, userModel.ErrTokenVersionMismatch) {
+			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
+		}
 		return nil, status.Error(codes.Internal, "刷新token失败")
 	}
 	s.svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
@@ -172,8 +221,24 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 	}, 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) {
+	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) {
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 	})

+ 11 - 0
internal/svc/servicecontext.go

@@ -18,7 +18,10 @@ type ServiceContext struct {
 	ProductLoginRateLimit rest.Middleware
 	AdminLoginRateLimit   rest.Middleware
 	SyncRateLimit         rest.Middleware
+	RefreshTokenRateLimit rest.Middleware
 	GrpcLoginLimiter      *limit.PeriodLimit
+	GrpcRefreshLimiter    *limit.PeriodLimit
+	GrpcVerifyLimiter     *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	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)
 	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)
+	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")
+	// 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")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
@@ -43,7 +51,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		ProductLoginRateLimit: productLoginRL.Handle,
 		AdminLoginRateLimit:   adminLoginRL.Handle,
 		SyncRateLimit:         syncRlMiddleware.Handle,
+		RefreshTokenRateLimit: refreshTokenRL.Handle,
 		GrpcLoginLimiter:      grpcLimiter,
+		GrpcRefreshLimiter:    grpcRefreshLimiter,
+		GrpcVerifyLimiter:     grpcVerifyLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		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)
 }
 
+// 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.
 func (m *MockSysPermModel) FindOne(ctx context.Context, id int64) (*perm.SysPerm, error) {
 	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)
 }
 
+// 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.
 func (m *MockSysProductModel) TableName() string {
 	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)
 }
 
-// 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()
-	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
+	ret := m.ctrl.Call(m, "Update", ctx, data)
 	ret0, _ := ret[0].(error)
 	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()
-	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()
-	ret := m.ctrl.Call(m, "Update", ctx, data)
+	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
 	ret0, _ := ret[0].(error)
 	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()
-	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.

+ 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)
 }
 
+// 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.
 func (m *MockSysUserModel) Insert(ctx context.Context, data *user.SysUser) (sql.Result, error) {
 	m.ctrl.T.Helper()

+ 4 - 3
perm.api

@@ -326,10 +326,11 @@ service perm-api {
 	post /auth/login (LoginReq) returns (LoginResp)
 }
 
-// 令牌刷新,不需要鉴权中间件,自行验证 refreshToken 有效性
+// 令牌刷新,不需要鉴权中间件,自行验证 refreshToken 有效性;受 IP 维度限流保护,防止签名爆破/CPU 放大 DoS
 @server (
-	prefix: /api
-	group:  pub
+	prefix:     /api
+	group:      pub
+	middleware: RefreshTokenRateLimit
 )
 service perm-api {
 	// 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-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 畸形时等价于未登录 |
+
+## 十四、 本轮新增对抗性用例(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** |
 | ⏭️ 跳过 | **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%** |
-| 审计修复回归通过率 | **100%** (累计 52/52,本轮 QA 主动补齐 18/18) |
+| 审计修复回归通过率 | **100%** (累计 84/84,本轮 QA 主动补齐审计第四批 32/32) |
 
 ### 1.1 各测试包结果 & 覆盖率
 
 | 测试包 | 状态 | 耗时 | 语句覆盖率 | 顶层 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 测试覆盖统计说明
 
-- **整体语句覆盖率 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 层覆盖.
 - `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 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
-- 整体 `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 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-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 发现的核心缺陷
 
 - **本轮测试未发现新 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 列出的 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 主动补齐)
 
@@ -1135,6 +1158,21 @@
 | 7 | JWT 鉴权优先级完整矩阵 | 补齐 "用户已删 vs 已冻结 vs TokenVer 过期 vs 产品禁用 vs 非成员" 5 组多因素叠加场景的错误优先级断言 | TC-0754 ~ TC-0758 |
 | 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 ./...` 串行执行规避, 不阻塞发布.