Просмотр исходного кода

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

BaiLuoYan 3 недель назад
Родитель
Сommit
7dbe686aa8
46 измененных файлов с 2614 добавлено и 1107 удалено
  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)
 ## 🚩 核心逻辑漏洞 (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
   ```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
   ```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
   ```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
   ```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
   ```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
      ```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
   ```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
   ```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
      ```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
      ```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
   ```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
   ```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 | 概要 |
 | 优先级 | 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"
 	"errors"
 	"fmt"
 	"fmt"
 	"math"
 	"math"
+	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/model"
 	"perms-system-server/internal/model"
@@ -178,6 +179,36 @@ func (l *UserDetailsLoader) Clean(ctx context.Context, userId int64) {
 	l.cleanByIndex(ctx, idxKey)
 	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 清除指定产品下所有用户的缓存。
 // CleanByProduct 清除指定产品下所有用户的缓存。
 func (l *UserDetailsLoader) CleanByProduct(ctx context.Context, productCode string) {
 func (l *UserDetailsLoader) CleanByProduct(ctx context.Context, productCode string) {
 	idxKey := l.productIndexKey(productCode)
 	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) {
 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)
 	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/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 
 
@@ -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 检查当前操作者是否有权管理目标用户。
 // CheckManageAccess 检查当前操作者是否有权管理目标用户。
 // 规则:
 // 规则:
 //  1. SUPER_ADMIN 完全豁免
 //  1. SUPER_ADMIN 完全豁免
@@ -38,7 +55,7 @@ func memberTypePriority(memberType string) int {
 //  4. 权限级别检查:操作者的级别必须严格高于目标用户
 //  4. 权限级别检查:操作者的级别必须严格高于目标用户
 //     - 先比 memberType 优先级(SUPER_ADMIN > ADMIN > DEVELOPER > MEMBER)
 //     - 先比 memberType 优先级(SUPER_ADMIN > ADMIN > DEVELOPER > MEMBER)
 //     - 同 memberType 时比 permsLevel(数值越小权限越高)
 //     - 同 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)
 	caller := middleware.GetUserDetails(ctx)
 	if caller == nil {
 	if caller == nil {
 		return response.ErrUnauthorized("未登录")
 		return response.ErrUnauthorized("未登录")
@@ -51,7 +68,16 @@ func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUs
 		return nil
 		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
 		return err
 	}
 	}
 
 
@@ -134,25 +160,33 @@ func HasFullProductPerms(caller *loaders.UserDetails) bool {
 
 
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
 // 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 {
 	if callerId == targetUserId {
-		return response.ErrBadRequest("不能修改自己的状态")
+		return nil, response.ErrBadRequest("不能修改自己的状态")
 	}
 	}
 	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
 	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
 	if err != nil {
 	if err != nil {
-		return response.ErrNotFound("用户不存在")
+		return nil, response.ErrNotFound("用户不存在")
 	}
 	}
 	if target.IsSuperAdmin == consts.IsSuperAdminYes {
 	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 {
 	if caller.MemberType == consts.MemberTypeAdmin {
 		return nil
 		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 {
 	if caller.DeptId == 0 {
 		return response.ErrForbidden("您未归属任何部门,无权管理其他用户")
 		return response.ErrForbidden("您未归属任何部门,无权管理其他用户")
 	}
 	}
@@ -161,9 +195,13 @@ func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller
 		return response.ErrForbidden("您的部门信息异常,无法执行此操作")
 		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 {
 	if target.DeptId == 0 {
 		return response.ErrForbidden("目标用户未归属部门,仅超管或产品管理员可管理")
 		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 (
 import (
 	"context"
 	"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/svc"
 	"perms-system-server/internal/types"
 	"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) {
 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)
 	list, err := l.svcCtx.SysDeptModel.FindAll(l.ctx)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		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))
 	items := make([]*types.DeptItem, 0, len(list))
 	for _, d := range list {
 	for _, d := range list {
 		items = append(items, &types.DeptItem{
 		items = append(items, &types.DeptItem{
@@ -53,13 +80,15 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 
 
 	roots := make([]*types.DeptItem, 0)
 	roots := make([]*types.DeptItem, 0)
 	for _, item := range items {
 	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)
 			roots = append(roots, item)
-		} else if parent, ok := itemMap[item.ParentId]; ok {
-			parent.Children = append(parent.Children, item)
 		} else {
 		} 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,子部门不受影响,
 	// 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 {
 	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 {
 		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))
 			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("成员不存在")
 			return response.ErrNotFound("成员不存在")
 		}
 		}
 		if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
 		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 {
 			if err != nil {
 				return err
 				return err
 			}
 			}
-			if adminCount <= 1 {
+			if otherAdminCount == 0 {
 				return response.ErrBadRequest("不能移除该产品的最后一个管理员")
 				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
 		wasActiveAdmin := locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled
 		willBeActiveAdmin := nextType == consts.MemberTypeAdmin && nextStatus == consts.StatusEnabled
 		willBeActiveAdmin := nextType == consts.MemberTypeAdmin && nextStatus == consts.StatusEnabled
 		if wasActiveAdmin && !willBeActiveAdmin {
 		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 {
 			if err != nil {
 				return err
 				return err
 			}
 			}
-			if adminCount <= 1 {
+			if otherAdminCount == 0 {
 				return response.ErrBadRequest("不能降级或禁用该产品的最后一个管理员")
 				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) {
 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)
 	product, err := l.svcCtx.SysProductModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 	if err != nil {
 		return nil, response.ErrNotFound("产品不存在")
 		return nil, response.ErrNotFound("产品不存在")
 	}
 	}
 
 
-	caller := middleware.GetUserDetails(l.ctx)
+	if !caller.IsSuperAdmin && product.Code != caller.ProductCode {
+		return nil, response.ErrNotFound("产品不存在")
+	}
+
 	item := &types.ProductItem{
 	item := &types.ProductItem{
 		Id:         product.Id,
 		Id:         product.Id,
 		Code:       product.Code,
 		Code:       product.Code,
@@ -41,7 +50,7 @@ func (l *ProductDetailLogic) ProductDetail(req *types.ProductDetailReq) (resp *t
 		Status:     product.Status,
 		Status:     product.Status,
 		CreateTime: product.CreateTime,
 		CreateTime: product.CreateTime,
 	}
 	}
-	if caller != nil && caller.IsSuperAdmin {
+	if caller.IsSuperAdmin {
 		item.AppKey = product.AppKey
 		item.AppKey = product.AppKey
 	}
 	}
 	return item, nil
 	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"
 	"context"
 
 
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/util"
 	"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) {
 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)
 	page, pageSize := util.NormalizePage(req.Page, req.PageSize)
 
 
 	list, total, err := l.svcCtx.SysProductModel.FindList(l.ctx, page, 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
 		return nil, err
 	}
 	}
 
 
-	caller := middleware.GetUserDetails(l.ctx)
 	items := make([]types.ProductItem, 0, len(list))
 	items := make([]types.ProductItem, 0, len(list))
 	for _, p := range list {
 	for _, p := range list {
-		item := types.ProductItem{
+		items = append(items, types.ProductItem{
 			Id:         p.Id,
 			Id:         p.Id,
 			Code:       p.Code,
 			Code:       p.Code,
 			Name:       p.Name,
 			Name:       p.Name,
+			AppKey:     p.AppKey,
 			Remark:     p.Remark,
 			Remark:     p.Remark,
 			Status:     p.Status,
 			Status:     p.Status,
 			CreateTime: p.CreateTime,
 			CreateTime: p.CreateTime,
-		}
-		if caller != nil && caller.IsSuperAdmin {
-			item.AppKey = p.AppKey
-		}
-		items = append(items, item)
+		})
 	}
 	}
 
 
 	return &types.PageResp{
 	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"
 	"context"
 	"crypto/subtle"
 	"crypto/subtle"
 	"errors"
 	"errors"
+	"fmt"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
@@ -38,10 +40,19 @@ func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.Logi
 		return nil, response.ErrUnauthorized("managementKey无效")
 		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 {
 	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 {
 		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分钟后再试"}
 		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 {
 	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 子句并成功递增,
 	// 原子 CAS 递增 tokenVersion:只有持有当前 tokenVersion 的那一次能命中 WHERE 子句并成功递增,
 	// 并发刷新中落败的请求直接返回 401,避免"两个请求都拿到新令牌"导致的会话劫持。
 	// 并发刷新中落败的请求直接返回 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 err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
 			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"
 	"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) {
 func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 	defer ctrl.Finish()
@@ -36,17 +41,16 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 			AppSecret: string(hashedSecret),
 			AppSecret: string(hashedSecret),
 			Status:    1,
 			Status:    1,
 		}, nil)
 		}, 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 := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "test_product").
+	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "test_product").
 		Return(map[string]*permModel.SysPerm{
 		Return(map[string]*permModel.SysPerm{
 			"existing_code": {
 			"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)
 		}, nil)
 
 
@@ -73,6 +77,9 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 	})
 	})
 
 
 	assert.Nil(t, resp)
 	assert.Nil(t, resp)
-	assert.Error(t, err)
+	require.Error(t, err)
+	// H-3 后的统一错误文案;原 DB 驱动错误必须被吞掉,避免泄漏内部实现。
 	assert.Contains(t, err.Error(), "同步权限事务失败")
 	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 (
 import (
 	"context"
 	"context"
+	"errors"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	permModel "perms-system-server/internal/model/perm"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
-	"perms-system-server/internal/util"
 
 
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
@@ -47,7 +47,7 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 	}
 	}
 
 
 	if len(perms) == 0 {
 	if len(perms) == 0 {
-		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
+		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空"}
 	}
 	}
 
 
 	// 去重请求列表,避免同一笔同步里 codes 互相冲突。
 	// 去重请求列表,避免同一笔同步里 codes 互相冲突。
@@ -63,47 +63,52 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		dedupPerms = append(dedupPerms, item)
 		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()
 	now := time.Now().Unix()
 	var added, updated, disabled int64
 	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 len(toInsert) > 0 {
 			if insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil {
 			if insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil {
 				return insertErr
 				return insertErr
@@ -119,8 +124,9 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		return disableErr
 		return disableErr
 	})
 	})
 	if err != nil {
 	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: "同步权限事务失败"}
 		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
 		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
 	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
 		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
 	return nil
 }
 }

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

@@ -37,12 +37,13 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return response.ErrUnauthorized("未登录")
 		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("用户不存在")
 		return response.ErrNotFound("用户不存在")
 	}
 	}
 
 
 	productCode := middleware.GetProductCode(l.ctx)
 	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
 		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(拒绝)两种效果,用于角色权限之外的细粒度调整。
 // SetUserPerms 设置用户个性化权限。对指定用户在当前产品下做权限全量覆盖,支持 ALLOW(附加)和 DENY(拒绝)两种效果,用于角色权限之外的细粒度调整。
 func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 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("用户不存在")
 		return response.ErrNotFound("用户不存在")
 	}
 	}
 
 
@@ -51,7 +52,7 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		return response.ErrBadRequest("产品已被禁用,无法设置权限")
 		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
 		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 {
 		if req.DeptId != nil || req.Status != 0 {
 			return response.ErrForbidden("不允许修改自己的部门和状态")
 			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
 			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 {
 	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)
 	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
 		return err
 	}
 	}
 
 
 	productCode := middleware.GetProductCode(l.ctx)
 	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
 		return err
 	}
 	}
 
 
-	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
-	if err != nil {
-		return response.ErrNotFound("用户不存在")
-	}
 	if user.Status == req.Status {
 	if user.Status == req.Status {
 		return nil
 		return nil
 	}
 	}

+ 30 - 4
internal/middleware/ratelimitMiddleware.go

@@ -5,10 +5,12 @@ import (
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
+	"strings"
 
 
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 
 
 	"github.com/zeromicro/go-zero/core/limit"
 	"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/core/stores/redis"
 	"github.com/zeromicro/go-zero/rest/httpx"
 	"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 {
 func ExtractClientIP(r *http.Request, behindProxy bool) string {
 	if behindProxy {
 	if behindProxy {
-		if ip := r.Header.Get("X-Real-IP"); ip != "" {
+		if ip := firstValidIP(r.Header.Get("X-Forwarded-For")); ip != "" {
 			return 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)
 	host, _, err := net.SplitHostPort(r.RemoteAddr)
 	if err != nil {
 	if err != nil {
@@ -63,3 +71,21 @@ func ExtractClientIP(r *http.Request, behindProxy bool) string {
 	}
 	}
 	return host
 	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")
 	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 回归:产品登录与管后登录必须使用独立的限流桶
 // 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)
 		FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error)
 		CountActiveAdmins(ctx context.Context, productCode string) (int64, error)
 		CountActiveAdmins(ctx context.Context, productCode string) (int64, error)
 		CountActiveAdminsTx(ctx context.Context, session sqlx.Session, 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)
 		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
 	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) {
 func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error) {
 	var data SysProductMember
 	var data SysProductMember
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `id` = ? FOR UPDATE", sysProductMemberRows, m.table)
 	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()
 	id, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 	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)
 	require.NoError(t, err)
 	assert.Equal(t, int64(6), got, "expected 命中时返回 DB 真实递增后的新版本")
 	assert.Equal(t, int64(6), got, "expected 命中时返回 DB 真实递增后的新版本")
 
 
@@ -70,7 +70,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_Mismatch_NoSideEffect(t *test
 	id, _ := res.LastInsertId()
 	id, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 	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 未命中时必须返回错误")
 	require.Error(t, err, "expected 未命中时必须返回错误")
 	assert.True(t, errors.Is(err, user.ErrTokenVersionMismatch), "错误必须是 ErrTokenVersionMismatch 以供 logic 层分辨")
 	assert.True(t, errors.Is(err, user.ErrTokenVersionMismatch), "错误必须是 ErrTokenVersionMismatch 以供 logic 层分辨")
 	assert.Equal(t, int64(0), got)
 	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 零副作用")
 	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,
 // TC-0805: H-1 并发回归 —— N 个 goroutine 用同一个 expected 去 CAS,
 // 必须恰好只有 1 个返回 success,其余全部 ErrTokenVersionMismatch;
 // 必须恰好只有 1 个返回 success,其余全部 ErrTokenVersionMismatch;
@@ -128,7 +120,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner(t *tes
 		go func(idx int) {
 		go func(idx int) {
 			defer wg.Done()
 			defer wg.Done()
 			<-start // 最大程度对齐并发起跑线
 			<-start // 最大程度对齐并发起跑线
-			v, e := m.IncrementTokenVersionIfMatch(ctx, id, 20)
+			v, e := m.IncrementTokenVersionIfMatch(ctx, id, username, 20)
 			switch {
 			switch {
 			case e == nil:
 			case e == nil:
 				atomic.AddInt32(&successCnt, 1)
 				atomic.AddInt32(&successCnt, 1)
@@ -186,7 +178,7 @@ func TestSysUserModel_IncrementTokenVersionIfMatch_InvalidatesCaches(t *testing.
 	require.NoError(t, err)
 	require.NoError(t, err)
 	require.Equal(t, int64(0), u0b.TokenVersion)
 	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.NoError(t, err)
 	require.Equal(t, int64(1), got)
 	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
 		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
 		UpdateStatus(ctx context.Context, id int64, status int64) error
 		UpdateStatus(ctx context.Context, id int64, status int64) error
 		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
 		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
-		IncrementTokenVersionIfMatch(ctx context.Context, id, expected int64) (int64, error)
+		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error)
 	}
 	}
 
 
 	customSysUserModel struct {
 	customSysUserModel struct {
@@ -183,17 +183,15 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 // IncrementTokenVersionIfMatch 原子递增 tokenVersion;仅当 DB 里当前 tokenVersion == expected 时才会生效。
 // IncrementTokenVersionIfMatch 原子递增 tokenVersion;仅当 DB 里当前 tokenVersion == expected 时才会生效。
 // 这是 refreshToken rotation 的原子 CAS:两个并发的刷新请求只有一个能命中 WHERE tokenVersion=expected,
 // 这是 refreshToken rotation 的原子 CAS:两个并发的刷新请求只有一个能命中 WHERE tokenVersion=expected,
 // 另一个 affected=0 返回 ErrTokenVersionMismatch,从而避免"两边都换到新令牌"的会话劫持窗口。
 // 另一个 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)
 	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
 	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)
 		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)
 		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id, expected)
 		if err != nil {
 		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 并各自拿到"新令牌"导致会话劫持。
 	// 原子 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 err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 			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)
 	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.
 // Delete mocks base method.
 func (m *MockSysProductMemberModel) Delete(ctx context.Context, id int64) error {
 func (m *MockSysProductMemberModel) Delete(ctx context.Context, id int64) error {
 	m.ctrl.T.Helper()
 	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.
 // 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()
 	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)
 	ret0, _ := ret[0].(int64)
 	ret1, _ := ret[1].(error)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 	return ret0, ret1
 }
 }
 
 
 // IncrementTokenVersionIfMatch indicates an expected call of IncrementTokenVersionIfMatch.
 // 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()
 	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.
 // 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-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-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-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`
 ### 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-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-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-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 |
 | TC-0090 | POST /api/product/update | updateProduct 非法状态值被拒绝 | status=99 | 400 "产品状态值无效" | 输入校验 | P0 | H-4: 仅允许 1/2 |
 
 
 ### 2.8 创建部门 `POST /api/dept/create`
 ### 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-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-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-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`
 ### 2.10 权限列表 `POST /api/perm/list`
 
 
@@ -940,7 +931,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0551 | 不同IP独立限流 | 两个不同IP | 各自独立计数, 互不影响 | 功能验证 | P0 | key隔离 |
 | 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-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-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 |
 | 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-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-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-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-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 |
 | 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-0809 | `LockByCodeTx` 对存在 code 返回完整行 | Tx 内 `SELECT ... FOR UPDATE` | 行数据完整 | 正常路径 | P0 | 基础设施:事务内锁产品行 |
 | TC-0810 | 对不存在 code | Tx | `sqlx.ErrNotFound` | 分支 | P0 | 让 logic 层分辨"产品不存在" vs "DB 错误" |
 | TC-0810 | 对不存在 code | Tx | `sqlx.ErrNotFound` | 分支 | P0 | 让 logic 层分辨"产品不存在" vs "DB 错误" |
 | TC-0811 | 两个事务同时锁同一行 | 并发 `LockByCodeTx` | 后者被阻塞,前者 commit 后才继续 | 并发/锁 | P0 | 实证 FOR UPDATE 的行级锁语义 |
 | 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`)
 ### 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-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 隔离 |
 | 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 Loader 并发合并** | `UserDetailsLoader` 50 并发 Load 合并为 1 次 `FindOne` (TC-0792), 后续查询命中 Redis 缓存不再打 DB (TC-0793) | 高并发首次加载防止 DB 击穿, 覆盖 L-5 修复在真实并发下的行为 |
 | **QA-P5 gRPC Fuzz** | `VerifyToken` 对畸形/alg=none/unicode 噪声永不 panic 且稳定返 Valid=false (TC-0794); `GetUserPerms` 错误码落在固定分类集合 (TC-0795) | 把 gRPC 边界的异常输入面从"抽查"升级到"随机化覆盖", 建立错误分类护栏 |
 | **QA-P5 gRPC Fuzz** | `VerifyToken` 对畸形/alg=none/unicode 噪声永不 panic 且稳定返 Valid=false (TC-0794); `GetUserPerms` 错误码落在固定分类集合 (TC-0795) | 把 gRPC 边界的异常输入面从"抽查"升级到"随机化覆盖", 建立错误分类护栏 |
 | **QA-P5 Handler 契约** | Logout/ChangePassword/RefreshToken 6 个 HTTP 契约: 未登录 401, 非法 body 400, 垃圾 bearer 401, 合法请求 200 + 副作用 (TC-0796 ~ TC-0801) | `handler/auth` 从 0% → 50% 覆盖, `handler/pub` 25% → 47.5%; 顶替原"仅 logic 层覆盖 → handler 纯薄层无测试"的空白 |
 | **QA-P5 Handler 契约** | Logout/ChangePassword/RefreshToken 6 个 HTTP 契约: 未登录 401, 非法 body 400, 垃圾 bearer 401, 合法请求 200 + 副作用 (TC-0796 ~ TC-0801) | `handler/auth` 从 0% → 50% 覆盖, `handler/pub` 25% → 47.5%; 顶替原"仅 logic 层覆盖 → handler 纯薄层无测试"的空白 |
-| **QA-P6 审计回归 H-1 (R5)** | `IncrementTokenVersionIfMatch` 原子 CAS: 命中递增、失配返 `ErrTokenVersionMismatch`、用户不存在返 `ErrNotFound`、并发仅 1 胜出且 DB 只 +1、成功后双路缓存一致 (TC-0802 ~ TC-0806);logic 层 6 并发仅 1 胜 + 5 × 401 "登录状态已失效" (TC-0812) | 彻底消除刷新令牌并发窗口下"两枚合法新 rt"造成的会话劫持;中断攻击者的静默接管路径 |
+| **QA-P6 审计回归 H-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-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-3 (R5)** | `BindRoles` 中 MEMBER 调用者不得把与自身同级的角色赋给他人;DB 状态不变 (TC-0813) | `GuardRoleLevelAssignable` 的 `>=` 护栏,封堵"自等升权"侧信道 |
 | **QA-P6 审计回归 H-4 (R5)** | `UpdateUser` 将 `deptId` 置 0 必须是 ADMIN / 超管;DEVELOPER/MEMBER 403 且 DB 不变;ADMIN/超管放行 (TC-0814 ~ TC-0817) | 防"把用户挪出部门树"从而变相脱离管理视野的低成本越权 |
 | **QA-P6 审计回归 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 审计回归 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-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-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) | 结构性防剥离 + 运行期验证双保险,杜绝"中间件被误删但接口看似正常"的静默回滚 |
 | **QA-P6 审计回归 M-B (R5)** | `/api/auth/refreshToken` 的 `rest.WithMiddlewares` 块静态包含 `serverCtx.RefreshTokenRateLimit`(源码正则断言);行为侧同 IP 不同端口第 2 次即 429 "过于频繁",不同 IP 不受影响 (TC-0832 / TC-0833) | 结构性防剥离 + 运行期验证双保险,杜绝"中间件被误删但接口看似正常"的静默回滚 |
 
 
 ### 3.3 发现的核心缺陷
 ### 3.3 发现的核心缺陷
@@ -1170,7 +1170,7 @@
 | 6 | L-4 checkPermLevel fail-close | 通用 DB 错误 500,ErrNotFound 仍 403 | TC-0819 / TC-0820 |
 | 6 | L-4 checkPermLevel fail-close | 通用 DB 错误 500,ErrNotFound 仍 403 | TC-0819 / TC-0820 |
 | 7 | M-3 UserDetailsLoader 负缓存 | sentinel 写入/不进索引/并发收敛 | TC-0821 ~ TC-0823 |
 | 7 | M-3 UserDetailsLoader 负缓存 | sentinel 写入/不进索引/并发收敛 | TC-0821 ~ TC-0823 |
 | 8 | M-5 CreateProduct 通用 1062 映射 | message 不含 "uk_code" 仍返 409 | TC-0827 |
 | 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 |
 | 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.*$` 形式每次执行, 避免新引入的崩溃路径静默遗漏.
 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` 行为的混沌测试.
 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 框架级中间件链。
 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` 行为的混沌测试。
+