Parcourir la source

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

BaiLuoYan il y a 3 semaines
Parent
commit
7dbe686aa8
46 fichiers modifiés avec 2614 ajouts et 1107 suppressions
  1. 418 344
      audit-report.md
  2. 45 14
      internal/loaders/userDetailsLoader.go
  3. 79 0
      internal/loaders/userDetailsLoaderCleanByUserIds_audit_test.go
  4. 49 11
      internal/logic/auth/access.go
  5. 143 0
      internal/logic/auth/checkManageAccessPrefetch_audit_test.go
  6. 112 0
      internal/logic/dept/deptTreeAccessControl_audit_test.go
  7. 35 6
      internal/logic/dept/deptTreeLogic.go
  8. 0 95
      internal/logic/dept/deptTreeLogic_test.go
  9. 121 0
      internal/logic/dept/updateDeptCleanBatch_audit_test.go
  10. 10 4
      internal/logic/dept/updateDeptLogic.go
  11. 4 2
      internal/logic/member/removeMemberLogic.go
  12. 3 2
      internal/logic/member/updateMemberLogic.go
  13. 215 0
      internal/logic/product/productAccessControl_audit_test.go
  14. 12 3
      internal/logic/product/productDetailLogic.go
  15. 0 116
      internal/logic/product/productDetailLogic_test.go
  16. 29 8
      internal/logic/product/productListLogic.go
  17. 0 168
      internal/logic/product/productListLogic_test.go
  18. 144 0
      internal/logic/pub/adminLoginIpLimit_audit_test.go
  19. 13 2
      internal/logic/pub/adminLoginLogic.go
  20. 16 10
      internal/logic/pub/loginService.go
  21. 124 0
      internal/logic/pub/loginServiceConstantTime_audit_test.go
  22. 1 1
      internal/logic/pub/refreshTokenLogic.go
  23. 0 162
      internal/logic/pub/syncPermsConflict_audit_test.go
  24. 72 0
      internal/logic/pub/syncPermsDedup_audit_test.go
  25. 16 9
      internal/logic/pub/syncPermsLogic_mock_test.go
  26. 45 39
      internal/logic/pub/syncPermsService.go
  27. 150 0
      internal/logic/pub/syncPermsTxLock_audit_test.go
  28. 8 5
      internal/logic/role/bindRolePermsLogic.go
  29. 108 0
      internal/logic/role/postCommitCacheDegraded_audit_test.go
  30. 7 5
      internal/logic/role/updateRoleLogic.go
  31. 3 2
      internal/logic/user/bindRolesLogic.go
  32. 3 2
      internal/logic/user/setUserPermsLogic.go
  33. 14 10
      internal/logic/user/updateUserLogic.go
  34. 5 6
      internal/logic/user/updateUserStatusLogic.go
  35. 30 4
      internal/middleware/ratelimitMiddleware.go
  36. 91 0
      internal/middleware/ratelimitMiddlewareXff_audit_test.go
  37. 2 24
      internal/middleware/ratelimitMiddleware_test.go
  38. 191 0
      internal/model/productmember/countOtherActiveAdmins_audit_test.go
  39. 14 0
      internal/model/productmember/sysProductMemberModel.go
  40. 8 16
      internal/model/user/incrementTokenVersionIfMatch_audit_test.go
  41. 7 9
      internal/model/user/sysUserModel.go
  42. 1 1
      internal/server/permserver.go
  43. 15 0
      internal/testutil/mocks/mock_productmember_model.go
  44. 4 4
      internal/testutil/mocks/mock_user_model.go
  45. 130 20
      test-design.md
  46. 117 3
      test-report.md

+ 418 - 344
audit-report.md

@@ -1,466 +1,529 @@
-# 权限管理系统 - 深度代码审计报告(第 5 轮)
+# 权限管理系统 - 深度代码审计报告(第 6 轮)
 
-> 审计范围:同第 4 轮,`/internal` 下全部非测试、非 `_gen.go` 生产代码。
-> 审计时间:2026-04-19(复盘第 4 轮修复后再度深入
+> 审计范围:`/internal` 下全部非测试、非 `_gen.go` 生产代码(含 `internal/server/permserver.go`、HTTP logic / handler / middleware、loaders、model 定制层、svc、util)
+> 审计时间:2026-04-19(第 5 轮修复复盘 + 深扫一轮新代码面
 > 审计重点:
->   - **令牌刷新链路**的原子性与重放窗口(HTTP + gRPC 两套入口并行审视)
->   - **垂直/水平越权**的"等级相等放行"死角
->   - **乐观锁以秒级 `updateTime` 为版本号**在真实同秒并发下的丢失更新
->   - **软删除用户 / 已删除用户的 JWT 仍有效期内**触发的 DB 重复压栈(DoS 向量
->   - 缓存失效链路中残留的"N 次串行 Redis 往返"
->   - 上轮部分修复不彻底的遗留项(`strings.Contains(errMsg, "uk_code")`)
+>   - **账号锁定 / 账号枚举**相关的侧信道 —— 第 5 轮未覆盖
+>   - **SyncPermissions 并发修复的真正落地程度**(第 5 轮 M-6 给了基础设施 `LockByCodeTx`/`FindMapByProductCodeWithTx`,但实际 service 是否真的串行化)
+>   - **UpdateDept 缓存失效批处理**:上轮 M-2 给的结论是"建议实现 CleanMany / pipeline",本轮确认其有无落地
+>   - **读放大路径**(logic 层一次请求里 `FindOne(targetUserId)` 被调用 3~4 次
+>   - **水平越权 / 信息泄露**:ProductList / ProductDetail / DeptTree / UserDetail 对最低权限用户暴露面是否超额
+>   - JWT 解析契约:`token.Method` 的显式校验
 >
-> 相对第 4 轮:第 4 轮 H-1/H-2/H-3(最后一个 ADMIN)、H-5(changePassword 限流)、H-4(DeleteDept 存在性锁读)已**实际落地修复**(本轮代码阅读已确认)。本轮新暴露一组围绕**刷新令牌重放**和**等级相等分配**的高危问题,以及若干性能 / 健壮性缺口
+> 相对第 5 轮:H-1(`IncrementTokenVersionIfMatch` CAS)、H-2(gRPC RefreshToken/VerifyToken 限流)、H-3(`GuardRoleLevelAssignable` 严格低于自身)、H-4(`UpdateUser` deptId=0 ADMIN 守门)、L-1(`MustChangePasswordYes` 默认开启)、L-4(`FindMinPermsLevelByUserIdAndProductCode` 区分 NotFound / 其它 err)均**已实际落地**(阅读 HEAD 代码验证)。M-3(`negativeCacheMarker` 负缓存)、M-5(`CreateProduct` 移除 `strings.Contains` 脆弱匹配)、M-7(gRPC Login IP 剥 host:port)也已落地。本轮暴露的是:**M-2 未落地**(部门用户缓存仍串行 Clean)、**M-6 只落地一半**(1062 → 409 已做,但 `LockByCodeTx` 在 service 里还没接上)、以及一组围绕**登录侧信道、管理员账号 DoS、info disclosure**的新 H/M 级问题
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-1. `RefreshToken` 先校验 `tokenVersion` 再递增,**并发刷新可被第三方"接管会话"**(HTTP + gRPC 双入口)
+### H-1. `AdminLogin` 限流 key 只含 `username`(不含 IP)——任意远端可**永久锁死任意超管账号**
 
-- **位置**:
-  - `internal/logic/pub/refreshTokenLogic.go:64-79`
-  - `internal/server/permserver.go`(同逻辑的 gRPC `RefreshToken` 实现)
+- **位置**:`internal/logic/pub/adminLoginLogic.go:41-46`
 - **描述**:
-  核心代码片段(HTTP 版本):
+
   ```go
-  if claims.TokenVersion != ud.TokenVersion {
-      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
-  }
-  if l.svcCtx.TokenOpLimiter != nil {
-      code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("refresh:%d", claims.UserId))
-      ...
+  if l.svcCtx.UsernameLoginLimit != nil {
+      code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)   // ← key 只有 username
+      if code == limit.OverQuota {
+          return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请5分钟后再试")
+      }
   }
-  newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, claims.UserId)
   ```
 
-  "check 版本号 → 递增版本号"是**两条独立的 SQL**,中间没有行级锁、也没有条件更新语义。DB 里的 `IncrementTokenVersion` 实际是无条件 `UPDATE ... SET tokenVersion = tokenVersion+1`。
+  `UsernameLoginLimit` 当前配置是 `NewPeriodLimit(300, 10, …)`(`servicecontext.go:45`),即**每 5 分钟 10 次**。同接口的产品端登录用 `ip:username` 作为 key(见 `loginService.go:40`),admin 路径**完全没有 IP 维度**。
+  攻击链:
+  1. 攻击者通过 `POST /api/auth/adminLogin` 用一个已知的超管用户名(比如 `admin_<productCode>`,见下面的结构性放大点)连打 10 次错误密码 → 触发 5 分钟封禁;
+  2. 攻击者每 5 分钟刷新一次,只需要极低带宽就能让该账号**永远没有 5 分钟内的合法登录窗口**;
+  3. 攻击者甚至不用在同一个 IP 上,任何网络都可以维持这个封禁循环;
+  4. 合法超管在任何 IP、任何地理位置都无法登录。
 
-  **攻击 / 真实泄露场景**:
-  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 自然过期前,可以一直续期、一直维持会话)。
+  **结构性放大点**(这才是真正让这条变 High 的地方):`CreateProductLogic.go:77` 里产品管理员账号的用户名是 `admin_<productCode>`,是**可从 `ProductList` 端点枚举出来的**(`ProductList` 不做任何访问控制,见 M-3):任何一枚普通登录账号都可以拉到 `code` 列表,立刻得到所有产品 admin 账号用户名。
 
-  换句话说,refresh token rotation 的"旧 token 必须在发新 token 的同一瞬间失效"这一原子性前提被打破。在**攻击者 + 合法用户并发一次**的窗口下,会话被直接接管。
+  攻击者因此可以:
+  - **一次性把全站所有产品 admin 都锁掉**(`adminLogin` 不走 JWT 中间件,只需要能到达 `/api/auth/adminLogin`);
+  - 配合"managementKey 即便泄露也仅定位到请求能被送出去"这一事实:整条链不要求攻击者有合法凭证,只要 `managementKey` 写错即可,每次请求都会在 `UsernameLoginLimit.Take(username)` 处计数(上面代码是先限流再做 managementKey 校验的顺序吗?——不是,managementKey 校验在第 37 行先做,失败直接 return,**不会走到 Take**)。
+  - 但即便 managementKey 正确,用错密码还是会计数。也就是说:攻击者只要**一次 valid managementKey 泄露**(或运维错误把它 push 到 git),这个 DoS 立即变成"可长期维持"。
 
 - **影响**:
-  - 这不是普通的时序抖动,是**会话劫持** / **账号被盗**级别的 P0 问题。
-  - gRPC 版本因为**根本没有限流**(见 H-2),攻击者可以程序化拉高并发,几乎必然落到 race 窗口里。
-  - 更恶劣的是:受害者前端只会看到"登录状态已失效"一次,下次重新登录即可,几乎没有任何异常信号可以让风控察觉
+  - 所有产品的 ADMIN 账号被远端**静默批量锁死**,业务侧显示的只是"登录过于频繁,请 5 分钟后再试",没有任何 IP 信号可被风控切入;
+  - 与 L-5(`ExtractClientIP` 在 `behindProxy=true` 时信任 `X-Real-IP`)叠加:攻击者发假 `X-Real-IP` 不会绕过 admin 限流(因为 key 根本没用 IP),反而会让产品登录的限流绕过——两条攻击面互补;
+  - 根本上违反了 OWASP ASVS 2.2.1"按用户限流必须包含 IP 或设备维度"
 
 - **修复方案**:
-  把 check + increment 合并成**单条带版本条件的原子更新**:
+  1. 把 key 改成**IP + username 双键**(与产品登录对齐),并**额外**保留一个 username 维度但**配额更大**的桶作为"全局上限":
+     ```go
+     // per-IP + per-user:防暴力破解,严格
+     perIPUserKey := fmt.Sprintf("admin:%s:%s", clientIP, req.Username)
+     if code, _ := l.svcCtx.UsernameLoginLimit.Take(perIPUserKey); code == limit.OverQuota {
+         return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请稍后再试")
+     }
+     // per-user 全局:防分布式爆破,配额宽松(100/5min)
+     if code, _ := l.svcCtx.AdminUserSoftLimit.Take("admin:u:" + req.Username); code == limit.OverQuota {
+         // 告警但不强制返回 429,记录可疑事件
+         logx.WithContext(l.ctx).Errorf("admin_login suspicious rate u=%s ip=%s", req.Username, clientIP)
+     }
+     ```
+  2. `adminLogin` 必须取出 `clientIP`(目前 handler 里并没有把 clientIP 注入 ctx,需先挂载 `RateLimitMiddleware` 或手动 `middleware.ExtractClientIP`)。
+  3. 连续失败 N 次后,**对 IP 封禁**(按 IP 拉黑几分钟),而不是对 username 封禁——永远不要把 DoS 面留给 "攻击者控制的 key"。
+
+---
+
+### H-2. `ValidateProductLogin` 在"状态=冻结"分支**早于 bcrypt 返回**:构造**账号存在性 / 冻结态的时序 + 明文错误信息**双重信道
+
+- **位置**:`internal/logic/pub/loginService.go:50-65`
+- **描述**:
+  关键顺序:
   ```go
-  // 新增 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()")
-      })
-      ...
+  u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)  // L50
+  if err != nil {
+      if errors.Is(err, user.ErrNotFound) {
+          bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))   // L53:等时 dummy
+          return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+      }
+      return nil, err
   }
-  ```
-  logic 层改为:
-  ```go
-  newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, claims.TokenVersion)
-  if errors.Is(err, userModel.ErrTokenVersionMismatch) {
-      return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
+  if u.Status != consts.StatusEnabled {
+      return nil, &LoginError{Code: 403, Message: "账号已被冻结"}      // L59-60:**没有 bcrypt**
+  }
+  if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
+      return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}  // L63-65:有 bcrypt
   }
   ```
-  这样两个并发请求只有一个能 `WHERE tokenVersion = V` 命中(`affected=1`),另一个 `affected=0`,明确失败并返回 401。HTTP 与 gRPC 两个入口**必须共享这一条原子更新逻辑**,不能只修一边。
+  第一个分支(用户名不存在)**有意**做了 dummy bcrypt 等时。但第二个分支(账号被冻结)**直接 return,跳过了 bcrypt**。三种输入的耗时差异:
+  | 输入                            | 耗时                       | 响应消息           | code |
+  | ------------------------------- | -------------------------- | ------------------ | ---- |
+  | 用户名不存在                    | ≈ bcrypt(dummy)           | "用户名或密码错误" | 401  |
+  | 用户名存在但 Status=2(冻结)   | ≪ bcrypt(直接跳过)        | **"账号已被冻结"** | 403  |
+  | 用户名存在,密码错              | ≈ bcrypt                    | "用户名或密码错误" | 401  |
+  | 用户名存在,密码对              | ≈ bcrypt + 后续一串 DB IO   | 登录成功           | 200  |
 
----
+  两种独立的侧信道各自就足够用了:
+  - **响应消息 + HTTP code**:`403 "账号已被冻结"` 是独一无二的(`401 "用户名或密码错误"` 覆盖了"不存在"和"密码错"两种)——攻击者每次请求都在做**一次无条件的账号存在性 + 状态 oracle**。
 
-### H-2. gRPC `RefreshToken` / `VerifyToken` **完全没有任何限流**
+  实际攻击路径:
+  1. 人员离职后,HR 走流程把账号状态置为冻结 → `Status=2` 驻留在 DB;这时 refreshToken 还没过期(refreshExpire 往往是 7~30 天);
+  2. 攻击者用公司常见命名规则批量扫一遍 `zhangsan / lisi / admin_xxx`,非 403 都排除,只留 403 的一批 → **这一批就是离职但还在 JWT 窗口里的高价值目标**;
+  3. 攻击者用钓鱼 / credentials stuffing / 内部 IM 撬这一批账号的 refreshToken,命中率显著高于随机喷撒。
 
-- **位置**:`internal/server/permserver.go`(gRPC `RefreshToken`、`VerifyToken` RPC 方法)
-- **描述**:
-  HTTP 端的 `/api/auth/refreshToken` 至少在**解析成功 claims 之后**调了 `TokenOpLimiter.Take("refresh:%d")`,做了一层**按用户**的令牌桶限流。但 gRPC 服务同名 RPC 完全没有做这件事:
-  - 没有 gRPC interceptor 级别的 IP 限流;
-  - 没有 per-user 限流;
-  - 也没有 per-refresh-secret 全局限流。
-
-  业务语义上 gRPC 是内网其他服务向权限中心"换 accessToken / 验证 token"的主链路,**本就应该是最需要限流的地方**(被错误部署 / 服务腔体被打穿时,可以直接把 DB 打爆)。
+  同样的 pattern 也出现在 `adminLoginLogic.go:57-67`(有 bcrypt 再检查 status/superAdmin,但错误 message 都归一到"用户名或密码错误"——**这一版 adminLogin 做对了**)。证明开发团队知道这个 pattern,但 product login 这条没同步修。
 
 - **影响**:
-  - 与 H-1 组合时:攻击者可在 gRPC 通道上对 `RefreshToken` 发起**任意并发**,几乎必然命中 H-1 的 race 窗口,把会话劫持概率从"需要运气"拉到"只要有网络带宽"。
-  - 即便没有 H-1:攻击者用一枚尚未过期的 refreshToken 可以无限换取 accessToken,作为**持续化 RCE 工具链的身份通道**;也可以对有效 token 进行大量 signature verification,把权限中心 CPU 打满。
-  - `VerifyToken` 无限流:任何下游被攻破后,可以对权限中心做 token-oracle 爆破
+  - 账号存在性 / 冻结态**单次请求可探测**。配合 H-1(admin 账号可从 ProductList 枚举),攻击者可以画出全组织的**"谁在,谁离职了,谁被短期冻结了"**的状态图谱;
+  - 违反 OWASP ASVS V2.1.12 "系统 shall not reveal account status or existence";
+  - 离职用户处于"冻结但 JWT 还在期内"的短窗(几天)是最容易被 social engineering 命中的。
 
 - **修复方案**:
-  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。
+  把 bcrypt 变成**无条件总是执行**,状态 / superAdmin 禁令走**登录成功之后的统一检查**,并且**所有用户侧可见的失败消息归一**为"用户名或密码错误":
+  ```go
+  u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
+  var userHash []byte
+  if err != nil {
+      if errors.Is(err, user.ErrNotFound) {
+          userHash = dummyBcryptHash // 等时
+      } else {
+          return nil, err
+      }
+  } else {
+      userHash = []byte(u.Password)
+  }
+  // 无论用户是否存在、是否冻结,统一 bcrypt 一次
+  bcryptErr := bcrypt.CompareHashAndPassword(userHash, []byte(password))
+  if err != nil || bcryptErr != nil {
+      return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+  }
+  if u.Status != consts.StatusEnabled {
+      // 密码正确的冻结用户,才提示冻结(此时攻击者已经猜中密码,再保留"冻结"已无意义)
+      return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
+  }
+  if u.IsSuperAdmin == consts.IsSuperAdminYes {
+      return nil, &LoginError{Code: 403, Message: "超级管理员请使用管理后台登录"}
+  }
+  ```
+  效果:**账号不存在 / 冻结 / 密码错**三者对外完全不可区分——响应耗时一致、消息一致、code 一致。只有密码正确后才"奖励性"地暴露后续语义。
 
 ---
 
-### H-3. `BindRoles` 允许**平级自增**:MEMBER 可给其他用户绑定与自己**最小等级相等**的角色,从而让目标等同自己 → 后续再也管不动
+### H-3. `SyncPermsService` 并发 1062 修复**只落地一半**:`LockByCodeTx` 已实现但没在 service 里调用
 
-- **位置**:`internal/logic/user/bindRolesLogic.go:86-91`
+- **位置**:`internal/logic/pub/syncPermsService.go:66-120`、`internal/model/product/sysProductModel.go:56-63`
 - **描述**:
-  ```go
-  if !authHelper.HasFullProductPerms(caller) {
-      if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
-          return response.ErrForbidden("不能分配权限级别高于自身的角色")
-      }
-  }
-  ```
-  权限模型约定:`PermsLevel` 数字越小表示权限越高。这里的判定是 `r.PermsLevel < caller.MinPermsLevel`,即 **严格小于** 才拒绝。结果:调用者 `MinPermsLevel = 5` 时允许分配 `PermsLevel = 5` 的角色。
+  第 5 轮 M-6 建议的完整修复是"FOR UPDATE 锁 product → tx 内 load existing → tx 内写"。当前 HEAD:
+  - `sysProductModel.LockByCodeTx` 已经实现(`sysProductModel.go:56-63`);
+  - `sysPermModel.FindMapByProductCodeWithTx` 已实现(`sysPermModel.go:98-109`);
+  - **但 `syncPermsService.go` 完全没有调用这两个**——`existingMap` 仍然在 tx 外被 `FindMapByProductCode` 读(L66),tx 内只做写(L106-120)。
 
-  但 `CheckManageAccess` 里 `checkPermLevel` 的判定是
+  代码里留了一段自认的注释:
   ```go
-  if caller.MinPermsLevel >= targetLevel {
-      return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
-  }
+  // NOTE(R5-M-6):理想方案是"同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行…";
+  // 但当前 mock 契约(syncPermsLogic_mock_test.go)把 FindMapByProductCodeWithTx 固定在 tx 外,
+  // 为不破坏测试约定,保留了原先的"tx 外预读 + tx 内写入"结构。
   ```
-  即目标用户的最小 PermsLevel **必须严格大于**调用者(即目标权限严格低于调用者)。两边判定口径不一致:
-
-  - 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,这里却允许"同级角色"。
+  问题:
+  1. 被测试约束反向拽住 = 架构决策被测试约束倒挂。正确做法是修测试 mock,让生产代码符合正确语义,而不是反过来;
+  2. "1062 → 409" 的兜底只是缓解症状:调用方看到 409 要重试,极端情况两个并发同步**同时在重试**仍可能继续撞,形成活锁;
+  3. 实际并发:同一个产品多部署实例启动时都会调一次 `POST /perm/sync`(部署流水线常见),两边都在热启动瞬间命中——409 并不比 500 友好多少,只是重试路径成立了。
 
 - **影响**:
-  - **垂直权限泄露**:即便没有恶意,普通配置错误就能制造出"产品里多个同级 owner,互相管不了对方"的死结,业务上得**靠人肉联系平台超管**收拾残局。
-  - 有恶意的 MEMBER 可以**主动把攻击者账号拉到自己平级**,从此攻击者的权限只能由超管吊销 → **实际上完成了一次越权提升**,攻击者绕过了"MEMBER 只能管下级"的心智模型。
-  - 与 `UpdateRoleLogic` 的"非超管不能降低 PermsLevel"结合:A 把 B 拉到平级后,如果再找一个 ADMIN 去调整角色 PermsLevel,整个产品里的等级模型会越发混乱。
+  - 同步接口在部署时**概率性失败**,客户端必须自己做重试(而且当前 `SyncPermsError` 结构只给 Code/Message,没有 Retry-After 提示);
+  - 上一轮的修复已经把基础设施准备好了(`LockByCodeTx` 是用代码+测试覆盖过的),**缺的只是最后接上**——这是一条"几十行代码 + 改 mock"可以直接落地的事情,风险收益比极高。
 
 - **修复方案**:
-  把 `<` 改为 `<=`,与 `checkPermLevel` 的 `>=` 对称
+  把 mock 测试里的 `FindMapByProductCode` 预期调用改为 `FindMapByProductCodeWithTx`,然后把 service 改为
   ```go
-  if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel <= caller.MinPermsLevel {
-      return response.ErrForbidden("不能分配权限级别高于或等于您自身的角色")
-  }
+  err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
+      // 1. 锁 product 行,把同一 product 的 SyncPermissions 串行化
+      if _, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code); err != nil {
+          if errors.Is(err, sqlx.ErrNotFound) {
+              return &SyncPermsError{Code: 404, Message: "产品不存在"}
+          }
+          return err
+      }
+      // 2. tx 内读 existing
+      existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code)
+      if err != nil { return err }
+      // 3. tx 内写(按原逻辑分 insert / update / disable)
+      ...
+  })
   ```
-  同时:
-  1. 在 `BindRoles` / `SetUserPerms` 所在文件里**抽一个 `guardRoleLevelAssignable(caller, role)` helper**,强约束"只能分配严格更低等级的角色"——与 `checkPermLevel`(严格更低等级才能管)对齐,后续任何新增绑定场景不会再走错。
-  2. 补一条单测:模拟 MEMBER (level=5) 给 MEMBER 绑 level=5 的角色,必须 403。
+  当 `LockByCodeTx` 把同一 product 的并发同步串行化后,`ON DUPLICATE KEY UPDATE` / 1062 兜底都可以不再依赖。
 
 ---
 
-### H-4. `UpdateUserLogic` 的 `DeptId = 0` 分支**跳过部门管辖校验**:下属可以被合法"移出部门"、失去上级管辖
+## ⚠️ 健壮性与性能建议 (Medium/Low)
+
+### M-1. `UpdateDeptLogic` 的 Clean 循环**仍未批处理**(第 5 轮 M-2 未落地)
 
-- **位置**:`internal/logic/user/updateUserLogic.go:106-120`
+- **位置**:`internal/logic/dept/updateDeptLogic.go:86-94`
 - **描述**:
   ```go
-  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("无权将用户调入非自己管辖的部门")
-          }
+  if deptTypeChanged || statusChanged {
+      userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+      for _, uid := range userIds {
+          l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
       }
-      deptId = *req.DeptId
+      ...
   }
   ```
-  新部门层级校验放在 `*req.DeptId > 0` 分支内。如果调用者传 `deptId: 0`,直接 `deptId = 0`,**跳过整个层级校验**。由 `access.go:146-150` 可知,`target.DeptId == 0` 会被 `checkDeptHierarchy` 视为"仅超管或产品 ADMIN 可管"。
+  每次 `Clean` 内部是 `SMEMBERS → DEL(批) → DEL(索引)`(`userDetailsLoader.go:204-218`),即 **3 个 Redis RTT**。一个有 300 个成员的部门改 deptType 会串行产生 900 次 RTT,卡在 HTTP handler 里。即使 Redis 0.5ms,300 个用户也至少 450ms——这个窗口内 handler 被 hold 住,其他写入堆积,连带拖慢 UpdateDept 的乐观锁重试。
+  第 5 轮已把问题点清楚暴露,并给出 `CleanByUserIds` 的草案,但**本轮代码里没有这个方法**,`cleanByIndex` 也没升级成 pipeline。
 
-  真实业务路径:
-  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 变成一个在组织结构里"无归属、无人能管"的幽灵高权账号。
-
-- **影响**:
-  - **这不是简单的一致性问题**,是 MEMBER 可以**合法**破坏组织架构语义 + 失去组织可管控性:账号进入"灰色托孤态",只有平台超管能打捞。
-  - 从合规角度:任何有"部门树 = 数据隔离边界"的业务(如多租户),本接口可以**越过部门边界丢弃账号的隶属关系**。
-  - 没有 audit log 配合的情况下,此动作甚至不会立即被察觉。
-
-- **修复方案**:
-  两种口径任选其一(建议第 2 种,更严格):
-  1. **禁止非 ADMIN 把 deptId 改为 0**(把原"只在 >0 时校验"变成"不等于当前 deptId 时都要校验"):
+- **建议**:
+  1. 给 `UserDetailsLoader` 新增 `CleanByUserIds(ctx, ids []int64)`:合并所有用户的 `userIndexKey` 一次 `SMEMBERS` pipeline → 合并所有 `cacheKey` 一次 `DEL` → 索引键一次 `DEL`,RTT 降到 3 次常数;
+  2. 如果仍想保留 `Clean` 单用户语义,在 `UpdateDeptLogic` 里改成一次 batch:
      ```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("无权将用户调入非自己管辖的部门")
-             }
+     if deptTypeChanged || statusChanged {
+         userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+         if err != nil {
+             logx.WithContext(l.ctx).Errorf("clean dept users cache, FindIdsByDeptId failed: %v", err) // 不能再 "_," 把错吞了
+         } else if len(userIds) > 0 {
+             l.svcCtx.UserDetailsLoader.CleanByUserIds(l.ctx, userIds)
          }
-         deptId = *req.DeptId
      }
      ```
-  2. 如果产品规范里本就不支持"用户无部门",应直接把 `0` 当作非法值:`if *req.DeptId <= 0 { return ErrBadRequest("部门ID无效") }`
+  3. 顺手把 `_, _ = FindIdsByDeptId(...)` 的**错误静默吞掉**修掉:当前代码把查询 error 直接丢,会导致"DB 抖动 → 缓存没清 → 旧权限缓存继续生效 5 分钟",对"被禁用的研发部"这种安全敏感变更是**静默绕过**。
 
 ---
 
-## ⚠️ 健壮性与性能建议 (Medium/Low)
+### M-2. `ProductList` / `ProductDetail` / `DeptTree` **对任意登录用户暴露全公司清单**
+
+- **位置**:
+  - `internal/logic/product/productListLogic.go:29-58`(无任何访问控制)
+  - `internal/logic/product/productDetailLogic.go:29-48`(无任何访问控制)
+  - `internal/logic/dept/deptTreeLogic.go:27-66`(无任何访问控制)
+- **描述**:
+  三个接口都挂在 `JwtAuth` 中间件后面(见 `handler/routes.go:47-74, 119-146`),但 logic 层里只做了"caller 非超管时遮蔽 AppKey"这种**字段级**防护,没有行/资源级防护:
+  - 一个只属于产品 A 的 MEMBER 级账号,可以从 `ProductList` 拉到全站所有产品的 `code / name / remark / status`;
+  - 同样可以从 `ProductDetail` 按 ID 逐个拉别的产品的详情(只是看不到 AppKey);
+  - `DeptTree` 直接返回整棵组织架构树,包括 MEMBER 根本无权管的兄弟部门和叔辈部门;
 
-### M-1. `RefreshTokenLogic` **先解析 JWT,再限流**:无效 token 穿透限流,可用于爆破 refreshSecret
+  这几个泄露叠加起来就是 H-1 的"可枚举 admin 用户名"的根源——一个 MEMBER 登录之后:
+  1. `/api/product/list` → 拉到 `code` 列表 → 拼出 `admin_<code>` 全量用户名;
+  2. `/api/dept/tree` → 得到对手组织结构 / 研发部节点位置,为 loadPerms 特权判定(`DeptType == DEV` 自动拥有全权)提供情报——知道哪些部门是 DEV,就知道哪些账号一旦拿下即"产品全权"。
+  3. 枚举结果喂给 H-1 的 admin DoS 或针对性的钓鱼。
 
-- **位置**:`internal/logic/pub/refreshTokenLogic.go:40-73`
-- **描述**:`TokenOpLimiter.Take("refresh:%d")` 的 key 需要 `claims.UserId`,所以必须**先 `ParseRefreshToken` 成功**才能限流。对一批**攻击用的无效 token**(例如在爆破 refresh secret 时构造的伪造签名),全部会走到:
+- **建议**:
+  1. `ProductList` / `ProductDetail`:**非超管只能看自己 `caller.ProductCode` 对应的那一条**。列表直接返回 `[自己产品]` 或空;详情校验 `product.Code == caller.ProductCode`。
+  2. `DeptTree`:**非超管按 `caller.DeptPath` 剪枝返回**——只返回以 caller.DeptPath 为根的子树 + 与业务需要对齐的上级路径链(通常做法是回传 `ancestors` 字段而非整棵树)。
+  3. 如果确实产品要"给所有员工看公司架构"这种展示场景,应有独立的 `/api/org/publicTree` 端点,返回**脱敏后的字段**(无 `remark`、无 `status`、无 `createTime`),并显式记为"公开"。
+
+---
+
+### M-3. `UserDetailLogic` 把 `Phone` / `Email` 返回给**任意同产品成员**
+
+- **位置**:`internal/logic/user/userDetailLogic.go:29-77`
+- **描述**:
   ```go
-  claims, err := authHelper.ParseRefreshToken(tokenStr, l.svcCtx.Config.Auth.RefreshSecret)
-  if err != nil {
-      return nil, response.ErrUnauthorized("refreshToken无效或已过期")
+  if !caller.IsSuperAdmin {
+      if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
+          return nil, response.ErrForbidden("无权查看非本产品成员的用户信息")
+      }
   }
+  ...
+  return &types.UserItem{
+      ...
+      Email:      user.Email,
+      Phone:      user.Phone,
+      ...
+  }, nil
   ```
-  不进入限流桶。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 等),建议补上。
+  判定规则是"同产品成员即可查看"——一个产品里最低权限 MEMBER 可以遍历同产品所有用户(userId 范围内 fuzz)的手机 / 邮箱。同产品有几百上千成员时,这等同于**暴露公司通讯录 PII**。
+  这里没有"**调用者对目标有管理权**"或"**看自己**"的更细粒度条款。对照 `BindRoles` / `UpdateUser` 使用 `CheckManageAccess`(含部门层级),`UserDetail` 是最宽松的。
+
+- **建议**:
+  1. `Phone` / `Email` 应仅对满足以下之一的 caller 返回:
+     - `caller.UserId == user.Id`(自己);
+     - `caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin`;
+     - `CheckManageAccess(...) == nil`(即调用者管辖该目标);
+     其余 caller 只返回脱敏字段(例如 `13***1234`)或不返回。
+  2. 在返回 `UserItem` 时,加一个 `filterPIIForCaller(user, caller)` 的 helper,所有返回用户详情 / 列表的 logic 都走同一个过滤器,避免未来再漏。
 
 ---
 
-### M-2. `UpdateDeptLogic` 对部门成员的 `UserDetailsLoader.Clean` 在 for 循环里**串行同步调用**
+### M-4. `BindRolePermsLogic` / `UpdateRoleLogic` 在**写成功后的缓存清理失败**时返回 500,客户端会把成功的写误判为失败并重试
 
-- **位置**:`internal/logic/dept/updateDeptLogic.go`(循环清缓存处)
-- **描述**:在 deptType / status 变更时,代码形如:
+- **位置**:
+  - `internal/logic/role/bindRolePermsLogic.go:128-134`
+  - `internal/logic/role/updateRoleLogic.go:79-85`
+- **描述**:
   ```go
-  userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
-  for _, uid := range userIds {
-      l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
+  if err := l.svcCtx.SysRolePermModel.TransactCtx(...); err != nil { return err }
+
+  affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId)
+  if err != nil {
+      logx.WithContext(l.ctx).Errorf("角色权限已更新但缓存清理失败 roleId=%d: %v", req.RoleId, err)
+      return response.NewCodeError(500, "权限已更新但缓存刷新失败,请稍后手动刷新")
   }
+  l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
   ```
-  单次 `Clean` 内部包含 `SMEMBERS + DEL(批) + DEL(索引键) + SREM`(见 `userDetailsLoader.go:185-199, 221-230`),至少 4 次 Redis 往返。一个 500 人的部门 = 2000 次 Redis 同步 RTT 卡在请求线程里。这会直接阻塞 HTTP handler(默认 go-zero timeout 可能都兜不住)。
+  出问题的细节在三个层面:
+  1. `FindUserIdsByRoleId` 是一次**普通 SELECT**,失败通常意味着 DB 抖动或连接池耗尽——此时**绑定权限的事务已经 commit**,DB 事实数据已改。给客户端返回 500 会让上层重试整个 `BindRolePerms`;重试时先 diff 得到 `toAdd/toRemove` 都为空 → `if len(toAdd) == 0 && len(toRemove) == 0 { return nil }`(`bindRolePermsLogic.go:102-104`)→ **静默返回 200,但期间的业务逻辑 / 回调不会再触发**。更糟的变体:`BindRoles`(`user/bindRolesLogic.go:118-121`)的同一 pattern 里有 `UserDetailsLoader.Clean` 但没有 `FindUserIdsByRoleId` 这步,所以 BindRoles 不受此影响,只有 `BindRolePerms` / `UpdateRole` 有;
+  2. `BatchDel` 失败只打 log 不改响应——那就让 `FindUserIdsByRoleId` 失败也同样处理才对称;
+  3. 业务表达:500 的 "权限已更新但缓存刷新失败" 这段话**既给客户端看又给运维看**,但客户端看到 500 不会知道"我不用重试,只要等 5 分钟缓存 TTL 自然过期即可"——这是文案 / 状态码错配。
+
 - **建议**:
-  1. loader 层加 `CleanMany(ctx, userIds)`:一次 `SMEMBERS` 取每个索引键,`DEL` 合并所有 `cacheKey`(Redis 支持任意多 key 的批量 DEL),索引键用一次 `DEL` 或 pipeline 批处理。
-  2. 或用 `BatchDel(userIds, productCode)`(已经在 loader 里)——不过 `BatchDel` 只清特定产品,对"部门跨多个产品"的场景要多次调用。更清爽的做法是按索引键一次性清:
+  1. 把这两处改成 "事务成功即视为成功;缓存刷新失败仅 log + 异步重试":
      ```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
+     if err := ...TransactCtx(...); err != nil { return err }
+
+     if userIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId); err == nil {
+         l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, userIds, role.ProductCode)
+     } else {
+         logx.WithContext(l.ctx).Errorf("postcommit cache cleanup fetch userIds failed roleId=%d: %v", req.RoleId, err)
+         // TODO(可选):入异步队列重试,或记 audit 让运维手工刷新
      }
+     return nil
      ```
-  3. 当部门用户数量超过阈值(如 200)时,**fire-and-forget 到后台 goroutine + 日志**,不要阻塞外部请求。
+  2. 真的要让客户端知道 "权限变更成功但部分缓存未刷新",应返回 200 + resp 里带 `cacheRefreshStatus: "degraded"`,而不是 500
 
 ---
 
-### M-3. `UserDetailsLoader.Load` 对**已删除用户**不做负缓存:一把有效 JWT + 删除账号 = 每次请求 7 条 DB 查询
+### M-5. `UpdateUserStatusLogic` / `BindRolesLogic` / `UpdateUserLogic` 的**同请求重复 `FindOne(targetUserId)`**(缓存命中但仍有 Redis 往返)
 
-- **位置**:`internal/loaders/userDetailsLoader.go:119-144`
+- **位置**:
+  - `internal/logic/user/updateUserStatusLogic.go:37-50`(3 次)
+  - `internal/logic/user/bindRolesLogic.go:40-50`(2 次 `FindOne(userId)` + 2 次 `FindOneByProductCodeUserId`)
+  - `internal/logic/user/updateUserLogic.go:47-58`(2 次 `FindOne(userId)`)
 - **描述**:
-  ```go
-  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}
-  }
+  以 `UpdateUserStatus` 为例:
   ```
-  当用户已被硬删除(`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 桶)
+  ValidateStatusChange(...)      → SysUserModel.FindOne(targetUserId)
+  CheckManageAccess(...) →
+      checkDeptHierarchy(...) → SysUserModel.FindOne(targetUserId)
+      checkPermLevel(...) → SysProductMemberModel.FindOneByProductCodeUserId(...)
+  // 回到 handler
+  user, _ := SysUserModel.FindOne(targetUserId)
+  ```
+  全部通过 go-zero cache 命中,但每次 `FindOne` 还是要 Redis GET(若 Redis 上无 key 会触发 DB)。即便 Redis GET 是 0.5ms,3 次同一 key 就浪费 1.5ms + 3 个连接池 slot;更糟的是:在"`FindOne` → `UpdateProfile` → `FindOne`(下一次)"的时间窗口里,如果上一次 `UpdateProfile` 把 cache invalid 掉了,下一次 `FindOne` 会触发一次 DB 查询。**同一个 request 内部**本不该做这种往返。
 
 - **建议**:
-  1. 对"Username 为空"的路径**也写入短 TTL(30~60s)的负缓存 sentinel**
+  1. 在 `CheckManageAccess` 签名里加一个可选 `prefetchedTarget *user.SysUser`:调用方已经有目标用户对象时,直接传进去,`checkDeptHierarchy` 复用;否则再查:
      ```go
-     if ud.Username == "" {
-         _ = l.rds.SetexCtx(ctx, key, `{"userId":0}`, 30) // 或带一个 "deleted":true 字段
-         return nil, nil
+     func CheckManageAccess(ctx, svcCtx, targetUserId, productCode, opts ...Option) error {
+         var target *user.SysUser
+         for _, opt := range opts { opt.apply(&target) }
+         if target == nil {
+             target, _ = svcCtx.SysUserModel.FindOne(ctx, targetUserId)
+         }
+         ...
      }
      ```
-     加载侧读到 sentinel 立刻返回空 `UserDetails`,不再走 DB。
-  2. 更彻底:引入**基于 userId 的全局"已删除"布隆过滤器**,在 `DeleteUser` 时添加;`Load` 读到命中时直接 short-circuit。
-  3. `jwtauthMiddleware` 对"用户被删除"的路径打印警告日志 + 对应 token 的 `userId` 加入短期封禁列表(5~10 分钟),避免垃圾 token 长期耗资源。
+  2. 更激进:在 handler 最外层或 middleware 里做**请求级 cache**(`context.WithValue` 一个小 map),`FindOne`/`FindOneByProductCodeUserId` 走这层再透传。这对所有类似 `UpdateUser + Check*` 的组合都直接受益。
 
 ---
 
-### M-4. `UpdateWithOptLock` / `UpdateProfile` / `UpdatePassword` 的乐观锁**以秒级 `updateTime` 为版本**:同秒并发写会丢更新
+### M-6. `ExtractClientIP` 在 `behindProxy=true` 时**只信任 `X-Real-IP`**,没 `X-Forwarded-For` fallback;且**未设头时回落到 proxy 的 RemoteAddr → 全站共享一个桶**
 
-- **位置**:
-  - `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`
+- **位置**:`internal/middleware/ratelimitMiddleware.go:54-65`
 - **描述**:
-  乐观锁的 `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 网关重放;
-  - 运维脚本并行刷数据。
+  ```go
+  func ExtractClientIP(r *http.Request, behindProxy bool) string {
+      if behindProxy {
+          if ip := r.Header.Get("X-Real-IP"); ip != "" { return ip }
+      }
+      host, _, err := net.SplitHostPort(r.RemoteAddr)
+      if err != nil { return r.RemoteAddr }
+      return host
+  }
+  ```
+  两个真实业务场景会 bug:
+  1. 部分运维 / 反向代理(特别是 K8s Ingress-nginx 默认配置)只设 `X-Forwarded-For`,不设 `X-Real-IP`。本实现会完全忽略 `X-Forwarded-For`,回落到 `r.RemoteAddr`——那个值是 ingress controller 的 IP → **全部请求共享一个 IP 限流桶**;
+  2. 反向代理忘记把客户端 `X-Real-IP` 清掉的话,客户端能直接自己伪造 `X-Real-IP: 1.1.1.1`,每次变换轻松绕过限流。
+  综合后果:一旦部署姿势稍有偏差,`/api/auth/login` / `/api/auth/refreshToken` / `/api/perm/sync` 上的限流全部等价于"共享一个桶或无限流",配合 H-1/H-2 直接放大。
 
 - **建议**:
-  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 每次真会变**,上面两种方案任选其一先修)
+  1. 按常规反代优先级解析 IP:`X-Forwarded-For`(取第一段非私网)→ `X-Real-IP` → `RemoteAddr`;
+  2. 显式校验解析结果是**合法的 IP**(`net.ParseIP(ip) != nil`),非法(空串、乱码)时退化到 `RemoteAddr`,并**打印 warn 日志**让运维能尽快发现代理链漏配;
+  3. `behindProxy` 开关和线上部署姿势必须在 README 显式对齐;如果线上实际用的是 ingress-nginx,得 `behindProxy=true` + 解析 `X-Forwarded-For`
 
 ---
 
-### M-5. `CreateProductLogic` 仍残留 `strings.Contains(errMsg, "uk_code")` 与 `strings.Contains(errMsg, req.Code)` 二级判定
+### M-7. JWT 解析三处(HTTP `jwtauthMiddleware` / gRPC `VerifyToken` / `ParseRefreshToken`)**都没显式检查 `token.Method`**
 
-- **位置**:`internal/logic/product/createProductLogic.go:135-146`
+- **位置**:
+  - `internal/middleware/jwtauthMiddleware.go:59-61`
+  - `internal/server/permserver.go:242-244`
+  - `internal/logic/auth/jwt.go:78-80`
 - **描述**:
+  三处 `keyfunc` 直接返回 `[]byte(secret)`,没有做:
   ```go
-  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("数据冲突,请稍后重试")
+  if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+      return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
   }
   ```
-  第一步正确使用 `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 的精确文案拦住了。
+  `jwt/v4` 默认对 `alg=none` 是关闭的(`SigningMethodNone` 需要显式 `jwt.UnsafeAllowNoneSignatureType`),所以**目前没有立即可利用的漏洞**。但:
+  1. 如果未来 refresh / access secret 被替换成 RSA / ECDSA 密钥(业务上合理:双密钥轮转、JWKS),`keyfunc` 返回 `[]byte` 就会被 `SigningMethodRSA.Verify` 当 public key 解析失败,这本身安全——但业务上会突然全站登录失败,调试困难;
+  2. 更重要的是:显式 `token.Method` 检查是深度防御的行业标准(OWASP JWT Cheat Sheet),任何 review 过 JWT 代码的审计都会拉出来。
+- **建议**:抽一个 `parseHS256(tokenStr, secret, claims)` 的小 helper,统一在 keyfunc 里断言 `*jwt.SigningMethodHMAC`,并在 `ParseRefreshToken` / access token 解析两处调用。
 
 ---
 
-### M-6. `SyncPermsService.ExecuteSyncPerms`:**读快照在 tx 外、写在 tx 内**,并发同步会撞 `DuplicateEntry`
+### M-8. `IncrementTokenVersion` / `IncrementTokenVersionIfMatch` 都**先 `FindOne` 一次**只为拿 `username` 构造 cache key
 
-- **位置**:`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 不在请求列表里的旧权限 ...
-  })
+- **位置**:`internal/model/user/sysUserModel.go:158-181, 186-214`
+- **描述**:
+  ```go
+  func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
+      data, err := m.FindOne(ctx, id)      // ← 只为下面的 usernameKey
+      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, ...)
+      _ = m.DelCacheCtx(ctx, sysUserIdKey, sysUserUsernameKey)
+      return newVersion, nil
+  }
   ```
-  两次并发 `SyncPermissions`
-  - 都读到 `existing` 中没有 code X
-  - 都 `InsertWithTx(..., code=X)`;
-  - 第二次 tx 在 `UNIQUE (productCode, code)` 上撞 1062 → rollback
+  每次 `RefreshToken`(HTTP + gRPC 两条路径)都多一次 `FindOne` 走 cache-aside
+  - Cache 命中:1 次 Redis GET(快)
+  - Cache 未命中:1 次 Redis GET + 1 次 DB SELECT + 1 次 Redis SETEX = 3 次 IO。
+  `RefreshToken` 已经调了 `UserDetailsLoader.Load`(含 `loadUser` 里的 `FindOne`)——同一请求内第二次 `FindOne`,完全是浪费;更关键的是:`RefreshToken` 的 cache flow 是 "Load → 比较 TokenVersion → IncrementIfMatch → Clean",`Clean` 会把 loader 缓存清掉但 **go-zero 的 `FindOne` 那一层 cache 不会被清**(不同 key 空间),所以下一次请求还是会再查一次
 
-  当前错误处理把 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。
+  1. 把 `IncrementTokenVersionIfMatch` 的第一次 `FindOne` 去掉,cache key 的 username 部分**改成读 ExecCtx 的"写入后新值"**(go-zero 支持把 `DelCacheCtx` 放进 `ExecCtx` 的 hook 里,只传 `sysUserIdKey`;`sysUserUsernameKey` 可以在事务内再 `session.QueryRowCtx` 拿 `username`,或者**干脆只删 ID key**——`FindOneByUsername` 的 cache 也只会因为密码 / status 等变化才会 stale,tokenVersion 变更并不会让"按 username 查"的结果在业务上过期);
+  2. 或者让上游 `refreshTokenLogic.go` 在调用 `IncrementTokenVersionIfMatch` 时把 `ud.Username` 也传进来,省掉这次 FindOne:
+     ```go
+     IncrementTokenVersionIfMatch(ctx, userId, username, expected) (int64, error)
+     ```
+     logic 层已经手上有 `ud.Username`,直接透传即可。
 
 ---
 
-### M-7. gRPC `PermServer.Login` 的**对端 IP 提取 fail-open**
+### L-1. `SyncPermsService` 的"如需禁用所有权限请使用专用接口"是**文案指向幽灵端点**
 
-- **位置**:`internal/server/permserver.go`(`Login` 方法里对 `peer.FromContext` 和 `net.SplitHostPort` 的错误处理分支,clientIP 回退为 `"unknown"` 或原始 `p.Addr.String()`)
+- **位置**:`internal/logic/pub/syncPermsService.go:49-51`
 - **描述**:
-  - `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 的修复路径)。
+  ```go
+  if len(perms) == 0 {
+      return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
+  }
+  ```
+  然而 `perm.api` / `routes.go` / gRPC `PermService` 里**并没有"禁用所有权限的专用接口"**。这行错误消息是历史设计遗留——要么这个接口被砍了,要么从来没实现过。接入方看到 400 文案会去找"专用接口",浪费排查时间。
+- **建议**:把文案改成客观事实"权限列表不能为空",并把"禁用所有权限"这条产品需求**显式 TODO 或删除**(真要支持,需要一个独立授权模型,不能走 appKey 就把产品的所有权限抹了——这本身是个有争议的能力)。
 
 ---
 
-### M-8. `ChangePasswordLogic` / `UpdatePassword`:**无乐观锁 + 并发修改密码双方都成功**
+### L-2. `DeleteDeptLogic` 的多次 `FOR UPDATE` 顺序**可能死锁**(真实可能性较低)
 
-- **位置**:
-  - `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` 返回前端"请刷新后重试"。
+- **位置**:`internal/logic/dept/deleteDeptLogic.go:36-62`
+- **描述**:
+  同一事务内依次:`SELECT dept_id FROM sys_dept FOR UPDATE` → `SELECT child ids FROM sys_dept WHERE parentId FOR UPDATE` → `SELECT user ids FROM sys_user WHERE deptId FOR UPDATE` → `DELETE sys_dept WHERE id`。顺序"sys_dept 行 → sys_dept 按 parentId 的范围锁 → sys_user 按 deptId 的范围锁"。
+
+  如果另一 tx 在做 `CreateDept`(L3 的"`InsertWithTx → UpdateWithTx`"路径)或 `UpdateUserLogic`(先读 `sys_user` 再 `UpdateProfile`),两边获取锁的顺序不一致时理论上能构成 AB-BA。实际业务里:
+  - DeleteDept 仅超管调用,频率极低(删除部门是稀有操作);
+  - CreateDept 同样低频;
+  - 真正会撞的是 "DeleteDept 碰巧与大批量 UpdateUser deptId" 同时——这种场景通常不会主动并发;
+  但审计视角上仍是一个"范围锁 → 可阻塞后续"的定时炸弹。
+- **建议**:
+  1. 确保锁顺序在所有涉及 `sys_dept + sys_user` 的事务里一致(总是先锁 dept,再锁 user);
+  2. `DeleteDept` 内可以把 `sys_user WHERE deptId = ? FOR UPDATE` 换成不加锁的 `SELECT ... LOCK IN SHARE MODE` + 事务隔离级 REPEATABLE READ(反正只是存在性判定),降低阻塞面。
 
 ---
 
-### L-1. `CreateUserLogic` 默认 `mustChangePassword = consts.MustChangePasswordNo`
+### L-3. `UserDetailsLoader.registerCacheKey` **每次都做 4 次 Redis 单独 RTT**(SAdd + Expire + SAdd + Expire)
 
-- **位置**:`internal/logic/user/createUserLogic.go`
-- **描述**:管理员为新用户创建账号时,密码是管理员"代填"的,业务上通常应**强制首登改密**。当前默认值是 `No`,意味着管理员口头告诉员工密码后,员工可以长期不改;如果管理员密码库泄露,所有新建账号都通用。
-- **建议**:把默认改为 `consts.MustChangePasswordYes`;或请求体加 `mustChangePassword *int` 字段,不传时按 `Yes` 处理。
+- **位置**:`internal/loaders/userDetailsLoader.go:220-238`
+- **描述**:每次 `Load` cache miss 后都会:
+  ```go
+  SADD ud:idx:u:<userId>   <key>
+  EXPIRE ud:idx:u:<userId> <ttl+60>
+  SADD ud:idx:p:<productCode> <key>
+  EXPIRE ud:idx:p:<productCode> <ttl+60>
+  ```
+  4 次独立 RTT。一个大产品启动瞬间(N 个并发用户都走 middleware 的首次 Load),`4*N` 次 RTT 打满 Redis cluster 的连接队列。
+- **建议**:用 go-zero redis 的 `Pipelined` 或 `TxPipelined` 批做,合并为 1 RTT;更激进可以把索引维护推到**异步通道**,Load 主路径不 wait。
 
 ---
 
-### L-2. `RefreshToken` 限流 key 仅含 `userId`,**不含 IP**
+### L-4. `Logout` 仍用**无条件 `IncrementTokenVersion`**(非 CAS)
 
-- **位置**:`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`)。
+- **位置**:`internal/logic/auth/logoutLogic.go:46`
+- **描述**:`RefreshToken` 已经切到 `IncrementTokenVersionIfMatch`,但 `Logout` 还是老版本 `IncrementTokenVersion`(无条件 `SET tokenVersion = tokenVersion+1`)。业务语义上 `Logout` 确实是"无论当前版本多少都失效",**所以目前正确**——但这也意味着:
+  1. 在 `Logout` 和并发 `RefreshToken` 相撞时,`Logout` 可能在 `RefreshToken` 的 `IncrementTokenVersionIfMatch` 之后再 +1,导致刚签发的新 token 立即失效(体验问题,非安全问题);
+  2. `IncrementTokenVersion` 实际只剩 `Logout` 一个调用点 + model 层的测试引用,继续保留这个"大杀器" API 会让未来新功能误用(比如某人在新增逻辑时图方便调了无条件 +1 版本)。
+- **建议**:
+  1. 不强制替换 `Logout`(语义正确),但**把 `IncrementTokenVersion` 加上显式的安全注释**:"仅限业务语义为'强制全量失效'的场景(Logout / 封禁账号),**禁止**在 Refresh/Rotation 场景使用——Refresh 必须走 `IncrementTokenVersionIfMatch`"。
+  2. 更激进:用 golang build tag / linter 限制 `IncrementTokenVersion` 的调用方范围(仅限 `auth/logoutLogic.go` + 未来的封禁接口)。
 
 ---
 
-### L-3. `CreateDeptLogic` 的 `InsertWithTx → FindOneWithTx → UpdateWithTx` 组合:**缓存早于事务提交失效**
+### L-5. `removeMemberLogic`:移除 ADMIN 前的 `CountActiveAdminsTx` **与目标成员自己的 state 判定耦合**
 
-- **位置**:`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 语句)。简化为:
+- **位置**:`internal/logic/member/removeMemberLogic.go:45-54`
+- **描述**:
   ```go
-  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)
+  if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
+      adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
+      ...
+      if adminCount <= 1 { return "不能移除最后一个管理员" }
+  }
   ```
-  同时用 `m.DelCacheCtx(cacheSysDeptIdPrefix+deptId)` 显式在 tx 外做一次二次清理。
-
----
-
-### L-4. `CheckManageAccess` / `checkPermLevel` 把 `targetLevel = math.MaxInt64`(查不到角色)当"目标无权限"处理
-
-- **位置**:`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)` 时直接返回错误而不是降级放行。
+  `CountActiveAdminsTx` 是 `SELECT COUNT(*) WHERE productCode=? AND memberType='ADMIN' AND status='enabled'`——包括了**正要被删除的 locked 自己**。因此 `adminCount <= 1` 刚好拦住了"删最后一个",但边界判定非常 subtle:
+  - 若 `locked` 本身虽是 ADMIN 但 `status=disabled`(禁用但未删),流程会**跳过 admin-count 检查直接删除**——OK,因为他反正不是 active admin;
+  - 但如果 `locked.MemberType=ADMIN && locked.Status=Enabled` 且 DB 里另有一个 "ADMIN + Disabled" 的 stale 记录,count 不包含它——正确;
+  - 隐含约束:`CountActiveAdminsTx` 必须和 `locked` 的"active admin 定义"**完全一致**(MemberType=ADMIN 且 Status=Enabled)。如果未来新增一种 ADMIN 子类型(比如 SuperProductAdmin),这个 count 会漏算。
+- **建议**:把 `CountActiveAdminsTx` 改成返回"除了这个 id 之外还有几个 active admin":
+  ```sql
+  SELECT COUNT(*) FROM sys_product_member WHERE productCode = ? AND memberType = 'ADMIN' AND status = 1 AND id != ?
+  ```
+  外层判定 `if adminCount == 0 → 最后一个`,语义更贴合业务,少一步反向推理。`UpdateMemberLogic` 的同名调用同样受益。
 
 ---
 
-### L-5. `CreateProductLogic.generateRandomHex` 的 tx 外密钥生成:**事务失败时密钥被泄露到日志 / 响应**
+### L-6. `CheckManageAccess` 在 caller `DeptId == 0` 时直接 403,漏了"**非 ADMIN 的超级管理员**"的心智模型
 
-- **位置**:`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 或要求创建者当场抄写,别持久化在响应体。
+- **位置**:`internal/logic/auth/access.go:155-162`
+- **描述**:
+  ```go
+  func checkDeptHierarchy(ctx, svcCtx, caller, targetUserId) error {
+      if caller.MemberType == consts.MemberTypeAdmin { return nil }
+      if caller.DeptId == 0 { return ErrForbidden("您未归属任何部门,无权管理其他用户") }
+      if caller.DeptPath == "" { return ErrForbidden("您的部门信息异常...") }
+      ...
+  }
+  ```
+  `CheckManageAccess` 最外层已经对 `caller.IsSuperAdmin` 做了 early return(`access.go:47`),所以 `checkDeptHierarchy` 进到这里一定不是超管——OK。
+  但目前代码里"caller `DeptId=0`"这件事并不一定异常:**L1 中 H-4 的修复落地后**,普通 MEMBER 的 `DeptId` 绝不会是 0;但早期数据 / 迁移遗留数据里仍可能有 `DeptId=0` 的 MEMBER(例如从老系统搬来的账号)。这些账号会发现**任何管理操作都拒绝**,包括合法的"管理自己所在产品里的下属"——实际上 `checkPermLevel` 本来可以单独判定,但被 `checkDeptHierarchy` 先拦了。
+- **建议**:在无部门归属的 MEMBER 场景下,至少应允许"管理自己"和"纯 product-admin-downward"的操作;或者运维侧跑一次 data fix 把 `DeptId=0` 的 MEMBER 都归入"默认部门"。代码侧加一条 TODO 记录这个假设,便于未来发现幽灵账号时做定向修复。
 
 ---
 
@@ -468,25 +531,36 @@
 
 | 优先级 | finding | 概要 |
 | --- | --- | --- |
-| **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 降级 / 密钥生命周期 |
+| **P0** | H-1 | AdminLogin 限流仅用 username → 可远端批量锁死所有产品 admin 账号(配合 M-2 的 ProductList 枚举放大) |
+| **P0** | H-2 | `ValidateProductLogin` 在冻结账号路径跳过 bcrypt → 响应时间 + 明文错误双重账号状态 oracle |
+| **P0** | H-3 | SyncPermsService 的 FOR UPDATE 修复只做到基础设施层,service 层仍然 tx 外读 / tx 内写,1062 → 409 只是缓解不是根治 |
+| P1 | M-1 | UpdateDept 部门用户缓存仍串行 Clean(R5 M-2 未落地) |
+| P1 | M-2 | ProductList / ProductDetail / DeptTree 对任意登录用户无访问控制,组合放大 H-1 |
+| P1 | M-3 | UserDetail 向任意同产品成员泄露 Phone / Email(PII 超额披露) |
+| P1 | M-4 | BindRolePerms / UpdateRole 在 post-commit 缓存步骤失败返回 500 → 客户端误判失败 |
+| P2 | M-5 / M-6 / M-7 | 请求内重复 FindOne / ExtractClientIP 代理链解析不足 / JWT 未显式验签方法 |
+| P2 | M-8 | IncrementTokenVersionIfMatch 为拿 username 多 1 次 FindOne |
+| P3 | L-1 ~ L-6 | 幽灵端点文案 / 锁序风险 / Redis 往返 pipeline / 无 CAS 注释 / CountActiveAdmins 语义 / DeptId=0 心智模型 |
 
 ### 建议的修复次序
 
-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 代码中复现无误,均有可触发的真实业务 / 攻击路径。
+1. **P0 同批上线**:H-1 + H-2(登录侧信道 + DoS 一起打补丁),H-3(SyncPerms 的 FOR UPDATE 接上 `LockByCodeTx` + 改掉 mock)。**这三条是本轮最关键的质变修复,前两条决定账号安全边界,第三条决定同步的可靠性。**
+2. **P1 第二批**:
+   - M-1:`UserDetailsLoader.CleanByUserIds` + `UpdateDept` 批处理;
+   - M-2:ProductList / ProductDetail / DeptTree 的访问控制(非超管自动剪枝);
+   - M-3:`UserDetail` 的 PII filter;
+   - M-4:post-commit 缓存清理失败不再映射 500。
+3. **P2 配套**:
+   - M-5:`CheckManageAccess` 支持 prefetched target;
+   - M-6:`ExtractClientIP` 支持 `X-Forwarded-For` + IP 合法性校验;
+   - M-7:统一的 `parseHS256` helper 显式校验签名算法;
+   - M-8:`IncrementTokenVersionIfMatch` 接收 username 参数。
+4. **P3 收尾 / 日常清理**:L-1 ~ L-6。
+
+### 与第 5 轮的关系
+
+- 第 5 轮 **实际落地并复盘通过**:H-1(CAS)、H-2(gRPC 限流)、H-3(同级放行修 `<=`)、H-4(deptId=0 守门)、M-3(负缓存)、M-5(错误消息字符串匹配移除)、M-7(gRPC IP 剥 host:port)、M-8(ChangePassword 限流)、L-1(MustChangePasswordYes)、L-4(FindMinPermsLevelByUserIdAndProductCode 区分 NotFound)。
+- 第 5 轮 **未完全落地** / **遗留**:M-2(UpdateDept Clean 批处理)→ 本轮 M-1;M-6(SyncPerms FOR UPDATE)→ 本轮 H-3(因基础设施完成提高到 P0)。
+- 第 5 轮 **未覆盖**:登录侧信道(H-2)、admin DoS(H-1)、水平信息披露(M-2 / M-3)、post-commit 缓存失败的 500 误映射(M-4)、客户端 IP 提取不全(M-6)、JWT `token.Method` 断言(M-7)——本轮全部新增。
+
+> 说明:本轮 findings 均在当前 HEAD 代码中复现无误;H 级别问题均给出可触发的真实业务 / 攻击路径,而非纯理论风险。

+ 45 - 14
internal/loaders/userDetailsLoader.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"math"
+	"time"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/model"
@@ -178,6 +179,36 @@ func (l *UserDetailsLoader) Clean(ctx context.Context, userId int64) {
 	l.cleanByIndex(ctx, idxKey)
 }
 
+// CleanByUserIds 批量清除多个用户在所有产品下的缓存:利用 Redis SUNION 把 N 个用户索引集合合并,
+// 配合一次批量 DEL,RTT 从"N × 3"压到常数 2,用于部门字段批量变更后的一次性缓存失效(见审计 M-1)。
+// 调用方(如 UpdateDeptLogic)需要先拿到受影响的 userIds 再整体调用一次,避免在 handler 里串行清理
+// 几百个用户的 Clean 把请求时长推到秒级。
+func (l *UserDetailsLoader) CleanByUserIds(ctx context.Context, userIds []int64) {
+	if len(userIds) == 0 {
+		return
+	}
+	idxKeys := make([]string, 0, len(userIds))
+	for _, uid := range userIds {
+		idxKeys = append(idxKeys, l.userIndexKey(uid))
+	}
+
+	cacheKeys, err := l.rds.SunionCtx(ctx, idxKeys...)
+	if err != nil {
+		logx.WithContext(ctx).Errorf("CleanByUserIds sunion failed: %v", err)
+		return
+	}
+
+	toDelete := make([]string, 0, len(cacheKeys)+len(idxKeys))
+	toDelete = append(toDelete, cacheKeys...)
+	toDelete = append(toDelete, idxKeys...)
+	if len(toDelete) == 0 {
+		return
+	}
+	if _, err := l.rds.DelCtx(ctx, toDelete...); err != nil {
+		logx.WithContext(ctx).Errorf("CleanByUserIds bulk del failed: %v", err)
+	}
+}
+
 // CleanByProduct 清除指定产品下所有用户的缓存。
 func (l *UserDetailsLoader) CleanByProduct(ctx context.Context, productCode string) {
 	idxKey := l.productIndexKey(productCode)
@@ -218,22 +249,22 @@ func (l *UserDetailsLoader) cleanByIndex(ctx context.Context, indexKey string) {
 }
 
 func (l *UserDetailsLoader) registerCacheKey(ctx context.Context, cacheKey string, userId int64, productCode string) {
+	// 索引维护使用 pipeline 把 SADD + EXPIRE(以及 productCode 维度的另一对)合并为单次 RTT,
+	// 避免大产品启动瞬间 N 个并发 Load 各自打 4 次 RTT 把 Redis 连接队列打满(见审计 L-3)。
 	uIdxKey := l.userIndexKey(userId)
-	if _, err := l.rds.SaddCtx(ctx, uIdxKey, cacheKey); err != nil {
-		logx.WithContext(ctx).Errorf("sadd user index failed: %v", err)
-	}
-	if err := l.rds.ExpireCtx(ctx, uIdxKey, l.ttl+60); err != nil {
-		logx.WithContext(ctx).Errorf("expire user index failed: %v", err)
-	}
-
-	if productCode != "" {
-		pIdxKey := l.productIndexKey(productCode)
-		if _, err := l.rds.SaddCtx(ctx, pIdxKey, cacheKey); err != nil {
-			logx.WithContext(ctx).Errorf("sadd product index failed: %v", err)
-		}
-		if err := l.rds.ExpireCtx(ctx, pIdxKey, l.ttl+60); err != nil {
-			logx.WithContext(ctx).Errorf("expire product index failed: %v", err)
+	expireSec := time.Duration(l.ttl+60) * time.Second
+	err := l.rds.PipelinedCtx(ctx, func(pipe redis.Pipeliner) error {
+		pipe.SAdd(ctx, uIdxKey, cacheKey)
+		pipe.Expire(ctx, uIdxKey, expireSec)
+		if productCode != "" {
+			pIdxKey := l.productIndexKey(productCode)
+			pipe.SAdd(ctx, pIdxKey, cacheKey)
+			pipe.Expire(ctx, pIdxKey, expireSec)
 		}
+		return nil
+	})
+	if err != nil {
+		logx.WithContext(ctx).Errorf("registerCacheKey pipeline failed: %v", err)
 	}
 }
 

+ 79 - 0
internal/loaders/userDetailsLoaderCleanByUserIds_audit_test.go

@@ -0,0 +1,79 @@
+package loaders
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-1 修复回归 —— UserDetailsLoader.CleanByUserIds
+//   * 对多个 userId 的所有产品下缓存 + 用户索引 key 必须整体删除;
+//   * 对空输入必须立即返回,不打 Redis;
+//   * 基于 SUNION + 批 DEL,单次 RTT 常数,调用方(UpdateDept)调一次即可清完。
+// ---------------------------------------------------------------------------
+
+// TC-0846: 预埋 3 用户 × 2 产品的缓存,CleanByUserIds 一次性清光所有 ud: key 与 idx: key。
+func TestCleanByUserIds_WipesAllUserProductKeysAndIndexes(t *testing.T) {
+	rds := testRedis()
+	loader := newTestLoader()
+	ctx := context.Background()
+
+	type cell struct {
+		uid int64
+		pc  string
+	}
+	cells := []cell{
+		{1000001, "pcX"}, {1000001, "pcY"},
+		{1000002, "pcX"}, {1000002, "pcY"},
+		{1000003, "pcX"}, {1000003, "pcY"},
+	}
+
+	// 预埋缓存:每个 cell 写一条 value 到 cacheKey,并 SADD 到 user / product 索引。
+	cacheKeys := make([]string, 0, len(cells))
+	for _, c := range cells {
+		ck := loader.cacheKey(c.uid, c.pc)
+		require.NoError(t, rds.SetCtx(ctx, ck, "dummy"))
+		_, _ = rds.SaddCtx(ctx, loader.userIndexKey(c.uid), ck)
+		_, _ = rds.SaddCtx(ctx, loader.productIndexKey(c.pc), ck)
+		cacheKeys = append(cacheKeys, ck)
+	}
+
+	// 调用 CleanByUserIds 触发 SUNION + 批 DEL。
+	loader.CleanByUserIds(ctx, []int64{1000001, 1000002, 1000003})
+
+	// 6 条 ud: key 必须全消失。
+	for _, ck := range cacheKeys {
+		exist, err := rds.ExistsCtx(ctx, ck)
+		require.NoError(t, err)
+		assert.False(t, exist, "M-1:cacheKey %s 必须被清理", ck)
+	}
+	// 3 条 user 索引 key 必须也被清掉(否则会漏缓存)。
+	for _, uid := range []int64{1000001, 1000002, 1000003} {
+		exist, err := rds.ExistsCtx(ctx, loader.userIndexKey(uid))
+		require.NoError(t, err)
+		assert.False(t, exist,
+			"M-1:user 索引集合必须被 DEL,否则下次 Clean 会复活假指针")
+	}
+
+	// 清理 product 索引残留(修复 SLA 不负责 product 索引,其残留 key 已在 user 索引里一并清掉
+	// 的那一组;但为了测试幂等性,手动 cleanup)。
+	t.Cleanup(func() {
+		_, _ = rds.DelCtx(ctx, loader.productIndexKey("pcX"), loader.productIndexKey("pcY"))
+	})
+}
+
+// TC-0847: 空 ids 切片必须直接返回,不打 Redis。
+// 如果源码退化成把空 SUNION 交给 Redis,会收到 "SUNION wrong number of arguments" 错误;
+// 我们通过断言 Redis 未产生任何错误以及函数未 panic 来验证。
+func TestCleanByUserIds_EmptyIds_NoOp(t *testing.T) {
+	loader := newTestLoader()
+	// 只要不 panic、返回即可;如果源码 foundation 有 wrong-args 会 logx.Errorf 输出,
+	// 这里做最小断言:调用返回控制权。
+	loader.CleanByUserIds(context.Background(), nil)
+	loader.CleanByUserIds(context.Background(), []int64{})
+	// 若走到了 SUNION 分支,Redis 会在 wrong-args 下被 logx 记 Errorf,
+	// 业务回调仍然返回,此时不应 panic;通过到达本行说明 OK。
+}

+ 49 - 11
internal/logic/auth/access.go

@@ -9,6 +9,7 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 
@@ -30,6 +31,22 @@ func memberTypePriority(memberType string) int {
 	}
 }
 
+// ManageAccessOption 给 CheckManageAccess 的可选参数;主要用来传递调用方已经拿到的目标用户对象,
+// 避免 checkDeptHierarchy 内部再做一次 FindOne(targetUserId)(见审计 M-5)。
+type ManageAccessOption func(*manageAccessOpts)
+
+type manageAccessOpts struct {
+	prefetchedTarget *userModel.SysUser
+}
+
+// WithPrefetchedTarget 供调用方透传已获取的目标用户数据。仅在 target.Id == targetUserId 时有效,
+// 调用方负责保证一致性;不一致时该选项被忽略,回落到普通 FindOne。
+func WithPrefetchedTarget(target *userModel.SysUser) ManageAccessOption {
+	return func(o *manageAccessOpts) {
+		o.prefetchedTarget = target
+	}
+}
+
 // CheckManageAccess 检查当前操作者是否有权管理目标用户。
 // 规则:
 //  1. SUPER_ADMIN 完全豁免
@@ -38,7 +55,7 @@ func memberTypePriority(memberType string) int {
 //  4. 权限级别检查:操作者的级别必须严格高于目标用户
 //     - 先比 memberType 优先级(SUPER_ADMIN > ADMIN > DEVELOPER > MEMBER)
 //     - 同 memberType 时比 permsLevel(数值越小权限越高)
-func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUserId int64, productCode string) error {
+func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUserId int64, productCode string, opts ...ManageAccessOption) error {
 	caller := middleware.GetUserDetails(ctx)
 	if caller == nil {
 		return response.ErrUnauthorized("未登录")
@@ -51,7 +68,16 @@ func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUs
 		return nil
 	}
 
-	if err := checkDeptHierarchy(ctx, svcCtx, caller, targetUserId); err != nil {
+	options := &manageAccessOpts{}
+	for _, opt := range opts {
+		opt(options)
+	}
+	prefetched := options.prefetchedTarget
+	if prefetched != nil && prefetched.Id != targetUserId {
+		prefetched = nil
+	}
+
+	if err := checkDeptHierarchy(ctx, svcCtx, caller, targetUserId, prefetched); err != nil {
 		return err
 	}
 
@@ -134,25 +160,33 @@ func HasFullProductPerms(caller *loaders.UserDetails) bool {
 
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
-func ValidateStatusChange(ctx context.Context, svcCtx *svc.ServiceContext, callerId, targetUserId int64) error {
+//
+// 返回校验通过时对应的目标用户对象,方便调用方透传给 CheckManageAccess 的 WithPrefetchedTarget
+// 选项和后续业务使用,避免同一请求内重复 FindOne(见审计 M-5)。
+func ValidateStatusChange(ctx context.Context, svcCtx *svc.ServiceContext, callerId, targetUserId int64) (*userModel.SysUser, error) {
 	if callerId == targetUserId {
-		return response.ErrBadRequest("不能修改自己的状态")
+		return nil, response.ErrBadRequest("不能修改自己的状态")
 	}
 	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
 	if err != nil {
-		return response.ErrNotFound("用户不存在")
+		return nil, response.ErrNotFound("用户不存在")
 	}
 	if target.IsSuperAdmin == consts.IsSuperAdminYes {
-		return response.ErrForbidden("不能修改超级管理员的状态")
+		return nil, response.ErrForbidden("不能修改超级管理员的状态")
 	}
-	return nil
+	return target, nil
 }
 
-func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64) error {
+func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, prefetchedTarget *userModel.SysUser) error {
 	if caller.MemberType == consts.MemberTypeAdmin {
 		return nil
 	}
 
+	// TODO(L-6): H-4 落地之后,新建 MEMBER/DEVELOPER 不会再出现 DeptId=0;但迁移/老数据里仍可能存在
+	// "MemberType!=ADMIN 且 DeptId=0" 的幽灵账号,此处一律 403 会让这类账号失去任何管理能力
+	// (包括原本可以由 checkPermLevel 通过的 product-admin-downward 操作)。运维应补一次 data fix
+	// 把这类账号归入默认部门;若未来需要放宽,可在此允许"管理自己"或跳过部门链校验直接交由
+	// checkPermLevel 判定。
 	if caller.DeptId == 0 {
 		return response.ErrForbidden("您未归属任何部门,无权管理其他用户")
 	}
@@ -161,9 +195,13 @@ func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller
 		return response.ErrForbidden("您的部门信息异常,无法执行此操作")
 	}
 
-	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
-	if err != nil {
-		return response.ErrNotFound("目标用户不存在")
+	target := prefetchedTarget
+	if target == nil {
+		t, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
+		if err != nil {
+			return response.ErrNotFound("目标用户不存在")
+		}
+		target = t
 	}
 	if target.DeptId == 0 {
 		return response.ErrForbidden("目标用户未归属部门,仅超管或产品管理员可管理")

+ 143 - 0
internal/logic/auth/checkManageAccessPrefetch_audit_test.go

@@ -0,0 +1,143 @@
+package auth
+
+import (
+	"context"
+	"math"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
+	productmemberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-5 修复回归 —— CheckManageAccess(WithPrefetchedTarget(...))
+// 允许调用方透传已经 FindOne 到的 target,避免单次请求内重复 FindOne(targetUserId)。
+//
+// 修复前:UpdateUserStatus / UpdateUser 一次请求会先做 ValidateStatusChange 里的 FindOne,
+// 紧接着 checkDeptHierarchy 里又 FindOne 一次,DB/缓存都白打一次 RTT。
+//
+// 修复后的契约:
+//   * Option 与参数一致(target.Id == targetUserId)时,FindOne 必须被跳过;
+//   * 不一致时 option 失效(defensive ignore),checkDeptHierarchy 回退到原有 FindOne 路径。
+// ---------------------------------------------------------------------------
+
+func buildMemberCallerCtx() context.Context {
+	caller := &loaders.UserDetails{
+		UserId:        1,
+		Username:      "op",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeMember,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "pc_m5",
+		DeptId:        100,
+		DeptPath:      "/100/",
+		MinPermsLevel: 50,
+	}
+	return middleware.WithUserDetails(context.Background(), caller)
+}
+
+// TC-0860: 透传的 prefetched.Id 与 targetUserId 一致 → SysUserModel.FindOne 必须一次都不被调用。
+func TestCheckManageAccess_PrefetchedTarget_SkipsFindOne(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	userMock := mocks.NewMockSysUserModel(ctrl)
+	// 关键断言:FindOne 次数为 0。gomock 默认不允许未声明的调用;省略 EXPECT 即相当于 0 次。
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	pmMock := mocks.NewMockSysProductMemberModel(ctrl)
+	roleMock := mocks.NewMockSysRoleModel(ctrl)
+
+	// 目标用户所在部门的 Path 需满足 HasPrefix caller.DeptPath="/100/"
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
+		Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
+
+	// 目标产品成员存在,MemberType=MEMBER 与 caller 同级 → 走 permsLevel 比较分支。
+	pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
+		Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
+
+	// 目标的 permsLevel 高于 caller(数值更大 → 权限更低),校验放行。
+	roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
+		Return(int64(100), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
+	})
+
+	prefetched := &userModel.SysUser{Id: 42, DeptId: 101}
+	err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(prefetched))
+	assert.NoError(t, err,
+		"M-5:prefetched 与 targetUserId 一致且业务级校验全部通过时应放行")
+	// ctrl.Finish() 里会自动校验 userMock.FindOne 调用次数为 0(未显式 EXPECT),
+	// 若源码回退到 FindOne 路径测试会抛 "unexpected call to FindOne" 直接 FAIL。
+}
+
+// TC-0861: 透传的 prefetched.Id 与 targetUserId 不一致 → option 被 defensive 忽略,
+// 必须真实调用 SysUserModel.FindOne(ctx, targetUserId) 一次。
+// 这是一条 "调用方把错 id 传进来时不能被当做合法 prefetched" 的安全断言:
+// 如果源码直接信任 prefetched 而不校验 Id,就会出现 "用 A 的 userDetails 去放行对 B 的管理"。
+func TestCheckManageAccess_PrefetchedIdMismatch_IgnoredAndFallsBackToFindOne(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	userMock := mocks.NewMockSysUserModel(ctrl)
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	pmMock := mocks.NewMockSysProductMemberModel(ctrl)
+	roleMock := mocks.NewMockSysRoleModel(ctrl)
+
+	// 关键断言:FindOne(targetUserId=42) 必须真实被调用一次,说明 prefetched 没被盲信。
+	// 我们返回的真实对象 DeptId=101(与乱传的 prefetched 一致),好让流程继续走通。
+	userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
+		Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
+
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
+		Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
+	pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
+		Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
+	roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
+		Return(int64(100), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
+	})
+
+	// 故意传 Id=999,与 targetUserId=42 不一致。
+	wrong := &userModel.SysUser{Id: 999, DeptId: 101}
+	err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(wrong))
+	assert.NoError(t, err,
+		"M-5:prefetched.Id 不匹配时回退 FindOne 后,本场景仍应通过业务级校验")
+}
+
+// 正向防御:prefetched 为 nil 时也不应 panic,且必须走 FindOne 一次(不传 option 的等价路径)。
+func TestCheckManageAccess_NilPrefetched_FallsBackToFindOne(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	userMock := mocks.NewMockSysUserModel(ctrl)
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	pmMock := mocks.NewMockSysProductMemberModel(ctrl)
+	roleMock := mocks.NewMockSysRoleModel(ctrl)
+
+	userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
+		Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
+		Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
+	pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
+		Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
+	roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
+		Return(int64(math.MaxInt64), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
+	})
+
+	err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(nil))
+	assert.NoError(t, err)
+}

+ 112 - 0
internal/logic/dept/deptTreeAccessControl_audit_test.go

@@ -0,0 +1,112 @@
+package dept
+
+import (
+	"context"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-2 修复回归 —— 非超管/非 ADMIN 调 /api/dept/tree 时必须按
+// caller.DeptPath 前缀过滤部门,只返回以其为根的子树。避免:
+//   * MEMBER 级账号枚举全公司组织结构;
+//   * 定位 DEV 部门再针对性申请权限。
+//
+// ADMIN / SuperAdmin 保留完整树(运营使用场景)。
+//
+// 测试数据:一棵 "/100/" 根下挂 "/100/1/"、"/100/1/5/",以及一个平行分支 "/200/"。
+// 期望:caller DeptPath="/100/1/" 只能看到 "/100/1/" 和 "/100/1/5/"。
+// ---------------------------------------------------------------------------
+
+var allDepts = []*deptModel.SysDept{
+	{Id: 100, ParentId: 0, Path: "/100/", Name: "root"},
+	{Id: 1, ParentId: 100, Path: "/100/1/", Name: "childA"},
+	{Id: 5, ParentId: 1, Path: "/100/1/5/", Name: "grandchild"},
+	{Id: 2, ParentId: 100, Path: "/100/2/", Name: "childB"},
+	{Id: 200, ParentId: 0, Path: "/200/", Name: "siblingRoot"},
+}
+
+func ctxWith(caller *loaders.UserDetails) context.Context {
+	return middleware.WithUserDetails(context.Background(), caller)
+}
+
+// TC-0855: MEMBER DeptPath="/100/1/" 应只看到 "/100/1/" 和 "/100/1/5/",且局部根就是 "/100/1/"。
+func TestDeptTree_Member_PrunedToSubtree(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := &loaders.UserDetails{
+		UserId: 42, IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
+		DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
+	}
+	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
+	require.NoError(t, err)
+
+	// 剪枝后只剩 2 个节点;根仍应只有一个(id=1,grandchild 挂在其下)。
+	require.Len(t, tree, 1, "M-2:剪枝后根只剩 1 个(caller 所在部门)")
+	assert.Equal(t, int64(1), tree[0].Id, "M-2:局部根必须是 /100/1/,不得把 /100/ 也暴露")
+	assert.Equal(t, "/100/1/", tree[0].Path)
+	require.Len(t, tree[0].Children, 1, "M-2:grandchild 必须挂在局部根下")
+	assert.Equal(t, int64(5), tree[0].Children[0].Id)
+}
+
+// TC-0856: MEMBER DeptPath="" —— 返回空切片,即使 DB 有数据。
+func TestDeptTree_OrphanMember_ReturnsEmpty(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	// 注意:当前实现会先 FindAll 再剪枝,空 DeptPath 直接走空返回,但 FindAll 仍被调用一次(有些成本),
+	// 这里设置 AnyTimes 适配 "就算调用一次也可以"。
+	deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil).AnyTimes()
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := &loaders.UserDetails{
+		UserId: 43, IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
+		DeptId: 0, DeptPath: "", ProductCode: "pA",
+	}
+	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
+	require.NoError(t, err)
+	assert.Len(t, tree, 0, "M-2:DeptPath 为空必须返回空树,不能泄露组织结构")
+}
+
+// TC-0857: 产品 ADMIN —— 视为 fullAccess,返回完整树(两个根)。
+func TestDeptTree_Admin_FullTree(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := &loaders.UserDetails{
+		UserId: 2, IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
+		DeptId: 100, DeptPath: "/100/", ProductCode: "pA",
+	}
+	tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
+	require.NoError(t, err)
+
+	// 完整树:根有 2 个(id=100, id=200)。
+	require.Len(t, tree, 2, "M-2:ADMIN 应看到完整部门树,包括兄弟分支")
+	var rootIds []int64
+	for _, r := range tree {
+		rootIds = append(rootIds, r.Id)
+	}
+	assert.ElementsMatch(t, []int64{100, 200}, rootIds)
+}

+ 35 - 6
internal/logic/dept/deptTreeLogic.go

@@ -2,7 +2,12 @@ package dept
 
 import (
 	"context"
+	"strings"
 
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
+	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
@@ -23,13 +28,35 @@ func NewDeptTreeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeptTree
 	}
 }
 
-// DeptTree 部门树。一次性返回完整的组织架构树形结构,用于前端部门选择器和组织架构展示。
+// DeptTree 部门树。超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath
+// 为根的子树,避免 MEMBER 级账号枚举全公司组织结构、定位 DEV 部门用于针对性特权获取
+// (见审计 M-2)。未归属部门或 DeptPath 为空的成员返回空树。
 func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
 	list, err := l.svcCtx.SysDeptModel.FindAll(l.ctx)
 	if err != nil {
 		return nil, err
 	}
 
+	fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin
+
+	if !fullAccess {
+		if caller.DeptPath == "" {
+			return make([]*types.DeptItem, 0), nil
+		}
+		filtered := make([]*deptModel.SysDept, 0, len(list))
+		for _, d := range list {
+			if strings.HasPrefix(d.Path, caller.DeptPath) {
+				filtered = append(filtered, d)
+			}
+		}
+		list = filtered
+	}
+
 	items := make([]*types.DeptItem, 0, len(list))
 	for _, d := range list {
 		items = append(items, &types.DeptItem{
@@ -53,13 +80,15 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 
 	roots := make([]*types.DeptItem, 0)
 	for _, item := range items {
-		if item.ParentId == 0 {
+		// 非特权成员下只保留 caller 子树:原树的上级部门不出现在 items 中,item.ParentId
+		// 找不到父节点时应当把当前节点当成局部根展示,而不是报错丢弃。
+		if _, hasParent := itemMap[item.ParentId]; item.ParentId == 0 || !hasParent {
+			if fullAccess && item.ParentId != 0 {
+				l.Errorf("DeptTree: dept id=%d has parentId=%d which does not exist, treated as root", item.Id, item.ParentId)
+			}
 			roots = append(roots, item)
-		} else if parent, ok := itemMap[item.ParentId]; ok {
-			parent.Children = append(parent.Children, item)
 		} else {
-			l.Errorf("DeptTree: dept id=%d has parentId=%d which does not exist, treated as root", item.Id, item.ParentId)
-			roots = append(roots, item)
+			itemMap[item.ParentId].Children = append(itemMap[item.ParentId].Children, item)
 		}
 	}
 

+ 0 - 95
internal/logic/dept/deptTreeLogic_test.go

@@ -1,95 +0,0 @@
-package dept
-
-import (
-	"context"
-	"testing"
-
-	"perms-system-server/internal/svc"
-	"perms-system-server/internal/testutil"
-	"perms-system-server/internal/types"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-// TC-0110: 正常获取
-func TestDeptTree_Normal(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-
-	rootId, err := insertDeptRaw(ctx, svcCtx, 0, "tree_root_"+testutil.UniqueId(), "/")
-	require.NoError(t, err)
-	root, _ := svcCtx.SysDeptModel.FindOne(ctx, rootId)
-
-	c1Id, err := insertDeptRaw(ctx, svcCtx, rootId, "tree_c1_"+testutil.UniqueId(), root.Path)
-	require.NoError(t, err)
-	c2Id, err := insertDeptRaw(ctx, svcCtx, rootId, "tree_c2_"+testutil.UniqueId(), root.Path)
-	require.NoError(t, err)
-
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", c1Id, c2Id, rootId) })
-
-	treeLogic := NewDeptTreeLogic(ctx, svcCtx)
-	tree, err := treeLogic.DeptTree()
-	require.NoError(t, err)
-	require.NotNil(t, tree)
-
-	var rootNode *types.DeptItem
-	for _, node := range tree {
-		if node.Id == rootId {
-			rootNode = node
-			break
-		}
-	}
-	require.NotNil(t, rootNode, "should find the test root node in tree")
-	assert.Equal(t, int64(0), rootNode.ParentId)
-	assert.Equal(t, "NORMAL", rootNode.DeptType)
-	require.Len(t, rootNode.Children, 2)
-
-	childIds := []int64{rootNode.Children[0].Id, rootNode.Children[1].Id}
-	assert.ElementsMatch(t, []int64{c1Id, c2Id}, childIds)
-}
-
-// TC-0111: 空数据
-func TestDeptTree_Empty(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	treeLogic := NewDeptTreeLogic(ctx, svcCtx)
-	tree, err := treeLogic.DeptTree()
-	require.NoError(t, err)
-	assert.NotNil(t, tree)
-	// Known limitation: shared integration DB may hold rows from other tests or packages,
-	// so we cannot assert len(tree)==0 here without isolating or truncating sys_dept.
-}
-
-// TC-0112: 孤儿节点
-func TestDeptTree_OrphanBecomesRoot(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-
-	parentId, err := insertDeptRaw(ctx, svcCtx, 0, "orphan_p_"+testutil.UniqueId(), "/")
-	require.NoError(t, err)
-	parent, _ := svcCtx.SysDeptModel.FindOne(ctx, parentId)
-
-	childId, err := insertDeptRaw(ctx, svcCtx, parentId, "orphan_c_"+testutil.UniqueId(), parent.Path)
-	require.NoError(t, err)
-
-	testutil.CleanTable(ctx, conn, "`sys_dept`", parentId)
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId) })
-
-	treeLogic := NewDeptTreeLogic(ctx, svcCtx)
-	tree, err := treeLogic.DeptTree()
-	require.NoError(t, err)
-
-	var orphanNode *types.DeptItem
-	for _, node := range tree {
-		if node.Id == childId {
-			orphanNode = node
-			break
-		}
-	}
-	require.NotNil(t, orphanNode, "orphan node should appear as root")
-	assert.Equal(t, parentId, orphanNode.ParentId)
-}

+ 121 - 0
internal/logic/dept/updateDeptCleanBatch_audit_test.go

@@ -0,0 +1,121 @@
+package dept
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	deptModel "perms-system-server/internal/model/dept"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-1 修复回归 —— UpdateDept 在 deptType / status 真正变更时:
+//   * 必须调用 SysUserModel.FindIdsByDeptId 获取受影响 userIds;
+//   * FindIdsByDeptId 返回 err 时仅 Errorf 记录,handler 返回 nil(degraded 成功);
+//   * FindIdsByDeptId 成功时只调用 "1 次",避免在 handler 里串行按用户 Clean 造成秒级延迟。
+// ---------------------------------------------------------------------------
+
+func superAdminCtx() context.Context {
+	return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 1, Username: "su",
+		IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
+		Status: consts.StatusEnabled,
+	})
+}
+
+// TC-0848: deptType 变更 → FindIdsByDeptId 恰好调用 1 次,返回 [100, 101];handler 返回 nil。
+func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	userMock := mocks.NewMockSysUserModel(ctrl)
+
+	// 老部门是 NORMAL,请求改成 DEV → deptTypeChanged=true 才能触发 FindIdsByDeptId。
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(77)).
+		Return(&deptModel.SysDept{
+			Id: 77, Name: "n", DeptType: consts.DeptTypeNormal,
+			Status: consts.StatusEnabled, UpdateTime: 500,
+		}, nil)
+	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(500)).Return(nil)
+
+	// 关键断言:恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。
+	userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(77)).
+		Return([]int64{100, 101}, nil).Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Dept: deptMock, User: userMock,
+	})
+
+	err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
+		Id: 77, Name: "n", Sort: 0, Remark: "", DeptType: consts.DeptTypeDev,
+	})
+	require.NoError(t, err,
+		"M-1:正常场景 UpdateDept 必须返回 nil,且仅调用一次 FindIdsByDeptId")
+}
+
+// TC-0849: FindIdsByDeptId 返回 err —— handler 仍返回 nil(degraded 成功),旧缓存由 TTL 过期兜底。
+func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	userMock := mocks.NewMockSysUserModel(ctrl)
+
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(88)).
+		Return(&deptModel.SysDept{
+			Id: 88, Name: "n", DeptType: consts.DeptTypeNormal,
+			Status: consts.StatusEnabled, UpdateTime: 1000,
+		}, nil)
+	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
+
+	userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(88)).
+		Return(nil, errors.New("transient DB error"))
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Dept: deptMock, User: userMock,
+	})
+
+	err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
+		Id: 88, Name: "n", DeptType: consts.DeptTypeDev,
+	})
+	assert.NoError(t, err,
+		"M-1:FindIdsByDeptId 失败不得映射 500;TTL 过期兜底,客户端不应重试整次 UpdateDept")
+}
+
+// 补充:deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴)。
+func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	userMock := mocks.NewMockSysUserModel(ctrl)
+
+	// 老部门 DEV,请求也是 DEV;status 未传 → 没有变更。
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(99)).
+		Return(&deptModel.SysDept{
+			Id: 99, Name: "x", DeptType: consts.DeptTypeDev,
+			Status: consts.StatusEnabled, UpdateTime: 200,
+		}, nil)
+	deptMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(200)).Return(nil)
+
+	// 关键:没有任何 FindIdsByDeptId EXPECT 即等价 Times(0)。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Dept: deptMock, User: userMock,
+	})
+
+	err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
+		Id: 99, Name: "x", DeptType: consts.DeptTypeDev, Sort: 1,
+	})
+	require.NoError(t, err, "M-1:无变更时 UpdateDept 只做元字段更新,不得触发缓存清理风暴")
+}

+ 10 - 4
internal/logic/dept/updateDeptLogic.go

@@ -82,13 +82,19 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	}
 
 	// loadPerms 只检查用户自身部门的 deptType/status,子部门不受影响,
-	// 因此仅需清理本部门直属用户缓存,且仅在 deptType 或 status 真正变更时才需要
+	// 因此仅需清理本部门直属用户缓存,且仅在 deptType 或 status 真正变更时才需要。
+	// 使用 CleanByUserIds 把 N 用户 × 3 RTT 的串行 Clean 压成常数 2 RTT,避免把 handler
+	// 挂住拖慢乐观锁重试(见审计 M-1)。FindIdsByDeptId 的错误必须显式 Errorf 记录而不能
+	// 静默 "_, _" 吞掉——DB 抖动会导致"被禁用部门"的旧权限缓存继续在 TTL 窗口内生效,
+	// 这是安全敏感变更;但遵循 M-4 post-commit 模式不映射 500,由 TTL 过期兜底。
 	if deptTypeChanged || statusChanged {
-		userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
-		for _, uid := range userIds {
-			l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
+		userIds, err := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+		if err != nil {
+			l.Errorf("UpdateDept id=%d deptType=%s status=%d 部门已更新但 FindIdsByDeptId 失败,用户权限缓存未能主动失效,将等待 TTL 自然过期: %v", req.Id, dept.DeptType, dept.Status, err)
+			return nil
 		}
 		if len(userIds) > 0 {
+			l.svcCtx.UserDetailsLoader.CleanByUserIds(l.ctx, userIds)
 			l.Infof("UpdateDept id=%d deptType=%s status=%d affectedUsers=%d", req.Id, dept.DeptType, dept.Status, len(userIds))
 		}
 	}

+ 4 - 2
internal/logic/member/removeMemberLogic.go

@@ -44,11 +44,13 @@ func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 			return response.ErrNotFound("成员不存在")
 		}
 		if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
-			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
+			// 使用 CountOtherActiveAdminsTx 排除目标自己,返回 0 即目标为最后一个 active admin,
+			// 不再依赖"count<=1 包含自己"的反向推理(见审计 L-5)。
+			otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
 			if err != nil {
 				return err
 			}
-			if adminCount <= 1 {
+			if otherAdminCount == 0 {
 				return response.ErrBadRequest("不能移除该产品的最后一个管理员")
 			}
 		}

+ 3 - 2
internal/logic/member/updateMemberLogic.go

@@ -65,11 +65,12 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		wasActiveAdmin := locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled
 		willBeActiveAdmin := nextType == consts.MemberTypeAdmin && nextStatus == consts.StatusEnabled
 		if wasActiveAdmin && !willBeActiveAdmin {
-			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
+			// 排除当前正在降级/禁用的这一行后还有几个 active admin;为 0 时即为最后一个(见审计 L-5)。
+			otherAdminCount, err := l.svcCtx.SysProductMemberModel.CountOtherActiveAdminsTx(ctx, session, member.ProductCode, locked.Id)
 			if err != nil {
 				return err
 			}
-			if adminCount <= 1 {
+			if otherAdminCount == 0 {
 				return response.ErrBadRequest("不能降级或禁用该产品的最后一个管理员")
 			}
 		}

+ 215 - 0
internal/logic/product/productAccessControl_audit_test.go

@@ -0,0 +1,215 @@
+package product
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	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/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-2 修复回归 —— 非超管调用 /api/product/list、/api/product/detail
+// 时必须做 "行/资源级" 访问控制:
+//   * List:只返回 caller.ProductCode 对应的那一条;caller 无 productCode 时返回空列表。
+//   * Detail:目标 product.Code != caller.ProductCode 时 404(不披露 "存在但无权")。
+// 同时保留字段级脱敏:非超管看不到 AppKey。
+// ---------------------------------------------------------------------------
+
+// ——— 工具:各种身份的 ctx ———
+
+func memberWithProduct(productCode string) context.Context {
+	return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId:       42, Username: "m",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, ProductCode: productCode,
+	})
+}
+
+func orphanMember() context.Context {
+	return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 43, Username: "orphan",
+		IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, ProductCode: "", // 无 product
+	})
+}
+
+func superAdminCtx() context.Context {
+	return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId: 1, Username: "su",
+		IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
+		Status: consts.StatusEnabled,
+	})
+}
+
+// TC-0850: MEMBER 调 ProductList —— 即使 DB 里有多个产品,也只看到自己的一个。
+func TestProductList_Member_OnlySeesOwnProduct(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	// 关键:非超管路径不应调用 FindList(全站列表),而应精确调用 FindOneByCode(ownCode)。
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "pA").
+		Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductListLogic(memberWithProduct("pA"), svcCtx).
+		ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+
+	assert.Equal(t, int64(1), resp.Total, "M-2:MEMBER 只能看到自己一个产品")
+	items, ok := resp.List.([]types.ProductItem)
+	require.True(t, ok, "resp.List 必须是 []types.ProductItem")
+	require.Len(t, items, 1)
+	item := items[0]
+	assert.Equal(t, "pA", item.Code)
+	assert.Empty(t, item.AppKey,
+		"M-2:非超管路径下 AppKey 必须保持脱敏,不得泄露")
+}
+
+// TC-0851: MEMBER 调 ProductList 但 ProductCode=="" —— 返回空列表,不访问 DB。
+func TestProductList_OrphanMember_ReturnsEmpty(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	// 断言:任何 DB 查询都不应发生。不登记任何 EXPECT 即隐含 Times(0)。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductListLogic(orphanMember(), svcCtx).
+		ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+
+	assert.Equal(t, int64(0), resp.Total)
+	items, ok := resp.List.([]types.ProductItem)
+	require.True(t, ok, "resp.List 必须是 []types.ProductItem")
+	assert.Len(t, items, 0)
+}
+
+// TC-0852: MEMBER 请求查询 "别人的产品" 详情 —— 必须 404(而非 403 或 200)。
+func TestProductDetail_Member_OtherProduct_Returns404(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	// DB 里 id=22 属于产品 pB,调用方属于 pA → 应当 404。
+	mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
+		Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
+		ProductDetail(&types.ProductDetailReq{Id: 22})
+	assert.Nil(t, resp)
+	require.Error(t, err)
+
+	ce, ok := err.(*response.CodeError)
+	require.True(t, ok, "M-2:应返回 response.CodeError")
+	assert.Equal(t, 404, ce.Code(),
+		"M-2:非超管查他产品必须 404,而不是 403/200,避免被用作存在性 oracle")
+}
+
+// TC-0853: MEMBER 查自己产品详情 —— 200 OK,但 AppKey 必须为空。
+func TestProductDetail_Member_OwnProduct_AppKeyHidden(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	mockProd.EXPECT().FindOne(gomock.Any(), int64(11)).
+		Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
+		ProductDetail(&types.ProductDetailReq{Id: 11})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, "pA", resp.Code)
+	assert.Empty(t, resp.AppKey,
+		"M-2:自己产品也不应看到 AppKey(M-2 不取消 AppKey 脱敏)")
+}
+
+// TC-0854: 超管查任何产品详情都能看到 AppKey。
+func TestProductDetail_SuperAdmin_SeesAppKey(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
+		Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
+		ProductDetail(&types.ProductDetailReq{Id: 22})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, "pB", resp.Code)
+	assert.Equal(t, "SECRET-B", resp.AppKey,
+		"超管路径下 AppKey 必须可见,否则超管无法管理集成")
+}
+
+// TC-0871: 超管调 ProductList 走完整分页路径,FindList 被调用且 AppKey 原样返回。
+// 覆盖清理掉的 TestProductList_Normal / TestProductList_SuperAdminAppKeyVisible —— 这些旧用例
+// 的"默认分页值 / pageSize 上限"等边界已由 util.TestNormalizePage 单元测试独立覆盖,
+// 这里只做一条最小契约保留:超管路径确实从 FindList 拉全站列表且 AppKey 不被脱敏。
+func TestProductList_SuperAdmin_AppKeyVisibleAndFindListCalled(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	// 关键 1:必须走 FindList(分页路径),而不是 FindOneByCode(非超管路径)。
+	mockProd.EXPECT().FindList(gomock.Any(), int64(1), int64(20)).
+		Return([]*productModel.SysProduct{
+			{Id: 11, Code: "pA", Name: "ProdA", AppKey: "AK-A"},
+			{Id: 12, Code: "pB", Name: "ProdB", AppKey: "AK-B"},
+		}, int64(2), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductListLogic(superAdminCtx(), svcCtx).
+		ProductList(&types.ProductListReq{Page: 1, PageSize: 20})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, int64(2), resp.Total)
+
+	items, ok := resp.List.([]types.ProductItem)
+	require.True(t, ok)
+	require.Len(t, items, 2)
+	// 关键 2:超管路径下 AppKey 不脱敏(与 TC-0850/TC-0853 的 MEMBER 路径形成互为镜像的契约)。
+	assert.Equal(t, "AK-A", items[0].AppKey, "M-2:超管必须看到 AppKey 以便管理产品集成")
+	assert.Equal(t, "AK-B", items[1].AppKey)
+}
+
+// TC-0872: ProductDetail 路径上 FindOne 抛 err(例如 NotFound)必须映射成 404 "产品不存在"。
+// 覆盖清理掉的 TestProductDetail_NotFound —— 这条路径与 TC-0852 的"别人的产品 → 404"不同:
+// 这里是 DB 层直接报"行不存在",logic 必须同样返回 404 以保持对枚举攻击的无差别响应。
+func TestProductDetail_FindOneError_MapsTo404(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	// FindOne 返回 sqlx.ErrNotFound(或任何 err)都应在 logic 层被映射成 404。
+	mockProd.EXPECT().FindOne(gomock.Any(), int64(999999)).
+		Return(nil, errors.New("sql: no rows in result set"))
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
+	resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
+		ProductDetail(&types.ProductDetailReq{Id: 999999})
+	assert.Nil(t, resp)
+	require.Error(t, err)
+
+	ce, ok := err.(*response.CodeError)
+	require.True(t, ok, "必须是 response.CodeError")
+	assert.Equal(t, 404, ce.Code())
+	assert.Equal(t, "产品不存在", ce.Error(),
+		"M-2:FindOne 失败与'别人的产品'都返回同一份 404 文案,避免被用作存在性 oracle")
+}

+ 12 - 3
internal/logic/product/productDetailLogic.go

@@ -25,14 +25,23 @@ func NewProductDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Pro
 	}
 }
 
-// ProductDetail 产品详情。根据产品 ID 查询产品的完整信息,超管可见 appKey。
+// ProductDetail 产品详情。超管可查任何产品的完整信息(含 appKey);非超管只能查自己所属的产品,
+// 对其他产品一律返回 404,避免将"存在但无权"和"不存在"区分开后被用作枚举 oracle(见审计 M-2)。
 func (l *ProductDetailLogic) ProductDetail(req *types.ProductDetailReq) (resp *types.ProductItem, err error) {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
 	product, err := l.svcCtx.SysProductModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 		return nil, response.ErrNotFound("产品不存在")
 	}
 
-	caller := middleware.GetUserDetails(l.ctx)
+	if !caller.IsSuperAdmin && product.Code != caller.ProductCode {
+		return nil, response.ErrNotFound("产品不存在")
+	}
+
 	item := &types.ProductItem{
 		Id:         product.Id,
 		Code:       product.Code,
@@ -41,7 +50,7 @@ func (l *ProductDetailLogic) ProductDetail(req *types.ProductDetailReq) (resp *t
 		Status:     product.Status,
 		CreateTime: product.CreateTime,
 	}
-	if caller != nil && caller.IsSuperAdmin {
+	if caller.IsSuperAdmin {
 		item.AppKey = product.AppKey
 	}
 	return item, nil

+ 0 - 116
internal/logic/product/productDetailLogic_test.go

@@ -1,116 +0,0 @@
-package product
-
-import (
-	"context"
-	"errors"
-	"testing"
-	"time"
-
-	productModel "perms-system-server/internal/model/product"
-	"perms-system-server/internal/response"
-	"perms-system-server/internal/svc"
-	"perms-system-server/internal/testutil"
-	"perms-system-server/internal/testutil/ctxhelper"
-	"perms-system-server/internal/types"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-// TC-0084: 正常查询
-func TestProductDetail_Success(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	code := testutil.UniqueId()
-	now := time.Now().Unix()
-
-	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code:       code,
-		Name:       "详情测试产品",
-		AppKey:     "dk_" + code,
-		AppSecret:  "ds_" + code,
-		Remark:     "详情备注",
-		Status:     1,
-		CreateTime: now,
-		UpdateTime: now,
-	})
-	require.NoError(t, err)
-	id, _ := result.LastInsertId()
-
-	t.Cleanup(func() {
-		testutil.CleanTable(ctx, conn, "`sys_product`", id)
-	})
-
-	logic := NewProductDetailLogic(ctx, svcCtx)
-	item, err := logic.ProductDetail(&types.ProductDetailReq{Id: id})
-	require.NoError(t, err)
-	require.NotNil(t, item)
-
-	assert.Equal(t, id, item.Id)
-	assert.Equal(t, code, item.Code)
-	assert.Equal(t, "详情测试产品", item.Name)
-	assert.Equal(t, "", item.AppKey, "AppKey should be hidden for non-superadmin")
-	assert.Equal(t, "详情备注", item.Remark)
-	assert.Equal(t, int64(1), item.Status)
-	assert.Equal(t, now, item.CreateTime)
-}
-
-// TC-0085: 不存在
-func TestProductDetail_NotFound(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewProductDetailLogic(ctx, svcCtx)
-	_, err := logic.ProductDetail(&types.ProductDetailReq{Id: 999999999})
-
-	require.Error(t, err)
-	var codeErr *response.CodeError
-	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 404, codeErr.Code())
-	assert.Equal(t, "产品不存在", codeErr.Error())
-}
-
-// TC-0088: 非超管AppKey隐藏
-func TestProductDetail_NonSuperAdminAppKeyHidden(t *testing.T) {
-	ctx := ctxhelper.MemberCtx("test_product")
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	code := testutil.UniqueId()
-	now := time.Now().Unix()
-
-	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: code, Name: "detail_appkey", AppKey: "secret_dk_" + code, AppSecret: "ds_" + code,
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	id, _ := result.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
-
-	logic := NewProductDetailLogic(ctx, svcCtx)
-	item, err := logic.ProductDetail(&types.ProductDetailReq{Id: id})
-	require.NoError(t, err)
-	assert.Empty(t, item.AppKey, "非超管不应看到AppKey")
-}
-
-// TC-0089: 超管可见AppKey
-func TestProductDetail_SuperAdminAppKeyVisible(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	code := testutil.UniqueId()
-	now := time.Now().Unix()
-
-	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: code, Name: "detail_appkey_sa", AppKey: "visible_dk_" + code, AppSecret: "ds_" + code,
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	id, _ := result.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
-
-	logic := NewProductDetailLogic(ctx, svcCtx)
-	item, err := logic.ProductDetail(&types.ProductDetailReq{Id: id})
-	require.NoError(t, err)
-	assert.Equal(t, "visible_dk_"+code, item.AppKey, "超管应能看到AppKey")
-}

+ 29 - 8
internal/logic/product/productListLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/util"
@@ -25,8 +26,32 @@ func NewProductListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Produ
 	}
 }
 
-// ProductList 产品列表。分页查询系统中所有产品的基本信息,超管可见全部产品的 appKey。
+// ProductList 产品列表。超管分页查询系统中所有产品;非超管仅能看到自己所属的那一个产品
+// (防止 MEMBER 级账号枚举全站 code 列表后拼装 admin_<code> 做针对性撞库,见审计 M-2)。
 func (l *ProductListLogic) ProductList(req *types.ProductListReq) (resp *types.PageResp, err error) {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
+	if !caller.IsSuperAdmin {
+		items := make([]types.ProductItem, 0, 1)
+		if caller.ProductCode != "" {
+			p, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, caller.ProductCode)
+			if err == nil {
+				items = append(items, types.ProductItem{
+					Id:         p.Id,
+					Code:       p.Code,
+					Name:       p.Name,
+					Remark:     p.Remark,
+					Status:     p.Status,
+					CreateTime: p.CreateTime,
+				})
+			}
+		}
+		return &types.PageResp{Total: int64(len(items)), List: items}, nil
+	}
+
 	page, pageSize := util.NormalizePage(req.Page, req.PageSize)
 
 	list, total, err := l.svcCtx.SysProductModel.FindList(l.ctx, page, pageSize)
@@ -34,21 +59,17 @@ func (l *ProductListLogic) ProductList(req *types.ProductListReq) (resp *types.P
 		return nil, err
 	}
 
-	caller := middleware.GetUserDetails(l.ctx)
 	items := make([]types.ProductItem, 0, len(list))
 	for _, p := range list {
-		item := types.ProductItem{
+		items = append(items, types.ProductItem{
 			Id:         p.Id,
 			Code:       p.Code,
 			Name:       p.Name,
+			AppKey:     p.AppKey,
 			Remark:     p.Remark,
 			Status:     p.Status,
 			CreateTime: p.CreateTime,
-		}
-		if caller != nil && caller.IsSuperAdmin {
-			item.AppKey = p.AppKey
-		}
-		items = append(items, item)
+		})
 	}
 
 	return &types.PageResp{

+ 0 - 168
internal/logic/product/productListLogic_test.go

@@ -1,168 +0,0 @@
-package product
-
-import (
-	"context"
-	"testing"
-	"time"
-
-	productModel "perms-system-server/internal/model/product"
-	"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"
-)
-
-// TC-0079: 正常分页
-func TestProductList_Normal(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	now := time.Now().Unix()
-
-	var ids []int64
-	for i := 0; i < 3; i++ {
-		code := testutil.UniqueId()
-		result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-			Code: code, Name: "列表测试产品", AppKey: "k_" + code, AppSecret: "s_" + code,
-			Status: 1, CreateTime: now, UpdateTime: now,
-		})
-		require.NoError(t, err)
-		id, _ := result.LastInsertId()
-		ids = append(ids, id)
-	}
-
-	t.Cleanup(func() {
-		testutil.CleanTable(ctx, conn, "`sys_product`", ids...)
-	})
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 10})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.True(t, resp.Total >= 3)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	assert.True(t, len(items) >= 3)
-}
-
-// TC-0080: 默认分页
-func TestProductList_DefaultPagination(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 0, PageSize: 0})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	assert.True(t, len(items) <= 20, "default pageSize should be 20")
-}
-
-// TC-0081: pageSize超过上限
-func TestProductList_PageSizeExceedsLimit(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 500})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	assert.True(t, len(items) <= 100, "pageSize should be capped at 100")
-}
-
-// TC-0082: pageSize=0
-func TestProductList_PageSizeZero_DefaultsTo20(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 0})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	assert.True(t, len(items) <= 20, "default pageSize should be 20")
-}
-
-// TC-0083: page负值
-func TestProductList_NegativePage_DefaultsTo1(t *testing.T) {
-	ctx := context.Background()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: -1, PageSize: 10})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-}
-
-// TC-0086: 非超管AppKey隐藏
-func TestProductList_NonSuperAdminAppKeyHidden(t *testing.T) {
-	ctx := ctxhelper.MemberCtx("test_product")
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	now := time.Now().Unix()
-	code := testutil.UniqueId()
-
-	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: code, Name: "appkey_test", AppKey: "secret_key_" + code, AppSecret: "s_" + code,
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	id, _ := result.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 100})
-	require.NoError(t, err)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	for _, item := range items {
-		if item.Id == id {
-			assert.Empty(t, item.AppKey, "非超管不应看到AppKey")
-			return
-		}
-	}
-	t.Fatal("未找到插入的测试产品")
-}
-
-// TC-0087: 超管可见AppKey
-func TestProductList_SuperAdminAppKeyVisible(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
-	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-	now := time.Now().Unix()
-	code := testutil.UniqueId()
-
-	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: code, Name: "appkey_test_sa", AppKey: "visible_key_" + code, AppSecret: "s_" + code,
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	id, _ := result.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
-
-	logic := NewProductListLogic(ctx, svcCtx)
-	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 100})
-	require.NoError(t, err)
-
-	items, ok := resp.List.([]types.ProductItem)
-	require.True(t, ok)
-	for _, item := range items {
-		if item.Id == id {
-			assert.Equal(t, "visible_key_"+code, item.AppKey, "超管应能看到AppKey")
-			return
-		}
-	}
-	t.Fatal("未找到插入的测试产品")
-}

+ 144 - 0
internal/logic/pub/adminLoginIpLimit_audit_test.go

@@ -0,0 +1,144 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 H-1 修复回归 —— AdminLogin 限流按 `admin:<clientIP>:<username>` 双维。
+//
+// H-1 攻击:攻击者只靠已知或枚举出的超管用户名 `admin_<productCode>`,从任意远端 IP 连打
+// 错误密码 → 触发 5 分钟封禁 → 合法超管任何 IP 都无法登录。修复后 key 上挂 clientIP:
+// 换 IP 的远端不继承上一桶计数,合法用户自身 IP 仍能进入。
+// ---------------------------------------------------------------------------
+
+// newAdminLimitSvcCtx 构造一个挂了独立 UsernameLoginLimit (quota=1) 的 svcCtx,
+// 避免测试之间的限流状态串扰。返回的 svcCtx 可直接传给 NewAdminLoginLogic。
+func newAdminLimitSvcCtx(t *testing.T, quota int) *svc.ServiceContext {
+	t.Helper()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, quota, rds,
+		cfg.CacheRedis.KeyPrefix+":rl:adminlogin:ut:"+testutil.UniqueId())
+	return svcCtx
+}
+
+// TC-0834: 同 IP + 同 username 超过 quota 必须 429,文案为新版本。
+func TestAdminLogin_H1_SameIPSameUsername_OverQuota429(t *testing.T) {
+	svcCtx := newAdminLimitSvcCtx(t, 1)
+	username := "h1_user_" + testutil.UniqueId()
+	ctx := middleware.WithClientIP(context.Background(), "1.2.3.4")
+	req := &types.AdminLoginReq{
+		Username:      username,
+		Password:      "bad",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	}
+
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code(), "首次调用应被限流放行并进入业务层,得到 401")
+
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code(), "同 IP+同 username 第二次必须 429")
+	assert.Equal(t, "登录尝试过于频繁,请5分钟后再试", ce.Error())
+}
+
+// TC-0835: 同 username 换远端 IP 不得继承配额。
+func TestAdminLogin_H1_DifferentIPSameUsername_IndependentBucket(t *testing.T) {
+	svcCtx := newAdminLimitSvcCtx(t, 1)
+	username := "h1_iso_" + testutil.UniqueId()
+	req := &types.AdminLoginReq{
+		Username:      username,
+		Password:      "bad",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	}
+
+	ctxA := middleware.WithClientIP(context.Background(), "10.0.0.1")
+	_, err := NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+
+	_, err = NewAdminLoginLogic(ctxA, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code(), "IP-A 配额已满")
+
+	ctxB := middleware.WithClientIP(context.Background(), "10.0.0.2")
+	_, err = NewAdminLoginLogic(ctxB, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code(),
+		"H-1 修复:换远端 IP 必须命中独立限流桶,不能被同 username 的旧计数拖连")
+}
+
+// TC-0836: ctx 里无 clientIP —— 退化为 "unknown" 共享桶,仍能限流,不得绕过。
+func TestAdminLogin_H1_MissingClientIP_FallbackBucket(t *testing.T) {
+	svcCtx := newAdminLimitSvcCtx(t, 1)
+	username := "h1_unk_" + testutil.UniqueId()
+	req := &types.AdminLoginReq{
+		Username:      username,
+		Password:      "bad",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	}
+	ctx := context.Background()
+
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(req)
+	require.Error(t, err)
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 429, ce.Code(),
+		"无 clientIP 时应该退化到 'unknown' 桶继续限流,严禁直接绕过")
+}
+
+// TC-0837: managementKey 错误路径不消耗 username quota(Take 顺序冻结)。
+func TestAdminLogin_H1_BadManagementKey_DoesNotConsumeQuota(t *testing.T) {
+	svcCtx := newAdminLimitSvcCtx(t, 1)
+	username := "h1_mk_" + testutil.UniqueId()
+	ctx := middleware.WithClientIP(context.Background(), "172.16.0.9")
+
+	_, err := NewAdminLoginLogic(ctx, svcCtx).AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      "whatever",
+		ManagementKey: "WRONG-KEY",
+	})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+	assert.Equal(t, "managementKey无效", ce.Error())
+
+	_, err = NewAdminLoginLogic(ctx, svcCtx).AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      "whatever",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Error(t, err)
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code(),
+		"H-1 顺序:managementKey 错误应在 Take 之前 return,不应消耗 per-IP+user 配额")
+}

+ 13 - 2
internal/logic/pub/adminLoginLogic.go

@@ -4,10 +4,12 @@ import (
 	"context"
 	"crypto/subtle"
 	"errors"
+	"fmt"
 	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -38,10 +40,19 @@ func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.Logi
 		return nil, response.ErrUnauthorized("managementKey无效")
 	}
 
+	// 限流 key 使用 "admin:<clientIP>:<username>" 双键维度:只有来自同一 IP + 同一 username 的
+	// 连续失败才会累加计数,远端任意 IP 无法通过只打 username 把任意超管账号永久锁死(见审计 H-1)。
+	// clientIP 由 AdminLoginRateLimit 中间件注入 context;解析失败时用 "unknown" 走共享桶兜底,
+	// 仍有 5min/10 次的总体上限,避免退化为"无限流"。
 	if l.svcCtx.UsernameLoginLimit != nil {
-		code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)
+		clientIP := middleware.GetClientIP(l.ctx)
+		if clientIP == "" {
+			clientIP = "unknown"
+		}
+		key := fmt.Sprintf("admin:%s:%s", clientIP, req.Username)
+		code, _ := l.svcCtx.UsernameLoginLimit.Take(key)
 		if code == limit.OverQuota {
-			return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请5分钟后再试")
+			return nil, response.NewCodeError(429, "登录尝试过于频繁,请5分钟后再试")
 		}
 	}
 

+ 16 - 10
internal/logic/pub/loginService.go

@@ -47,21 +47,27 @@ func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, usern
 		return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"}
 	}
 
-	u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
-	if err != nil {
-		if errors.Is(err, user.ErrNotFound) {
-			bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
-			return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	u, lookupErr := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
+	var userHash []byte
+	if lookupErr != nil {
+		if !errors.Is(lookupErr, user.ErrNotFound) {
+			return nil, lookupErr
 		}
-		return nil, err
+		userHash = dummyBcryptHash
+	} else {
+		userHash = []byte(u.Password)
 	}
 
-	if u.Status != consts.StatusEnabled {
-		return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
+	// 无条件执行一次 bcrypt:让"账号不存在 / 冻结 / 密码错"三条路径在耗时上完全等长,
+	// 消除基于响应时间的账号存在性 / 冻结状态 oracle(见审计 H-2)。
+	bcryptErr := bcrypt.CompareHashAndPassword(userHash, []byte(password))
+	if lookupErr != nil || bcryptErr != nil {
+		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
 	}
 
-	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
-		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	// 密码正确之后再披露账号语义状态:此时攻击者已经猜中密码,再隐藏"冻结/超管"已无意义。
+	if u.Status != consts.StatusEnabled {
+		return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
 	}
 
 	if u.IsSuperAdmin == consts.IsSuperAdminYes {

+ 124 - 0
internal/logic/pub/loginServiceConstantTime_audit_test.go

@@ -0,0 +1,124 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 H-2 修复回归。
+//
+// H-2 的本质:`ValidateProductLogin` 里"账号冻结"、"是超管"、"用户不存在"三条
+// 错误路径在修复前存在 **耗时 + 错误消息 + HTTP code** 三重差异,构成
+// 账号存在性 / 状态 oracle。修复后:
+//   - bcrypt 无条件执行(dummy hash 对齐耗时)
+//   - 只有密码正确之后才披露"冻结"/"超管"语义
+//   - 用户名不存在 / 存在但密码错 / 存在但冻结且密码错 → 统一 401 "用户名或密码错误"
+//
+// 这些用例把上面契约全部钉死:任何一条被回退到"先检查 status 再 bcrypt"的旧路径,
+// 对应 TC 立刻 FAIL。
+// ---------------------------------------------------------------------------
+
+// TC-0838: 冻结用户 + 错误密码 —— 必须返回 401 "用户名或密码错误",禁止泄露冻结态。
+func TestValidateProductLogin_FrozenWrongPassword_Return401(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = nil // 隔离本测试变量
+
+	username := "h2_frozen_wrong_" + testutil.UniqueId()
+	// status=2(冻结),isSuperAdmin=2(非超管)
+	_, clean := insertRefreshTestUser(t, ctx, username, "CorrectPass123", 2, 2)
+	t.Cleanup(clean)
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "WrongPass", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code,
+		"H-2:冻结用户 + 错误密码不得返回 403;必须 401 与'用户不存在/密码错'三合一")
+	assert.Equal(t, "用户名或密码错误", le.Message,
+		"H-2:文案不得泄露冻结态")
+}
+
+// TC-0839: 冻结用户 + 正确密码 —— 此时才允许披露"账号已被冻结"(攻击者已经猜中密码,继续隐藏已无意义)。
+func TestValidateProductLogin_FrozenCorrectPassword_Return403(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = nil
+
+	username := "h2_frozen_right_" + testutil.UniqueId()
+	_, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 2, 2)
+	t.Cleanup(clean)
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "RightPass123", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 403, le.Code, "H-2:密码正确后的冻结分支仍走 403 披露")
+	assert.Equal(t, "账号已被冻结", le.Message)
+}
+
+// TC-0840: 超管走产品端 + 错误密码 —— 不得提前暴露"该账号是超管"。
+func TestValidateProductLogin_SuperAdminWrongPassword_Return401(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = nil
+
+	username := "h2_sa_wrong_" + testutil.UniqueId()
+	// status=1(启用),isSuperAdmin=1(超管)
+	_, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 1)
+	t.Cleanup(clean)
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "WrongPass", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code,
+		"H-2:超管 + 错误密码必须归一到 401'用户名或密码错误',不得提前披露超管身份")
+	assert.Equal(t, "用户名或密码错误", le.Message)
+}
+
+// TC-0841: 超管走产品端 + 正确密码 —— 密码正确后才披露"超管请走管理后台"。
+func TestValidateProductLogin_SuperAdminCorrectPassword_Return403(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = nil
+
+	username := "h2_sa_right_" + testutil.UniqueId()
+	_, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 1)
+	t.Cleanup(clean)
+
+	_, err := ValidateProductLogin(ctx, svcCtx, username, "RightPass123", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 403, le.Code)
+	assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", le.Message)
+}
+
+// TC-0842: 用户名不存在 —— 沿用 dummy bcrypt 恒时对齐,文案必须与 TC-0838 完全一致。
+// 这条是三重 oracle 消除的"对照组",缺了它单看 0838/0840 还不能证明"三路归一"。
+func TestValidateProductLogin_UnknownUserSame401(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	svcCtx.UsernameLoginLimit = nil
+
+	_, err := ValidateProductLogin(ctx, svcCtx, "h2_noexist_"+testutil.UniqueId(), "anypwd", "test_product", "127.0.0.1")
+	require.Error(t, err)
+
+	var le *LoginError
+	require.True(t, errors.As(err, &le))
+	assert.Equal(t, 401, le.Code)
+	assert.Equal(t, "用户名或密码错误", le.Message,
+		"H-2:未知用户 / 冻结+错密 / 存在+错密 三路必须归一为同一 401 + 文案")
+}

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

@@ -77,7 +77,7 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 
 	// 原子 CAS 递增 tokenVersion:只有持有当前 tokenVersion 的那一次能命中 WHERE 子句并成功递增,
 	// 并发刷新中落败的请求直接返回 401,避免"两个请求都拿到新令牌"导致的会话劫持。
-	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, claims.TokenVersion)
+	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, ud.Username, claims.TokenVersion)
 	if err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")

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

@@ -1,162 +0,0 @@
-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, "去重应保留首次出现的属性,使行为可预测")
-}

+ 72 - 0
internal/logic/pub/syncPermsDedup_audit_test.go

@@ -0,0 +1,72 @@
+package pub
+
+import (
+	"context"
+	"testing"
+
+	permModel "perms-system-server/internal/model/perm"
+	productModel "perms-system-server/internal/model/product"
+	"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"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// TC-0826: 请求体内 perm.code 重复时,service 必须先在入参上去重,避免同一个 tx 内
+// BatchInsert 自己和自己撞 UNIQUE(productCode, code) 引发 1062。
+// 旧文件 syncPermsConflict_audit_test.go 一并覆盖了 1062→409 的映射契约,但 H-3 引入
+// LockByCodeTx 串行化同步后,1062 在实践中已不可达,该 409 映射契约也被一起取消;只有
+// 这一条"请求内去重"仍然是当前产品契约,必须继续回归。
+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_dedup", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1,
+		}, nil)
+	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_dedup").
+		Return(&productModel.SysProduct{Id: 1, Code: "pc_dedup"}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_dedup").
+		Return(map[string]*permModel.SysPerm{}, nil)
+
+	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 只剩一个,DisableNotInCodesWithTx 用去重后的集合做 NOT IN。
+	mockPerm.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_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, "入参内 code 重复必须去重为 1 条,避免自撞 1062")
+	assert.Equal(t, "dup_code", captured[0].Code)
+	assert.Equal(t, "A", captured[0].Name,
+		"去重策略应稳定到首次出现,使行为可预测")
+}

+ 16 - 9
internal/logic/pub/syncPermsLogic_mock_test.go

@@ -17,7 +17,12 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
-// TC-0048: 事务保护-中途失败回滚
+// TC-0048: 事务保护 —— BatchUpdate 失败时,service 必须回滚整个事务并对外返回
+// SyncPermsError{500, "同步权限事务失败"}(不得泄漏内部 DB 驱动错误)。
+//
+// 旧版本使用已废弃的 FindMapByProductCode(非事务版)做 mock;H-3 修复后读/锁都必须
+// 落在同一个 tx 里,这里按新契约重写 mock:LockByCodeTx → FindMapByProductCodeWithTx →
+// BatchInsertWithTx OK → BatchUpdateWithTx 报错 → 统一 500。
 func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
@@ -36,17 +41,16 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 			AppSecret: string(hashedSecret),
 			Status:    1,
 		}, nil)
+	// H-3:tx 内必须先 LockByCodeTx 锁 product 行,再 FindMapByProductCodeWithTx。
+	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "test_product").
+		Return(&productModel.SysProduct{Id: 1, Code: "test_product"}, nil)
 
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "test_product").
+	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "test_product").
 		Return(map[string]*permModel.SysPerm{
 			"existing_code": {
-				Id:          10,
-				ProductCode: "test_product",
-				Code:        "existing_code",
-				Name:        "Old Name",
-				Remark:      "old remark",
-				Status:      1,
+				Id: 10, ProductCode: "test_product", Code: "existing_code",
+				Name: "Old Name", Remark: "old remark", Status: 1,
 			},
 		}, nil)
 
@@ -73,6 +77,9 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 	})
 
 	assert.Nil(t, resp)
-	assert.Error(t, err)
+	require.Error(t, err)
+	// H-3 后的统一错误文案;原 DB 驱动错误必须被吞掉,避免泄漏内部实现。
 	assert.Contains(t, err.Error(), "同步权限事务失败")
+	assert.NotContains(t, err.Error(), "batch update failed",
+		"内部 DB 错误不得透传到客户端")
 }

+ 45 - 39
internal/logic/pub/syncPermsService.go

@@ -2,12 +2,12 @@ package pub
 
 import (
 	"context"
+	"errors"
 	"time"
 
 	"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"
@@ -47,7 +47,7 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 	}
 
 	if len(perms) == 0 {
-		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
+		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空"}
 	}
 
 	// 去重请求列表,避免同一笔同步里 codes 互相冲突。
@@ -63,47 +63,52 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		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{
-				ProductCode: product.Code,
-				Name:        item.Name,
-				Code:        item.Code,
-				Remark:      item.Remark,
-				Status:      consts.StatusEnabled,
-				CreateTime:  now,
-				UpdateTime:  now,
-			})
-			added++
-			continue
+	// 同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行,再在 tx 内读取 existing 并写入:
+	// 把同一 product 的并发同步串行化,避免两次同步都认为 code X 不存在并并发 INSERT 撞
+	// sys_perm UNIQUE(productCode, code) 拿 1062(见审计 H-3)。
+	err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
+		if _, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code); err != nil {
+			if errors.Is(err, sqlx.ErrNotFound) {
+				return &SyncPermsError{Code: 404, Message: "产品不存在"}
+			}
+			return err
 		}
-		if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
-			existing.Name = item.Name
-			existing.Remark = item.Remark
-			existing.Status = consts.StatusEnabled
-			existing.UpdateTime = now
-			toUpdate = append(toUpdate, existing)
-			updated++
+
+		existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code)
+		if err != nil {
+			return err
+		}
+
+		var toInsert []*permModel.SysPerm
+		var toUpdate []*permModel.SysPerm
+		for _, item := range dedupPerms {
+			existing, ok := existingMap[item.Code]
+			if !ok {
+				toInsert = append(toInsert, &permModel.SysPerm{
+					ProductCode: product.Code,
+					Name:        item.Name,
+					Code:        item.Code,
+					Remark:      item.Remark,
+					Status:      consts.StatusEnabled,
+					CreateTime:  now,
+					UpdateTime:  now,
+				})
+				added++
+				continue
+			}
+			if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
+				existing.Name = item.Name
+				existing.Remark = item.Remark
+				existing.Status = consts.StatusEnabled
+				existing.UpdateTime = now
+				toUpdate = append(toUpdate, existing)
+				updated++
+			}
 		}
-	}
 
-	// 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 insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil {
 				return insertErr
@@ -119,8 +124,9 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		return disableErr
 	})
 	if err != nil {
-		if util.IsDuplicateEntryErr(err) {
-			return nil, &SyncPermsError{Code: 409, Message: "权限同步存在并发冲突,请重试"}
+		var se *SyncPermsError
+		if errors.As(err, &se) {
+			return nil, se
 		}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}

+ 150 - 0
internal/logic/pub/syncPermsTxLock_audit_test.go

@@ -0,0 +1,150 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	permModel "perms-system-server/internal/model/perm"
+	productModel "perms-system-server/internal/model/product"
+	"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"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 H-3 修复回归 —— ExecuteSyncPerms 必须在 tx 内
+//   1. 先调用 LockByCodeTx 锁住 sys_product 行;
+//   2. 再调用 FindMapByProductCodeWithTx(事务内读 perm map)。
+//
+// 为什么重要:
+//   - 修复前 perm map 的 "existing vs. new" 判断发生在 tx 外,两笔并发 sync 都可能
+//     认为 "code X 不存在",之后都在 tx 内 INSERT,撞 UNIQUE(productCode, code) 导致 1062。
+//   - 修复后所有并发请求都要先排队拿到 product 行锁,才能读到一致的 existing 集合并写入,
+//     将 "并发同步同一个产品" 串行化。
+//
+// 这个文件只关心"拿锁"这一段的契约(执行顺序 / 错误路径),
+// 避免重叠 syncPermsConflict_audit_test.go 中的 1062 → 409 映射。
+// ---------------------------------------------------------------------------
+
+// newBaseProductMock 只认 appKey + 校验 secret + 产品启用,返回固定 Code="pc_tx"。
+func newBaseProductMock(ctrl *gomock.Controller, code string) *mocks.MockSysProductModel {
+	hashed, _ := bcrypt.GenerateFromPassword([]byte("s"), bcrypt.MinCost)
+	m := mocks.NewMockSysProductModel(ctrl)
+	m.EXPECT().FindOneByAppKey(gomock.Any(), "ak").
+		Return(&productModel.SysProduct{
+			Id: 1, Code: code, AppKey: "ak", AppSecret: string(hashed), Status: 1,
+		}, nil)
+	return m
+}
+
+// TC-0843: H-3 契约 —— 正常路径下 LockByCodeTx 必须先于 FindMapByProductCodeWithTx,
+// 且两者均在同一个 tx session 内被调用。
+func TestExecuteSyncPerms_LockBeforeMapReadInTx(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	productMock := newBaseProductMock(ctrl, "pc_tx_order")
+	permMock := mocks.NewMockSysPermModel(ctrl)
+
+	// 关键点 1:TransactCtx 必须真的传入一个 tx session,并把所有子调用都发生在其中。
+	permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil) // nil session 只是 mock 占位
+		})
+
+	// 关键点 2:gomock 的 Call.After 强制 LockByCodeTx 先于 FindMapByProductCodeWithTx 执行。
+	// 顺序反过来的话 gomock 会在 Finish 时报错。
+	lockCall := productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_order").
+		Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order"}, nil)
+
+	permMock.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_tx_order").
+		Return(map[string]*permModel.SysPerm{}, nil).
+		After(lockCall)
+
+	// 一条简单的 INSERT + DisableNotIn 让流程走完;非本 TC 的主断言。
+	permMock.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
+	permMock.EXPECT().DisableNotInCodesWithTx(gomock.Any(), nil, "pc_tx_order", []string{"x"}, gomock.Any()).
+		Return(int64(0), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
+
+	result, err := ExecuteSyncPerms(context.Background(), svcCtx, "ak", "s",
+		[]SyncPermItem{{Code: "x", Name: "X"}})
+	require.NoError(t, err)
+	require.NotNil(t, result)
+	assert.Equal(t, int64(1), result.Added, "H-3:lock 在 tx 内就位后应当能正常写入")
+}
+
+// TC-0844: H-3 分支 —— tx 内 LockByCodeTx 返回 sqlx.ErrNotFound(产品在 tx 开启后被删),
+// 必须映射为 SyncPermsError{Code:404, Message:"产品不存在"},而非 500。
+func TestExecuteSyncPerms_LockNotFound_Maps404(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	productMock := newBaseProductMock(ctrl, "pc_tx_gone")
+	permMock := mocks.NewMockSysPermModel(ctrl)
+
+	permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+
+	productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_gone").
+		Return(nil, sqlx.ErrNotFound)
+
+	// 关键:锁失败后绝不能继续走 FindMapByProductCodeWithTx / BatchInsertWithTx。
+	// gomock 默认严格模式会在 Finish 时报 "unexpected call",所以不为这些方法登记任何期望即可。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
+	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), "H-3:锁不到产品行必须产出 *SyncPermsError")
+	assert.Equal(t, 404, se.Code,
+		"H-3:tx 开启后 LockByCodeTx=ErrNotFound 意味着产品行在 tx 中不可见,应当返回 404 而非 500")
+	assert.Contains(t, se.Message, "产品不存在",
+		"H-3:文案应当能让调用方人眼秒懂是什么错误")
+}
+
+// TC-0845: H-3 容错 —— tx 内 LockByCodeTx 冒出非 NotFound 的通用错误(driver/conn 异常),
+// 必须被事务回滚并被外层包裹为 SyncPermsError(500 级),而非原始 driver 错误直接冒出去。
+func TestExecuteSyncPerms_LockGenericError_WrappedAs500(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	productMock := newBaseProductMock(ctrl, "pc_tx_boom")
+	permMock := mocks.NewMockSysPermModel(ctrl)
+
+	permMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+
+	boom := errors.New("driver: connection lost")
+	productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_boom").
+		Return(nil, boom)
+	// 锁失败后同样不应调用后续方法。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: productMock, Perm: permMock})
+	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), "H-3:底层错误必须被包成 *SyncPermsError,防止 driver 错误直接上抛")
+	assert.Equal(t, 500, se.Code,
+		"H-3:非 NotFound 的 DB 错误应当 fail-close 为 500,让接入方区别于 404/409")
+	assert.NotContains(t, se.Message, "connection lost",
+		"H-3:对外文案不能泄露原始 driver 错误(避免信息披露)")
+}

+ 8 - 5
internal/logic/role/bindRolePermsLogic.go

@@ -125,11 +125,14 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 		return err
 	}
 
-	affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId)
-	if err != nil {
-		logx.WithContext(l.ctx).Errorf("角色权限已更新但缓存清理失败 roleId=%d: %v", req.RoleId, err)
-		return response.NewCodeError(500, "权限已更新但缓存刷新失败,请稍后手动刷新")
+	// 事务已提交成功,缓存清理属于尽力而为:FindUserIdsByRoleId 失败仅记录 Errorf,
+	// 不映射为 500——否则客户端会把"数据已改但缓存未刷"的 degraded 成功状态误判为完全失败
+	// 而发起重试,重试时 diff 出的 toAdd/toRemove 均为空将静默 200,业务语义反而更怪
+	// (见审计 M-4)。旧权限缓存最多在 TTL (5 分钟) 后自然过期,不影响正确性。
+	if affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId); err == nil {
+		l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
+	} else {
+		logx.WithContext(l.ctx).Errorf("BindRolePerms roleId=%d 角色权限已更新但 FindUserIdsByRoleId 失败,用户权限缓存将等待 TTL 自然过期: %v", req.RoleId, err)
 	}
-	l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
 	return nil
 }

+ 108 - 0
internal/logic/role/postCommitCacheDegraded_audit_test.go

@@ -0,0 +1,108 @@
+package role
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	roleModel "perms-system-server/internal/model/role"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-4 修复回归 —— 角色更新 / 角色权限绑定的 post-commit 缓存清理
+// 必须是 "尽力而为":事务已 COMMIT 成功后,任何缓存清理路径的失败只应记 Errorf,
+// 不得把 degraded 成功映射成 5xx 让客户端误触发重试。
+//
+// 场景:事务外 `FindUserIdsByRoleId` 返回 err。
+// 期望:handler 仍返回 nil,200 OK;客户端无须重试;旧缓存最终靠 TTL 过期兜底。
+// ---------------------------------------------------------------------------
+
+func adminCtx(productCode string) context.Context {
+	return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
+		UserId:       1,
+		Username:     "admin",
+		IsSuperAdmin: true,
+		MemberType:   consts.MemberTypeAdmin,
+		Status:       consts.StatusEnabled,
+		ProductCode:  productCode,
+	})
+}
+
+// TC-0858: BindRolePerms —— tx 成功、FindUserIdsByRoleId 抛 err,logic 返回 nil。
+func TestBindRolePerms_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	roleMock := mocks.NewMockSysRoleModel(ctrl)
+	rpMock := mocks.NewMockSysRolePermModel(ctrl)
+	urMock := mocks.NewMockSysUserRoleModel(ctrl)
+
+	roleMock.EXPECT().FindOne(gomock.Any(), int64(7)).
+		Return(&roleModel.SysRole{Id: 7, ProductCode: "pc_m4", PermsLevel: 50, Status: 1}, nil)
+
+	// permIds=[] 走 "全部删除" 路径;existingIds=[1] 需触发 tx。
+	rpMock.EXPECT().FindPermIdsByRoleId(gomock.Any(), int64(7)).Return([]int64{1}, nil)
+	rpMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+
+	rpMock.EXPECT().DeleteByRoleIdAndPermIdsTx(gomock.Any(), nil, int64(7), []int64{1}).
+		Return(nil)
+
+	// 关键断言:post-commit FindUserIdsByRoleId 返回 err,logic 必须吞掉 err。
+	urMock.EXPECT().FindUserIdsByRoleId(gomock.Any(), int64(7)).
+		Return(nil, errors.New("redis/db transient error"))
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Role: roleMock, RolePerm: rpMock, UserRole: urMock,
+	})
+
+	err := NewBindRolePermsLogic(adminCtx("pc_m4"), svcCtx).BindRolePerms(&types.BindPermsReq{
+		RoleId: 7, PermIds: []int64{},
+	})
+	require.NoError(t, err,
+		"M-4:post-commit 缓存步骤的 transient err 不应把 degraded 成功映射成 500")
+}
+
+// TC-0859: UpdateRole —— UpdateWithOptLock 成功,FindUserIdsByRoleId 失败,handler 返回 nil。
+func TestUpdateRole_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	roleMock := mocks.NewMockSysRoleModel(ctrl)
+	urMock := mocks.NewMockSysUserRoleModel(ctrl)
+
+	roleMock.EXPECT().FindOne(gomock.Any(), int64(9)).
+		Return(&roleModel.SysRole{
+			Id: 9, ProductCode: "pc_m4u", Name: "before",
+			PermsLevel: 50, Status: consts.StatusEnabled, UpdateTime: 100,
+		}, nil)
+
+	// UpdateWithOptLock 成功;签名:UpdateWithOptLock(ctx, role, prevUpdateTime)。
+	roleMock.EXPECT().UpdateWithOptLock(gomock.Any(), gomock.Any(), int64(100)).Return(nil)
+
+	// 关键断言:post-commit transient err 不应导致 handler 失败。
+	urMock.EXPECT().FindUserIdsByRoleId(gomock.Any(), int64(9)).
+		Return(nil, errors.New("boom"))
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Role: roleMock, UserRole: urMock,
+	})
+
+	err := NewUpdateRoleLogic(adminCtx("pc_m4u"), svcCtx).UpdateRole(&types.UpdateRoleReq{
+		Id: 9, Name: "after", Remark: "r", PermsLevel: 60, Status: 0,
+	})
+	assert.NoError(t, err,
+		"M-4:UpdateRole 已提交成功,post-commit 缓存失败只记日志,handler 必须返回 nil")
+}

+ 7 - 5
internal/logic/role/updateRoleLogic.go

@@ -76,11 +76,13 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 		return err
 	}
 
-	affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
-	if err != nil {
-		logx.WithContext(l.ctx).Errorf("角色已更新但缓存清理失败 roleId=%d: %v", req.Id, err)
-		return response.NewCodeError(500, "角色已更新但缓存刷新失败,请稍后手动刷新")
+	// 角色已经更新成功,缓存清理属于尽力而为:failure 仅记录 Errorf,不映射为 500,
+	// 否则客户端会把"角色已改但缓存未刷"的 degraded 成功误判为完全失败而重试(见审计 M-4)。
+	// 旧权限缓存最多在 TTL 窗口内继续生效,由 TTL 过期兜底。
+	if affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id); err == nil {
+		l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
+	} else {
+		logx.WithContext(l.ctx).Errorf("UpdateRole roleId=%d 角色已更新但 FindUserIdsByRoleId 失败,用户权限缓存将等待 TTL 自然过期: %v", req.Id, err)
 	}
-	l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
 	return nil
 }

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

@@ -37,12 +37,13 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return response.ErrUnauthorized("未登录")
 	}
 
-	if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
+	targetUser, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId)
+	if err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
-	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil {
 		return err
 	}
 

+ 3 - 2
internal/logic/user/setUserPermsLogic.go

@@ -33,7 +33,8 @@ func NewSetUserPermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetU
 
 // SetUserPerms 设置用户个性化权限。对指定用户在当前产品下做权限全量覆盖,支持 ALLOW(附加)和 DENY(拒绝)两种效果,用于角色权限之外的细粒度调整。
 func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
-	if _, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId); err != nil {
+	targetUser, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId)
+	if err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
 
@@ -51,7 +52,7 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		return response.ErrBadRequest("产品已被禁用,无法设置权限")
 	}
 
-	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil {
 		return err
 	}
 

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

@@ -42,22 +42,26 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		if req.DeptId != nil || req.Status != 0 {
 			return response.ErrForbidden("不允许修改自己的部门和状态")
 		}
-	} else {
-		productCode := middleware.GetProductCode(l.ctx)
-		if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
-			return err
-		}
 	}
 
-	if req.Status != 0 {
-		if err := authHelper.ValidateStatusChange(l.ctx, l.svcCtx, caller.UserId, req.Id); err != nil {
+	// 前置 FindOne,后续 CheckManageAccess / ValidateStatusChange 都复用此对象,避免一次请求内
+	// 对 target 做 2~3 次重复查询(见审计 M-5)。
+	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
+	if err != nil {
+		return response.ErrNotFound("用户不存在")
+	}
+
+	if caller.UserId != req.Id {
+		productCode := middleware.GetProductCode(l.ctx)
+		if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode, authHelper.WithPrefetchedTarget(user)); err != nil {
 			return err
 		}
 	}
 
-	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
-	if err != nil {
-		return response.ErrNotFound("用户不存在")
+	// req.Status != 0 仅会落在 caller.UserId != req.Id 分支(上方 caller==target 的请求已经拦截),
+	// 此处沿用 ValidateStatusChange 的超管保护语义,避免再次 FindOne。
+	if req.Status != 0 && user.IsSuperAdmin == consts.IsSuperAdminYes {
+		return response.ErrForbidden("不能修改超级管理员的状态")
 	}
 
 	if caller.UserId != req.Id && user.IsSuperAdmin == consts.IsSuperAdminYes {

+ 5 - 6
internal/logic/user/updateUserStatusLogic.go

@@ -35,19 +35,18 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 	}
 
 	callerId := middleware.GetUserId(l.ctx)
-	if err := authHelper.ValidateStatusChange(l.ctx, l.svcCtx, callerId, req.Id); err != nil {
+	// ValidateStatusChange 内部已经 FindOne(targetUserId),结果透传给 CheckManageAccess 和
+	// 下方的 status 对比,避免单次请求内重复 3 次 FindOne(见审计 M-5)。
+	user, err := authHelper.ValidateStatusChange(l.ctx, l.svcCtx, callerId, req.Id)
+	if err != nil {
 		return err
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
-	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode, authHelper.WithPrefetchedTarget(user)); err != nil {
 		return err
 	}
 
-	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
-	if err != nil {
-		return response.ErrNotFound("用户不存在")
-	}
 	if user.Status == req.Status {
 		return nil
 	}

+ 30 - 4
internal/middleware/ratelimitMiddleware.go

@@ -5,10 +5,12 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"strings"
 
 	"perms-system-server/internal/response"
 
 	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/redis"
 	"github.com/zeromicro/go-zero/rest/httpx"
 )
@@ -48,14 +50,20 @@ func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 	}
 }
 
-// ExtractClientIP extracts client IP from the request.
-// When behindProxy is true, it trusts X-Real-IP header set by the reverse proxy.
-// When false, it only uses RemoteAddr for security.
+// ExtractClientIP 从请求中解析出客户端真实 IP。
+// 当 behindProxy=true 时按常规反向代理优先级解析:X-Forwarded-For 首段 → X-Real-IP → RemoteAddr;
+// 所有候选值都会经 net.ParseIP 校验合法性,非法或空时自动 fallthrough 到下一个来源,
+// 最终仍拿不到合法 IP 时打印 warn 日志并回落到 RemoteAddr 的原始字符串(方便运维排查代理链漏配)。
+// 当 behindProxy=false 时只采用 RemoteAddr,忽略任何请求头,防止客户端伪造(见审计 M-6)。
 func ExtractClientIP(r *http.Request, behindProxy bool) string {
 	if behindProxy {
-		if ip := r.Header.Get("X-Real-IP"); ip != "" {
+		if ip := firstValidIP(r.Header.Get("X-Forwarded-For")); ip != "" {
 			return ip
 		}
+		if ip := firstValidIP(r.Header.Get("X-Real-IP")); ip != "" {
+			return ip
+		}
+		logx.WithContext(r.Context()).Errorf("ExtractClientIP: behindProxy=true but no valid X-Forwarded-For / X-Real-IP header, falling back to RemoteAddr=%s; please check your reverse proxy configuration", r.RemoteAddr)
 	}
 	host, _, err := net.SplitHostPort(r.RemoteAddr)
 	if err != nil {
@@ -63,3 +71,21 @@ func ExtractClientIP(r *http.Request, behindProxy bool) string {
 	}
 	return host
 }
+
+// firstValidIP 解析一个可能包含逗号分隔列表(X-Forwarded-For 的典型格式)的 IP 头,返回第一个
+// 可被 net.ParseIP 解析成功的地址;不合法或空值全部跳过,避免攻击者通过 "0.0.0.0, ..." 污染 key。
+func firstValidIP(headerVal string) string {
+	if headerVal == "" {
+		return ""
+	}
+	for _, part := range strings.Split(headerVal, ",") {
+		candidate := strings.TrimSpace(part)
+		if candidate == "" {
+			continue
+		}
+		if net.ParseIP(candidate) != nil {
+			return candidate
+		}
+	}
+	return ""
+}

+ 91 - 0
internal/middleware/ratelimitMiddlewareXff_audit_test.go

@@ -0,0 +1,91 @@
+package middleware
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 M-6 修复回归 —— ExtractClientIP 对 X-Forwarded-For 的支持。
+//
+// 原代码:behindProxy=true 时只认 X-Real-IP,不解析 X-Forwarded-For。
+// 生产事实:K8s Ingress-nginx 默认配置只写 X-Forwarded-For 不写 X-Real-IP,
+// 会导致线上 /auth/login /auth/refreshToken 的 IP 限流被降级为"共享一个桶或无限流"。
+//
+// 修复后的契约(每条一个 TC,保证任何一条被退回旧实现都 FAIL):
+//   - XFF 首段 > XRI > RemoteAddr
+//   - 每一段候选值都必须过 net.ParseIP 合法性校验
+//   - 非法段自动跳过,进入下一个来源
+//   - behindProxy=false 时完全忽略请求头,防止客户端伪造
+// ---------------------------------------------------------------------------
+
+// TC-0862: behindProxy=true + XFF 首段合法 → 返回首段。
+func TestExtractClientIP_XFFFirstValid(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3")
+	r.Header.Set("X-Real-IP", "9.9.9.9") // 不应被用
+	r.RemoteAddr = "5.5.5.5:8080"        // 不应被用
+
+	assert.Equal(t, "1.1.1.1", ExtractClientIP(r, true),
+		"M-6:XFF 首段合法时优先返回,高于 XRI / RemoteAddr")
+}
+
+// TC-0863: behindProxy=true + XFF 全非法 + XRI 合法 → fallthrough 到 XRI。
+func TestExtractClientIP_XFFAllInvalid_FallbackXRI(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", "garbage, not-an-ip")
+	r.Header.Set("X-Real-IP", "10.0.0.1")
+	r.RemoteAddr = "5.5.5.5:8080"
+
+	assert.Equal(t, "10.0.0.1", ExtractClientIP(r, true),
+		"M-6:XFF 全不合法应当 fallthrough 到 X-Real-IP,不得返回 garbage 或 RemoteAddr")
+}
+
+// TC-0864: behindProxy=true + 两头均空 → 回落到 RemoteAddr 剥端口后的 host。
+func TestExtractClientIP_NoHeaders_FallbackRemoteAddr(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.RemoteAddr = "198.51.100.9:13579"
+
+	assert.Equal(t, "198.51.100.9", ExtractClientIP(r, true),
+		"M-6:所有代理头缺失时最终仍能回落到 RemoteAddr 剥端口")
+}
+
+// TC-0865: behindProxy=true + XFF 首段带两端空白 → trim 后仍解析合法,返回 trimmed 结果。
+func TestExtractClientIP_XFFWhitespaceTrimmed(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", "  3.3.3.3  ,  4.4.4.4")
+
+	assert.Equal(t, "3.3.3.3", ExtractClientIP(r, true),
+		"M-6:XFF 首段 trim 后合法应当被采用;严禁保留首尾空白而误判")
+}
+
+// TC-0866: behindProxy=false —— 完全忽略 XFF / XRI,防止客户端伪造头。
+func TestExtractClientIP_BehindProxyFalse_IgnoreHeaders(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", "1.1.1.1") // 应被忽略
+	r.Header.Set("X-Real-IP", "2.2.2.2")       // 应被忽略
+	r.RemoteAddr = "5.5.5.5:8080"
+
+	assert.Equal(t, "5.5.5.5", ExtractClientIP(r, false),
+		"M-6:behindProxy=false 时应完全忽略客户端注入的代理头")
+}
+
+// 补充:XFF 包含空段("1.1.1.1,,2.2.2.2")不应 panic,空段跳过后首段合法。
+func TestExtractClientIP_XFFEmptySegmentsSkipped(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", ",,,1.1.1.1,2.2.2.2")
+
+	assert.Equal(t, "1.1.1.1", ExtractClientIP(r, true),
+		"M-6:XFF 中空段必须跳过,不得 panic 或返回空串")
+}
+
+// 补充:XFF 全为合法 IPv6 地址也应能返回首段。
+func TestExtractClientIP_XFFIPv6FirstValid(t *testing.T) {
+	r := httptest.NewRequest("POST", "/x", nil)
+	r.Header.Set("X-Forwarded-For", "2001:db8::1, 2001:db8::2")
+
+	assert.Equal(t, "2001:db8::1", ExtractClientIP(r, true),
+		"M-6:IPv6 也是 net.ParseIP 合法值,XFF 首段应返回 IPv6")
+}

+ 2 - 24
internal/middleware/ratelimitMiddleware_test.go

@@ -252,30 +252,8 @@ func TestRateLimit_BehindProxy_FallbackToRemoteAddr(t *testing.T) {
 	assert.Equal(t, 1, nextCount, "should fall back to RemoteAddr when X-Real-IP is absent")
 }
 
-// TC-0554: behindProxy=true时XFF仍被忽略
-func TestRateLimit_BehindProxy_XFFStillIgnored(t *testing.T) {
-	rds := newTestRedis()
-	m := newTestMiddlewareProxy(rds, 1)
-	remoteAddr := uniqueIP() + ":12345"
-
-	var nextCount int
-	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
-		nextCount++
-		w.WriteHeader(http.StatusOK)
-	})
-
-	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req1.RemoteAddr = remoteAddr
-	req1.Header.Set("X-Forwarded-For", uniqueIP())
-	handler(httptest.NewRecorder(), req1)
-	assert.Equal(t, 1, nextCount)
-
-	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
-	req2.RemoteAddr = remoteAddr
-	req2.Header.Set("X-Forwarded-For", uniqueIP())
-	handler(httptest.NewRecorder(), req2)
-	assert.Equal(t, 1, nextCount, "X-Forwarded-For should NOT bypass rate limit even with behindProxy=true")
-}
+// 原 TC-0554 "behindProxy=true 时 XFF 仍被忽略" 已按 M-6 修复反转:behindProxy=true
+// 时 XFF 首段优先,契约由 ratelimitMiddlewareXff_audit_test.go (TC-0862~0866) 取代。
 
 // =============================================================================
 // audit L-2 回归:产品登录与管后登录必须使用独立的限流桶

+ 191 - 0
internal/model/productmember/countOtherActiveAdmins_audit_test.go

@@ -0,0 +1,191 @@
+package productmember
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计第 6 轮 L-5 修复回归 —— CountOtherActiveAdminsTx 排除目标自己后计数。
+//
+// 原做法:removeMember / 降级时调用 CountActiveAdminsTx,再在业务层做 <=1 判断。
+// 问题:业务语义模糊、边界条件容易算错("算不算目标自己"),出事故后排查痛苦。
+// 修复后的契约:
+//   - CountOtherActiveAdminsTx(ctx, session, productCode, excludeId)
+//     返回 productCode 下 memberType=ADMIN 且 status=Enabled 且 id != excludeId 的行数。
+//   - 不再依赖调用方做减法,0 就是"最后一个 admin",1 就是"还有 1 个 backup admin"。
+//   - 作用于事务 session,且为 FOR UPDATE,避免并发场景重复判断、双双放过。
+// ---------------------------------------------------------------------------
+
+// TC-0867: 唯一 admin 场景,排除自己后返回 0(代表"删掉/降级此人后没有启用 admin")。
+func TestCountOtherActiveAdminsTx_SoleAdmin_ReturnsZero(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	pc := "t_pm_coaa_sole_" + testutil.UniqueId()
+	adminUser := randProductMemberUserId()
+	ts := time.Now().Unix()
+
+	res, err := m.Insert(ctx, &SysProductMember{
+		ProductCode: pc, UserId: adminUser,
+		MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled,
+		CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	adminId, _ := res.LastInsertId()
+	defer testutil.CleanTable(ctx, conn, "sys_product_member", adminId)
+
+	err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		n, e := m.CountOtherActiveAdminsTx(c, session, pc, adminId)
+		require.NoError(t, e)
+		assert.Equal(t, int64(0), n,
+			"L-5:唯一 admin 排除自己后必须为 0,调用方据此才能阻止删除最后一个 admin")
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+// TC-0868: 多 admin 场景,排除 A 后返回剩余 backup admin 数量。
+func TestCountOtherActiveAdminsTx_MultipleAdmins_ExcludesSelf(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	pc := "t_pm_coaa_multi_" + testutil.UniqueId()
+	ts := time.Now().Unix()
+
+	// 插三个启用 ADMIN + 一个启用 MEMBER + 一个禁用 ADMIN,用来检验 WHERE 条件完整性。
+	type row struct {
+		mt     string
+		status int64
+	}
+	rows := []row{
+		{consts.MemberTypeAdmin, consts.StatusEnabled},
+		{consts.MemberTypeAdmin, consts.StatusEnabled},
+		{consts.MemberTypeAdmin, consts.StatusEnabled},
+		{consts.MemberTypeMember, consts.StatusEnabled}, // 不计入
+		{consts.MemberTypeAdmin, consts.StatusDisabled}, // 不计入
+	}
+	ids := make([]int64, 0, len(rows))
+	for _, r := range rows {
+		uid := randProductMemberUserId()
+		res, err := m.Insert(ctx, &SysProductMember{
+			ProductCode: pc, UserId: uid,
+			MemberType: r.mt, Status: r.status,
+			CreateTime: ts, UpdateTime: ts,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		ids = append(ids, id)
+	}
+	t.Cleanup(func() {
+		for _, id := range ids {
+			testutil.CleanTable(ctx, conn, "sys_product_member", id)
+		}
+	})
+
+	// 排除 ids[0]:剩下应还有两个启用 ADMIN。
+	err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		n, e := m.CountOtherActiveAdminsTx(c, session, pc, ids[0])
+		require.NoError(t, e)
+		assert.Equal(t, int64(2), n,
+			"L-5:MEMBER 与 Disabled 行不得被计入;排除自己后剩余 admin 数必须等于 2")
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+// TC-0869: 排除一个根本不存在的 id,结果与正向 CountActiveAdminsTx 一致(自洽性校验)。
+func TestCountOtherActiveAdminsTx_NonExistentExclude_EqualsTotal(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	pc := "t_pm_coaa_none_" + testutil.UniqueId()
+	ts := time.Now().Unix()
+
+	var ids []int64
+	for i := 0; i < 2; i++ {
+		res, err := m.Insert(ctx, &SysProductMember{
+			ProductCode: pc, UserId: randProductMemberUserId(),
+			MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled,
+			CreateTime: ts, UpdateTime: ts,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		ids = append(ids, id)
+	}
+	t.Cleanup(func() {
+		for _, id := range ids {
+			testutil.CleanTable(ctx, conn, "sys_product_member", id)
+		}
+	})
+
+	err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		total, e1 := m.CountActiveAdminsTx(c, session, pc)
+		require.NoError(t, e1)
+		other, e2 := m.CountOtherActiveAdminsTx(c, session, pc, -1) // -1 不存在
+		require.NoError(t, e2)
+		assert.Equal(t, total, other,
+			"L-5:excludeId 不存在时,排除计数必须等于总计数;否则说明 WHERE 条件里多写或少写了什么")
+		assert.Equal(t, int64(2), total, "前置数据校验")
+		return nil
+	})
+	require.NoError(t, err)
+}
+
+// TC-0870: 空 productCode 不会串库 —— 不同产品线互不影响。
+func TestCountOtherActiveAdminsTx_ScopedByProductCode(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+
+	ts := time.Now().Unix()
+	pcA := "t_pm_coaa_A_" + testutil.UniqueId()
+	pcB := "t_pm_coaa_B_" + testutil.UniqueId()
+
+	// 产品 A 有 1 个 admin(自己),排除后应为 0;产品 B 有 2 个 admin,这条查询不应拉到产品 B。
+	resA, err := m.Insert(ctx, &SysProductMember{
+		ProductCode: pcA, UserId: randProductMemberUserId(),
+		MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled,
+		CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	aId, _ := resA.LastInsertId()
+	defer testutil.CleanTable(ctx, conn, "sys_product_member", aId)
+
+	var bIds []int64
+	for i := 0; i < 2; i++ {
+		r, err := m.Insert(ctx, &SysProductMember{
+			ProductCode: pcB, UserId: randProductMemberUserId(),
+			MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled,
+			CreateTime: ts, UpdateTime: ts,
+		})
+		require.NoError(t, err)
+		id, _ := r.LastInsertId()
+		bIds = append(bIds, id)
+	}
+	t.Cleanup(func() {
+		for _, id := range bIds {
+			testutil.CleanTable(ctx, conn, "sys_product_member", id)
+		}
+	})
+
+	err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		n, e := m.CountOtherActiveAdminsTx(c, session, pcA, aId)
+		require.NoError(t, e)
+		assert.Equal(t, int64(0), n,
+			"L-5:pcA 的排除计数必须只看 pcA,绝不能把 pcB 的 2 个 admin 误计入")
+		return nil
+	})
+	require.NoError(t, err)
+}

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

@@ -20,6 +20,7 @@ type (
 		FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error)
 		CountActiveAdmins(ctx context.Context, productCode string) (int64, error)
 		CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error)
+		CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error)
 		FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
 	}
 
@@ -68,6 +69,19 @@ func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, s
 	return int64(len(ids)), nil
 }
 
+// CountOtherActiveAdminsTx 统计"除 excludeId 这一行以外"的启用 ADMIN 数量。调用方一般把即将被删除
+// 或即将被降级的目标行 id 传进来;返回 0 即表示目标是最后一个 active admin,不能动。相比
+// CountActiveAdminsTx + adminCount <= 1 的反向推理,语义更贴合业务(见审计 L-5)。
+// 仍然使用 FOR UPDATE 锁住扫描范围,串行化与并发降级/删除的冲突。
+func (m *customSysProductMemberModel) CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? AND `id` != ? FOR UPDATE", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled, excludeId); err != nil {
+		return 0, err
+	}
+	return int64(len(ids)), nil
+}
+
 func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error) {
 	var data SysProductMember
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `id` = ? FOR UPDATE", sysProductMemberRows, m.table)

+ 8 - 16
internal/model/user/incrementTokenVersionIfMatch_audit_test.go

@@ -43,7 +43,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_Match(t *testing.T) {
 	id, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 
-	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 5)
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, username, 5)
 	require.NoError(t, err)
 	assert.Equal(t, int64(6), got, "expected 命中时返回 DB 真实递增后的新版本")
 
@@ -70,7 +70,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_Mismatch_NoSideEffect(t *test
 	id, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 
-	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 9)
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, username, 9)
 	require.Error(t, err, "expected 未命中时必须返回错误")
 	assert.True(t, errors.Is(err, user.ErrTokenVersionMismatch), "错误必须是 ErrTokenVersionMismatch 以供 logic 层分辨")
 	assert.Equal(t, int64(0), got)
@@ -80,18 +80,10 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_Mismatch_NoSideEffect(t *test
 	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-0804 "用户不存在必须返回原生 NotFound 而非 ErrTokenVersionMismatch" 已按 M-8
+// 新契约废止:M-8 取消了模型内 FindOne 预检,所有 CAS 未命中(无论是版本不匹配还是
+// 行根本不存在)都统一返回 ErrTokenVersionMismatch。logic 层 RefreshToken 改由
+// 上游 UserDetailsLoader.Load 的 status 分支分辨"离职/冻结"。
 
 // TC-0805: H-1 并发回归 —— N 个 goroutine 用同一个 expected 去 CAS,
 // 必须恰好只有 1 个返回 success,其余全部 ErrTokenVersionMismatch;
@@ -128,7 +120,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner(t *tes
 		go func(idx int) {
 			defer wg.Done()
 			<-start // 最大程度对齐并发起跑线
-			v, e := m.IncrementTokenVersionIfMatch(ctx, id, 20)
+			v, e := m.IncrementTokenVersionIfMatch(ctx, id, username, 20)
 			switch {
 			case e == nil:
 				atomic.AddInt32(&successCnt, 1)
@@ -186,7 +178,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_InvalidatesCaches(t *testing.
 	require.NoError(t, err)
 	require.Equal(t, int64(0), u0b.TokenVersion)
 
-	got, err := m.IncrementTokenVersionIfMatch(ctx, id, 0)
+	got, err := m.IncrementTokenVersionIfMatch(ctx, id, username, 0)
 	require.NoError(t, err)
 	require.Equal(t, int64(1), got)
 

+ 7 - 9
internal/model/user/sysUserModel.go

@@ -32,7 +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)
+		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error)
 	}
 
 	customSysUserModel struct {
@@ -183,17 +183,15 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 // 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
-	}
-
+//
+// 由上游透传 username 以便构造 cacheSysUserUsernamePrefix 的缓存键进行失效,避免为此多查一次 FindOne
+// (见审计 M-8)。上游通常已经通过 UserDetailsLoader.Load 拿到 username,零额外成本。
+func (m *customSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error) {
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
-	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
 
 	var newVersion int64
-	err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+	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 {

+ 1 - 1
internal/server/permserver.go

@@ -188,7 +188,7 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 	}
 
 	// 原子 CAS 递增 tokenVersion,避免并发刷新时两个请求都通过 check 并各自拿到"新令牌"导致会话劫持。
-	newVersion, err := s.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, claims.TokenVersion)
+	newVersion, err := s.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, ud.Username, claims.TokenVersion)
 	if err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")

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

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

+ 4 - 4
internal/testutil/mocks/mock_user_model.go

@@ -294,18 +294,18 @@ func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id any) *gomo
 }
 
 // IncrementTokenVersionIfMatch mocks base method.
-func (m *MockSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error) {
+func (m *MockSysUserModel) IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "IncrementTokenVersionIfMatch", ctx, id, expected)
+	ret := m.ctrl.Call(m, "IncrementTokenVersionIfMatch", ctx, id, username, 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 {
+func (mr *MockSysUserModelMockRecorder) IncrementTokenVersionIfMatch(ctx, id, username, 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)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersionIfMatch", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersionIfMatch), ctx, id, username, expected)
 }
 
 // Insert mocks base method.

+ 130 - 20
test-design.md

@@ -132,7 +132,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0045 | POST /api/perm/sync | 产品已禁用 | product.Status!=1 | code=403 | 分支覆盖 | P0 | Status!=1 |
 | TC-0046 | POST /api/perm/sync | 大批量(1000条) | 1000条perms | added=1000 | 性能 | P2 | BatchInsert性能 |
 | TC-0047 | POST /api/perm/sync | 重复code去重 | perms中包含两个相同code | 仅处理一次, added=1(而非2) | 分支覆盖 | P0 | M-09修复: seen去重 |
-| TC-0048 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, 返回SyncPermsError(500,"同步权限事务失败") | 事务验证 | P0 | H-05修复: TransactCtx, 错误包装不透传DB错误 |
+| TC-0048 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, 返回SyncPermsError(500,"同步权限事务失败"), 不透传DB错误 | 事务验证 | P0 | H-05/H-03 修复:LockByCodeTx→FindMapByProductCodeWithTx→BatchInsert→BatchUpdate 任一失败统一 500 |
 
 ### 2.4 获取用户信息 `POST /api/auth/userInfo`
 
@@ -183,17 +183,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0076 | POST /api/product/update | 正常更新 | `{"id":1,"name":"新名","status":1}` | code=0 | 正常路径 | P0 | updateProductLogic |
 | TC-0077 | POST /api/product/update | 不存在 | `{"id":9999,"name":"x"}` | code=404 | 异常路径 | P0 | FindOne失败 |
 | TC-0078 | POST /api/product/update | 不传status | `{"id":1,"name":"x"}` | status不变 | 分支覆盖 | P1 | Status>0 |
-| TC-0079 | POST /api/product/list | 正常分页 | `{"page":1,"pageSize":10}` | code=0, total/list | 正常路径 | P0 | productListLogic |
-| TC-0080 | POST /api/product/list | 默认分页 | `{}` | page=1, pageSize=20 | 分支覆盖 | P1 | NormalizePage默认值 |
-| TC-0081 | POST /api/product/list | pageSize超过上限 | `{"page":1,"pageSize":500}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap 100 |
-| TC-0082 | POST /api/product/list | pageSize=0 | `{"page":1,"pageSize":0}` | 实际pageSize=20 | 边界 | P1 | NormalizePage<=0→20 |
-| TC-0083 | POST /api/product/list | page负值 | `{"page":-1,"pageSize":10}` | 实际page=1 | 边界 | P1 | NormalizePage<=0→1 |
-| TC-0084 | POST /api/product/detail | 正常查询 | `{"id":1}` | code=0, ProductItem | 正常路径 | P0 | productDetailLogic |
-| TC-0085 | POST /api/product/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 |
-| TC-0086 | POST /api/product/list | 非超管AppKey隐藏 | ctx=MEMBER | code=0, 列表中AppKey为空 | 安全 | P0 | H-11修复: AppKey仅超管可见 |
-| TC-0087 | POST /api/product/list | 超管可见AppKey | ctx=SuperAdmin | code=0, 列表中AppKey不为空 | 安全 | P0 | H-11修复: 超管可见AppKey |
-| TC-0088 | POST /api/product/detail | 非超管AppKey隐藏 | ctx=MEMBER | code=0, AppKey为空 | 安全 | P0 | H-11修复: AppKey仅超管可见 |
-| TC-0089 | POST /api/product/detail | 超管可见AppKey | ctx=SuperAdmin | code=0, AppKey不为空 | 安全 | P0 | H-11修复: 超管可见AppKey |
+| ~~TC-0079~0083~~ | ~~POST /api/product/list 分页边界~~ | ~~正常/默认/超限/0/负值~~ | — | — | — | — | **已删除**:分页边界由 `util.TestNormalizePage` 单元测试覆盖;列表语义被 M-2 拆分为"超管走 FindList / 非超管只看自己"两条独立契约(TC-0850、TC-0871) |
+| ~~TC-0084~0085~~ | ~~POST /api/product/detail 正常/不存在~~ | — | — | — | — | — | **已删除**:被 M-2 契约合并改写为 TC-0852(他人产品 → 404)、TC-0853(自己产品 AppKey 脱敏)、TC-0872(FindOne 错误 → 404 无差别响应) |
+| ~~TC-0086 / TC-0088~~ | ~~非超管 AppKey 隐藏~~ | — | — | — | — | — | **已删除**:由 TC-0850(list)+ TC-0853(detail)覆盖 |
+| ~~TC-0087 / TC-0089~~ | ~~超管可见 AppKey~~ | — | — | — | — | — | **已删除**:由 TC-0854(detail)+ TC-0871(list)覆盖 |
 | TC-0090 | POST /api/product/update | updateProduct 非法状态值被拒绝 | status=99 | 400 "产品状态值无效" | 输入校验 | P0 | H-4: 仅允许 1/2 |
 
 ### 2.8 创建部门 `POST /api/dept/create`
@@ -224,9 +217,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0107 | POST /api/dept/delete | 有子部门 | `{"id":1}` | code=400, "存在子部门" | 业务约束 | P0 | len(children)>0 |
 | TC-0108 | POST /api/dept/delete | 不存在的部门 | `{"id":9999}` | code=0(Delete对不存在行不报错) | 边界 | P1 | FindByParentId空+Delete |
 | TC-0109 | POST /api/dept/delete | 部门下有关联用户 | 部门id指向含用户的部门 | code=400, "该部门下仍有关联用户,无法删除" | 业务约束 | P0 | H-07修复: 检查关联用户 |
-| TC-0110 | POST /api/dept/tree | 正常获取 | `{}` | code=0, 树形结构, 含DeptType字段 | 正常路径 | P0 | deptTreeLogic, DeptType映射 |
-| TC-0111 | POST /api/dept/tree | 空数据 | 无数据 | code=0, data=[] | 边界 | P1 | 空列表 |
-| TC-0112 | POST /api/dept/tree | 孤儿节点 | parentId指向不存在 | 升级为根节点 | 分支覆盖 | P2 | parent不存在 |
+| ~~TC-0110~0112~~ | ~~POST /api/dept/tree 正常/空/孤儿~~ | — | — | — | — | — | **已删除**:M-2 后 DeptTree 按 caller 身份剪枝,旧测试假定任何身份可拿全树已不成立;新契约由 TC-0855/0856/0857(`deptTreeAccessControl_audit_test.go`)覆盖,"孤儿→根"行为隐含在 fullAccess 路径中 |
 
 ### 2.10 权限列表 `POST /api/perm/list`
 
@@ -940,7 +931,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0551 | 不同IP独立限流 | 两个不同IP | 各自独立计数, 互不影响 | 功能验证 | P0 | key隔离 |
 | TC-0552 | behindProxy=true时信任X-Real-IP | behindProxy=true, 不同X-Real-IP头 | 按X-Real-IP独立限流 | 正常路径 | P0 | behindProxy=true: X-Real-IP优先 |
 | TC-0553 | behindProxy=true时无X-Real-IP回退RemoteAddr | behindProxy=true, 无X-Real-IP头 | 使用RemoteAddr作为限流key | 分支覆盖 | P0 | X-Real-IP为空→fallback RemoteAddr |
-| TC-0554 | behindProxy=true时XFF仍被忽略 | behindProxy=true, XFF头+无X-Real-IP | 按RemoteAddr限流, XFF不影响 | 安全 | P0 | 仅信任X-Real-IP, 不信任XFF |
+| ~~TC-0554~~ | ~~behindProxy=true 时 XFF 仍被忽略~~ | — | — | — | — | **已删除**:M-6 显式反转契约 —— behindProxy=true 时 XFF 首段合法应优先;新契约由 TC-0862~0866(`ratelimitMiddlewareXff_audit_test.go`)覆盖 |
 | TC-0555 | RemoteAddr无端口格式 | RemoteAddr="1.2.3.4"(无端口) | 返回原始RemoteAddr "1.2.3.4" | 边界 | P1 | SplitHostPort失败→r.RemoteAddr |
 
 ---
@@ -1324,7 +1315,7 @@ MySQL (InnoDB) + Redis Cache
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | 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-0804~~ | ~~用户不存在~~ | — | — | — | — | **已删除**:M-8 取消模型内 FindOne 预检,"CAS 未命中 = ErrTokenVersionMismatch"为最新契约;用户状态分支改由上游 `UserDetailsLoader.Load` 的 status 字段分流 |
 | 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 |
@@ -1389,9 +1380,8 @@ MySQL (InnoDB) + Redis Cache
 | 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 的主因之一 |
+| ~~TC-0824 / TC-0825~~ | ~~1062 映射成 409 / logic 映射 409→HTTP 409~~ | — | — | — | — | **已删除**:H-3 的 `LockByCodeTx` FOR UPDATE 已经串行化并发同步,1062 在新架构下不可达;tx 内任何未命名错误统一为 `SyncPermsError{500}`,409 重试契约随之取消 |
+| TC-0826 | 同一 perm code 在 req 中重复 | `perms = [A, A]` | 落盘仅 1 条(入参内部去重) | 防自伤 | P0 | 入参级去重,避免 tx 内自撞 UNIQUE |
 
 ### M-B HTTP /refreshToken 中间件挂载(`internal/handler/refreshTokenRouteWiring_audit_test.go`)
 
@@ -1400,3 +1390,123 @@ MySQL (InnoDB) + Redis Cache
 | 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 隔离 |
 
+
+
+## 十五、 本轮新增对抗性用例(QA 主动补齐 · 第五批 / 审计报告第 6 轮修复回归)
+
+> 本批与 2026-04-19 第 6 轮 `audit-report.md` 逐项对齐。每条修复均挂一条或一组独立 TC,
+> 断言 **修复后的预期行为**(不是源码当前的观测)。任何人只要把修复改回旧路径,相应用例立刻 FAIL。
+>
+> 覆盖域:
+>
+> * **H-1** `AdminLogin` 限流 key 使用 `admin:<clientIP>:<username>` 双维,换 IP 不得被同一用户名上一次的配额连带锁死
+> * **H-2** `ValidateProductLogin` 无条件 bcrypt + 冻结/超管状态只在密码正确后才披露(消除账号存在性 & 冻结状态 oracle)
+> * **H-3** `SyncPermsService` 事务内 `LockByCodeTx` + `FindMapByProductCodeWithTx` 串行化同 product 并发同步
+> * **M-1** `UpdateDeptLogic` 改用 `CleanByUserIds` 批量清缓存;`FindIdsByDeptId` 失败仅 Errorf 不再吞错且不返回 500
+> * **M-2** `ProductList` / `ProductDetail` / `DeptTree` 三个接口对非超管实施行/资源级访问控制
+> * **M-4** `BindRolePerms` / `UpdateRole` post-commit 缓存获取失败不再映射 500(degraded 成功)
+> * **M-5** `CheckManageAccess` 支持 `WithPrefetchedTarget` option,避免单次请求内重复 FindOne
+> * **M-6** `ExtractClientIP` 解析 `X-Forwarded-For` 首段 + `net.ParseIP` 合法性校验 + `behindProxy=false` 忽略请求头
+> * **M-8** `IncrementTokenVersionIfMatch` 新签名 `(ctx, id, username, expected)`,调用方透传 username 省掉多一次 FindOne
+> * **L-5** `CountOtherActiveAdminsTx` 新方法:排除目标自己后计数,正/反向用例均匹配
+> * **L-4 (复盘第 5 轮)** `checkPermLevel` 对 DB 瞬时错误 fail-close 500(已验证)
+> * **新增负面契约** H-1/H-2 回归:frozen + wrong password 不得返回 403、远端换 IP 不得被上一次 per-username 计数锁死
+
+### H-1 AdminLogin 限流按 IP+username 双维(`internal/logic/pub/adminLoginIpLimit_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0834 | 相同 IP + 相同 username 连续打满 quota | clientIP=`1.2.3.4`, username=superA,连打 >quota 次错误密码 | 超限后返回 429 "登录尝试过于频繁,请5分钟后再试",同 IP 下同 username 不再放行 | 安全/限流 | P0 | H-1:修复后 key=`admin:<ip>:<u>`;按 quota 阈值打入,需命中 429 |
+| TC-0835 | 同 username 但换远端 IP | clientIP=`1.2.3.4` 打满后,换 clientIP=`5.6.7.8` 继续 | 不同 IP 分别计数,换 IP 仍应触达 bcrypt → 进入下游业务断言(此处为密码错误 401),而非继承上一桶 429 | 安全/限流 | P0 | H-1:确认 key 含 IP 维度,远端"任何 IP"都能永久锁死的攻击路径被阻断 |
+| TC-0836 | clientIP 缺失(未挂 RateLimit 中间件) | clientIP 未注入 ctx | key 退化为 `admin:unknown:<u>`,仍能正常限流到共享桶,不得直接 panic 或跳过限流 | 安全/鲁棒 | P0 | H-1:fail-closed 兜底;未来删除中间件只会退化 key 不会绕过 |
+| TC-0837 | managementKey 无效路径不得消耗 username 配额 | 任意密码,managementKey 错误 | 401 "managementKey无效";同 username 立刻换 managementKey 正确再来仍有完整 quota | 安全 | P1 | H-1:managementKey 校验在限流 `Take` 之前,防匿名攻击者只靠错 key 就把配额打满 |
+
+### H-2 ValidateProductLogin 恒时 bcrypt & 延迟披露状态(`internal/logic/pub/loginServiceConstantTime_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0838 | 用户冻结 + **错误密码** | status=2 的用户,密码错误 | 401 "用户名或密码错误"(**不能**是 403 "账号已被冻结") | 安全/侧信道 | P0 | H-2:账号存在性 + 冻结状态必须在密码正确之前完全不可观察 |
+| TC-0839 | 用户冻结 + **正确密码** | status=2 的用户,密码正确 | 403 "账号已被冻结"(奖励性披露) | 正常 | P0 | H-2:只有拿到密码后才披露状态,仍保留业务可见性 |
+| TC-0840 | 超管走产品端登录 + 错误密码 | IsSuperAdmin=1,密码错误 | 401 "用户名或密码错误"(不得提前暴露"超管"身份) | 安全/侧信道 | P0 | H-2:超管状态同样延迟披露 |
+| TC-0841 | 超管走产品端登录 + 正确密码 | IsSuperAdmin=1,密码正确 | 403 "超级管理员不允许通过产品端登录,请使用管理后台" | 正常 | P0 | H-2:披露顺序反转后的正面路径仍保持原有业务语义 |
+| TC-0842 | 用户名不存在 | 不存在用户名 + 任意密码 | 401 "用户名或密码错误",走 dummy bcrypt 恒时对齐 | 安全/枚举 | P0 | H-2:沿用 dummy hash 路径,不被 H-2 新顺序破坏 |
+
+### H-3 SyncPermsService 事务内锁产品 & 事务内读 perm map(`internal/logic/pub/syncPermsTxLock_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0843 | mock 断言事务内必调用 `LockByCodeTx` | 1 次正常 sync | `LockByCodeTx` 在 tx 内被调用过且先于 `FindMapByProductCodeWithTx` | 架构 | P0 | H-3:锁必须落在 tx 内,顺序固定 |
+| TC-0844 | mock `LockByCodeTx` 返回 `sqlx.ErrNotFound` | 在 tx 内返回 NotFound | `SyncPermsError.Code == 404`,文案 "产品不存在" | 分支 | P0 | H-3:tx 内识别产品被删 → 404 |
+| TC-0845 | mock `LockByCodeTx` 返回通用 error | boom | SyncPermsError/500 包裹;不泄露原始 driver 错误 | 容错 | P1 | H-3:非 NotFound 错误必须回滚为 500 |
+
+### M-1 UpdateDept 批量 Clean + 错误不再吞掉(`internal/logic/dept/updateDeptCleanBatch_audit_test.go` + `internal/loaders/userDetailsLoaderCleanByUserIds_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0846 | `CleanByUserIds` 批量清理多用户缓存 | 预埋 3 用户各 2 产品缓存 | Redis 中 6 条 `ud:userId:productCode` + 3 条 `ud:idx:u:*` 均被删除 | 正确性 | P0 | M-1 基础设施:SUNION + 批 DEL 必须覆盖所有索引 |
+| TC-0847 | `CleanByUserIds` 空 ids 切片 | `[]` | 立即返回,不 panic,不调用 Redis | 边界 | P1 | M-1:防未来调用方传空列表打空 RTT |
+| TC-0848 | `UpdateDept` 改 deptType 时调 CleanByUserIds | mock: FindIdsByDeptId → [100,101],断言 CleanByUserIds 路径(通过 mock 的 FindIdsByDeptId 期望 +真实 loader 执行) | 无错误返回;FindIdsByDeptId 被调用恰好 1 次 | 行为 | P0 | M-1:UpdateDept 在变更时才触达用户列表 |
+| TC-0849 | `UpdateDept` 的 `FindIdsByDeptId` 失败 | mock 返回 err | 返回 `nil`(不是 500);旧权限缓存 TTL 兜底 | 容错 | P0 | M-1:修复后的 degraded 成功语义 |
+
+### M-2 ProductList / ProductDetail / DeptTree 访问控制(`internal/logic/product/productListAccessControl_audit_test.go`、`internal/logic/product/productDetailAccessControl_audit_test.go`、`internal/logic/dept/deptTreeAccessControl_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0850 | MEMBER 调 ProductList | `caller.ProductCode=pA` | 仅返回 pA 一条(即使 DB 内有 pB、pC) | 安全/访问控制 | P0 | M-2:非超管只见自己产品 |
+| TC-0851 | MEMBER 调 ProductList 且 `ProductCode==""` | 游离 MEMBER | 返回空列表 `Total=0, List=[]` | 边界 | P0 | M-2:无 productCode 时降级为 0 条 |
+| TC-0852 | MEMBER 调 ProductDetail 查他产品 | 目标 id 属于 pB | 404 "产品不存在"(不暴露存在性) | 安全/枚举 | P0 | M-2:区分开"存在但无权"会被当 oracle |
+| TC-0853 | MEMBER 调 ProductDetail 查自己产品 | 目标 id 属于 pA | 200 OK,`AppKey` 字段为空(保持原 AppKey-hidden 语义) | 正常路径 | P0 | M-2:字段级脱敏不被取消 |
+| TC-0854 | 超管调 ProductDetail | 任意 id | 200 OK + AppKey 可见 | 正常路径 | P1 | M-2:超管路径不受访问控制影响 |
+| TC-0855 | MEMBER 调 DeptTree | `DeptPath="/1/2/"` | 返回树中 Path 前缀匹配的子树;父部门/兄弟部门不可见 | 安全 | P0 | M-2:按 DeptPath 剪枝 |
+| TC-0856 | MEMBER 调 DeptTree 且 DeptPath="" | 游离成员 | 返回空切片 `[]` | 边界 | P0 | M-2:无 DeptPath 降级空树 |
+| TC-0857 | ADMIN 调 DeptTree | 产品 ADMIN | 返回完整树(ADMIN fullAccess) | 正常路径 | P1 | M-2:ADMIN 保留组织视图 |
+
+### M-4 BindRolePerms/UpdateRole 缓存获取失败降级(`internal/logic/role/postCommitCacheDegrade_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0858 | `BindRolePerms`:事务 OK,`FindUserIdsByRoleId` 返回 err | mock | 返回 nil(200)——不再映射 500 | 错误映射 | P0 | M-4:degraded 成功;客户端不应重试 |
+| TC-0859 | `UpdateRole`:事务 OK,`FindUserIdsByRoleId` 返回 err | mock | 返回 nil(200) | 错误映射 | P0 | M-4:同上 |
+
+### M-5 CheckManageAccess Prefetched(`internal/logic/auth/checkManageAccessPrefetched_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0860 | 传入 prefetched target,不再查 FindOne | caller=MEMBER,prefetched.DeptId 合法 | `SysUserModel.FindOne` 次数 = 0;业务结果同无 option 版本 | 性能/契约 | P1 | M-5:避免重复 FindOne |
+| TC-0861 | prefetched target.Id 与参数 targetUserId 不一致 | 被 defensive 忽略 | 依然触发一次 FindOne 真实查询(option 失效) | 安全 | P1 | M-5:prefetched 自洽校验,不让调用方传错 id 绕过访问控制 |
+
+### M-6 ExtractClientIP XFF/合法性/behindProxy(`internal/middleware/ratelimitMiddlewareXff_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0862 | behindProxy=true + `X-Forwarded-For: 1.1.1.1, 2.2.2.2` | 首段合法 | 返回 `1.1.1.1` | 契约 | P0 | M-6:XFF 首段优先 |
+| TC-0863 | behindProxy=true + `X-Forwarded-For` 全非法 + `X-Real-IP: 10.0.0.1` | XFF=`garbage, abc`,XRI 合法 | fallthrough 到 `10.0.0.1` | 契约 | P0 | M-6:非法段跳过后走 XRI |
+| TC-0864 | behindProxy=true + 两头均空 | 无 XFF / 无 XRI | 回落到 `RemoteAddr` 剥端口后的 host | 容错 | P1 | M-6:降级路径 |
+| TC-0865 | behindProxy=true + `X-Forwarded-For: " 3.3.3.3 "` (空白) | 首段带空白 | 返回 `3.3.3.3`(trim 后合法) | 边界 | P1 | M-6:trim 后再解析 |
+| TC-0866 | behindProxy=false + XFF=`1.1.1.1` | RemoteAddr=`5.5.5.5:8080` | 忽略 XFF,返回 `5.5.5.5` | 安全/伪造 | P0 | M-6:不信任客户端注入的头部 |
+
+### M-8 IncrementTokenVersionIfMatch 签名(已落地·契约冻结)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0867 | 透传 username 与 DB 一致 | CAS 正常 | 成功且 username-key 缓存也失效(沿用 TC-0806 验证) | 契约 | P0 | M-8:签名扩展不破坏既有 CAS 语义 |
+
+### L-5 CountOtherActiveAdminsTx(`internal/model/productmember/countOtherActiveAdminsTx_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0868 | 产品内 3 个 active admin,排除其中 1 | excludeId=第二个 admin | 返回 2 | 计数 | P0 | L-5:排除目标后正确计数 |
+| TC-0869 | 唯一 active admin,排除他自己 | excludeId=唯一 admin | 返回 0 → 上层识别为"最后一个" | 语义 | P0 | L-5:removeMember/updateMember 据此防"降级/移除最后一个 admin" |
+| TC-0870 | 存在 1 个 active + 1 个 disabled admin | excludeId=active | 返回 0(disabled 不计入) | 语义 | P0 | L-5:仍需状态=enabled 才算 |
+
+---
+
+### 4.14 第 6 轮过时用例按新契约重写(2026-04-19 · QA 清理补充)
+
+> 以下 TC 是在清理阶段按 **新契约** 重写,用于替代被删除的旧用例(见 §4.12、§4.13 各行"已删除"备注)。
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0871 | 超管调 ProductList 走分页路径 | ctx=SuperAdmin, page/pageSize=1/20 | 调用 `FindList(1,20)`,返回列表且每条 `AppKey` 原样可见 | 正常路径 | P0 | M-2:取代旧 TC-0079/0087,钉死"超管路径 != 非超管路径"分叉 |
+| TC-0872 | ProductDetail FindOne 失败 | DB 返回 err | 统一返回 `CodeError{404,"产品不存在"}`,文案与"他人产品 → 404"完全一致 | 异常路径 | P0 | M-2:取代旧 TC-0085,钉死"无差别 404"避免被用作存在性 oracle |
+

+ 117 - 3
test-report.md

@@ -1115,7 +1115,7 @@
 | **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-1 (R5)** | `IncrementTokenVersionIfMatch` 原子 CAS: 命中递增、失配返 `ErrTokenVersionMismatch`、并发仅 1 胜出且 DB 只 +1、成功后双路缓存一致 (TC-0802 / TC-0803 / TC-0805 / TC-0806);logic 层 6 并发仅 1 胜 + 5 × 401 "登录状态已失效" (TC-0812)(TC-0804 已随 M-8 新契约删除,CAS 未命中统一为 `ErrTokenVersionMismatch`) | 彻底消除刷新令牌并发窗口下"两枚合法新 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) | 防"把用户挪出部门树"从而变相脱离管理视野的低成本越权 |
@@ -1123,7 +1123,7 @@
 | **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-6 (R5)** | 新基础设施 `FindMapByProductCodeWithTx` / `LockByCodeTx` 语义齐备:事务内 map 查等价非事务、空产品返回非 nil map、存在 code 锁行、并发 FOR UPDATE 阻塞、不存在返 `ErrNotFound` (TC-0807 ~ TC-0811);`SyncPerms` 入参级去重避免 tx 内自撞 UNIQUE (TC-0826) | 让产品权限同步在并发窗口下被 FOR UPDATE 串行化;避免自引用 1062(TC-0824/0825 的 409 映射契约随 H-3 彻底取消,由 TC-0048 "任何 tx 错误统一 500 不泄露驱动细节"取代) |
 | **QA-P6 审计回归 M-B (R5)** | `/api/auth/refreshToken` 的 `rest.WithMiddlewares` 块静态包含 `serverCtx.RefreshTokenRateLimit`(源码正则断言);行为侧同 IP 不同端口第 2 次即 429 "过于频繁",不同 IP 不受影响 (TC-0832 / TC-0833) | 结构性防剥离 + 运行期验证双保险,杜绝"中间件被误删但接口看似正常"的静默回滚 |
 
 ### 3.3 发现的核心缺陷
@@ -1170,7 +1170,7 @@
 | 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 |
+| 9 | M-6 SyncPerms 基础设施 + 入参去重 | Tx 读 / 锁产品行 / 入参去重(1062→409 映射契约随 H-3 取消) | TC-0807 ~ TC-0811 / TC-0826 |
 | 10 | M-B HTTP 路由中间件挂载 | routes.go 静态 wiring + 行为级 429 | TC-0832 / TC-0833 |
 
 #### ⏳ 仍建议后续补强 (非产品缺陷, 基础设施或扩展方向)
@@ -1179,3 +1179,117 @@
 2. **Fuzz 语料纳入 CI**: 目前 TC-0794/TC-0795 以 seed corpus 形式跑过一次, 建议把 `testdata/fuzz/**` 的回归用例在 CI 中用 `go test -run=^Fuzz.*$` 形式每次执行, 避免新引入的崩溃路径静默遗漏.
 3. **Chaos 级 Redis/DB 故障注入**: 本轮 fail-open 只覆盖 "Redis 整个不可达" 的全断场景; 后续可补 Redis 超时 / 抖动 / 读副本滞后, 以及 MySQL 只读副本迟滞下 `FOR UPDATE` 行为的混沌测试.
 4. **HTTP 层 E2E**: 当前 handler 契约测试走 `httptest.NewRecorder`; 建议在发布前以 `go-zero rest.Server` 起实例, 用真实 HTTP Client 打一轮冒烟, 以覆盖 go-zero 框架级中间件链。
+
+---
+
+## 九、第 6 轮审计回归补充(2026-04-19 · QA 主动补齐第五批 + 同日清理)
+
+> 本节对齐 `audit-report.md` 第 6 轮审计条目 H-1 / H-2 / H-3 / M-1 / M-2 / M-4 / M-5 / M-6 / M-8 / L-5。
+> 初批新增 **41 条独立 Test 函数**(对应 `test-design.md` TC-0834 ~ TC-0870 共 37 条 TC 及少量补充防御用例);
+> 同日又按用户指示"过时用例按新契约重写,无用的直接删除"完成清理:
+>
+> - 新增 **2 条** (TC-0871 ProductList 超管路径 / TC-0872 ProductDetail 无差别 404)
+> - 就地重写 **1 条** (TC-0048 tx 回滚按 H-3 新契约)
+> - 迁移 **1 条** (TC-0826 入参去重从被删文件拆出来)
+> - 删除 **18 条** 过时用例(见 §9.3.1)
+>
+> 每条新/留存用例均对修复后的**契约**断言,任何人只要把修复改回旧路径,对应 TC 立即 FAIL。
+> 清理完成后全仓 `go test ./... = 0 FAIL`。
+
+### 9.1 新增测试文件与 TC 映射
+
+| 新增文件 | 审计条目 | 覆盖 TC | 新增 Test 数 | 全部 pass |
+| :--- | :--- | :--- | ---: | :---: |
+| `internal/logic/pub/adminLoginIpLimit_audit_test.go` | H-1 | TC-0834 ~ TC-0837 | 4 | ✅ |
+| `internal/logic/pub/loginServiceConstantTime_audit_test.go` | H-2 | TC-0838 ~ TC-0842 | 5 | ✅ |
+| `internal/logic/pub/syncPermsTxLock_audit_test.go` | H-3 | TC-0843 ~ TC-0845 | 3 | ✅ |
+| `internal/logic/dept/updateDeptCleanBatch_audit_test.go` | M-1 | TC-0848 / TC-0849 + 1 边界 | 3 | ✅ |
+| `internal/loaders/userDetailsLoaderCleanByUserIds_audit_test.go` | M-1 | TC-0846 / TC-0847 | 2 | ✅ |
+| `internal/logic/product/productAccessControl_audit_test.go` | M-2 | TC-0850 ~ TC-0854 | 5 | ✅ |
+| `internal/logic/dept/deptTreeAccessControl_audit_test.go` | M-2 | TC-0855 ~ TC-0857 | 3 | ✅ |
+| `internal/logic/role/postCommitCacheDegraded_audit_test.go` | M-4 | TC-0858 / TC-0859 | 2 | ✅ |
+| `internal/logic/auth/checkManageAccessPrefetch_audit_test.go` | M-5 | TC-0860 / TC-0861 + 1 防御 | 3 | ✅ |
+| `internal/middleware/ratelimitMiddlewareXff_audit_test.go` | M-6 | TC-0862 ~ TC-0866 + 2 防御 | 7 | ✅ |
+| `internal/model/productmember/countOtherActiveAdmins_audit_test.go` | L-5 | TC-0867 ~ TC-0870 | 4 | ✅ |
+| `internal/logic/product/productAccessControl_audit_test.go`(清理增补) | M-2 | TC-0871 / TC-0872 | 2 | ✅ |
+| `internal/logic/pub/syncPermsLogic_mock_test.go`(按新契约就地重写) | H-3 | TC-0048 | 1(改写) | ✅ |
+| `internal/logic/pub/syncPermsDedup_audit_test.go`(从被删文件迁移) | H-3 | TC-0826 | 1 | ✅ |
+| **合计** | — | **39 TC + 4 防御 + 1 就地重写** | **45** | **45/45** |
+
+> M-8 (`IncrementTokenVersionIfMatch` 新签名) 在前一轮已有 `incrementTokenVersionIfMatch_audit_test.go`,本轮同步签名并删除 TC-0804(旧"用户不存在 ≠ Mismatch"契约已被新契约取代)。
+
+### 9.2 新增测试结果一览
+
+命令:`go test -count=1 -run '<第 9.1 节 41 条新增 Test 的并集正则>' ./...`
+
+| 统计 | 数值 |
+| :--- | :--- |
+| 第 6 轮新增 TC(test-design.md) | **39**(TC-0834~0870 + TC-0871/0872)|
+| 第 6 轮新增 Test 函数 | **43**(41 初批 + 2 清理增补 TC-0871/0872)|
+| 第 6 轮就地重写 / 迁移 Test | **2**(TC-0048 按新契约重写、TC-0826 从被删文件迁移)|
+| ✅ 通过 | **45/45** |
+| ❌ 失败 | **0** |
+| 第 6 轮 QA 补齐通过率 | **100%** |
+| 清理删除过时 Test 函数 | **-18**(旧契约已取消,不再保留)|
+| 累计审计修复回归 Test 数 | **84 + 43 = 127** |
+| 累计审计回归通过率 | **127/127 = 100%** |
+| 全仓 `go test ./...` | **832/832 pass,0 FAIL** |
+
+### 9.3 全量回归暴露的过时用例处置(2026-04-19 清理补充)
+
+> 本轮把新契约钉死后,`go test ./...` 一度产生 17 条 FAIL。定性后全部归为**历史测试写在"旧错误行为"上**,
+> 修复已取代旧契约;按用户指示"过时用例按新契约重写,无用的直接删除"统一处置,新契约覆盖
+> 到位后全仓 `go test ./... = 0 FAIL`。
+
+#### 9.3.1 删除/重写记录
+
+| # | 原失败测试 | 原 TC | 关联审计条目 | 处置方式 | 新契约 TC |
+| :--- | :--- | :--- | :--- | :--- | :--- |
+| 1 | `TestDeptTree_Normal` | TC-0110 | M-2 | 删除(文件 `deptTreeLogic_test.go` 整体删除) | TC-0857 (Admin 全树) |
+| 2 | `TestDeptTree_Empty` | TC-0111 | M-2 | 删除 | TC-0856 (orphan 空树) |
+| 3 | `TestDeptTree_OrphanBecomesRoot` | TC-0112 | M-2 | 删除(fullAccess 路径隐含覆盖) | TC-0857 |
+| 4 | `TestProductDetail_Success` | TC-0084 | M-2 | 删除(文件 `productDetailLogic_test.go` 整体删除) | TC-0853 |
+| 5 | `TestProductDetail_NotFound` | TC-0085 | M-2 | 删除 → 按新契约重写为"无差别 404" | **TC-0872** (新增) |
+| 6 | `TestProductDetail_NonSuperAdminAppKeyHidden` | TC-0088 | M-2 | 删除 | TC-0853 |
+| 7 | `TestProductDetail_SuperAdminAppKeyVisible` | TC-0089 | M-2 | 删除(保留原等价用例 TC-0854) | TC-0854 |
+| 8-12 | `TestProductList_*` 分页边界 5 条 | TC-0079 ~ TC-0083 | M-2 | 删除(文件 `productListLogic_test.go` 整体删除),分页边界由 `util.TestNormalizePage` 单元测试覆盖 | — |
+| 13 | `TestProductList_NonSuperAdminAppKeyHidden` | TC-0086 | M-2 | 删除 | TC-0850 |
+| 14 | `TestProductList_SuperAdminAppKeyVisible` | TC-0087 | M-2 | 删除 → 按新契约重写为"超管走 FindList + AppKey 可见" | **TC-0871** (新增) |
+| 15 | `TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail` | TC-0048 | H-3 | 按新契约重写:`LockByCodeTx → FindMapByProductCodeWithTx → BatchUpdate 报错 → 统一 500` | TC-0048 (in-place rewrite) |
+| 16 | `TestExecuteSyncPerms_DuplicateEntry_Maps409` | TC-0824 | H-3 | 删除:H-3 的 FOR UPDATE 已消除 1062 可达性,409 映射契约取消 | — |
+| 17 | `TestSyncPermsLogic_ConflictMapsTo409HTTP` | TC-0825 | H-3 | 删除(同上) | — |
+| 18 | `TestExecuteSyncPerms_DeduplicatesRequest` | TC-0826 | H-3 | 保留(入参级去重仍是现行契约),移入 `syncPermsDedup_audit_test.go` | TC-0826 |
+| 19 | `TestRateLimit_BehindProxy_XFFStillIgnored` | TC-0554 | M-6 | 删除:M-6 明确反转契约 | TC-0862 ~ TC-0866 |
+| 20 | `TestSysUserModel_IncrementTokenVersionIfMatch_UserNotFound` | TC-0804 | M-8 | 删除:M-8 新契约"CAS 未命中 = ErrTokenVersionMismatch"不再区分用户不存在 | — |
+
+#### 9.3.2 清理后的体量变化
+
+| 指标 | 清理前 | 清理后 | 变化 |
+| :--- | ---: | ---: | ---: |
+| 顶层 Test 函数数 | 848 | **832** | -16 (删除 19 条、新增 2 条、就地重写 1 条) |
+| 过时用例 FAIL | 17 | **0** | -17 |
+| 全仓 `go test ./...` | 17 FAIL | **0 FAIL** | ✅ |
+| 第 6 轮新增/重写 TC | 37 | **37 + TC-0871 + TC-0872 = 39** | +2 |
+
+### 9.4 审计条目逐项结论(清理后)
+
+| 审计条目 | QA 回归结论 | 关联 TC |
+| :--- | :--- | :--- |
+| **H-1** `admin:<ip>:<user>` 双维限流 | ✅ 修复可回归:同 IP 同 username 打满后 429;换 IP 立即解锁;缺失 IP 退化到 `unknown` 桶;managementKey 错不消耗配额 | TC-0834 ~ TC-0837 |
+| **H-2** ValidateProductLogin 常时 + 延迟披露 | ✅ 修复可回归:冻结/超管在错误密码下一律 401 "用户名或密码错误";正确密码才披露 403 | TC-0838 ~ TC-0842 |
+| **H-3** SyncPerms 事务内锁 + 事务内读 | ✅ 锁顺序 + 404 分支被钉死;1062→409 映射契约随 FOR UPDATE 串行化一并取消,任何 tx 错误统一 500 不透传驱动细节 | TC-0843 ~ TC-0845、TC-0048、TC-0826 |
+| **M-1** CleanByUserIds 批量清理 + 错误不吞 | ✅ 修复可回归:6 cacheKey + 3 userIdx 一次清光;空 ids no-op;deptType 变更时 FindIdsByDeptId 恰 1 次;失败降级 200 | TC-0846 ~ TC-0849 |
+| **M-2** ProductList / ProductDetail / DeptTree 访问控制 | ✅ 修复可回归:非超管/非 ADMIN 按 productCode 或 DeptPath 剪枝;404 代替 403 防枚举;AppKey 脱敏保留 | TC-0850 ~ TC-0857 + TC-0871 / TC-0872 |
+| **M-4** post-commit 缓存失败 degraded 成功 | ✅ 修复可回归:`FindUserIdsByRoleId` 抛 err,handler 仍 200 | TC-0858 / TC-0859 |
+| **M-5** `WithPrefetchedTarget` | ✅ 修复可回归:prefetched.Id==targetUserId 时 FindOne=0 次;Id 不匹配时自动忽略并回退 FindOne | TC-0860 / TC-0861 |
+| **M-6** ExtractClientIP XFF 支持 | ✅ 修复可回归:XFF 首段优先 + IP 合法性校验 + trim + behindProxy=false 忽略头 | TC-0862 ~ TC-0866 |
+| **M-8** IncrementTokenVersionIfMatch 新签名 | ✅ 契约确认:任何 CAS 未命中(含用户不存在)统一返 `ErrTokenVersionMismatch`;用户状态分支由上游 `UserDetailsLoader.Load` 的 status 字段分流 | TC-0802 / TC-0803 / TC-0805 / TC-0806 |
+| **L-5** CountOtherActiveAdminsTx | ✅ 修复可回归:排除自己计数;尊重 productCode 边界;Disabled/Member 不计入 | TC-0867 ~ TC-0870 |
+
+### 9.5 下一轮建议
+
+1. **保持 CI gate**:`go test -count=1 ./...` 必须 0 FAIL 再合入;过时用例一旦被新契约取代,立即按"按新契约重写或直接删除"处置,避免持续污染 CI。
+2. **M-8 的 UX 观测**:虽然"用户不存在 = ErrTokenVersionMismatch"在新契约下是可接受的(登录凭证维度不泄露用户存在性),但产品层仍建议在 `UserDetailsLoader.Load` 侧补一条 audit-log"refresh token 命中已删除用户 id=N"供排障。
+3. **H-3 的 1062 监控**:LockByCodeTx 串行化后 1062 理论不可达;建议在 DB 层加一条 `mysql_error_1062{table="sys_perm"}` 的 metric + 告警,一旦出现代表 H-3 失效,需要补回 409 重试契约。
+4. **Chaos 级 Redis/DB 故障注入**:本轮 fail-open 只覆盖"Redis 整个不可达"的全断场景;后续可补 Redis 超时/抖动、MySQL 只读副本迟滞下 `FOR UPDATE` 行为的混沌测试。
+