Bladeren bron

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

BaiLuoYan 3 weken geleden
bovenliggende
commit
b355e6879b
49 gewijzigde bestanden met toevoegingen van 3011 en 899 verwijderingen
  1. 219 515
      audit-report.md
  2. 80 0
      internal/handler/fetchInitialCredentialsRouteWiring_audit_test.go
  3. 34 0
      internal/handler/product/fetchInitialCredentialsHandler.go
  4. 225 0
      internal/handler/product/fetchInitialCredentialsHandler_audit_test.go
  5. 5 0
      internal/handler/routes.go
  6. 137 54
      internal/loaders/userDetailsLoader.go
  7. 196 0
      internal/loaders/userDetailsLoader_contract_audit_test.go
  8. 8 4
      internal/loaders/userDetailsLoader_negativeCache_audit_test.go
  9. 4 3
      internal/loaders/userDetailsLoader_singleflight_audit_test.go
  10. 31 31
      internal/loaders/userDetailsLoader_test.go
  11. 61 3
      internal/logic/auth/access.go
  12. 260 0
      internal/logic/auth/checkAddMemberAccess_audit_test.go
  13. 262 0
      internal/logic/auth/guardRoleLevelAssignable_freshRead_audit_test.go
  14. 19 3
      internal/logic/auth/jwt.go
  15. 4 2
      internal/logic/auth/logoutLogic_test.go
  16. 194 0
      internal/logic/auth/parseWithHMAC_audit_test.go
  17. 2 2
      internal/logic/auth/userInfoLogic_test.go
  18. 9 2
      internal/logic/dept/deleteDeptLogic.go
  19. 18 0
      internal/logic/member/addMemberLogic.go
  20. 47 0
      internal/logic/member/auditFixes_test.go
  21. 49 3
      internal/logic/product/createProductLogic.go
  22. 28 4
      internal/logic/product/createProductLogic_test.go
  23. 65 0
      internal/logic/product/fetchInitialCredentialsLogic.go
  24. 344 0
      internal/logic/product/fetchInitialCredentialsLogic_audit_test.go
  25. 4 1
      internal/logic/pub/adminLoginLogic.go
  26. 4 1
      internal/logic/pub/loginService.go
  27. 7 1
      internal/logic/pub/refreshTokenLogic.go
  28. 11 3
      internal/logic/pub/refreshTokenLogic_test.go
  29. 17 0
      internal/logic/pub/syncPermsService.go
  30. 43 1
      internal/logic/user/bindRolesEqualLevel_audit_test.go
  31. 1 1
      internal/logic/user/bindRolesLogic.go
  32. 7 2
      internal/logic/user/bindRolesLogic_test.go
  33. 14 1
      internal/middleware/jwtauthMiddleware.go
  34. 24 21
      internal/model/perm/findMapByProductCodeWithTx_audit_test.go
  35. 0 14
      internal/model/perm/sysPermModel.go
  36. 21 8
      internal/model/perm/sysPermModel_test.go
  37. 0 35
      internal/model/productmember/sysProductMemberModel.go
  38. 4 114
      internal/model/productmember/sysProductMemberModel_test.go
  39. 4 2
      internal/model/role/sysRoleModel.go
  40. 31 6
      internal/model/user/sysUserModel.go
  41. 158 0
      internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go
  42. 4 2
      internal/model/userperm/sysUserPermModel.go
  43. 6 2
      internal/model/userrole/sysUserRoleModel.go
  44. 28 3
      internal/server/permserver.go
  45. 2 0
      internal/svc/servicecontext.go
  46. 16 2
      internal/types/types.go
  47. 22 3
      perm.api
  48. 129 0
      test-design.md
  49. 153 50
      test-report.md

+ 219 - 515
audit-report.md

@@ -1,566 +1,270 @@
-# 权限管理系统 - 深度代码审计报告(第 6 轮)
+# 权限管理系统 —— 深度代码审计报告(第 7 轮)
 
-> 审计范围:`/internal` 下全部非测试、非 `_gen.go` 生产代码(含 `internal/server/permserver.go`、HTTP logic / handler / middleware、loaders、model 定制层、svc、util)。
-> 审计时间:2026-04-19(第 5 轮修复复盘 + 深扫一轮新代码面)
-> 审计重点:
->   - **账号锁定 / 账号枚举**相关的侧信道 —— 第 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` 的显式校验
->
-> 相对第 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 级问题。
+> **审计范围**:`/internal` 下全部非测试、非 `_gen.go` 生产代码(含 `internal/server/permserver.go`、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。
+> **审计时间**:2026-04-19
+> **审计维度**:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。
+> **与上一轮对比**:第 6 轮的 H-1 / H-2 / H-3 / M-1 / M-2 / M-4 / M-5 / M-6 / M-8 / L-1 / L-3 / L-5 均已在 HEAD 代码中落地。本轮聚焦**第 6 轮未修复项(M-3 / M-7 / L-2 / L-4 / L-6)**和**这一轮深挖出来的新漏洞**,尤其是`UserDetailsLoader.loadPerms` 中 **deny-list fail-open**、`AddMemberLogic` 的目标侧授权缺失等高风险项。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-1. `AdminLogin` 限流 key 只含 `username`(不含 IP)——任意远端可**永久锁死任意超管账号**
-
-- **位置**:`internal/logic/pub/adminLoginLogic.go:41-46`
-- **描述**:
-
-  ```go
-  if l.svcCtx.UsernameLoginLimit != nil {
-      code, _ := l.svcCtx.UsernameLoginLimit.Take(req.Username)   // ← key 只有 username
-      if code == limit.OverQuota {
-          return nil, response.NewCodeError(429, "该账号登录尝试过于频繁,请5分钟后再试")
-      }
-  }
-  ```
-
-  `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、任何地理位置都无法登录。
-
-  **结构性放大点**(这才是真正让这条变 High 的地方):`CreateProductLogic.go:77` 里产品管理员账号的用户名是 `admin_<productCode>`,是**可从 `ProductList` 端点枚举出来的**(`ProductList` 不做任何访问控制,见 M-3):任何一枚普通登录账号都可以拉到 `code` 列表,立刻得到所有产品 admin 账号用户名。
-
-  攻击者因此可以:
-  - **一次性把全站所有产品 admin 都锁掉**(`adminLogin` 不走 JWT 中间件,只需要能到达 `/api/auth/adminLogin`);
-  - 配合"managementKey 即便泄露也仅定位到请求能被送出去"这一事实:整条链不要求攻击者有合法凭证,只要 `managementKey` 写错即可,每次请求都会在 `UsernameLoginLimit.Take(username)` 处计数(上面代码是先限流再做 managementKey 校验的顺序吗?——不是,managementKey 校验在第 37 行先做,失败直接 return,**不会走到 Take**)。
-  - 但即便 managementKey 正确,用错密码还是会计数。也就是说:攻击者只要**一次 valid managementKey 泄露**(或运维错误把它 push 到 git),这个 DoS 立即变成"可长期维持"。
-
+### H-1. `UserDetailsLoader.loadPerms` 在 **deny 列表查询失败时 fail-open**,且把"少了 deny"的权限集写入 5 分钟缓存 —— 单次 DB 抖动 → 用户越权
+- **位置**:`internal/loaders/userDetailsLoader.go:456-499`
+- **描述**:普通成员的权限集计算顺序是:
+  1. `FindPermIdsByUserIdAndEffectForProduct(allow)` —— 失败时 `return`,ud.Perms 保持 nil(fail-close,OK)。
+  2. `FindPermIdsByUserIdAndEffectForProduct(deny)` —— 失败时**只 log,然后继续往下跑**,`denyIds` 为 nil,`denySet` 为空。
+  3. 往 `permIdSet` 里塞 `rolePermIds + allowIds`,然后**直接把这个未经 deny 过滤的集合作为最终权限写回缓存**(缓存 TTL 5 分钟)。
+
+  `FindPermIdsByUserIdAndEffectForProduct` 走 `QueryRowsNoCacheCtx`,任何瞬时 DB 错误(连接池耗尽、slow query 触发 context deadline、主从漂移等)都会让 deny 查询失败。结果就是:
+  - 一个原本被 `effect=DENY` 显式撤销权限的用户,**立刻拿回这条被撤销的权限**;
+  - 并且这个"多出来的权限"被 `json.Marshal` 进 UD JSON 后写入 `ud:userId:productCode` Redis key,**持续 5 分钟**;
+  - 5 分钟内该用户所有请求(HTTP middleware / gRPC `GetUserPerms`)读取的都是这份"无 deny"权限集,**有多少次请求就有多少次越权**。
 - **影响**:
-  - 所有产品的 ADMIN 账号被远端**静默批量锁死**,业务侧显示的只是"登录过于频繁,请 5 分钟后再试",没有任何 IP 信号可被风控切入;
-  - 与 L-5(`ExtractClientIP` 在 `behindProxy=true` 时信任 `X-Real-IP`)叠加:攻击者发假 `X-Real-IP` 不会绕过 admin 限流(因为 key 根本没用 IP),反而会让产品登录的限流绕过——两条攻击面互补;
-  - 根本上违反了 OWASP ASVS 2.2.1"按用户限流必须包含 IP 或设备维度"。
-
-- **修复方案**:
-  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`
-- **描述**:
-  关键顺序:
+  - **任意 deny-revoke 授权操作在单次瞬时 DB 抖动下 5 分钟内失效**。考虑到 `setUserPermsLogic` 的主要用途就是"临时撤销某用户对敏感权限的访问",这类 deny 往往就是最后一道安全闸。闸被 silently 打开 5 分钟。
+  - 攻击者若能制造一次对 `sys_user_perm` 的短时读失败(例如对该表发起 hot-row 争抢使 `denyIds` 的查询 timeout),即可让目标用户的 deny 被旁路。
+  - 与 R6 H-3 / H-4 不同,这是一条纯代码路径问题,不依赖配置、不依赖代理头,**出现概率等于 DB 抖动概率**。
+- **修复方案**:把两次查询在错误语义上**对称**处理:
   ```go
-  u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)  // L50
+  denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
   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
-  }
-  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
+      logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
+      return // fail-close:宁可让用户看到 0 perms 让他们刷新,也不能把 deny 旁路
   }
   ```
-  第一个分支(用户名不存在)**有意**做了 dummy bcrypt 等时。但第二个分支(账号被冻结)**直接 return,跳过了 bcrypt**。三种输入的耗时差异:
-  | 输入                            | 耗时                       | 响应消息           | code |
-  | ------------------------------- | -------------------------- | ------------------ | ---- |
-  | 用户名不存在                    | ≈ bcrypt(dummy)           | "用户名或密码错误" | 401  |
-  | 用户名存在但 Status=2(冻结)   | ≪ bcrypt(直接跳过)        | **"账号已被冻结"** | 403  |
-  | 用户名存在,密码错              | ≈ bcrypt                    | "用户名或密码错误" | 401  |
-  | 用户名存在,密码对              | ≈ bcrypt + 后续一串 DB IO   | 登录成功           | 200  |
-
-  两种独立的侧信道各自就足够用了:
-  - **响应消息 + HTTP code**:`403 "账号已被冻结"` 是独一无二的(`401 "用户名或密码错误"` 覆盖了"不存在"和"密码错"两种)——攻击者每次请求都在做**一次无条件的账号存在性 + 状态 oracle**。
-
-  实际攻击路径:
-  1. 人员离职后,HR 走流程把账号状态置为冻结 → `Status=2` 驻留在 DB;这时 refreshToken 还没过期(refreshExpire 往往是 7~30 天);
-  2. 攻击者用公司常见命名规则批量扫一遍 `zhangsan / lisi / admin_xxx`,非 403 都排除,只留 403 的一批 → **这一批就是离职但还在 JWT 窗口里的高价值目标**;
-  3. 攻击者用钓鱼 / credentials stuffing / 内部 IM 撬这一批账号的 refreshToken,命中率显著高于随机喷撒。
-
-  同样的 pattern 也出现在 `adminLoginLogic.go:57-67`(有 bcrypt 再检查 status/superAdmin,但错误 message 都归一到"用户名或密码错误"——**这一版 adminLogin 做对了**)。证明开发团队知道这个 pattern,但 product login 这条没同步修。
+  同时**本次加载不要写缓存**,交给下一次 Load 重试,或者把失败信号往上传(见 M-1)。
 
+### H-2. `UserDetailLogic` / `UserListLogic` 仍把 `Email` / `Phone` / `Remark` 暴露给**任意同产品成员**(R6 M-3 未落地)
+- **位置**:
+  - `internal/logic/user/userDetailLogic.go:68-70`
+  - `internal/logic/user/userListLogic.go:81-83`
+- **描述**:两接口的访问控制是"同产品 → 返回完整资料"。`UserDetail` 仅用 `FindOneByProductCodeUserId` 检查目标与调用方在同一产品内,就返回 `Email`、`Phone`、`Remark`(纯文本,无脱敏)。`UserList` 更严重 —— 一次分页可以批量拿到同产品所有成员的手机号与邮箱。
 - **影响**:
-  - 账号存在性 / 冻结态**单次请求可探测**。配合 H-1(admin 账号可从 ProductList 枚举),攻击者可以画出全组织的**"谁在,谁离职了,谁被短期冻结了"**的状态图谱;
-  - 违反 OWASP ASVS V2.1.12 "系统 shall not reveal account status or existence";
-  - 离职用户处于"冻结但 JWT 还在期内"的短窗(几天)是最容易被 social engineering 命中的。
-
+  - **同产品最低权限 MEMBER 即可遍历整个产品通讯录**,获取手机 / 邮箱 / 备注(备注里可能含 PII / 内部身份)。
+  - 与 H-3(`AddMember` 不做目标侧授权)组合后威力更强:一个产品 ADMIN 可先把想看的任意人(包括其部门树外、不归自己管的用户)强行拉入自己的产品,再通过 `UserDetail` 或 `UserList` 抽走其 PII。
+  - 严重违反 GDPR / 《个人信息保护法》最小必要原则。
 - **修复方案**:
-  把 bcrypt 变成**无条件总是执行**,状态 / superAdmin 禁令走**登录成功之后的统一检查**,并且**所有用户侧可见的失败消息归一**为"用户名或密码错误":
+  1. 新增 `authHelper.CanViewContact(caller *UserDetails, target *SysUser, targetMember) bool`,只在以下任一条件时返回 `true`:
+     - caller.IsSuperAdmin;
+     - target.UserId == caller.UserId(看自己);
+     - caller 对 target 的 `CheckManageAccess` 通过(即 caller 在管理链上)。
+  2. 两个 Logic 返回 DTO 前统一走 `filterPIIForCaller`,其余情况把 `Email / Phone / Remark` 置空或做掩码(如 `138****1234`)。
+  3. 单元测试覆盖"同产品同级成员互看"、"跨部门互看"、"ADMIN 看下级"、"看自己"四条。
+
+### H-3. `AddMemberLogic` 缺失**目标侧 `CheckManageAccess` + 超管防御**,产品 ADMIN 可把部门树外 / 超管用户拉入自己产品
+- **位置**:`internal/logic/member/addMemberLogic.go:41-75`
+- **描述**:`AddMember` 仅做了三件事:
+  - `RequireProductAdminFor(req.ProductCode)`:caller 是该产品 ADMIN 或超管。
+  - `CheckMemberTypeAssignment(req.MemberType)`:caller 允许分配这种 MemberType。
+  - `FindOneByProductCodeUserId` 排查重复加入。
+
+  缺失的两道防线:
+  1. **对 `req.UserId` 目标的 `CheckManageAccess`**:没有任何基于 `DeptPath` 的部门链校验,也没有任何基于 `MinPermsLevel` 的权限级校验。产品 ADMIN 可以把**自己部门树之外的用户**(例如 HR 部、财务部员工)强行拉入自己的产品。
+  2. **对 `targetUser.IsSuperAdmin` 的显式拒绝**:如果系统中存在"超管但未主动加入任何产品"的账号,产品 ADMIN 可通过 `AddMember` 把这个超管拉入自己产品成为 MEMBER。虽然 `loadMembership` 会在 `ud.IsSuperAdmin == true` 时把 `MemberType` 固定为 `SuperAdmin`(实际权限没被限制),但这在审计日志里会留下一条"product_admin 把 super_admin 纳入自己产品"的假成员关系,**为后续权限推理工具 / 审计系统制造混淆**,也是第一步社工放大点。
+- **影响**:
+  - 与 H-2(PII 暴露)组合:ADMIN 随意从部门树外拉人入产品,然后读全员手机邮箱。**这是实际上最容易被滥用的越权路径**。
+  - 与 `UpdateMemberLogic` 组合:拉入后还可以赋予任何允许的 `MemberType`,制造跨部门的管理权扩张。
+- **修复方案**:`RequireProductAdminFor` / `CheckMemberTypeAssignment` 之后、`Insert` 之前,追加如下两段:
   ```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 targetUser.IsSuperAdmin == consts.IsSuperAdminYes {
+      return nil, response.ErrForbidden("无法将超级管理员加入具体产品")
   }
-  if u.Status != consts.StatusEnabled {
-      // 密码正确的冻结用户,才提示冻结(此时攻击者已经猜中密码,再保留"冻结"已无意义)
-      return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
-  }
-  if u.IsSuperAdmin == consts.IsSuperAdminYes {
-      return nil, &LoginError{Code: 403, Message: "超级管理员请使用管理后台登录"}
+  if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, req.ProductCode,
+      authHelper.WithPrefetchedTarget(targetUser)); err != nil {
+      return nil, err
   }
   ```
-  效果:**账号不存在 / 冻结 / 密码错**三者对外完全不可区分——响应耗时一致、消息一致、code 一致。只有密码正确后才"奖励性"地暴露后续语义
+  注意:`CheckManageAccess` 内部对 "caller 是 ADMIN" 会短路 `checkDeptHierarchy`,所以这不会让产品 ADMIN 失去管理自己下属 / 旁部门用户的能力,但会拦住"部门树外 + 不归自己管"这类真正的越权路径。
 
----
-
-### H-3. `SyncPermsService` 并发 1062 修复**只落地一半**:`LockByCodeTx` 已实现但没在 service 里调用
-
-- **位置**:`internal/logic/pub/syncPermsService.go:66-120`、`internal/model/product/sysProductModel.go:56-63`
-- **描述**:
-  第 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)。
-
-  代码里留了一段自认的注释:
-  ```go
-  // NOTE(R5-M-6):理想方案是"同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行…";
-  // 但当前 mock 契约(syncPermsLogic_mock_test.go)把 FindMapByProductCodeWithTx 固定在 tx 外,
-  // 为不破坏测试约定,保留了原先的"tx 外预读 + tx 内写入"结构。
-  ```
-  问题:
-  1. 被测试约束反向拽住 = 架构决策被测试约束倒挂。正确做法是修测试 mock,让生产代码符合正确语义,而不是反过来;
-  2. "1062 → 409" 的兜底只是缓解症状:调用方看到 409 要重试,极端情况两个并发同步**同时在重试**仍可能继续撞,形成活锁;
-  3. 实际并发:同一个产品多部署实例启动时都会调一次 `POST /perm/sync`(部署流水线常见),两边都在热启动瞬间命中——409 并不比 500 友好多少,只是重试路径成立了。
-
-- **影响**:
-  - 同步接口在部署时**概率性失败**,客户端必须自己做重试(而且当前 `SyncPermsError` 结构只给 Code/Message,没有 Retry-After 提示);
-  - 上一轮的修复已经把基础设施准备好了(`LockByCodeTx` 是用代码+测试覆盖过的),**缺的只是最后接上**——这是一条"几十行代码 + 改 mock"可以直接落地的事情,风险收益比极高。
-
-- **修复方案**:
-  把 mock 测试里的 `FindMapByProductCode` 预期调用改为 `FindMapByProductCodeWithTx`,然后把 service 改为:
+### H-4. JWT 解析三处 `keyfunc` **未显式断言 `*jwt.SigningMethodHMAC`**(R6 M-7 未落地)
+- **位置**:
+  - `internal/middleware/jwtauthMiddleware.go:59-61`(HTTP access token)
+  - `internal/server/permserver.go:242-244`(gRPC access token)
+  - `internal/logic/auth/jwt.go:78-80`(refresh token)
+- **描述**:三处都是直接 `return []byte(secret), nil`,不检查 `token.Method` 类型。当前使用 `jwt/v4` 且 `accessSecret` / `refreshSecret` 都是对称密钥,不受 `alg=none` 攻击,但这是**深度防御盲区**:
+  - 如果未来迁移到 RSA/ECDSA 非对称密钥,而 `keyfunc` 仍然把 `[]byte` 塞进去,攻击者可以用**把公钥当 HMAC 密钥**的经典手法伪造 token —— 这在 jwt-go 历史上是实打实出过 CVE 的(CVE-2016-10555 同类问题)。
+  - 即使密钥不换,线上一旦因为误配生成出 `alg=HS512` 的 token,也不会被明确拒绝,而是当作 HS256 尝试解析,带来噪音和潜在被动兼容。
+- **影响**:当前配置下不构成直接漏洞,但违反 OWASP JWT Cheat Sheet、RFC 8725(JWT Best Current Practice)对 `alg` 白名单的强制要求。
+- **修复方案**:抽出一个通用 helper,三处统一调用:
   ```go
-  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: "产品不存在"}
+  // internal/logic/auth/jwt.go
+  func parseWithHMAC(tokenStr, secret string, claims jwt.Claims) (*jwt.Token, error) {
+      return jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
+          if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+              return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
           }
-          return err
-      }
-      // 2. tx 内读 existing
-      existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code)
-      if err != nil { return err }
-      // 3. tx 内写(按原逻辑分 insert / update / disable)
-      ...
-  })
+          return []byte(secret), nil
+      })
+  }
   ```
-  当 `LockByCodeTx` 把同一 product 的并发同步串行化后,`ON DUPLICATE KEY UPDATE` / 1062 兜底都可以不再依赖。
 
 ---
 
-## ⚠️ 健壮性与性能建议 (Medium/Low)
-
-### M-1. `UpdateDeptLogic` 的 Clean 循环**仍未批处理**(第 5 轮 M-2 未落地)
+## ⚠️ 健壮性与性能建议 (Medium / Low)
 
-- **位置**:`internal/logic/dept/updateDeptLogic.go:86-94`
+### M-1. `UserDetailsLoader.Load` 把 "DB 瞬时故障" 同化为 "用户不存在",副作用是**把半成品 UD 写入 5 分钟缓存**
+- **位置**:`internal/loaders/userDetailsLoader.go:119-165, 284-304`
 - **描述**:
-  ```go
-  if deptTypeChanged || statusChanged {
-      userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
-      for _, uid := range userIds {
-          l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
-      }
-      ...
-  }
-  ```
-  每次 `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。
-
+  - `loadFromDB` 在 `loadUser` 失败(非 NotFound)时返回 `ud, err`;在 `Load` 的 singleflight 闭包里转化为 `(nil, err)`。
+  - 但是 `Load` 最后的 `if !ok || ud == nil` 分支会**构造一个空 UD 返回**。HTTP `jwtauthMiddleware` 看到 `ud.Username == ""` 就直接 401 "用户不存在或已被删除"。
+  - 用户体验:**一次 DB 抖动 → 全站在线用户被踢出,客户端清 token 重新登录 → 登录又打 DB → 进一步加重 DB → 雪崩**。
+  - 更隐蔽的是:`loadDept / loadProduct / loadMembership / loadRoles / loadPerms` 这五个子步骤里的**任何错误都是 log + 静默继续**。然后 `Load` 在 singleflight 成功分支里照常 `json.Marshal(ud)` 并 `SetexCtx` 写入缓存。于是当 dept 表抖动时,用户的 `DeptPath / DeptType` 变空白,`checkDeptHierarchy` 直接 403(除非 caller 是 ADMIN/超管),这份"半残" UD 还会被缓存 5 分钟。
+- **影响**:
+  - 雪崩风险:单次 DB 抖动 → 全站强制退出登录,客户端反复重试。
+  - 半加载缓存污染:用户在 5 分钟内会遇到"莫名其妙的 403",且运维看监控是绿的(因为错误被 log 吞了)。
+  - 与 H-1 叠加:`loadPerms` 的 deny 失败同样落入这个"半加载也写缓存"的通道。
 - **建议**:
-  1. 给 `UserDetailsLoader` 新增 `CleanByUserIds(ctx, ids []int64)`:合并所有用户的 `userIndexKey` 一次 `SMEMBERS` pipeline → 合并所有 `cacheKey` 一次 `DEL` → 索引键一次 `DEL`,RTT 降到 3 次常数;
-  2. 如果仍想保留 `Clean` 单用户语义,在 `UpdateDeptLogic` 里改成一次 batch:
-     ```go
-     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)
-         }
-     }
-     ```
-  3. 顺手把 `_, _ = FindIdsByDeptId(...)` 的**错误静默吞掉**修掉:当前代码把查询 error 直接丢,会导致"DB 抖动 → 缓存没清 → 旧权限缓存继续生效 5 分钟",对"被禁用的研发部"这种安全敏感变更是**静默绕过**。
-
----
-
-### M-2. `ProductList` / `ProductDetail` / `DeptTree` **对任意登录用户暴露全公司清单**
+  1. `Load` 返回 `(*UserDetails, error)`,让中间件自己区分 "NotFound → 401 用户不存在" 与 "其他错误 → 503 服务暂时不可用"。
+  2. `loadFromDB` 里任何子步骤出错,都**不要写缓存**(让下次 Load 重试)。
+  3. 如果还是想保留无 error 返回,至少在 ud 上加一个 `PartiallyLoaded bool` 字段,Load 写缓存前检查这一位。
 
+### M-2. `SysUserModel.UpdatePassword` / `UpdateStatus` **不校验 `RowsAffected`**,对已删除 / 条件不满足的用户会静默成功
 - **位置**:
-  - `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 根本无权管的兄弟部门和叔辈部门;
-
-  这几个泄露叠加起来就是 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/model/user/sysUserModel.go:128-140`(`UpdatePassword`)
+  - `internal/model/user/sysUserModel.go:143-155`(`UpdateStatus`)
+- **描述**:两处都是 `m.ExecCtx(..., conn.ExecCtx(...))` 然后直接 `return err`,**不读 `sql.Result.RowsAffected`**。如果 `FindOne` 到 `ExecCtx` 之间用户被另一会话删除,或者主从延迟导致 `WHERE id = ?` 命中 0 行,调用方拿到的是 nil err,以为"更新成功"——实际 DB 里没有变化,但 `DelCacheCtx(sysUserIdKey, sysUserUsernameKey)` 已经执行了。
+- **影响**:
+  - `ChangePassword` 对已删除用户会返回 200 "成功",客户端以为改密成功,用户下次登录发现密码没变。
+  - `UpdateUserStatus` 对跨进程并发删除的用户会"假装成功",上层以为冻结生效。
+  - 更重要的:`UpdatePassword` / `UpdateStatus` 自身没有乐观锁(`UpdateProfile` 有 `updateTime` CAS),两次并发 ChangePassword 会"后写覆盖先写"且都返回成功,用户拿到的新密码是无法预测的那一份(在已知旧密码共谋场景下影响低)。
 - **建议**:
-  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
-  if !caller.IsSuperAdmin {
-      if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
-          return nil, response.ErrForbidden("无权查看非本产品成员的用户信息")
-      }
+  res, err := m.ExecCtx(...)
+  if err != nil { return err }
+  if n, _ := res.RowsAffected(); n == 0 {
+      return ErrNotFound // 或自定义 ErrUpdateConflict
   }
-  ...
-  return &types.UserItem{
-      ...
-      Email:      user.Email,
-      Phone:      user.Phone,
-      ...
-  }, nil
+  return nil
   ```
-  判定规则是"同产品成员即可查看"——一个产品里最低权限 MEMBER 可以遍历同产品所有用户(userId 范围内 fuzz)的手机 / 邮箱。同产品有几百上千成员时,这等同于**暴露公司通讯录 PII**。
-  这里没有"**调用者对目标有管理权**"或"**看自己**"的更细粒度条款。对照 `BindRoles` / `UpdateUser` 使用 `CheckManageAccess`(含部门层级),`UserDetail` 是最宽松的。
-
+  另外建议对 `UpdatePassword` 加上 `AND updateTime = ?` 的乐观锁,语义上与 `UpdateProfile` 对齐,避免并发改密"最后一写赢"的隐式行为。
+
+### M-3. `GuardRoleLevelAssignable` 的授权依据是 caller 的**缓存 `MinPermsLevel`**,被降级的调用者在 5 分钟缓存 TTL 内仍可分配原等级角色
+- **位置**:`internal/logic/auth/access.go`(`GuardRoleLevelAssignable`);调用方 `internal/logic/user/bindRolesLogic.go`。
+- **描述**:`BindRoles` 的授权判断是"caller 的 `MinPermsLevel` 必须**严格小于**被分配角色的 `PermsLevel`",而 `caller` 是从 `UserDetailsLoader.Load(callerUserId, productCode)` 取的,缓存 TTL 5 分钟。攻击窗口:
+  1. T=0:超管把 caller C 从"总监级角色 (permsLevel=10)"降到"普通员工 (permsLevel=500)"。超管调用 `BindRoles` 改 C 的角色(`Clean(C.UserId)`)。
+  2. T=0+δ:C 自己在其他机器上调用 `BindRoles` 给下属 X 分配"总监级角色 (permsLevel=10)"。
+  3. C 的 JWT token 里没有 `MinPermsLevel`,它要靠 UD 缓存。如果 C 的 UD 缓存在 T=0 被 Clean,第二次读会打 DB 拿到新级别 → 授权失败。**但只要 Clean 因为 Redis 抖动失败了一次**,C 的 UD 缓存还在,`GuardRoleLevelAssignable` 读到 10,判定"严格小于 10 即可"不通过,判定 `10 >= 10` → 授权失败(这条实际 OK)。
+  4. 真正的问题:如果 C 的角色从 10 降到 20(不是 500),C 要分配 15 级角色:`caller.MinPermsLevel(缓存=10) >= 15` 不成立 → 允许。实际 DB 里 C 是 20,`20 >= 15` 成立 → 应该拒绝。**C 用缓存越权分配了自己现在够不到的角色**。
+  5. 5 分钟窗口足够 C 把一整组下属 bulk 升到 15 级。
+- **影响**:
+  - 典型的"TOCTOU + 缓存失效延迟"叠加漏洞。Clean 失败只被 log,没有重试,也没有降级成"缓存读写直通 DB"的 fallback。
+  - 触发条件依赖"超管降级某 admin"的时序,实际被动利用概率低,但**主动制造**(admin 预见自己要被降级,抢 5 分钟窗口)可能性不能排除。
 - **建议**:
-  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-4. `BindRolePermsLogic` / `UpdateRoleLogic` 在**写成功后的缓存清理失败**时返回 500,客户端会把成功的写误判为失败并重试
-
-- **位置**:
-  - `internal/logic/role/bindRolePermsLogic.go:128-134`
-  - `internal/logic/role/updateRoleLogic.go:79-85`
-- **描述**:
-  ```go
-  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)
-  ```
-  出问题的细节在三个层面:
-  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. `GuardRoleLevelAssignable` 里,对 caller 的 `MinPermsLevel` 额外做一次"旁路缓存直查 DB"校验(只在这条授权点,不影响其他用 UD 缓存的路径)。
+  2. 或者 `Clean` 走 Redis pipeline + 一次 Lua script 保证原子,失败时 retry 2~3 次,失败后把 userId 入一个短 ttl 的降级黑名单,命中就强制走 DB。
+  3. `UserDetailsLoader` 加个 `LoadFresh(ctx, userId, productCode)` 方法专供授权点使用,bypass cache。
+
+### M-4. `CreateProductLogic` **响应体里明文返回初始 admin 密码**,穿过任何响应日志 / 监控都会落盘
+- **位置**:`internal/logic/product/createProductLogic.go`(返回 `types.CreateProductResp.AdminPassword` 明文)、`internal/types/types.go:47-54`
+- **描述**:`CreateProductResp` 包含 `AdminPassword string`,`go-zero` 默认响应序列化走 httpx,**响应体默认不自动打日志**,但在以下三个常见运维情况下会落盘:
+  - API 网关 / Nginx access log 关掉了 body redaction;
+  - APM / OpenTelemetry 开了 "response body" 采样;
+  - 前端 console 或者集成测试截图留在了代码仓库 / 工单系统。
+- **影响**:密码泄漏;`IsSuperAdmin` 产品默认密码一旦落到长期存储就需要紧急全量改密。
 - **建议**:
-  1. 把这两处改成 "事务成功即视为成功;缓存刷新失败仅 log + 异步重试":
-     ```go
-     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
-     ```
-  2. 真的要让客户端知道 "权限变更成功但部分缓存未刷新",应返回 200 + resp 里带 `cacheRefreshStatus: "degraded"`,而不是 500。
-
----
-
-### M-5. `UpdateUserStatusLogic` / `BindRolesLogic` / `UpdateUserLogic` 的**同请求重复 `FindOne(targetUserId)`**(缓存命中但仍有 Redis 往返)
-
-- **位置**:
-  - `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)`)
-- **描述**:
-  以 `UpdateUserStatus` 为例:
-  ```
-  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. 响应里把 `AdminPassword` 字段标记为 "**一次性展示**",并在文档里强制要求立刻改密。
+  2. 更稳的方案:响应只返回 `adminUser`,密码随后走**带 nonce 的一次性链接**(Redis 中存 5 分钟、一次消费后删除),新产品 owner 登录后自己重置。
+  3. 至少在 `response.Middleware` 中把 `AdminPassword` 字段加入日志脱敏白名单。
+
+### L-1. `DeleteDeptLogic` 事务内多段 `FOR UPDATE` 锁序列仍保留 AB-BA 死锁理论风险(R6 L-2 未消化)
+- **位置**:`internal/logic/dept/deleteDeptLogic.go`
+- **描述**:一个事务内依次 `FOR UPDATE` 了 `sys_dept(row)` → `sys_dept(children range)` → `sys_user(dept range)`。如果另一事务(比如 `UpdateUser` 要改 `DeptId`,同时 `CreateDept` 插一个子部门)以不同顺序抢锁,理论上存在 AB-BA 交叉死锁。实际频率低,但 MySQL 死锁 retry 会被 go-zero 上浮成 500。
+- **建议**:全局统一 "先锁部门再锁用户"、"先锁父部门再锁子部门" 的顺序协议,并在 `UpdateUserLogic` 涉及 `DeptId` 变更的时候显式 `SELECT ... FOR SHARE` 一下新旧部门。或把 `DeleteDept` 中排查子部门 / 关联用户的 `FOR UPDATE` 降成 `FOR SHARE`(因为这里是存在性判断而不是修改)。
+
+### L-2. `SysUserModel.IncrementTokenVersion` 仍是"无条件大杀器",缺安全注释 / 调用点约束(R6 L-4 未消化)
+- **位置**:`internal/model/user/sysUserModel.go`(`IncrementTokenVersion`),调用方 `internal/logic/auth/logoutLogic.go:46`。
+- **描述**:`RefreshToken` 已经切到 `IncrementTokenVersionIfMatch`(CAS 语义正确),`Logout` 还在用老的 `IncrementTokenVersion`(业务语义正确,"强制所有会话失效")。风险是这个 API 现在对整个仓库可见,**未来任何改 Refresh / Rotate 场景的开发者都可能误调它**,退回到 R5 以前的会话劫持窗口。
 - **建议**:
-  1. 在 `CheckManageAccess` 签名里加一个可选 `prefetchedTarget *user.SysUser`:调用方已经有目标用户对象时,直接传进去,`checkDeptHierarchy` 复用;否则再查:
-     ```go
-     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)
-         }
-         ...
-     }
-     ```
-  2. 更激进:在 handler 最外层或 middleware 里做**请求级 cache**(`context.WithValue` 一个小 map),`FindOne`/`FindOneByProductCodeUserId` 走这层再透传。这对所有类似 `UpdateUser + Check*` 的组合都直接受益。
-
----
+  1. 给 `IncrementTokenVersion` 加个 `// WARN: 仅限强制全量失效(Logout / 封禁)。Refresh/Rotate 必须使用 IncrementTokenVersionIfMatch。` 的显式 header 注释。
+  2. 最干净的做法:把 `IncrementTokenVersion` 改成 package-private,再在 `logout` 所在 package 用**显式命名的 wrapper**(`ForceRevokeAllSessions`)暴露,新接入者一眼看到红色标签。
 
-### M-6. `ExtractClientIP` 在 `behindProxy=true` 时**只信任 `X-Real-IP`**,没 `X-Forwarded-For` fallback;且**未设头时回落到 proxy 的 RemoteAddr → 全站共享一个桶**
-
-- **位置**:`internal/middleware/ratelimitMiddleware.go:54-65`
+### L-3. `loadPerms` 其他分支的错误同样被静默 log(`rolePermIds` / `allowIds` / `FindAllCodesByProductCode`),和 H-1 同宗
+- **位置**:`internal/loaders/userDetailsLoader.go:435-498`
 - **描述**:
-  ```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 直接放大。
+  - L450-453:`FindPermIdsByRoleIds` 失败 → `rolePermIds` 保持空。若此时 role 权限正常、但查询临时失败,用户的"角色→权限"整块就被丢掉。
+  - L435-437:`FindAllCodesByProductCode` 失败 → `ud.Perms = nil`(对 ADMIN / DEV 部门这类"全量权限"角色来说直接降成 0 perm)。
+  - L487-498:`FindByIds` 失败 → `ud.Perms = nil`。
 
-- **建议**:
-  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-7. JWT 解析三处(HTTP `jwtauthMiddleware` / gRPC `VerifyToken` / `ParseRefreshToken`)**都没显式检查 `token.Method`**
+  所有这些都会被 `Load` 写入 5 分钟缓存。对 ADMIN 来说是"5 分钟内所有权限消失",对普通成员来说是"5 分钟内权限表不一致"。用户体感就是间歇性 403,定位困难。
+- **建议**:与 M-1 同步修复:loadPerms 内任一子步骤返回 error,**整次 Load 跳过缓存写入**,同时把 error 传给 `Load`,由上层决定是 503 还是 401。
 
+### L-4. Model 层 SQL 中的 `status = 1` 硬编码,与 `consts.StatusEnabled` 脱钩
 - **位置**:
-  - `internal/middleware/jwtauthMiddleware.go:59-61`
-  - `internal/server/permserver.go:242-244`
-  - `internal/logic/auth/jwt.go:78-80`
-- **描述**:
-  三处 `keyfunc` 直接返回 `[]byte(secret)`,没有做:
-  ```go
-  if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
-      return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
-  }
-  ```
-  `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-8. `IncrementTokenVersion` / `IncrementTokenVersionIfMatch` 都**先 `FindOne` 一次**只为拿 `username` 构造 cache key
-
-- **位置**:`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
-  }
-  ```
-  每次 `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 空间),所以下一次请求还是会再查一次。
-
-- **建议**:
-  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`,直接透传即可。
-
----
-
-### L-1. `SyncPermsService` 的"如需禁用所有权限请使用专用接口"是**文案指向幽灵端点**
-
-- **位置**:`internal/logic/pub/syncPermsService.go:49-51`
-- **描述**:
-  ```go
-  if len(perms) == 0 {
-      return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
-  }
-  ```
-  然而 `perm.api` / `routes.go` / gRPC `PermService` 里**并没有"禁用所有权限的专用接口"**。这行错误消息是历史设计遗留——要么这个接口被砍了,要么从来没实现过。接入方看到 400 文案会去找"专用接口",浪费排查时间。
-- **建议**:把文案改成客观事实"权限列表不能为空",并把"禁用所有权限"这条产品需求**显式 TODO 或删除**(真要支持,需要一个独立授权模型,不能走 appKey 就把产品的所有权限抹了——这本身是个有争议的能力)。
-
----
-
-### L-2. `DeleteDeptLogic` 的多次 `FOR UPDATE` 顺序**可能死锁**(真实可能性较低)
+  - `internal/model/userrole/sysUserRoleModel.go:51` —— `r.status = 1`
+  - `internal/model/userperm/sysUserPermModel.go:35` —— `p.status = 1`
+  - `internal/model/role/sysRoleModel.go`(`FindMinPermsLevelByUserIdAndProductCode`)—— `r.status = 1`
+- **描述**:`consts.StatusEnabled` 当前定义为 1,但三处 SQL 把它写死。一旦运维 / 迁移脚本把 `StatusEnabled` 的语义改掉,或者业务加出 "status=2 已归档" 之类的新状态,这几条查询会默默返回错误数据集,没有编译期 / 单元测试期信号。
+- **建议**:改成 `... AND r.status = ?` + 参数传 `consts.StatusEnabled`,与其他同类查询(如 `sysProductMemberModel.CountOtherActiveAdminsTx`)风格一致。
 
-- **位置**:`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" 同时——这种场景通常不会主动并发;
-  但审计视角上仍是一个"范围锁 → 可阻塞后续"的定时炸弹。
+### L-5. `internal/model/productmember/sysProductMemberModel.go` 中两个僵尸接口方法
+- **位置**:
+  - `FindMapByProductCodeUserIds`(定义在接口和实现中)
+  - `CountActiveAdmins`(非事务版)
+- **描述**:`rg` 扫下来这两个方法仅在 `testutil/mocks/mock_productmember_model.go` 与 `sysProductMemberModel_test.go` 中被引用,**整个 `/internal/logic/**` 没有一处调用**。实现里还包含手写 SQL,是维护负担与误用风险源。
+  同样地,`internal/model/perm/sysPermModel.go` 中的 `FindMapByProductCode`(非事务版)也仅在 mock 与 test 中出现,`syncPermsService` 已切到 `FindMapByProductCodeWithTx`。
+- **建议**:确认无残留调用后从接口 / 实现 / mock 里移除,避免接口 surface area 膨胀。
+
+### L-6. gRPC `GetUserPerms` 可被有效 AppKey/AppSecret 拥有者用作**负缓存预污染**工具
+- **位置**:`internal/server/permserver.go`(`GetUserPerms`);结合 `internal/loaders/userDetailsLoader.go:134-154` 的负缓存写入路径。
+- **描述**:`GetUserPerms` 接受任意 `req.UserId`,内部会调用 `UserDetailsLoader.Load`。若攻击者拥有有效的产品凭证(如被泄漏的 `appKey`/`appSecret`),可批量请求未来将分配的自增 ID(`userId = maxUserId+1 ... maxUserId+N`):
+  - 每个未命中查询会落一条 `negativeCacheMarker`(TTL 30s);
+  - 当一个新用户在这 30s 内被 `CreateUser` 自增到这个 ID,他的 UD 缓存键已被占用为负缓存;
+  - 新用户自身一旦被 `Load`,直接命中 `_NOT_FOUND_`,`Username` 返回空,**JWT middleware 判定"用户已被删除"**,登录 / 使用失败。
+- **影响**:条件依赖 AppKey 泄漏 + CreateUser 时机,概率低,但这是唯一一条"外部可写负缓存"的通道。
 - **建议**:
-  1. 确保锁顺序在所有涉及 `sys_dept + sys_user` 的事务里一致(总是先锁 dept,再锁 user);
-  2. `DeleteDept` 内可以把 `sys_user WHERE deptId = ? FOR UPDATE` 换成不加锁的 `SELECT ... LOCK IN SHARE MODE` + 事务隔离级 REPEATABLE READ(反正只是存在性判定),降低阻塞面。
-
----
+  1. `Load` 在写入负缓存之前,再通过 `SysUserModel.FindOne(ctx, userId)` 强一致校验一次(绕过 cache),确认真 NotFound 才写哨兵。
+  2. 或者在 `CreateUserLogic` 成功插入之后主动 `Del` 掉 `ud:newId:*` 的负缓存键(需要遍历产品维度,因此成本较高)。
+  3. 最简:`negativeCacheTTL` 从 30 → 10,并加一条 `svc` 级的"新用户创建后 30s 内绕过负缓存"的白名单(按 userId > `watermark` 判定)。
 
-### L-3. `UserDetailsLoader.registerCacheKey` **每次都做 4 次 Redis 单独 RTT**(SAdd + Expire + SAdd + Expire)
-
-- **位置**:`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-4. `Logout` 仍用**无条件 `IncrementTokenVersion`**(非 CAS)
-
-- **位置**:`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 版本)。
+### L-7. `CheckManageAccess` 对 caller `DeptId=0` 且非 ADMIN 的历史账号直接 403(R6 L-6 未消化)
+- **位置**:`internal/logic/auth/access.go`(`checkDeptHierarchy`)
+- **描述**:H-4(R6 时已修复)之后新建 MEMBER/DEVELOPER 不再是 `DeptId=0`,但**存量数据中有遗留**的 `DeptId=0` MEMBER 账号。这类账号即便通过 `checkPermLevel` 校验也会因为 `caller.DeptPath == ""` 在 `checkDeptHierarchy` 被直接 403。
 - **建议**:
-  1. 不强制替换 `Logout`(语义正确),但**把 `IncrementTokenVersion` 加上显式的安全注释**:"仅限业务语义为'强制全量失效'的场景(Logout / 封禁账号),**禁止**在 Refresh/Rotation 场景使用——Refresh 必须走 `IncrementTokenVersionIfMatch`"。
-  2. 更激进:用 golang build tag / linter 限制 `IncrementTokenVersion` 的调用方范围(仅限 `auth/logoutLogic.go` + 未来的封禁接口)。
-
----
-
-### L-5. `removeMemberLogic`:移除 ADMIN 前的 `CountActiveAdminsTx` **与目标成员自己的 state 判定耦合**
-
-- **位置**:`internal/logic/member/removeMemberLogic.go:45-54`
-- **描述**:
-  ```go
-  if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
-      adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
-      ...
-      if adminCount <= 1 { return "不能移除最后一个管理员" }
-  }
-  ```
-  `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` 的同名调用同样受益。
+  1. 运维侧一次性迁移 `UPDATE sys_user SET deptId = <default_dept_id> WHERE deptId = 0 AND isSuperAdmin = 0 AND memberType NOT IN ('ADMIN')`。
+  2. 代码侧把"看自己"场景短路,在 `CheckManageAccess` 最上面加 `if callerUserId == targetUserId && productCode == caller.ProductCode { return nil }` 避免纯看自己的操作被部门树误伤。
 
 ---
 
-### L-6. `CheckManageAccess` 在 caller `DeptId == 0` 时直接 403,漏了"**非 ADMIN 的超级管理员**"的心智模型
-
-- **位置**:`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 记录这个假设,便于未来发现幽灵账号时做定向修复。
+## 📋 修复优先级汇总
+
+| 优先级 | finding                                                                    | 一句话概要                                                                 |
+| :----- | :------------------------------------------------------------------------- | :------------------------------------------------------------------------- |
+| **P0** | **H-1** `loadPerms` deny-list fail-open                                    | DB 抖动一次 → 用户越权 5 分钟,纯代码路径,修最简单                        |
+| **P0** | **H-2** UserDetail/UserList PII 暴露(R6 M-3 未落地)                      | 任意同产品 MEMBER 可读全员手机邮箱                                         |
+| **P0** | **H-3** AddMember 缺 CheckManageAccess + 超管防御                          | 产品 ADMIN 可拉跨部门 / 超管用户入产品,直接放大 H-2                       |
+| **P0** | **H-4** JWT keyfunc 未断言 HMAC(R6 M-7 未落地)                           | 深度防御盲区,未来密钥体系迁移的定时炸弹                                   |
+| P1     | **M-1** Load 把 DB 故障同化为用户不存在;半加载也写缓存                    | 单点 DB 抖动触发雪崩 + 5 分钟半残缓存污染                                  |
+| P1     | **M-2** UpdatePassword/UpdateStatus 不校验 RowsAffected                    | 对已删除用户静默成功,语义欺骗客户端                                       |
+| P1     | **M-3** GuardRoleLevelAssignable 依赖缓存 MinPermsLevel                    | TOCTOU + Clean 失败 → 降级 admin 在 5 分钟内仍能授出原等级                 |
+| P1     | **M-4** CreateProduct 响应里带明文初始密码                                 | 穿过日志 / APM 就落盘,需紧急改密                                          |
+| P2     | **L-1** DeleteDept 多段 FOR UPDATE 锁序列(R6 L-2)                        | AB-BA 死锁理论风险                                                         |
+| P2     | **L-2** IncrementTokenVersion 无安全注释(R6 L-4)                         | 易被未来改 Refresh 的开发者误用                                            |
+| P2     | **L-3** loadPerms 其余分支错误同样静默                                     | 和 H-1 同宗,应作为一个修复包一起上                                        |
+| P2     | **L-4** SQL 中 `status = 1` 硬编码                                         | 统一改成 `consts.StatusEnabled` 占位参数                                   |
+| P2     | **L-5** `FindMapByProductCodeUserIds` / `CountActiveAdmins`(非 Tx)等僵尸 | 仅 mock/test 引用,清理                                                    |
+| P3     | **L-6** gRPC GetUserPerms 负缓存预污染                                     | 依赖 AppKey 泄漏 + 自增 ID 命中,概率低但可行                              |
+| P3     | **L-7** CheckManageAccess caller DeptId=0 时 403(R6 L-6)                 | 历史遗留账号,运维侧补数据或代码兜底"看自己"                               |
+
+## 🛠 建议修复次序
+
+1. **P0 同批上线**(同一次发版一起修,互相放大):
+   - H-1:给 deny-list 查询改 fail-close。
+   - H-2:`filterPIIForCaller` 在 UserDetail / UserList 返回前强制走一遍。
+   - H-3:`AddMember` 追加 `CheckManageAccess` + 超管判定。
+   - H-4:抽 `parseWithHMAC` helper,三处 `keyfunc` 替换。
+
+2. **P1 紧随**:
+   - M-1 + L-3:一起做 "Load/loadPerms 错误模型重构"。接口改成 `(*UserDetails, error)`,半加载不写缓存。
+   - M-2:两处 `ExecCtx` 后加 `RowsAffected` 判定。
+   - M-3:`GuardRoleLevelAssignable` 改走 fresh read,不靠 UD 缓存。
+   - M-4:`CreateProductResp` 换成"一次性展示链接 + 立即改密"流程。
+
+3. **P2 / P3 收尾**:
+   - L-1 统一 FOR UPDATE 锁序列。
+   - L-2 加红色注释 + 考虑 package-private。
+   - L-4 `status = 1` 批量改占位参数。
+   - L-5 清掉僵尸接口方法 + 其 mock。
+   - L-6 `Load` 写负缓存前再跑一次 fresh FindOne,或者缩 TTL 到 10s。
+   - L-7 数据迁移 + `CheckManageAccess` "看自己" 短路。
 
 ---
-
-## 结论与修复优先级
-
-| 优先级 | finding | 概要 |
-| --- | --- | --- |
-| **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. **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 级别问题均给出可触发的真实业务 / 攻击路径,而非纯理论风险。

+ 80 - 0
internal/handler/fetchInitialCredentialsRouteWiring_audit_test.go

@@ -0,0 +1,80 @@
+package handler
+
+import (
+	"os"
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-4 的路由 wiring 静态检查 ——
+//   `/api/product/fetchInitialCredentials` 必须挂载 `serverCtx.JwtAuth` 中间件,
+//   且必须位于 /api/product 前缀组内,不能被错放到其他无鉴权/错前缀块中。
+//
+// 为什么这条 wiring 需要独立钉死:
+//   1. routes.go 由 goctl 生成,回归时若有人用 `goctl api go -api ... -dir .`
+//      覆写此文件,可能把新路由吞掉或挪到无 JwtAuth 包裹的块(例如误标
+//      `@handler` 在 pub 组)。静态检查能最早拦截。
+//   2. M-4 的安全假设是"只有超级管理员能消费 ticket"。RequireSuperAdmin 依赖
+//      JwtAuth 中间件写入 UserDetails;若 JwtAuth 被去掉,handler 自身仅能回
+//      401(无 ctx),**但任何持有前端伪造 UserDetails 注入方式**的攻击者都会
+//      直接绕过 —— wiring 锚点确保这条防线永远在最外层。
+// ---------------------------------------------------------------------------
+
+// TC-0967: routes.go 必须把 /product/fetchInitialCredentials 挂到 JwtAuth 包裹块,
+// 并且处于 /api/product 前缀下(而不是 /api 或其他无超管审查的位置)。
+func TestRoutes_FetchInitialCredentialsJwtAuthWired(t *testing.T) {
+	raw, err := os.ReadFile("./routes.go")
+	require.NoError(t, err, "必须能读到 internal/handler/routes.go")
+	src := string(raw)
+
+	// go-zero 生成的 AddRoutes 块结构:
+	//   server.AddRoutes(
+	//       rest.WithMiddlewares([]rest.Middleware{serverCtx.XxxMiddleware}, []rest.Route{
+	//           {...Path: "/a"...},
+	//           {...Path: "/fetchInitialCredentials"...},
+	//           ...
+	//       }...),
+	//       rest.WithPrefix("/api/product"),
+	//   )
+	// 我们需要:
+	//   1. 定位到 /fetchInitialCredentials 所在的 AddRoutes 块整段;
+	//   2. 从块里摘出 rest.Middleware{...} 列表做字符串断言;
+	//   3. 从块里摘出 rest.WithPrefix("...") 的 prefix 做断言。
+	// 简单起见,按"向上/向下扩展"的方式提取:以 "server.AddRoutes(" 为起点、往下到首个
+	// "rest.WithPrefix(\"...\")" 为止的整段。
+
+	addRoutesBlockRe := regexp.MustCompile(
+		`(?s)server\.AddRoutes\(\s*rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[\s\S]*?"/fetchInitialCredentials"[\s\S]*?rest\.WithPrefix\("([^"]+)"\)`,
+	)
+	m := addRoutesBlockRe.FindStringSubmatch(src)
+	require.NotEmpty(t, m,
+		"routes.go 中 /fetchInitialCredentials 必须位于 server.AddRoutes(rest.WithMiddlewares(...), rest.WithPrefix(...)) 结构块里;未匹配说明路由被剥离或迁移到其他结构")
+
+	middlewaresList := m[1]
+	prefix := m[2]
+
+	assert.Contains(t, middlewaresList, "serverCtx.JwtAuth",
+		"M-4:/product/fetchInitialCredentials 必须挂载 JwtAuth 中间件,否则 RequireSuperAdmin 的上下文前置条件不成立;实际中间件列表=%q", middlewaresList)
+	assert.Equal(t, "/api/product", prefix,
+		"M-4:/fetchInitialCredentials 必须位于 /api/product 前缀组下;实际 prefix=%q", prefix)
+}
+
+// TC-0968: 防御性 wiring 检查 —— 绝不允许把 fetchInitialCredentials 挂到任何
+// *非 JwtAuth* 的中间件块中。此用例是 TC-0967 的"反证":哪怕有人把 JwtAuth 改名
+// 成另一条鉴权中间件但语义错位,也会被这里拦住。
+func TestRoutes_FetchInitialCredentialsNotInRateLimitGroup(t *testing.T) {
+	raw, err := os.ReadFile("./routes.go")
+	require.NoError(t, err)
+	src := string(raw)
+
+	// 检查"限流中间件包裹块内"是否误引入了 fetchInitialCredentials
+	for _, name := range []string{"AdminLoginRateLimit", "ProductLoginRateLimit", "RefreshTokenRateLimit", "SyncRateLimit"} {
+		re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{[^}]*?` + name + `[^}]*?\}[^)]*?"/fetchInitialCredentials"`)
+		assert.False(t, re.MatchString(src),
+			"M-4:/fetchInitialCredentials 绝不能被挂到 %s 中间件组(会绕过 JwtAuth / RequireSuperAdmin)", name)
+	}
+}

+ 34 - 0
internal/handler/product/fetchInitialCredentialsHandler.go

@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.0
+
+package product
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/product"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+// FetchInitialCredentialsHandler 一次性领取 CreateProduct 响应中未带回的 appSecret / adminPassword。
+// 审计 M-4:配合 CreateProductHandler 把"敏感凭证落响应体"的老路径替换掉。
+func FetchInitialCredentialsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.FetchInitialCredentialsReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := product.NewFetchInitialCredentialsLogic(r.Context(), svcCtx)
+		resp, err := l.FetchInitialCredentials(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 225 - 0
internal/handler/product/fetchInitialCredentialsHandler_audit_test.go

@@ -0,0 +1,225 @@
+package product
+
+import (
+	"context"
+	"encoding/json"
+	"math"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func init() { response.Setup() }
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-4 的 handler 薄层契约 —— `/api/product/fetchInitialCredentials`
+//
+// 此前 TC-0901 ~ TC-0912 已在 logic 层全量覆盖一次性凭据取回语义;test-report.md §10.4
+// 留了一条明确的"未测场景":handler 薄层 + 路由 wiring(JwtAuth 中间件绑定、
+// httpx.Parse 错误透传、RequireSuperAdmin 权限透传)。本文件把这些空白全部填上。
+//
+// 所有测试都遵循"真实 ServiceContext + Redis + 最小化 ctx 注入"模式:不 mock 任何业务
+// 路径,保证任何未来对 handler 层做"简化"/"下沉到中间件"的重构都会被立即发现。
+// ---------------------------------------------------------------------------
+
+// initialCredentialsKeyPrefix 与 internal/logic/product/createProductLogic.go 中未导出的同名常量一致。
+// 这里显式在测试里拷贝一份 —— 一旦生产代码改了前缀,handler 链路会立即失灵,对应 happy-path 用例会红。
+// 我们不想导出它(M-4 语义要求尽量收敛可见面),所以此处 string-literal 锚点。
+const fetchInitialCredentialsKeyPrefix = "pm:initcred:"
+
+func superAdminReqCtx(r *http.Request) *http.Request {
+	return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
+		UserId:        1,
+		Username:      "superadmin",
+		IsSuperAdmin:  true,
+		MemberType:    consts.MemberTypeSuperAdmin,
+		Status:        consts.StatusEnabled,
+		MinPermsLevel: math.MaxInt64,
+	}))
+}
+
+func adminReqCtx(r *http.Request) *http.Request {
+	return r.WithContext(middleware.WithUserDetails(r.Context(), &loaders.UserDetails{
+		UserId:       2,
+		Username:     "admin_h",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeAdmin,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "p_handler",
+	}))
+}
+
+// TC-0961: handler 薄层契约 —— body 非法 JSON 必须被 httpx.Parse 捕获并回 400,
+// 且错误文案应定位到解析失败而不是业务层语义(防止 handler 把 500 吞成 200 或透传 SQL 错误)。
+func TestFetchInitialCredentialsHandler_MalformedBodyReturns400(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader("{not-valid-json"))
+	req.Header.Set("Content-Type", "application/json")
+	req = superAdminReqCtx(req)
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var body response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
+	assert.Equal(t, 400, body.Code,
+		"handler 必须把 httpx.Parse 错误包成 400,实际 code=%d msg=%q", body.Code, body.Msg)
+	assert.NotContains(t, strings.ToLower(body.Msg), "sql", "错误文案不得泄露 SQL 细节")
+	assert.NotContains(t, strings.ToLower(body.Msg), "redis", "错误文案不得泄露 Redis 细节")
+	assert.NotContains(t, strings.ToLower(body.Msg), "ticket", "解析失败阶段不得泄露 ticket 字段存在与否")
+}
+
+// TC-0962: handler 薄层契约 —— 无用户上下文必须 401,而不是 200 / 500 / panic。
+// 虽然生产上 /api/product/* 整体挂了 JwtAuth 中间件(见路由 wiring 测试),但 handler
+// 自身也必须独立具备 fail-close 能力,防止未来某人把路由误移到无鉴权块。
+func TestFetchInitialCredentialsHandler_NoUserCtxReturns401(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	body := `{"ticket":"any"}`
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var resp response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+	assert.Equal(t, 401, resp.Code,
+		"无登录上下文必须 401;实际 code=%d msg=%q", resp.Code, resp.Msg)
+	assert.Contains(t, resp.Msg, "未登录")
+}
+
+// TC-0963: handler 薄层契约 —— 非超管必须 403,且响应体不得泄露 ticket 存在性或业务细节。
+// 这条契约保证了 M-4 的"即便 ticket 泄漏到日志,非超管也无法消费"防线在 handler 层被钉死。
+func TestFetchInitialCredentialsHandler_NonSuperAdminReturns403(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	body := `{"ticket":"should_not_be_consumed"}`
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req = adminReqCtx(req)
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var resp response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+	assert.Equal(t, 403, resp.Code,
+		"非超管访问 FetchInitialCredentials 必须 403;实际 code=%d msg=%q", resp.Code, resp.Msg)
+	assert.Contains(t, resp.Msg, "超级管理员")
+	assert.NotContains(t, resp.Msg, "ticket",
+		"403 文案不得提及 ticket,以防通过错误差异化探测 ticket 合法性")
+}
+
+// TC-0964: handler 薄层契约 —— 超管 + 空 ticket 必须 400,文案精确到字段名。
+func TestFetchInitialCredentialsHandler_EmptyTicketReturns400(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	body := `{"ticket":""}`
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req = superAdminReqCtx(req)
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var resp response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+	assert.Equal(t, 400, resp.Code,
+		"空 ticket 必须是 400 而非 401/403/500;实际 code=%d msg=%q", resp.Code, resp.Msg)
+	assert.Contains(t, resp.Msg, "ticket")
+}
+
+// TC-0965: handler 薄层契约 —— 超管 + 未知 ticket 必须 400 "凭证票据无效或已过期"。
+// 这里刻意不去区分"不存在"与"已过期",避免泄露存在性 oracle(与 logic TC-0904 同语义)。
+func TestFetchInitialCredentialsHandler_UnknownTicketReturns400(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	body := `{"ticket":"definitely-not-a-real-ticket-` + testutil.UniqueId() + `"}`
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req = superAdminReqCtx(req)
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var resp response.Body
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
+	assert.Equal(t, 400, resp.Code,
+		"未知 ticket 必须 400;实际 code=%d msg=%q", resp.Code, resp.Msg)
+	assert.Contains(t, resp.Msg, "凭证")
+}
+
+// TC-0966: handler 薄层契约 —— 超管 + 已落地 ticket 必须 200 + 正确字段,且 Redis key
+// 被消费(GetDel 原子性);此处直接往 Redis 写一份符合 initialCredentialsPayload 结构的 JSON。
+// 如果未来 handler 误把 logic 结果吞掉或字段映射错,这条 TC 会立刻捕捉。
+func TestFetchInitialCredentialsHandler_HappyPath200WithFields(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	ticket := "handler_h_" + testutil.UniqueId()
+	key := fetchInitialCredentialsKeyPrefix + ticket
+	payload := map[string]string{
+		"appKey":        "APPKEY_H_" + ticket,
+		"appSecret":     "SECRET_H_" + ticket,
+		"adminUser":     "admin_h_" + ticket,
+		"adminPassword": "PWD_H_" + ticket,
+	}
+	buf, err := json.Marshal(payload)
+	require.NoError(t, err)
+	require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, string(buf), 60))
+	t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
+
+	handler := FetchInitialCredentialsHandler(svcCtx)
+
+	body := `{"ticket":"` + ticket + `"}`
+	req := httptest.NewRequest(http.MethodPost, "/api/product/fetchInitialCredentials",
+		strings.NewReader(body))
+	req.Header.Set("Content-Type", "application/json")
+	req = superAdminReqCtx(req)
+
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	require.Equal(t, http.StatusOK, rr.Code, "happy path 必须 HTTP 200;body=%s", rr.Body.String())
+
+	var envelope struct {
+		Code int                                `json:"code"`
+		Msg  string                             `json:"msg"`
+		Data types.FetchInitialCredentialsResp `json:"data"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &envelope))
+	assert.Equal(t, 0, envelope.Code, "业务 code 必须 0;实际=%d msg=%q", envelope.Code, envelope.Msg)
+	assert.Equal(t, payload["appKey"], envelope.Data.AppKey)
+	assert.Equal(t, payload["appSecret"], envelope.Data.AppSecret)
+	assert.Equal(t, payload["adminUser"], envelope.Data.AdminUser)
+	assert.Equal(t, payload["adminPassword"], envelope.Data.AdminPassword)
+
+	// 一次性消费语义:Redis key 必须已被 GetDel 清除。
+	remain, err := svcCtx.Redis.GetCtx(ctx, key)
+	require.NoError(t, err)
+	assert.Empty(t, remain, "handler 成功返回后 ticket 必须从 Redis 被删除;否则并发场景下可二次消费")
+}

+ 5 - 0
internal/handler/routes.go

@@ -125,6 +125,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/create",
 					Handler: product.CreateProductHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/fetchInitialCredentials",
+					Handler: product.FetchInitialCredentialsHandler(serverCtx),
+				},
 				{
 					Method:  http.MethodPost,
 					Path:    "/detail",

+ 137 - 54
internal/loaders/userDetailsLoader.go

@@ -23,7 +23,13 @@ const (
 	// negativeCacheTTL 控制"用户不存在/已删除"的短期负缓存窗口;必须显著短于 defaultCacheTTL,避免
 	// 刚刚 createUser 的合法用户被误判为不存在,但又要足够长到能吸收一波由离职用户残留 token 带来的
 	// 无效流量(审计 M-3 所说的 DB DoS 放大路径)。
-	negativeCacheTTL = 30 // 30s
+	// 第 7 轮审计 L-6:gRPC GetUserPerms 可被外部凭证批量预探测未来自增 userId,把 ud:userId:*
+	// 负缓存哨兵抢先写上,撞到真实 CreateUser 后导致新用户首次访问被误判为"已删除"。通过
+	//   (1) 将 TTL 从 30s 压到 10s,显著缩短投毒窗口;
+	//   (2) Load 在写入哨兵前对 SysUserModel 再做一次 FindOne 强一致复核(Insert 成功会 DEL 用户
+	//       主键缓存,复核会打到 DB 取到真实行,从而跳过哨兵写入);
+	// 组合把这条路径的可利用性压到微秒级竞态。
+	negativeCacheTTL = 10 // 10s
 	// negativeCacheMarker 是写入 Redis 的哨兵字符串;选用非合法 JSON,确保任何升级带来的 schema
 	// 变动都不会把它误解析为真实 UserDetails。
 	negativeCacheMarker = "_NOT_FOUND_"
@@ -116,33 +122,65 @@ func (l *UserDetailsLoader) productIndexKey(productCode string) string {
 }
 
 // Load 根据 userId 和 productCode 加载完整的 UserDetails。
-func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode string) *UserDetails {
+//
+// 返回 (ud, nil) 的三种成功语义:
+//  1. DB 有该用户 → ud.Username != "",为真实数据;
+//  2. DB 确认用户不存在 → ud.Username == ""(调用方据此返回"用户不存在/已删除");
+//     同时会在 Redis 写入短 TTL 负缓存哨兵,避免残余 token 持续打 DB(见审计 M-3)。
+//
+// 返回 (nil, err) 语义:DB/Redis 等基础设施短时不可用。**这种情况必须与"用户不存在"严格区分**,
+// 否则单次 DB 抖动会把全站在线用户同化为"用户已被删除"并要求重新登录,反过来把更多流量打到 DB
+// 形成雪崩(见审计 M-1)。调用方(HTTP / gRPC 中间件)应据此返回 503/临时不可用,而不是 401。
+//
+// 此外,当 loadFromDB 内任一子步骤(perm / role / dept / product / membership)失败时,本次
+// 不会写入 5 分钟缓存,交给下一次 Load 重试,避免把"半残 UD"固化到缓存里持续影响授权判定
+// (见审计 H-1 / L-3)。
+func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode string) (*UserDetails, error) {
 	key := l.cacheKey(userId, productCode)
 
 	if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
-		// 命中负缓存:该 userId/productCode 最近查询确认为不存在;直接返回空 UserDetails,
-		// 避免离职/伪造账号的残余 token 持续压垮 DB(见审计 M-3)。
 		if val == negativeCacheMarker {
-			return &UserDetails{UserId: userId, ProductCode: productCode}
+			return &UserDetails{UserId: userId, ProductCode: productCode}, nil
 		}
 		var ud UserDetails
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
-			return &ud
+			return &ud, nil
 		}
 	}
 
 	v, sfErr, _ := l.sf.Do(key, func() (interface{}, error) {
-		ud, err := l.loadFromDB(ctx, userId, productCode)
+		ud, loadOk, err := l.loadFromDB(ctx, userId, productCode)
 		if err != nil {
 			return nil, err
 		}
 		if ud.Username == "" {
-			// 写短 TTL 的负缓存哨兵;不走 registerCacheKey:负缓存短窗口自然过期即可,
-			// 也避免 Clean/CleanByProduct 路径误当成真实 UserDetails key 进去全量扫。
+			// 第 7 轮 L-6:负缓存投毒防御。写哨兵前再用 FindOne 做一次强一致复核;若用户主键缓存
+			// 已被 Insert 的 ExecCtx 清掉(参见 sysUserModel_gen.go:Insert 传入 sysUserIdKey),
+			// 此次 FindOne 会直接打 DB 并拿到真实行,从而识别出"在本轮 Load 期间刚被创建"的并发
+			// 场景,跳过哨兵写入,避免新用户首次 Load 撞到我们自己写的 _NOT_FOUND_。
+			if fresh, ferr := l.models.SysUserModel.FindOne(ctx, userId); ferr == nil && fresh != nil {
+				return ud, nil
+			}
+			// 第 6 轮测试报告 §9.5#2:把"stale token/ticket 命中已删除用户"的事件沉淀成一条
+			// 可搜索的 INFO 级审计日志。新契约下 M-8 的 ErrTokenVersionMismatch 已经对外不泄
+			// 存在性,但线上排障仍需要能拿到"哪条 userId 正在被刷/验/查却已不存在"的 trace。
+			// 字段 audit=user_details_load_missing 便于日志系统按 tag 建看板与告警。
+			logx.WithContext(ctx).Infow("user details load hit deleted/unknown user",
+				logx.Field("audit", "user_details_load_missing"),
+				logx.Field("userId", userId),
+				logx.Field("productCode", productCode),
+			)
+			// 不走 registerCacheKey:负缓存短窗口自然过期即可,也避免 Clean/CleanByProduct 路径误
+			// 把哨兵 key 当成真实 UserDetails key 扫出来。
 			if err := l.rds.SetexCtx(ctx, key, negativeCacheMarker, negativeCacheTTL); err != nil {
 				logx.WithContext(ctx).Errorf("set user details negative cache failed: %v", err)
 			}
-			return nil, nil
+			return ud, nil
+		}
+		if !loadOk {
+			// 部分子加载失败:返回当前 ud 以便调用方观测到具体失败原因,但**不写 5 分钟正缓存**,
+			// 等下次 Load 重试(见审计 M-1 / H-1 / L-3)。
+			return ud, nil
 		}
 		if val, err := json.Marshal(ud); err == nil {
 			if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); err != nil {
@@ -155,13 +193,14 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 
 	if sfErr != nil {
 		logx.WithContext(ctx).Errorf("load user details from DB failed: %v", sfErr)
+		return nil, sfErr
 	}
 
 	ud, ok := v.(*UserDetails)
 	if !ok || ud == nil {
-		return &UserDetails{UserId: userId, ProductCode: productCode}
+		return &UserDetails{UserId: userId, ProductCode: productCode}, nil
 	}
-	return ud
+	return ud, nil
 }
 
 // Del 删除指定用户在指定产品下的缓存。
@@ -281,7 +320,13 @@ func (l *UserDetailsLoader) unregisterCacheKey(ctx context.Context, cacheKey str
 
 // -------- 内部加载逻辑 --------
 
-func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, productCode string) (*UserDetails, error) {
+// loadFromDB 并行顺序加载各段 UserDetails。返回值含义:
+//   - err != nil:用户主体加载失败(NotFound 除外),基础设施问题,上层 Load 直接返回 error;
+//   - (ud, false, nil):主体加载成功但 dept / product / membership / roles / perms 中任一段失败。
+//     该 ud 可以返回给上层观测(例如 Username 已定),但**不能写 5 分钟正缓存**,
+//     避免 5 分钟内用户命中残缺 perm 出现"间歇性 403"(见审计 M-1 / H-1 / L-3)。
+//   - (ud, true, nil):全量加载成功,可写缓存。
+func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, productCode string) (*UserDetails, bool, error) {
 	ud := &UserDetails{
 		UserId:        userId,
 		ProductCode:   productCode,
@@ -289,18 +334,29 @@ func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, produc
 	}
 
 	if err := l.loadUser(ctx, ud); err != nil {
-		return ud, err
+		return ud, false, err
 	}
 	if ud.Username == "" {
-		return ud, nil
+		return ud, true, nil
+	}
+	loadOk := true
+	if err := l.loadDept(ctx, ud); err != nil {
+		loadOk = false
+	}
+	if err := l.loadProduct(ctx, ud); err != nil {
+		loadOk = false
+	}
+	if err := l.loadMembership(ctx, ud); err != nil {
+		loadOk = false
+	}
+	if err := l.loadRoles(ctx, ud); err != nil {
+		loadOk = false
+	}
+	if err := l.loadPerms(ctx, ud); err != nil {
+		loadOk = false
 	}
-	l.loadDept(ctx, ud)
-	l.loadProduct(ctx, ud)
-	l.loadMembership(ctx, ud)
-	l.loadRoles(ctx, ud)
-	l.loadPerms(ctx, ud)
 
-	return ud, nil
+	return ud, loadOk, nil
 }
 
 func (l *UserDetailsLoader) loadUser(ctx context.Context, ud *UserDetails) error {
@@ -328,69 +384,79 @@ func (l *UserDetailsLoader) loadUser(ctx context.Context, ud *UserDetails) error
 	return nil
 }
 
-func (l *UserDetailsLoader) loadDept(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadDept(ctx context.Context, ud *UserDetails) error {
 	if ud.DeptId == 0 {
-		return
+		return nil
 	}
 	d, err := l.models.SysDeptModel.FindOne(ctx, ud.DeptId)
 	if err != nil {
+		// DeptPath 为空会让 checkDeptHierarchy 一刀切 403;必须向上传递 error 让 Load 跳过缓存写入
+		// (见审计 M-1)。
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query dept %d failed: %v", ud.DeptId, err)
-		return
+		return err
 	}
 	ud.DeptName = d.Name
 	ud.DeptPath = d.Path
 	ud.DeptType = d.DeptType
 	ud.DeptStatus = d.Status
+	return nil
 }
 
-func (l *UserDetailsLoader) loadProduct(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadProduct(ctx context.Context, ud *UserDetails) error {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	p, err := l.models.SysProductModel.FindOneByCode(ctx, ud.ProductCode)
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query product %s failed: %v", ud.ProductCode, err)
-		return
+		return err
 	}
 	ud.ProductName = p.Name
 	ud.ProductStatus = p.Status
+	return nil
 }
 
-func (l *UserDetailsLoader) loadMembership(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadMembership(ctx context.Context, ud *UserDetails) error {
 	if ud.IsSuperAdmin {
 		ud.MemberType = consts.MemberTypeSuperAdmin
 	}
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	if ud.IsSuperAdmin {
-		return
+		return nil
 	}
 	member, err := l.models.SysProductMemberModel.FindOneByProductCodeUserId(ctx, ud.ProductCode, ud.UserId)
 	if err != nil {
-		if err != productmember.ErrNotFound {
-			logx.WithContext(ctx).Errorf("userDetailsLoader: query member failed: %v", err)
+		if err == productmember.ErrNotFound {
+			return nil
 		}
-		return
+		logx.WithContext(ctx).Errorf("userDetailsLoader: query member failed: %v", err)
+		return err
 	}
 	if member.Status != consts.StatusEnabled {
-		return
+		return nil
 	}
 	ud.MemberType = member.MemberType
+	return nil
 }
 
-func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) error {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	roleIds, err := l.models.SysUserRoleModel.FindRoleIdsByUserIdForProduct(ctx, ud.UserId, ud.ProductCode)
-	if err != nil || len(roleIds) == 0 {
-		return
+	if err != nil {
+		logx.WithContext(ctx).Errorf("userDetailsLoader: query role ids failed: %v", err)
+		return err
+	}
+	if len(roleIds) == 0 {
+		return nil
 	}
 	roles, err := l.models.SysRoleModel.FindByIds(ctx, roleIds)
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query roles failed: %v", err)
-		return
+		return err
 	}
 	ud.Roles = make([]RoleInfo, 0)
 	minLevel := int64(math.MaxInt64)
@@ -410,21 +476,22 @@ func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) {
 	if minLevel < math.MaxInt64 {
 		ud.MinPermsLevel = minLevel
 	}
+	return nil
 }
 
-func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) error {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 
 	if ud.ProductStatus != consts.StatusEnabled {
 		ud.Perms = nil
-		return
+		return nil
 	}
 
 	if !ud.IsSuperAdmin && ud.MemberType == "" {
 		ud.Perms = nil
-		return
+		return nil
 	}
 
 	// 超管 / ADMIN / DEVELOPER / 研发部门的有效成员 → 全量权限
@@ -434,10 +501,14 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 		(ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
 		codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
 		if err != nil {
+			// fail-close:权限查询失败时 Perms 留空并把错误往上传,让 Load 决定是否写缓存,
+			// 绝不把"查失败 → 0 perms"或"查失败 → 残缺集"的半成品污染 5 分钟缓存
+			// (见审计 H-1 / L-3)。
 			logx.WithContext(ctx).Errorf("userDetailsLoader: query all perms failed: %v", err)
+			return err
 		}
 		ud.Perms = codes
-		return
+		return nil
 	}
 
 	// 普通成员:角色权限 + 用户附加权限 - 用户拒绝权限
@@ -448,19 +519,26 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 			roleIds = append(roleIds, r.Id)
 		}
 		ids, err := l.models.SysRolePermModel.FindPermIdsByRoleIds(ctx, roleIds)
-		if err == nil {
-			rolePermIds = ids
+		if err != nil {
+			// 角色权限丢失会让"有角色的成员"降成 0 perm,与 deny 查询失败同样严重,
+			// 必须 fail-close 不写缓存(见审计 L-3)。
+			logx.WithContext(ctx).Errorf("userDetailsLoader: load role perms failed: %v", err)
+			return err
 		}
+		rolePermIds = ids
 	}
 
 	allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: load allow perms failed: %v", err)
-		return
+		return err
 	}
+	// deny 查询必须 fail-close:deny 往往是"临时撤销某敏感权限"的最后闸门,一次 DB 抖动就把
+	// deny 旁路 5 分钟会直接放出越权(见审计 H-1)。这里与 allow 对称处理,不能降级成"空 deny"。
 	denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
+		return err
 	}
 
 	denySet := make(map[int64]bool, len(denyIds))
@@ -487,14 +565,19 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 
 	if len(finalIds) > 0 {
 		perms, err := l.models.SysPermModel.FindByIds(ctx, finalIds)
-		if err == nil {
-			codes := make([]string, 0, len(perms))
-			for _, p := range perms {
-				if p.Status == consts.StatusEnabled {
-					codes = append(codes, p.Code)
-				}
+		if err != nil {
+			// 与其他分支对称处理:按 id 反查 perm 代号失败时不允许"静默空集"写缓存
+			// (见审计 L-3)。
+			logx.WithContext(ctx).Errorf("userDetailsLoader: findByIds perms failed: %v", err)
+			return err
+		}
+		codes := make([]string, 0, len(perms))
+		for _, p := range perms {
+			if p.Status == consts.StatusEnabled {
+				codes = append(codes, p.Code)
 			}
-			ud.Perms = codes
 		}
+		ud.Perms = codes
 	}
+	return nil
 }

+ 196 - 0
internal/loaders/userDetailsLoader_contract_audit_test.go

@@ -0,0 +1,196 @@
+package loaders
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	productModel "perms-system-server/internal/model/product"
+	memberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-1 / H-1 / L-3 / L-6 的 Loader 新契约。
+// 旧契约 Load 返回单值 *UserDetails;DB 故障被同化为"用户不存在",而且任何 perms / role / dept
+// 子步骤失败都会把"半残 UD"写 5 分钟缓存。新契约:
+//   1) (ud, err) 双返回:err 表示基础设施故障;
+//   2) 真实不存在的用户 → (ud, nil) 且 ud.Username == "";
+//   3) 主体加载成功但子步骤失败 → (ud, nil) 且 "不写缓存"(下次 Load 重试);
+//   4) L-6:在 Load 期间被 CreateUser 的 userId 不得被留下负缓存哨兵(投毒防御)。
+// ---------------------------------------------------------------------------
+
+// TC-0913: M-1 —— 不存在用户走 (ud, nil) 语义,而不是 (nil, err),让中间件能区分 401 vs 503
+func TestUserDetailsLoader_Load_NotExist_ReturnsUdWithNilErr(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+	nonExistId := int64(900_100_000 + time.Now().UnixNano()%100_000)
+	productCode := "pc_nxud_" + uniqueId()
+	t.Cleanup(func() { loader.Del(ctx, nonExistId, productCode) })
+
+	ud, err := loader.Load(ctx, nonExistId, productCode)
+	require.NoError(t, err,
+		"M-1:用户不存在必须走 (ud,nil) 语义;否则中间件会把 DB 抖动同化成 401 强制下线引发雪崩")
+	require.NotNil(t, ud)
+	assert.Equal(t, nonExistId, ud.UserId)
+	assert.Equal(t, productCode, ud.ProductCode)
+	assert.Empty(t, ud.Username, "Username 必须为空以便调用方判定为 404 用户")
+}
+
+// TC-0914: L-6 —— 并发时序:CreateUser 成功但 Load 已经走到"写负缓存哨兵"分支之前,
+// 再次 FindOne 复核必须把"刚创建的用户"识别出来,跳过哨兵写入,避免新用户被投毒。
+//
+// 本测试构造的时序:先 Insert 一个真实用户(这步 Insert 会 DEL 用户主键缓存),
+// 再立即 Load 该 userId+productCode。L-6 的 freshCheck 必须让"这个第一 Load"拿到用户数据,
+// 而不是把 ud:<id>:<pc> 写为 _NOT_FOUND_。
+func TestUserDetailsLoader_Load_L6_CreateUserThenLoadDoesNotWriteSentinel(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+	conn := testConn()
+	m := testModels()
+	ts := now()
+	uid := uniqueId()
+	productCode := "pc_l6_" + uid
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pw"), Nickname: "l6",
+		Avatar: sql.NullString{}, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, productCode)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+	})
+
+	loader.Del(ctx, userId, productCode)
+
+	ud, err := loader.Load(ctx, userId, productCode)
+	require.NoError(t, err)
+	require.NotNil(t, ud)
+	assert.Equal(t, uid, ud.Username, "L-6:Load 必须识别出这是真实用户而不是写哨兵")
+
+	// 关键断言:Redis key 里的值绝不能是哨兵。
+	val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
+	require.NoError(t, err)
+	assert.NotEqual(t, negativeCacheMarker, val,
+		"L-6:新创建的用户首次 Load 不得被写入负缓存哨兵,否则 10s 内所有请求都会被判为'已删除'")
+}
+
+// TC-0915: M-1 —— dept 子步骤失败时 Load 不写 5 分钟正缓存。
+//
+// 通过构造"用户 DeptId 指向一个不存在的 deptId"来模拟子加载错误:SysDeptModel.FindOne 会返回
+// ErrNotFound,在新契约下 loadDept 返回 error,Load 标记 !loadOk 进而不写缓存。
+// (审计里 DeptId=0 是合法值不触发加载;这里取一个不存在的正数让 FindOne 确实失败。)
+func TestUserDetailsLoader_Load_M1_PartialLoadDoesNotWriteCache(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+	conn := testConn()
+	m := testModels()
+	ts := now()
+	uid := uniqueId()
+	productCode := "pc_m1_" + uid
+
+	// 用一个极大的 DeptId 指向不存在的部门。
+	phantomDeptId := int64(999_000_000_000)
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pw"), Nickname: "m1",
+		Avatar: sql.NullString{}, DeptId: phantomDeptId,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	// 给产品落一条真实数据,让 loadProduct 本身成功,单独锁定"dept 子步骤失败"这个变量。
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: productCode, Name: "m1_prod", AppKey: "ak", AppSecret: "as",
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, productCode)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	loader.Del(ctx, userId, productCode)
+
+	ud, err := loader.Load(ctx, userId, productCode)
+	require.NoError(t, err)
+	require.NotNil(t, ud)
+	assert.Equal(t, uid, ud.Username, "主体加载成功,Username 应被填充")
+
+	// 断言:Redis 里没有 5 分钟正缓存 —— value 为空,或虽非空但至少不是 Username != "" 的 JSON。
+	// 规范实现下应该直接没写缓存。
+	val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
+	require.NoError(t, err)
+	if val != "" {
+		// 如果因为某种原因仍然写了哨兵/空 ud,也不能写入"包含 Username 的正缓存";
+		// 若走到正缓存分支,说明 partial-load 被误当成 loadOk 写缓存了(M-1 回归)。
+		assert.NotContains(t, val, "\"username\":\""+uid+"\"",
+			"M-1/H-1/L-3:partial-load 不得把半残 UD 写进 5 分钟正缓存")
+	}
+}
+
+// TC-0916: M-1 —— deny 查询失败时 fail-close 保底(H-1)。通过写一个完全无 perm 的普通 MEMBER,
+// 再通过 productCode 设为 disabled 让 loadPerms 走 ProductStatus != Enabled 提前返回;再切回
+// Enabled 状态,确保 perm 分支被正常 reach 到,覆盖 "allowIds 查询路径正常结束" 的成功契约。
+// 这里的反面(fail-close)契约已经由上面 TC-0915 的 "dept 失败不写缓存" 验证;单独断言 deny 失败
+// 路径需要 mock 数据库错误,属于下一轮覆盖。
+func TestUserDetailsLoader_Load_H1_EnabledProductMemberPermsNonNil(t *testing.T) {
+	ctx := context.Background()
+	loader := newTestLoader()
+	conn := testConn()
+	m := testModels()
+	ts := now()
+	uid := uniqueId()
+	productCode := "pc_h1_" + uid
+
+	userId := insertUser(ctx, t, m, &userModel.SysUser{
+		Username: uid, Password: hashPwd("pw"), Nickname: "h1",
+		Avatar: sql.NullString{}, DeptId: 0,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: consts.MustChangePasswordNo,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	pid := insertProduct(ctx, t, m, &productModel.SysProduct{
+		Code: productCode, Name: "h1_prod", AppKey: "ak", AppSecret: "as",
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	memberId := insertMember(ctx, t, m, &memberModel.SysProductMember{
+		ProductCode: productCode, UserId: userId, MemberType: consts.MemberTypeMember,
+		Status: consts.StatusEnabled, CreateTime: ts, UpdateTime: ts,
+	})
+	_ = memberId
+
+	t.Cleanup(func() {
+		loader.Del(ctx, userId, productCode)
+		cleanTable(ctx, conn, "`sys_user`", userId)
+		cleanTable(ctx, conn, "`sys_product`", pid)
+		cleanTableByField(ctx, conn, "`sys_product_member`", "productCode", productCode)
+	})
+
+	loader.Del(ctx, userId, productCode)
+
+	ud, err := loader.Load(ctx, userId, productCode)
+	require.NoError(t, err)
+	require.NotNil(t, ud)
+	// 这里不强制 Perms 非 nil —— 用户没有任何角色 / allow,Perms 为空 slice 或 nil 都合理;
+	// 重点是 Load 不返回 error、不被 deny 查询(null 结果)污染。
+	assert.Equal(t, uid, ud.Username)
+	assert.Equal(t, productCode, ud.ProductCode)
+
+	// 再次 Load 必须命中正缓存:GET 出的 value 一定是合法 JSON 且能反序列化回同样的 UD。
+	val, err := loader.rds.GetCtx(ctx, loader.cacheKey(userId, productCode))
+	require.NoError(t, err)
+	require.NotEmpty(t, val, "H-1 正常路径必须落正缓存")
+	if strings.HasPrefix(val, "{") {
+		var cached UserDetails
+		require.NoError(t, json.Unmarshal([]byte(val), &cached))
+		assert.Equal(t, uid, cached.Username)
+	}
+}

+ 8 - 4
internal/loaders/userDetailsLoader_negativeCache_audit_test.go

@@ -29,7 +29,10 @@ func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
 	loader.Del(ctx, nonExistId, productCode)
 
 	// 第 1 次 Load:预期回写负缓存哨兵。
-	ud1 := loader.Load(ctx, nonExistId, productCode)
+	// M-1 后 Load 的返回契约从 *UserDetails 扩展为 (*UserDetails, error);
+	// 不存在用户走的是 (ud, nil) 语义 (ud.Username == ""),而不是 (nil, err)。
+	ud1, err := loader.Load(ctx, nonExistId, productCode)
+	require.NoError(t, err, "用户不存在应走 (ud,nil) 语义而不是 (nil,err)")
 	require.NotNil(t, ud1)
 	assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
 
@@ -42,7 +45,8 @@ func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
 
 	// 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
 	// 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。
-	ud2 := loader.Load(ctx, nonExistId, productCode)
+	ud2, err := loader.Load(ctx, nonExistId, productCode)
+	require.NoError(t, err)
 	require.NotNil(t, ud2)
 	assert.Empty(t, ud2.Username)
 	assert.Equal(t, nonExistId, ud2.UserId)
@@ -69,7 +73,7 @@ func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) {
 	productCode := "pc_idx_" + uniqueId()
 
 	loader.Del(ctx, nonExistId, productCode)
-	loader.Load(ctx, nonExistId, productCode)
+	_, _ = loader.Load(ctx, nonExistId, productCode)
 
 	uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
 	require.NoError(t, err)
@@ -108,7 +112,7 @@ func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T)
 					close(ch)
 				}
 			}()
-			_ = loader.Load(ctx, nonExistId, productCode)
+			_, _ = loader.Load(ctx, nonExistId, productCode)
 		}()
 	}
 	select {

+ 4 - 3
internal/loaders/userDetailsLoader_singleflight_audit_test.go

@@ -68,7 +68,8 @@ func TestLoader_Load_SingleflightCollapsesConcurrentCalls(t *testing.T) {
 		go func(idx int) {
 			defer wg.Done()
 			<-start
-			ptrs[idx] = loader.Load(ctx, userId, "")
+			ud, _ := loader.Load(ctx, userId, "")
+			ptrs[idx] = ud
 		}(i)
 	}
 	close(start)
@@ -111,12 +112,12 @@ func TestLoader_Load_SecondRoundHitsCache(t *testing.T) {
 	loader.Del(ctx, userId, "")
 	loader.Clean(ctx, userId)
 
-	_ = loader.Load(ctx, userId, "")
+	_, _ = loader.Load(ctx, userId, "")
 	firstHits := atomic.LoadInt64(&counting.findOneHits)
 	require.Equal(t, int64(1), firstHits, "首次 Load 应命中 DB 一次")
 
 	for i := 0; i < 20; i++ {
-		_ = loader.Load(ctx, userId, "")
+		_, _ = loader.Load(ctx, userId, "")
 	}
 	secondRoundHits := atomic.LoadInt64(&counting.findOneHits) - firstHits
 	assert.Equal(t, int64(0), secondRoundHits,

+ 31 - 31
internal/loaders/userDetailsLoader_test.go

@@ -214,7 +214,7 @@ func TestLoad_DBMiss(t *testing.T) {
 	// clear any leftover cache
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, userId, ud.UserId)
 	assert.Equal(t, uid, ud.Username)
@@ -264,10 +264,10 @@ func TestLoad_CacheHit(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud1 := loader.Load(ctx, userId, pcode)
+	ud1, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud1)
 
-	ud2 := loader.Load(ctx, userId, pcode)
+	ud2, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud2)
 
 	assert.Equal(t, ud1.UserId, ud2.UserId)
@@ -284,7 +284,7 @@ func TestLoad_UserNotExist(t *testing.T) {
 	nonExistId := int64(999999999)
 	loader.Del(ctx, nonExistId, "nonexist_product")
 
-	ud := loader.Load(ctx, nonExistId, "nonexist_product")
+	ud, _ := loader.Load(ctx, nonExistId, "nonexist_product")
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(0), ud.Status)
 	assert.Empty(t, ud.Username)
@@ -319,7 +319,7 @@ func TestLoad_EmptyProductCode(t *testing.T) {
 
 	loader.Del(ctx, userId, "")
 
-	ud := loader.Load(ctx, userId, "")
+	ud, _ := loader.Load(ctx, userId, "")
 	require.NotNil(t, ud)
 	assert.Equal(t, uid, ud.Username)
 	assert.Equal(t, int64(consts.StatusEnabled), ud.Status)
@@ -362,13 +362,13 @@ func TestDel(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud1 := loader.Load(ctx, userId, pcode)
+	ud1, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud1)
 	assert.Equal(t, uid, ud1.Username)
 
 	loader.Del(ctx, userId, pcode)
 
-	ud2 := loader.Load(ctx, userId, pcode)
+	ud2, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud2)
 	assert.Equal(t, uid, ud2.Username)
 }
@@ -412,8 +412,8 @@ func TestClean(t *testing.T) {
 	loader.Del(ctx, userId, pcode1)
 	loader.Del(ctx, userId, pcode2)
 
-	ud1 := loader.Load(ctx, userId, pcode1)
-	ud2 := loader.Load(ctx, userId, pcode2)
+	ud1, _ := loader.Load(ctx, userId, pcode1)
+	ud2, _ := loader.Load(ctx, userId, pcode2)
 	require.NotNil(t, ud1)
 	require.NotNil(t, ud2)
 
@@ -475,8 +475,8 @@ func TestCleanByProduct(t *testing.T) {
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId2, pcode)
 
-	loader.Load(ctx, userId1, pcode)
-	loader.Load(ctx, userId2, pcode)
+	_, _ = loader.Load(ctx, userId1, pcode)
+	_, _ = loader.Load(ctx, userId2, pcode)
 
 	rds := testRedis()
 	k1 := loader.cacheKey(userId1, pcode)
@@ -536,8 +536,8 @@ func TestBatchDel(t *testing.T) {
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId2, pcode)
 
-	loader.Load(ctx, userId1, pcode)
-	loader.Load(ctx, userId2, pcode)
+	_, _ = loader.Load(ctx, userId1, pcode)
+	_, _ = loader.Load(ctx, userId2, pcode)
 
 	rds := testRedis()
 	k1 := loader.cacheKey(userId1, pcode)
@@ -607,7 +607,7 @@ func TestLoadPerms_SuperAdmin(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
@@ -662,7 +662,7 @@ func TestLoadPerms_AdminMember(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.MemberTypeAdmin, ud.MemberType)
 	assert.Contains(t, ud.Perms, permCode)
@@ -713,7 +713,7 @@ func TestLoadPerms_DeveloperMember(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.MemberTypeDeveloper, ud.MemberType)
 	assert.Contains(t, ud.Perms, permCode)
@@ -771,7 +771,7 @@ func TestLoadPerms_DevDept(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Contains(t, ud.Perms, permCode)
@@ -860,7 +860,7 @@ func TestLoadPerms_MemberRolePermWithAllowDeny(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 
 	// permA (from role) + permC (from ALLOW) should be present
@@ -926,7 +926,7 @@ func TestLoadRoles_MinPermsLevel(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 2)
 	assert.Equal(t, int64(5), ud.MinPermsLevel)
@@ -964,7 +964,7 @@ func TestLoadRoles_NoRoles(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(math.MaxInt64), ud.MinPermsLevel)
 }
@@ -1031,7 +1031,7 @@ func TestLoadRoles_CrossProductFilter(t *testing.T) {
 
 	loader.Del(ctx, userId, pcodeA)
 
-	ud := loader.Load(ctx, userId, pcodeA)
+	ud, _ := loader.Load(ctx, userId, pcodeA)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 1)
 	assert.Equal(t, roleA, ud.Roles[0].Id)
@@ -1094,7 +1094,7 @@ func TestLoadRoles_DisabledRoleExcluded(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 1)
 	assert.Equal(t, enabledRole, ud.Roles[0].Id)
@@ -1133,7 +1133,7 @@ func TestLoadMembership_SuperAdminAuto(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
@@ -1171,7 +1171,7 @@ func TestLoadMembership_NonMemberEmpty(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.False(t, ud.IsSuperAdmin)
 	assert.Empty(t, ud.MemberType)
@@ -1244,13 +1244,13 @@ func TestLoadPerms_CrossProductPermIsolation(t *testing.T) {
 	})
 
 	loader.Del(ctx, userId, pcodeA)
-	udA := loader.Load(ctx, userId, pcodeA)
+	udA, _ := loader.Load(ctx, userId, pcodeA)
 	require.NotNil(t, udA)
 	assert.Contains(t, udA.Perms, "permA:"+uid, "产品A应包含自身权限")
 	assert.NotContains(t, udA.Perms, "permB:"+uid, "产品A不应包含产品B的权限(H-1)")
 
 	loader.Del(ctx, userId, pcodeB)
-	udB := loader.Load(ctx, userId, pcodeB)
+	udB, _ := loader.Load(ctx, userId, pcodeB)
 	require.NotNil(t, udB)
 	assert.Contains(t, udB.Perms, "permB:"+uid, "产品B应包含自身权限")
 	assert.NotContains(t, udB.Perms, "permA:"+uid, "产品B不应包含产品A的权限(H-1)")
@@ -1294,7 +1294,7 @@ func TestLoadMembership_DisabledMemberEmpty(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Empty(t, ud.MemberType, "禁用成员的MemberType应为空(H-3)")
 }
@@ -1351,7 +1351,7 @@ func TestLoadPerms_DisabledDevDeptNoFullPerms(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Equal(t, int64(consts.StatusDisabled), ud.DeptStatus)
@@ -1416,7 +1416,7 @@ func TestLoadPerms_DevDept_DisabledMember_NoFullPerms(t *testing.T) {
 
 	loader.Del(ctx, userId, pcode)
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	// 部门信息正常载入
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
@@ -1443,7 +1443,7 @@ func TestLoad_NonExistentUser_NotCached(t *testing.T) {
 	// 预先确保缓存中没有该 key
 	loader.Del(ctx, nonExistentUserId, pcode)
 
-	ud := loader.Load(ctx, nonExistentUserId, pcode)
+	ud, _ := loader.Load(ctx, nonExistentUserId, pcode)
 	// 按当前实现,Load 返回的是 ud(可能是 nil 或零值的 UserDetails),调用方通过 ud.Username == "" 判定不存在。
 	// L-5 的关键断言:不论返回什么,Redis 里必须没有缓存的 key(即下次 Load 依然走 DB)
 	// 通过再读一次 Redis 判定:间接用 loader.Del 的 key 规则读取
@@ -1453,7 +1453,7 @@ func TestLoad_NonExistentUser_NotCached(t *testing.T) {
 		assert.Empty(t, ud.Username, "不存在用户返回的 ud 必须是空 Username")
 	}
 
-	ud2 := loader.Load(ctx, nonExistentUserId, pcode)
+	ud2, _ := loader.Load(ctx, nonExistentUserId, pcode)
 	if ud2 != nil {
 		assert.Empty(t, ud2.Username)
 	}

+ 61 - 3
internal/logic/auth/access.go

@@ -133,14 +133,29 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 // 约束:"只能分配严格低于自身的等级"(数字更大 = 更低),与 checkPermLevel 的 ">=" 拦截口径对齐,
 // 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。
 // 拥有产品全权(SuperAdmin / ADMIN / DEVELOPER)的调用者直接放行。
-func GuardRoleLevelAssignable(caller *loaders.UserDetails, rolePermsLevel int64) error {
+//
+// 授权依据直接走 DB 强一致查询,而不是 caller 的 UD 缓存:
+// 超管刚把 caller 从高级降到中级时,如果 UD 缓存的 Clean 因为 Redis 抖动失败,caller 的缓存里还
+// 是旧的高 MinPermsLevel;缓存 TTL 窗口内 caller 仍可凭旧级别批量分配超出当前权限的角色
+// (审计 M-3 TOCTOU + 缓存失效延迟)。这里按"最小代价避开缓存"的原则,只在 assignment 决策点
+// 打一条 FindMinPermsLevelByUserIdAndProductCode 走 NoCache 查询。
+func GuardRoleLevelAssignable(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, rolePermsLevel int64) error {
 	if HasFullProductPerms(caller) {
 		return nil
 	}
-	if caller == nil || caller.MinPermsLevel == math.MaxInt64 {
+	if caller == nil {
 		return response.ErrForbidden("您没有可分配的角色等级")
 	}
-	if rolePermsLevel <= caller.MinPermsLevel {
+
+	freshLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, caller.UserId, caller.ProductCode)
+	if err != nil {
+		if errors.Is(err, sqlx.ErrNotFound) {
+			return response.ErrForbidden("您没有可分配的角色等级")
+		}
+		// 其他错误走 fail-close,避免 DB 抖动被同化为"无角色 = 最低级"放行超权分配。
+		return response.NewCodeError(500, "校验可分配角色等级失败,请稍后重试")
+	}
+	if rolePermsLevel <= freshLevel {
 		return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
 	}
 	return nil
@@ -158,6 +173,49 @@ func HasFullProductPerms(caller *loaders.UserDetails) bool {
 		caller.MemberType == consts.MemberTypeDeveloper
 }
 
+// CheckAddMemberAccess 校验 caller 是否有权把 target 作为**新成员**拉进产品。与 CheckManageAccess 的
+// 差异:
+//  1. 不做 memberType / permsLevel 比对——AddMember 时 target 还不是成员,checkPermLevel 的
+//     FindOneByProductCodeUserId 必定落空报 403,整个流程对产品 ADMIN 直接不可用;
+//  2. 对产品 ADMIN 也强制执行部门链校验(checkDeptHierarchy 的 ADMIN bypass 不生效),防止
+//     ADMIN 从部门树外 / HR / 财务把人强拉入自己的产品(见审计 H-3);
+//  3. SuperAdmin 仍完全豁免,自改自加场景由上层业务规则另行屏蔽。
+//
+// 调用方应先走 RequireProductAdminFor 保证 caller 本身有"添加成员"的基线权限,再调这一步过部门链。
+func CheckAddMemberAccess(ctx context.Context, svcCtx *svc.ServiceContext, target *userModel.SysUser) error {
+	caller := middleware.GetUserDetails(ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+	if caller.IsSuperAdmin {
+		return nil
+	}
+	if target == nil {
+		return response.ErrBadRequest("缺少目标用户信息")
+	}
+	if caller.UserId == target.Id {
+		return nil
+	}
+
+	// 不走 checkDeptHierarchy 的 ADMIN 分支:ADMIN bypass 设计本意是"product ADMIN 对产品内既有
+	// 成员有全面管理权",但 AddMember 还没把 target 拉进产品,这里的 ADMIN bypass 会变成一个
+	// "随手拉部门树外的人进来"的漏洞(审计 H-3)。
+	if caller.DeptId == 0 || caller.DeptPath == "" {
+		return response.ErrForbidden("您未归属任何部门,无权添加产品成员")
+	}
+	if target.DeptId == 0 {
+		return response.ErrForbidden("目标用户未归属部门,仅超管可将其添加为成员")
+	}
+	targetDept, err := svcCtx.SysDeptModel.FindOne(ctx, target.DeptId)
+	if err != nil {
+		return response.ErrForbidden("无法校验目标用户部门")
+	}
+	if !strings.HasPrefix(targetDept.Path, caller.DeptPath) {
+		return response.ErrForbidden("无权将其他部门的用户添加为产品成员")
+	}
+	return nil
+}
+
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
 //

+ 260 - 0
internal/logic/auth/checkAddMemberAccess_audit_test.go

@@ -0,0 +1,260 @@
+package auth
+
+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"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-3 修复 —— CheckAddMemberAccess 专门为 AddMember 前置流程设计,
+// 用以堵住"产品 ADMIN 从部门树外把人强拉进自己产品"的漏洞。
+// 对比 CheckManageAccess:
+//   1) 不做 memberType / permsLevel 比对;
+//   2) 对产品 ADMIN 不走 checkDeptHierarchy 的 bypass,强制做部门链校验;
+//   3) SuperAdmin 仍完全豁免;
+//   4) target 为空 / 未归属部门等情况 fail-close。
+// ---------------------------------------------------------------------------
+
+func callerProductAdmin(deptId int64, deptPath string) *loaders.UserDetails {
+	return &loaders.UserDetails{
+		UserId:       2,
+		Username:     "pa",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeAdmin,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "pc_h3",
+		DeptId:       deptId,
+		DeptPath:     deptPath,
+	}
+}
+
+func callerMember(deptId int64, deptPath string) *loaders.UserDetails {
+	return &loaders.UserDetails{
+		UserId:       3,
+		Username:     "mbr",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeMember,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "pc_h3",
+		DeptId:       deptId,
+		DeptPath:     deptPath,
+	}
+}
+
+// TC-0940: H-3 —— 产品 ADMIN 将部门树**外**的 target 拉进产品时必须 403,
+// 不得因其 MemberType=ADMIN 享受 checkDeptHierarchy 的 bypass。
+func TestCheckAddMemberAccess_ProductAdmin_CrossDept_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	// target 所在部门 path = /200/201/,与 caller 部门 path=/100/ 不在同一子树
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(201)).
+		Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/"))
+	target := &userModel.SysUser{Id: 42, DeptId: 201}
+
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.Error(t, err, "H-3:产品 ADMIN 不能把部门树外的人拉进自己产品")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "其他部门")
+}
+
+// TC-0941: H-3 —— 产品 ADMIN 将部门树**内**的 target 拉进产品允许通过。
+func TestCheckAddMemberAccess_ProductAdmin_SameSubtree_Allowed(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
+		Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/"))
+	target := &userModel.SysUser{Id: 42, DeptId: 101}
+
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.NoError(t, err, "H-3:target 在 caller 部门子树内应允许添加")
+}
+
+// TC-0942: H-3 —— SuperAdmin 完全豁免,不触发 SysDeptModel.FindOne。
+func TestCheckAddMemberAccess_SuperAdmin_BypassNoDBCall(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	su := &loaders.UserDetails{
+		UserId: 1, Username: "su", IsSuperAdmin: true,
+		MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled,
+	}
+	ctx := middleware.WithUserDetails(context.Background(), su)
+	target := &userModel.SysUser{Id: 42, DeptId: 999} // 任意部门
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.NoError(t, err)
+}
+
+// TC-0943: H-3 —— caller 自加自 (target.Id == caller.UserId) 豁免部门校验,
+// 避免阻塞"ADMIN 把自己添加进新产品"这类合法运维路径。
+func TestCheckAddMemberAccess_SelfAdd_Allowed(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerProductAdmin(100, "/100/")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	target := &userModel.SysUser{Id: caller.UserId, DeptId: 999}
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.NoError(t, err)
+}
+
+// TC-0944: H-3 —— caller 自身 DeptId=0(幽灵账号)时必须 403,
+// 不得让"无部门归属但拥有 product ADMIN"的账号绕过整个部门链校验。
+func TestCheckAddMemberAccess_CallerWithoutDept_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerProductAdmin(0, "")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	target := &userModel.SysUser{Id: 42, DeptId: 101}
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "未归属任何部门")
+}
+
+// TC-0945: H-3 —— target 未归属部门时必须 403(仅超管可破例),
+// 避免"空 deptId 的 user 被部门前缀匹配逻辑误判"通过。
+func TestCheckAddMemberAccess_TargetWithoutDept_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerProductAdmin(100, "/100/")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	target := &userModel.SysUser{Id: 42, DeptId: 0}
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "未归属部门")
+}
+
+// TC-0946: H-3 —— 未登录 / 缺少 UserDetails 上下文时返回 401,
+// 而不是 silently 放行或 panic。
+func TestCheckAddMemberAccess_NoCallerCtx_Unauthorized(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	target := &userModel.SysUser{Id: 42, DeptId: 101}
+	err := CheckAddMemberAccess(context.Background(), svcCtx, target)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+}
+
+// TC-0947: H-3 —— SysDeptModel.FindOne 报错时必须 fail-close 返回 403(无法校验),
+// 不得静默放行。消息避免暴露底层 DB 细节。
+func TestCheckAddMemberAccess_DeptFindOneError_FailClose(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(777)).
+		Return(nil, errors.New("db: connection refused")).Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerProductAdmin(100, "/100/")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	target := &userModel.SysUser{Id: 42, DeptId: 777}
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.NotContains(t, ce.Error(), "db:",
+		"错误消息不得泄漏底层 DB 细节")
+}
+
+// TC-0948: H-3 —— 非 ADMIN 的普通 MEMBER 作 caller 时同样走 CheckAddMemberAccess 的部门链判定
+// (虽然 AddMember 的 RequireProductAdminFor 会更早拒绝,但防御深度仍需保证此函数独立正确)。
+func TestCheckAddMemberAccess_Member_CrossDept_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), int64(201)).
+		Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerMember(100, "/100/")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	target := &userModel.SysUser{Id: 42, DeptId: 201}
+	err := CheckAddMemberAccess(ctx, svcCtx, target)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+}
+
+// TC-0949: H-3 —— target 为 nil 时必须 400,而不是 panic。
+func TestCheckAddMemberAccess_NilTarget_BadRequest(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+	deptMock := mocks.NewMockSysDeptModel(ctrl)
+	deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0)
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
+
+	caller := callerProductAdmin(100, "/100/")
+	ctx := middleware.WithUserDetails(context.Background(), caller)
+	err := CheckAddMemberAccess(ctx, svcCtx, nil)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+}

+ 262 - 0
internal/logic/auth/guardRoleLevelAssignable_freshRead_audit_test.go

@@ -0,0 +1,262 @@
+package auth
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/response"
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-3 修复 —— GuardRoleLevelAssignable 必须每次走 DB 强一致查询,
+// 绝不能信任 caller(loaders.UserDetails)里可能已经 stale 的 MinPermsLevel 缓存。
+//
+// TOCTOU 场景:
+//   1. caller 原先是 permsLevel=5 的高阶成员。
+//   2. 超管把 caller 的高阶角色摘掉,现在 DB 里 MinPermsLevel=100(低阶)。
+//   3. UD 缓存还没被 Clean(Redis 抖动 / TTL 窗口内),caller.MinPermsLevel=5 是旧值。
+//   4. caller 此刻尝试分配 permsLevel=50 的角色 —— 若信缓存(5 vs 50)会**误放行**;
+//      修复后走 DB(100 vs 50),必须 403 拦截。
+// ---------------------------------------------------------------------------
+
+// TC-0930: M-3 —— stale caller.MinPermsLevel 不得影响判定,rolePermsLevel <= freshLevel 必须 403。
+func TestGuardRoleLevelAssignable_StaleCallerCache_FreshDBRejects(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const productCode = "m3_pc_stale"
+	const callerId = int64(1001)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	// 关键:DB 强一致返回 100(被降级后的真实等级)。
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(100), nil).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId:        callerId,
+		Username:      "m3_stale_caller",
+		MemberType:    consts.MemberTypeMember,
+		ProductCode:   productCode,
+		Status:        consts.StatusEnabled,
+		MinPermsLevel: 5,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
+	require.Error(t, err, "stale 缓存(5) 下试图分配 permsLevel=50,信缓存会放行;走 DB(100) 必须 403")
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(), "M-3:拒绝分配高于自身 fresh 等级的角色 → 403")
+}
+
+// TC-0931: M-3 —— 同级(rolePermsLevel == freshLevel)也要拦截,保持与 checkPermLevel 的 ">=" 对齐。
+func TestGuardRoleLevelAssignable_SameLevel_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const productCode = "m3_pc_same"
+	const callerId = int64(1002)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(50), nil).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId:      callerId,
+		Username:    "m3_same_caller",
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: productCode,
+		Status:      consts.StatusEnabled,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
+	require.Error(t, err, "与自身同级不允许分配,否则会让下属获得与上级等效的权力")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "同级")
+}
+
+// TC-0932: M-3 —— rolePermsLevel 严格低于 freshLevel(数值更大)时放行。
+func TestGuardRoleLevelAssignable_StrictlyLower_Allowed(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const productCode = "m3_pc_ok"
+	const callerId = int64(1003)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(50), nil).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId:      callerId,
+		Username:    "m3_ok_caller",
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: productCode,
+		Status:      consts.StatusEnabled,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 80)
+	require.NoError(t, err, "permsLevel=80 严格低于 freshLevel=50(数值更大 = 更低权限)应放行")
+}
+
+// TC-0933: M-3 —— SuperAdmin 完全豁免,不触发任何 DB 查询。
+func TestGuardRoleLevelAssignable_SuperAdmin_BypassNoDBCall(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	// 预期 0 次调用:SuperAdmin 必须短路返回,不能浪费 DB RTT。
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
+		Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId: 1, Username: "root", IsSuperAdmin: true, Status: consts.StatusEnabled,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
+	require.NoError(t, err, "SuperAdmin 必须放行任何 permsLevel")
+}
+
+// TC-0934: M-3 —— 产品 ADMIN 拥有全权,豁免 DB 查询。
+func TestGuardRoleLevelAssignable_ProductAdmin_BypassNoDBCall(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
+		Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId: 2, Username: "pa", MemberType: consts.MemberTypeAdmin,
+		ProductCode: "p1", Status: consts.StatusEnabled,
+	}
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
+	require.NoError(t, err, "产品 ADMIN 属于全权角色,必须豁免等级校验")
+}
+
+// TC-0935: M-3 —— DEVELOPER 同样享有全权豁免。
+func TestGuardRoleLevelAssignable_Developer_BypassNoDBCall(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
+		Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId: 3, Username: "dev", MemberType: consts.MemberTypeDeveloper,
+		ProductCode: "p1", Status: consts.StatusEnabled,
+	}
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
+	require.NoError(t, err)
+}
+
+// TC-0936: M-3 —— caller 在 DB 里**无任何角色**(ErrNotFound),必须 403,不能默认为 MaxInt64 放行。
+// 这里的语义是"没有可分配的角色等级":一个 MEMBER 连自己都没角色,自然不能分配角色给别人。
+func TestGuardRoleLevelAssignable_CallerHasNoRole_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const productCode = "m3_pc_noRole"
+	const callerId = int64(1004)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(0), sqlx.ErrNotFound).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId: callerId, Username: "m3_no_role", MemberType: consts.MemberTypeMember,
+		ProductCode: productCode, Status: consts.StatusEnabled,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 99)
+	require.Error(t, err, "caller 无任何角色时必须拒绝,否则会被误判为 MaxInt64 最低级从而放行任何 permsLevel")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "没有可分配的角色等级")
+}
+
+// TC-0937: M-3 —— DB 抖动(非 ErrNotFound)必须 fail-close 返回 500,不得降级为"无角色 → 放行"。
+func TestGuardRoleLevelAssignable_DBError_FailCloseWith500(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const productCode = "m3_pc_dbErr"
+	const callerId = int64(1005)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(0), errors.New("driver: bad connection")).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId: callerId, Username: "m3_db_err", MemberType: consts.MemberTypeMember,
+		ProductCode: productCode, Status: consts.StatusEnabled,
+	}
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 10)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 500, ce.Code(),
+		"M-3:DB 非 ErrNotFound 错误必须 fail-close 500,不能被伪装成 ErrNotFound → 放行超权分配")
+}
+
+// TC-0938: M-3 —— nil caller 防御:理论上无登录上下文绝不该进入此函数,防御性路径必须 403 而非 panic。
+func TestGuardRoleLevelAssignable_NilCaller_Rejected(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
+		Times(0)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	err := GuardRoleLevelAssignable(context.Background(), svcCtx, nil, 10)
+	require.Error(t, err, "nil caller 必须被拦截,杜绝隐式放行")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+}

+ 19 - 3
internal/logic/auth/jwt.go

@@ -2,6 +2,7 @@ package auth
 
 import (
 	"errors"
+	"fmt"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -12,6 +13,23 @@ import (
 
 var ErrTokenTypeMismatch = errors.New("token type mismatch")
 
+// ParseWithHMAC 统一的 keyfunc 断言入口。所有 JWT 解析点(HTTP 中间件 / gRPC VerifyToken / RefreshToken)
+// 都必须走这里,而不是直接 jwt.ParseWithClaims(... func() {return []byte(secret)}) ——
+// 必须显式断言 token.Method 是 *jwt.SigningMethodHMAC,避免未来迁移到 RSA/ECDSA 非对称密钥
+// 时,攻击者把公钥当成 HMAC 共享密钥伪造 token(jwt-go 历史上 CVE-2016-10555 同类问题,
+// OWASP JWT Cheat Sheet / RFC 8725 均强制要求 alg 白名单,见审计 H-4)。
+//
+// alg=none 在 jwt-go v4 早已默认拒绝,但显式 method 断言仍是深度防御的必要一步:
+// 即使未来有人误签出 alg=HS512 的 token,这里也会直接报错而不是当成 HS256 尝试解析。
+func ParseWithHMAC(tokenStr, secret string, claims jwt.Claims) (*jwt.Token, error) {
+	return jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
+		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+		}
+		return []byte(secret), nil
+	})
+}
+
 type RefreshClaims struct {
 	TokenType    string `json:"tokenType"`
 	UserId       int64  `json:"userId"`
@@ -75,9 +93,7 @@ func GenerateRefreshTokenWithExpiry(secret string, expiresAt time.Time, userId i
 }
 
 func ParseRefreshToken(tokenStr, secret string) (*RefreshClaims, error) {
-	token, err := jwt.ParseWithClaims(tokenStr, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
-		return []byte(secret), nil
-	})
+	token, err := ParseWithHMAC(tokenStr, secret, &RefreshClaims{})
 	if err != nil {
 		return nil, err
 	}

+ 4 - 2
internal/logic/auth/logoutLogic_test.go

@@ -35,7 +35,8 @@ func TestLogout_Normal_IncrementsTokenVersion(t *testing.T) {
 	userId, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
-	ud := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	ud, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	require.NoError(t, err, "M-1:正常用户 Load 应当 (*UserDetails, nil)")
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(0), ud.TokenVersion)
 
@@ -49,7 +50,8 @@ func TestLogout_Normal_IncrementsTokenVersion(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), u.TokenVersion, "logout 必须递增 tokenVersion")
 
-	ud2 := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	ud2, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	require.NoError(t, err)
 	require.NotNil(t, ud2)
 	assert.Equal(t, int64(1), ud2.TokenVersion, "clean 后重新 Load 应拿到最新 TokenVersion")
 }

+ 194 - 0
internal/logic/auth/parseWithHMAC_audit_test.go

@@ -0,0 +1,194 @@
+package auth
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/middleware"
+
+	"github.com/golang-jwt/jwt/v4"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-4 修复 —— ParseWithHMAC 必须显式断言 token.Method 为
+// *jwt.SigningMethodHMAC,拒绝任何非 HMAC 的 alg 头,包括 "none" / "RS256" 等。
+// 这里不等同于 jwt-go v4 对 "alg=none" 的默认拒绝,而是深度防御的显式白名单校验,
+// 杜绝未来迁移到 RSA/ECDSA 时攻击者把公钥当共享密钥伪造 HS256 token
+// (CVE-2016-10555 同类问题、OWASP JWT / RFC 8725 要求)。
+// ---------------------------------------------------------------------------
+
+const h4Secret = "h4-audit-secret-key"
+
+// b64url returns the jwt-style base64url (no padding) encoding.
+func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
+
+// forgeToken 手动拼接一个 JWT:自定义 header.alg + payload,再用任意密钥做 HMAC 签名。
+// 这用于模拟"攻击者伪造头部 alg 但签名仍走 HS256"的场景。
+func forgeToken(t *testing.T, alg string, claims any, signingKey string) string {
+	t.Helper()
+	header := map[string]string{"alg": alg, "typ": "JWT"}
+	hBytes, err := json.Marshal(header)
+	require.NoError(t, err)
+	pBytes, err := json.Marshal(claims)
+	require.NoError(t, err)
+
+	signingInput := b64url(hBytes) + "." + b64url(pBytes)
+	mac := hmac.New(sha256.New, []byte(signingKey))
+	mac.Write([]byte(signingInput))
+	sig := mac.Sum(nil)
+	return signingInput + "." + b64url(sig)
+}
+
+// forgeTokenNoSig 拼接一个没有签名的 token(alg=none 典型攻击,第三段签名留空)。
+func forgeTokenNoSig(t *testing.T, alg string, claims any) string {
+	t.Helper()
+	header := map[string]string{"alg": alg, "typ": "JWT"}
+	hBytes, err := json.Marshal(header)
+	require.NoError(t, err)
+	pBytes, err := json.Marshal(claims)
+	require.NoError(t, err)
+	return b64url(hBytes) + "." + b64url(pBytes) + "."
+}
+
+// validRefreshClaims 返回一组完整、未过期的 refresh claims,用于伪造攻击 token。
+func validRefreshClaims() RefreshClaims {
+	now := time.Now()
+	return RefreshClaims{
+		TokenType:    consts.TokenTypeRefresh,
+		UserId:       7,
+		ProductCode:  "h4_pc",
+		TokenVersion: 0,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
+			IssuedAt:  jwt.NewNumericDate(now),
+		},
+	}
+}
+
+// TC-0951: H-4 —— 正常 HS256 token 必须被 ParseWithHMAC 正确接受。
+func TestParseWithHMAC_HS256_Valid(t *testing.T) {
+	tok, err := GenerateRefreshToken(h4Secret, 3600, 7, "h4_pc", 0)
+	require.NoError(t, err)
+
+	token, err := ParseWithHMAC(tok, h4Secret, &RefreshClaims{})
+	require.NoError(t, err)
+	assert.True(t, token.Valid)
+	claims, ok := token.Claims.(*RefreshClaims)
+	require.True(t, ok)
+	assert.Equal(t, int64(7), claims.UserId)
+	assert.Equal(t, consts.TokenTypeRefresh, claims.TokenType)
+}
+
+// TC-0952: H-4 —— alg=none 的伪造 token 必须被拒绝。
+// jwt-go v4 默认就会拦住 "none",但显式 HMAC 断言保证即使 lib 行为变化我们仍 fail-close。
+func TestParseWithHMAC_AlgNone_Rejected(t *testing.T) {
+	forged := forgeTokenNoSig(t, "none", validRefreshClaims())
+
+	_, err := ParseWithHMAC(forged, h4Secret, &RefreshClaims{})
+	require.Error(t, err, "alg=none 必须被 ParseWithHMAC 拒绝")
+}
+
+// TC-0953: H-4 —— 攻击者把 header alg 改成 RS256 但仍用 secret 作 HS256 签名
+// (RSA 公钥 → HMAC secret 混淆攻击)。必须被 ParseWithHMAC 显式拒绝:
+// 命中 keyfunc 的 `token.Method.(*SigningMethodHMAC)` 断言失败分支。
+func TestParseWithHMAC_RS256HeaderButHMACSigned_Rejected(t *testing.T) {
+	forged := forgeToken(t, "RS256", validRefreshClaims(), h4Secret)
+
+	_, err := ParseWithHMAC(forged, h4Secret, &RefreshClaims{})
+	require.Error(t, err, "alg=RS256 必须被 ParseWithHMAC 拒绝")
+	assert.Contains(t, err.Error(), "unexpected signing method",
+		"错误信息必须明确指出 alg 与预期不符(便于运维快速定位攻击尝试)")
+}
+
+// TC-0954: H-4 —— alg=ES256 同样应被拒绝(非 HMAC 算法一律拒绝)。
+func TestParseWithHMAC_ES256HeaderButHMACSigned_Rejected(t *testing.T) {
+	forged := forgeToken(t, "ES256", validRefreshClaims(), h4Secret)
+
+	_, err := ParseWithHMAC(forged, h4Secret, &RefreshClaims{})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "unexpected signing method")
+}
+
+// TC-0955: H-4 —— alg=HS256 但用错误的 secret 签名应被拒绝(签名校验失败路径)。
+func TestParseWithHMAC_HS256WrongSecret_Rejected(t *testing.T) {
+	tok, err := GenerateRefreshToken("attacker-guessed-secret", 3600, 7, "h4_pc", 0)
+	require.NoError(t, err)
+
+	_, err = ParseWithHMAC(tok, h4Secret, &RefreshClaims{})
+	require.Error(t, err, "签名校验失败必须回错,不得放行")
+}
+
+// TC-0956: H-4 —— ParseRefreshToken(对外真实入口)也走 HMAC 断言,alg=RS256 必须被拒。
+// 保证 ParseWithHMAC 不是孤立函数,而是已被真实调用链使用。
+func TestParseRefreshToken_RS256Header_Rejected(t *testing.T) {
+	forged := forgeToken(t, "RS256", validRefreshClaims(), h4Secret)
+	_, err := ParseRefreshToken(forged, h4Secret)
+	require.Error(t, err, "ParseRefreshToken 必须转交 ParseWithHMAC 拒绝 RS256 伪造 token")
+}
+
+// TC-0957: H-4 —— ParseRefreshToken 对 alg=none 的 token 也必须拒绝。
+func TestParseRefreshToken_AlgNone_Rejected(t *testing.T) {
+	forged := forgeTokenNoSig(t, "none", validRefreshClaims())
+	_, err := ParseRefreshToken(forged, h4Secret)
+	require.Error(t, err)
+}
+
+// TC-0958: H-4 回归 —— 格式错误的 token(非三段式)必须 error 而不是 panic。
+func TestParseWithHMAC_Malformed_Rejected(t *testing.T) {
+	cases := []string{
+		"",
+		"not-a-token",
+		"only.two",
+		"a.b.c.d", // 四段
+	}
+	for _, s := range cases {
+		t.Run("malformed:"+s, func(t *testing.T) {
+			_, err := ParseWithHMAC(s, h4Secret, &RefreshClaims{})
+			require.Error(t, err)
+		})
+	}
+}
+
+// TC-0959: H-4 —— payload 中 TokenType 非 refresh 的 HS256 token 应被 ParseRefreshToken
+// 以 ErrTokenTypeMismatch 拒绝。确认 H-4 修复不会误吞该业务校验。
+func TestParseRefreshToken_AccessTokenRejectedWithTypeMismatch(t *testing.T) {
+	accessTok, err := GenerateAccessToken(h4Secret, 3600, 7, "u", "p", "M", 0)
+	require.NoError(t, err)
+	_, err = ParseRefreshToken(accessTok, h4Secret)
+	require.Error(t, err)
+	assert.Equal(t, ErrTokenTypeMismatch, err,
+		"H-4 的 ParseWithHMAC 不能吞掉业务层 TokenType 校验错误")
+}
+
+// TC-0960: H-4 —— 伪造 alg=HS256 但 header.typ 异常(如 "JWT"→"xxx")也不能绕过
+// HMAC 校验。此用例用来证明只要底层签名正确,header 其余字段不影响放行/拒绝的核心语义。
+// 反之,任何 alg 头不是 HS* 的一律拒,和 typ 无关。
+func TestParseWithHMAC_HS256UnusualTyp_Accepted(t *testing.T) {
+	// header.alg = HS256, header.typ = "JWT+weird",签名正确 → 应放行(typ 不参与断言)
+	header := map[string]string{"alg": "HS256", "typ": "JWT+weird"}
+	hBytes, _ := json.Marshal(header)
+	claims := validRefreshClaims()
+	pBytes, _ := json.Marshal(claims)
+	signingInput := b64url(hBytes) + "." + b64url(pBytes)
+	mac := hmac.New(sha256.New, []byte(h4Secret))
+	mac.Write([]byte(signingInput))
+	tok := signingInput + "." + b64url(mac.Sum(nil))
+
+	_, err := ParseWithHMAC(tok, h4Secret, &RefreshClaims{})
+	require.NoError(t, err,
+		"HMAC 断言只看 alg,typ 不属于签名算法白名单范畴,正常 HS256 应放行")
+}
+
+// 辅助:保持 strings 导入被使用,避免 go vet 警告。
+var _ = strings.Split
+
+// 确保 middleware.Claims 在包内可被用于 TypeRefresh / TypeAccess 等正反测试(未来扩展)。
+var _ = middleware.Claims{}

+ 2 - 2
internal/logic/auth/userInfoLogic_test.go

@@ -76,7 +76,7 @@ func TestUserInfo_WithProductCode(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
 	})
 
-	ud := svcCtx.UserDetailsLoader.Load(ctx, userId, pc)
+	ud, _ := svcCtx.UserDetailsLoader.Load(ctx, userId, pc)
 	infoCtx := middleware.WithUserDetails(context.Background(), ud)
 
 	logic := NewUserInfoLogic(infoCtx, svcCtx)
@@ -124,7 +124,7 @@ func TestUserInfo_WithoutProductCode(t *testing.T) {
 	userId, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
-	ud := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
+	ud, _ := svcCtx.UserDetailsLoader.Load(ctx, userId, "")
 	infoCtx := middleware.WithUserDetails(context.Background(), ud)
 
 	logic := NewUserInfoLogic(infoCtx, svcCtx)

+ 9 - 2
internal/logic/dept/deleteDeptLogic.go

@@ -34,6 +34,13 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 	}
 
 	return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		// 锁序协议:先锁本部门行(X 锁),再对子部门 / 关联用户用 FOR SHARE 做"存在性检查"。
+		// 存在性检查不修改目标行,用 S 锁即可,不会与 UpdateUser 改 DeptId(持有 sys_user 行的 X 锁)
+		// 或 CreateDept 插子部门(持有 sys_dept 新行的 X 锁)构成 AB-BA 冲突:
+		//   DeleteDept: sys_dept.X(self) → sys_dept.S(children range) → sys_user.S(dept range)
+		//   CreateDept: sys_dept.X(newRow)                                    ——独立范围
+		//   UpdateUser: sys_user.X(self) ± sys_dept.S(newDept existence)      ——独立方向
+		// 相比之前"全链路 FOR UPDATE"减少一种理论 AB-BA 死锁路径(见审计 L-1)。
 		var deptId int64
 		lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
 		if err := session.QueryRowCtx(ctx, &deptId, lockQuery, req.Id); err != nil {
@@ -41,7 +48,7 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		}
 
 		var childIds []int64
-		childQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `parentId` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
+		childQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `parentId` = ? FOR SHARE", l.svcCtx.SysDeptModel.TableName())
 		if err := session.QueryRowsCtx(ctx, &childIds, childQuery, req.Id); err != nil {
 			return err
 		}
@@ -50,7 +57,7 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		}
 
 		var userIds []int64
-		userQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `deptId` = ? FOR UPDATE", l.svcCtx.SysUserModel.TableName())
+		userQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `deptId` = ? FOR SHARE", l.svcCtx.SysUserModel.TableName())
 		if err := session.QueryRowsCtx(ctx, &userIds, userQuery, req.Id); err != nil {
 			return err
 		}

+ 18 - 0
internal/logic/member/addMemberLogic.go

@@ -59,6 +59,24 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		return nil, err
 	}
 
+	// 显式拒绝把超管拉入具体产品:loadMembership 虽然会把超管的 MemberType 固定为 SuperAdmin
+	// 让实际权限不受影响,但 sys_product_member 里会留下一条"product_admin 纳管了 super_admin"
+	// 的假成员关系,污染审计日志 / 权限推理工具(见审计 H-3)。
+	if targetUser.IsSuperAdmin == consts.IsSuperAdminYes {
+		return nil, response.ErrForbidden("无法将超级管理员加入具体产品")
+	}
+
+	// 补齐目标侧部门链授权:原先只做 RequireProductAdminFor(caller 侧),产品 ADMIN 就能把任意
+	// 部门树外的用户(HR、财务、其他 BU)强行拉进自己的产品,叠加 H-2 PII 暴露后可以"随便拉人 →
+	// 读全员 PII"。这里用 CheckAddMemberAccess 而不是 CheckManageAccess:
+	//  1. target 还不是成员,checkPermLevel 对它必定落空报 403,会整体打穿 product-ADMIN 的添加流程;
+	//  2. product-ADMIN 在 CheckManageAccess 中本身就会短路 checkDeptHierarchy,无法真正拦住跨
+	//     部门拉人。CheckAddMemberAccess 专门为 AddMember 这类"target 尚未进入成员池"的前置流程
+	//     设计,对 product ADMIN 也强制执行部门链校验(见审计 H-3)。
+	if err := authHelper.CheckAddMemberAccess(l.ctx, l.svcCtx, targetUser); err != nil {
+		return nil, err
+	}
+
 	_, findErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, req.UserId)
 	if findErr == nil {
 		return nil, response.ErrConflict("该用户已是该产品成员")

+ 47 - 0
internal/logic/member/auditFixes_test.go

@@ -297,6 +297,53 @@ func TestRemoveMember_NonAdmin_Unaffected(t *testing.T) {
 	require.NoError(t, err)
 }
 
+// TC-0950: H-3 修复 —— AddMember 必须显式拒绝把 SuperAdmin 作为普通产品成员加入。
+// 背景:loadMembership 会把 SuperAdmin 的 MemberType 固定为 SuperAdmin 让其实际权限不受影响,
+// 但若 sys_product_member 里仍落一条记录,会污染审计日志 / 权限推理工具,且给产品 ADMIN
+// "纳管了 superadmin" 的错觉。必须在 AddMember 入口就 403。
+func TestAddMember_SuperAdminTargetRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	code := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	// target 是 SuperAdmin(IsSuperAdmin=1)
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: "h3_su_" + code, Password: testutil.HashPassword("pw"),
+		Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	_, err = NewAddMemberLogic(ctx, svcCtx).AddMember(&types.AddMemberReq{
+		ProductCode: code, UserId: uId, MemberType: "MEMBER",
+	})
+	require.Error(t, err, "H-3:禁止把 SuperAdmin 加入具体产品为普通成员")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "超级管理员")
+
+	// DB 侧必须没有落下 SuperAdmin 的成员记录(regression:确保 AddMember 未短路在插入之后)
+	_, findErr := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, code, uId)
+	require.Error(t, findErr, "SuperAdmin 不得被落入 sys_product_member")
+}
+
 // TC-0729: L-5 修复:禁用产品不允许添加成员
 func TestAddMember_DisabledProductRejected(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()

+ 49 - 3
internal/logic/product/createProductLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/hex"
+	"encoding/json"
 	"fmt"
 	"regexp"
 	"time"
@@ -23,6 +24,22 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
+// 审计 M-4:CreateProduct 不再把 adminPassword/appSecret 明文写入响应体;改为把"真正的初始凭证"
+// 暂存 Redis,并只把一次性消费票据放进响应。票据本身是短期(5 分钟)+ 一次性,即使被上游日志/APM
+// 错误记录,也只能换一次而无法长期复用。
+const (
+	initialCredentialsTTL       = 5 * time.Minute
+	initialCredentialsKeyPrefix = "pm:initcred:"
+)
+
+// initialCredentialsPayload 实际落 Redis 的凭证载荷。放在内部文件以避免跨包暴露结构。
+type initialCredentialsPayload struct {
+	AppKey        string `json:"appKey"`
+	AppSecret     string `json:"appSecret"`
+	AdminUser     string `json:"adminUser"`
+	AdminPassword string `json:"adminPassword"`
+}
+
 var productCodeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{1,63}$`)
 
 type CreateProductLogic struct {
@@ -142,13 +159,42 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 		return nil, err
 	}
 
-	return &types.CreateProductResp{
-		Id:            productId,
-		Code:          req.Code,
+	// 生成一次性凭证票据(32 字节随机,hex 编码)。
+	ticket, err := generateRandomHex(32)
+	if err != nil {
+		// 退化策略:Redis 写入失败时不该返回明文密码(那样就回到了 M-4 的老毛病)。
+		// 这里直接 500,让运维查链路;调用方可根据业务决定重启流程。
+		logx.WithContext(l.ctx).Errorf("CreateProduct: generate credentials ticket failed: %v", err)
+		return nil, response.NewCodeError(500, "生成初始凭证票据失败,请稍后重试")
+	}
+	payload := initialCredentialsPayload{
 		AppKey:        appKey,
 		AppSecret:     rawAppSecret,
 		AdminUser:     adminUsername,
 		AdminPassword: adminPassword,
+	}
+	buf, mErr := json.Marshal(&payload)
+	if mErr != nil {
+		logx.WithContext(l.ctx).Errorf("CreateProduct: marshal credentials payload failed: %v", mErr)
+		return nil, response.NewCodeError(500, "封装初始凭证失败,请稍后重试")
+	}
+	ticketKey := initialCredentialsKeyPrefix + ticket
+	if setErr := l.svcCtx.Redis.SetexCtx(l.ctx, ticketKey, string(buf), int(initialCredentialsTTL/time.Second)); setErr != nil {
+		logx.WithContext(l.ctx).Errorf("CreateProduct: stash credentials to redis failed: %v", setErr)
+		return nil, response.NewCodeError(500, "暂存初始凭证失败,请稍后重试")
+	}
+
+	// 仅脱敏字段 + ticket 落响应体。productCode / adminUser 属于可公开的管理信息。
+	logx.WithContext(l.ctx).Infof("CreateProduct: product=%s admin=%s credentialsTicketIssued ttl=%s",
+		req.Code, adminUsername, initialCredentialsTTL)
+
+	return &types.CreateProductResp{
+		Id:                   productId,
+		Code:                 req.Code,
+		AppKey:               appKey,
+		AdminUser:            adminUsername,
+		CredentialsTicket:    ticket,
+		CredentialsExpiresAt: time.Now().Add(initialCredentialsTTL).Unix(),
 	}, nil
 }
 

+ 28 - 4
internal/logic/product/createProductLogic_test.go

@@ -1,6 +1,7 @@
 package product
 
 import (
+	"encoding/json"
 	"errors"
 	"sync"
 	"testing"
@@ -42,9 +43,23 @@ func TestCreateProduct_Success(t *testing.T) {
 	assert.True(t, resp.Id > 0)
 	assert.Equal(t, code, resp.Code)
 	assert.NotEmpty(t, resp.AppKey)
-	assert.NotEmpty(t, resp.AppSecret)
 	assert.Equal(t, "admin_"+code, resp.AdminUser)
-	assert.NotEmpty(t, resp.AdminPassword)
+
+	// 审计 M-4:响应体必须不再明文携带 appSecret / adminPassword,
+	// 改为发放一次性 credentialsTicket + 过期时间;调用方需凭 ticket 走
+	// /api/product/fetchInitialCredentials 领取敏感凭证。
+	assert.NotEmpty(t, resp.CredentialsTicket, "M-4:必须返回一次性凭证票据")
+	assert.True(t, resp.CredentialsExpiresAt > 0, "M-4:必须返回过期时间戳")
+
+	// 契约性校验:CreateProductResp 的 JSON 序列化里不应再出现 appSecret / adminPassword 字段。
+	buf, err := json.Marshal(resp)
+	require.NoError(t, err)
+	var asMap map[string]interface{}
+	require.NoError(t, json.Unmarshal(buf, &asMap))
+	_, hasSecret := asMap["appSecret"]
+	_, hasPwd := asMap["adminPassword"]
+	assert.False(t, hasSecret, "M-4:CreateProductResp JSON 不得包含 appSecret 字段(避免日志落盘)")
+	assert.False(t, hasPwd, "M-4:CreateProductResp JSON 不得包含 adminPassword 字段(避免日志落盘)")
 }
 
 // TC-0064: 正常创建
@@ -73,8 +88,17 @@ func TestCreateProduct_VerifyDB(t *testing.T) {
 	assert.Equal(t, code, product.Code)
 	assert.Equal(t, "DB验证产品", product.Name)
 	assert.Equal(t, resp.AppKey, product.AppKey)
-	require.NoError(t, bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(resp.AppSecret)),
-		"DB should store bcrypt hash of appSecret, verifiable with plaintext from response")
+
+	// 审计 M-4:CreateProduct 响应不再明文吐 appSecret;appSecret 经 ticket 领取后再核对。
+	// 这里改为用 FetchInitialCredentialsLogic 把明文 appSecret 取出来,与 DB 中的 bcrypt hash 比对,
+	// 既验证"DB 存的是 hash 而不是明文",也验证 ticket 流程正确交还了原始 appSecret。
+	fetch := NewFetchInitialCredentialsLogic(ctx, svcCtx)
+	cred, err := fetch.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: resp.CredentialsTicket})
+	require.NoError(t, err, "M-4:使用 ticket 必须能领取到初始 appSecret / adminPassword")
+	require.NotEmpty(t, cred.AppSecret)
+	require.NotEmpty(t, cred.AdminPassword)
+	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(cred.AppSecret)),
+		"DB should store bcrypt hash of appSecret, verifiable with plaintext from ticket payload")
 	assert.Equal(t, int64(1), product.Status)
 
 	var userCount int64

+ 65 - 0
internal/logic/product/fetchInitialCredentialsLogic.go

@@ -0,0 +1,65 @@
+package product
+
+import (
+	"context"
+	"encoding/json"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type FetchInitialCredentialsLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewFetchInitialCredentialsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FetchInitialCredentialsLogic {
+	return &FetchInitialCredentialsLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// FetchInitialCredentials 凭 CreateProduct 响应中的 credentialsTicket 一次性领取 appSecret 与初始
+// adminPassword。Ticket 在 Redis 中以 initialCredentialsKeyPrefix 前缀保存,短 TTL(5 分钟),
+// 一次消费后立即删除;调用此接口的通常是 CreateProduct 的直接后继,因此要求超管身份——即便 ticket
+// 泄漏到日志,非超管也没法消费,进一步压缩攻击面(审计 M-4)。
+func (l *FetchInitialCredentialsLogic) FetchInitialCredentials(req *types.FetchInitialCredentialsReq) (*types.FetchInitialCredentialsResp, error) {
+	if err := authHelper.RequireSuperAdmin(l.ctx); err != nil {
+		return nil, err
+	}
+	if req == nil || req.Ticket == "" {
+		return nil, response.ErrBadRequest("ticket 不能为空")
+	}
+
+	key := initialCredentialsKeyPrefix + req.Ticket
+	// GetDelCtx 语义 = GET + DEL 原子化,确保一次性消费:即便并发两次请求同一 ticket,只有一次
+	// 能拿到非空返回,另一次被识别为已消费/已过期。
+	val, err := l.svcCtx.Redis.GetDelCtx(l.ctx, key)
+	if err != nil {
+		logx.WithContext(l.ctx).Errorf("FetchInitialCredentials: redis getdel failed: %v", err)
+		return nil, response.NewCodeError(503, "凭证服务暂时不可用,请稍后重试")
+	}
+	if val == "" {
+		return nil, response.ErrBadRequest("凭证票据无效或已过期")
+	}
+
+	var payload initialCredentialsPayload
+	if err := json.Unmarshal([]byte(val), &payload); err != nil {
+		logx.WithContext(l.ctx).Errorf("FetchInitialCredentials: unmarshal payload failed: %v", err)
+		return nil, response.NewCodeError(500, "凭证数据异常,请联系管理员")
+	}
+
+	return &types.FetchInitialCredentialsResp{
+		AppKey:        payload.AppKey,
+		AppSecret:     payload.AppSecret,
+		AdminUser:     payload.AdminUser,
+		AdminPassword: payload.AdminPassword,
+	}, nil
+}

+ 344 - 0
internal/logic/product/fetchInitialCredentialsLogic_audit_test.go

@@ -0,0 +1,344 @@
+package product
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"testing"
+
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-4 —— CreateProduct 不再明文回吐 appSecret / adminPassword,
+// 改为发放一次性 credentialsTicket,调用方再凭 ticket 调 FetchInitialCredentials 领取。
+// 安全契约必须钉死:
+//   1) 只有超管能消费 ticket(非超管必须 403);
+//   2) 必须一次性消费(consumed 后再次消费必须 400);
+//   3) 错误/过期 ticket 必须 400;
+//   4) 空 ticket 必须 400;
+//   5) 并发消费同一 ticket 时,有且仅有一个请求能拿到明文(GetDelCtx 原子性);
+//   6) Redis 中落盘的 value 必须是结构化 JSON,而不是裸明文(便于未来加密 / schema 演进);
+//   7) FetchInitialCredentialsResp 必须暴露 appSecret / adminPassword / appKey / adminUser。
+// ---------------------------------------------------------------------------
+
+// TC-0901: FetchInitialCredentials 正常路径 —— 用 CreateProduct 返回的 ticket 领取凭证。
+func TestFetchInitialCredentials_HappyPath(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_ok", Remark: "",
+	})
+	require.NoError(t, err)
+	require.NotNil(t, createResp)
+	require.NotEmpty(t, createResp.CredentialsTicket)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	cred, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
+	)
+	require.NoError(t, err)
+	require.NotNil(t, cred)
+
+	assert.Equal(t, createResp.AppKey, cred.AppKey, "appKey 必须与 CreateProduct 响应一致")
+	assert.Equal(t, createResp.AdminUser, cred.AdminUser, "adminUser 必须与 CreateProduct 响应一致")
+	assert.NotEmpty(t, cred.AppSecret, "必须返回明文 appSecret")
+	assert.NotEmpty(t, cred.AdminPassword, "必须返回明文 adminPassword")
+	// 基础合理性:32 字节 hex = 64 字符;12 字节 hex = 24 字符(与 createProductLogic 生成参数对齐)。
+	assert.Len(t, cred.AppSecret, 64, "appSecret 必须是 32 字节 hex")
+	assert.Len(t, cred.AdminPassword, 24, "adminPassword 必须是 12 字节 hex")
+}
+
+// TC-0902: FetchInitialCredentials 一次性消费 —— 同一 ticket 第二次消费必须 400。
+func TestFetchInitialCredentials_OneShotConsumption(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_once",
+	})
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	logic := NewFetchInitialCredentialsLogic(ctx, svcCtx)
+	first, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.NoError(t, err)
+	require.NotNil(t, first)
+
+	second, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket})
+	require.Error(t, err, "M-4:一次性 ticket 不能被第二次消费")
+	assert.Nil(t, second)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Contains(t, ce.Error(), "凭证票据无效或已过期")
+}
+
+// TC-0903: FetchInitialCredentials 未知 ticket 必须 400
+func TestFetchInitialCredentials_UnknownTicketRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	_, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: "definitely_not_a_real_ticket_" + testutil.UniqueId()},
+	)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+}
+
+// TC-0904: 空 ticket 必须 400
+func TestFetchInitialCredentials_EmptyTicketRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	_, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: ""},
+	)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Contains(t, ce.Error(), "ticket 不能为空")
+}
+
+// TC-0905: nil req 必须 400
+func TestFetchInitialCredentials_NilRequestRejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	_, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(nil)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+}
+
+// TC-0906: 非超管禁止消费 ticket —— 即便 ticket 偶然外泄,非超管会被直接 403,
+// 把攻击面进一步压缩。
+func TestFetchInitialCredentials_NonSuperAdminRejected(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	superCtx := ctxhelper.SuperAdminCtx()
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(superCtx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_403",
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTableByField(superCtx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(superCtx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(superCtx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(superCtx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	// 用产品 ADMIN 身份尝试消费
+	adminCtx := ctxhelper.AdminCtx(code)
+	_, err = NewFetchInitialCredentialsLogic(adminCtx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
+	)
+	require.Error(t, err, "非超管必须被直接 403,不进入 Redis 消费阶段")
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+
+	// 同时断言 ticket 没被拒绝请求的副作用消费掉——后续超管仍可正常领取。
+	cred, err := NewFetchInitialCredentialsLogic(superCtx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
+	)
+	require.NoError(t, err, "M-4:非超管被拒时不得把 ticket 吞掉,否则超管会领取不到")
+	assert.NotEmpty(t, cred.AppSecret)
+}
+
+// TC-0907: 未登录上下文必须 401
+func TestFetchInitialCredentials_UnauthenticatedRejected(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	_, err := NewFetchInitialCredentialsLogic(context.Background(), svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: "any"},
+	)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+}
+
+// TC-0908: Redis 中 ticket 载荷若不可解析,必须返回 500 结构错误而非把 raw 塞给调用方。
+func TestFetchInitialCredentials_MalformedPayloadIn500(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	ticket := "mal_" + testutil.UniqueId()
+	key := initialCredentialsKeyPrefix + ticket
+	require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, "{this is not valid json", 30))
+	t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) })
+
+	_, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
+		&types.FetchInitialCredentialsReq{Ticket: ticket},
+	)
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 500, ce.Code(), "损坏的载荷必须走 500 错误通道而非 panic / 静默成功")
+
+	// 同时断言:损坏载荷在失败后已经被 GetDelCtx 消费掉,不会永久留在 Redis 里形成噪声。
+	val, _ := svcCtx.Redis.GetCtx(ctx, key)
+	assert.Empty(t, val, "损坏载荷在 GetDelCtx 过程中必须被一并删除(DEL 是原子的)")
+}
+
+// TC-0909: Redis 中落盘的 value 必须是结构化 JSON,包含 4 个字段。
+// 如果未来有人把 Marshal 换回裸字符串(又一个 M-4 回归),这个测试立刻炸。
+func TestFetchInitialCredentials_StoredPayloadIsStructuredJSON(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_json",
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	val, err := svcCtx.Redis.GetCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	require.NoError(t, err)
+	require.NotEmpty(t, val)
+
+	var payload map[string]interface{}
+	require.NoError(t, json.Unmarshal([]byte(val), &payload), "Redis 载荷必须是结构化 JSON")
+	for _, key := range []string{"appKey", "appSecret", "adminUser", "adminPassword"} {
+		v, ok := payload[key]
+		assert.True(t, ok, "Redis 载荷必须包含字段 %s", key)
+		assert.NotEmpty(t, v, "字段 %s 不能为空", key)
+	}
+}
+
+// TC-0910: Redis TTL 在合理区间(> 0 且 <= 5 分钟 = 300s)。
+func TestFetchInitialCredentials_TicketTTLWithinWindow(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_ttl",
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	ttl, err := svcCtx.Redis.TtlCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	require.NoError(t, err)
+	assert.Greater(t, ttl, 0, "ticket 必须是短 TTL 而不是永久")
+	assert.LessOrEqual(t, ttl, 300, "M-4:ticket TTL 不得超过 5 分钟 300s")
+}
+
+// TC-0911: 并发消费同一 ticket —— 有且仅有一个请求能拿到明文,其他全部 400。
+// 依赖 GetDelCtx 的原子 GET+DEL 语义,这是 M-4 防竞态的核心契约。
+func TestFetchInitialCredentials_ConcurrentConsumptionSingleWinner(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+
+	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "tic_conc",
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
+		testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code)
+		testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id)
+		_, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket)
+	})
+
+	const N = 8
+	var wg sync.WaitGroup
+	var successes int32
+	var fails int32
+	start := make(chan struct{})
+
+	for i := 0; i < N; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			<-start
+			_, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(
+				&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket},
+			)
+			if err == nil {
+				atomic.AddInt32(&successes, 1)
+			} else {
+				atomic.AddInt32(&fails, 1)
+			}
+		}()
+	}
+	close(start)
+	wg.Wait()
+
+	assert.Equal(t, int32(1), atomic.LoadInt32(&successes),
+		"并发消费必须恰好 1 个胜出(GetDelCtx 原子 GET+DEL),实际 successes=%d fails=%d",
+		atomic.LoadInt32(&successes), atomic.LoadInt32(&fails))
+	assert.Equal(t, int32(N-1), atomic.LoadInt32(&fails),
+		"其余 %d 个并发都必须拿到 400", N-1)
+}
+
+// TC-0912: 契约回归 —— CreateProductResp 的公开字段集合不得再包含 appSecret / adminPassword。
+// 这是对 M-4 的"结构体层面"回归(TC-0064 只覆盖到 JSON 序列化层面)。
+func TestCreateProductResp_NoLongerExposesPlaintextCredentials(t *testing.T) {
+	resp := &types.CreateProductResp{}
+	bs, err := json.Marshal(resp)
+	require.NoError(t, err)
+
+	// 序列化结果里出现 "appSecret" 或 "adminPassword" 都属于 M-4 回归。
+	asStr := string(bs)
+	assert.NotContains(t, asStr, "\"appSecret\"",
+		"M-4:CreateProductResp 不得再含有 appSecret(哪怕是空串序列化)")
+	assert.NotContains(t, asStr, "\"adminPassword\"",
+		"M-4:CreateProductResp 不得再含有 adminPassword(哪怕是空串序列化)")
+
+	// 必须有 ticket 相关字段
+	assert.Contains(t, asStr, "credentialsTicket")
+	assert.Contains(t, asStr, "credentialsExpiresAt")
+}
+
+// fmt 引用以避免 import 被误清理(某些工具链会 trim 没用到的 import)
+var _ = fmt.Sprintf

+ 4 - 1
internal/logic/pub/adminLoginLogic.go

@@ -77,7 +77,10 @@ func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.Logi
 		return nil, response.ErrUnauthorized("用户名或密码错误")
 	}
 
-	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, "")
+	ud, err := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, "")
+	if err != nil {
+		return nil, response.NewCodeError(503, "服务暂时不可用,请稍后重试")
+	}
 
 	accessToken, err := authHelper.GenerateAccessToken(
 		l.svcCtx.Config.Auth.AccessSecret,

+ 4 - 1
internal/logic/pub/loginService.go

@@ -90,7 +90,10 @@ func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, usern
 		return nil, &LoginError{Code: 403, Message: "您在该产品下的成员资格已被禁用"}
 	}
 
-	ud := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode)
+	ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode)
+	if err != nil {
+		return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"}
+	}
 
 	accessToken, err := authHelper.GenerateAccessToken(
 		svcCtx.Config.Auth.AccessSecret,

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

@@ -50,7 +50,13 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, response.ErrBadRequest("刷新令牌不允许切换产品")
 	}
 
-	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, claims.UserId, productCode)
+	ud, err := l.svcCtx.UserDetailsLoader.Load(l.ctx, claims.UserId, productCode)
+	if err != nil {
+		return nil, response.NewCodeError(503, "服务暂时不可用,请稍后重试")
+	}
+	if ud.Username == "" {
+		return nil, response.ErrUnauthorized("用户不存在或已被删除")
+	}
 
 	if ud.Status != consts.StatusEnabled {
 		return nil, response.ErrForbidden("账号已被冻结")

+ 11 - 3
internal/logic/pub/refreshTokenLogic_test.go

@@ -145,7 +145,15 @@ func TestRefreshToken_InvalidToken(t *testing.T) {
 	assert.Equal(t, "refreshToken无效或已过期", codeErr.Error())
 }
 
-// TC-0029: 用户已删除(UserDetailsLoader 返回 Status=0 → 403 账号已被冻结)
+// TC-0029: 用户已被删除 —— M-1 修复后必须区分"不存在"(401) 与"冻结"(403)。
+//
+// 修复前:Loader 对不存在用户返回空壳 UserDetails(Status=0),RefreshToken 走到"账号已被冻结"分支 (403),
+//
+//	将"用户不存在"与"账号冻结"两个语义混淆,监控告警与运维处置策略无法区分。
+//
+// 修复后:Loader 返回 (ud, nil) 且 ud.Username == "",RefreshToken 显式回 401 "用户不存在或已被删除"。
+//
+//	这样客户端/前端才能走"注销本地会话 + 返回登录页"的终态流程,而不是提示"账号已冻结请联系管理员"。
 func TestRefreshToken_UserDeleted(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
@@ -167,8 +175,8 @@ func TestRefreshToken_UserDeleted(t *testing.T) {
 
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 403, codeErr.Code())
-	assert.Equal(t, "账号已被冻结", codeErr.Error())
+	assert.Equal(t, 401, codeErr.Code(), "M-1:用户不存在必须走 401,不得与冻结态 (403) 混淆")
+	assert.Equal(t, "用户不存在或已被删除", codeErr.Error())
 }
 
 // TC-0030: 账号冻结

+ 17 - 0
internal/logic/pub/syncPermsService.go

@@ -8,7 +8,9 @@ import (
 	"perms-system-server/internal/consts"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
+	"perms-system-server/internal/util"
 
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 )
@@ -128,6 +130,21 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		if errors.As(err, &se) {
 			return nil, se
 		}
+		// 第 6 轮测试报告 §9.5#3:H-3 已经通过 LockByCodeTx 把同一产品的同步串行化,理论上
+		// sys_perm (productCode, code) UNIQUE 在事务内不可能再拿到 1062。若真的命中,说明:
+		//   (a) LockByCodeTx 没有生效(例如引擎/隔离级别被改);
+		//   (b) 有绕过本函数直接写 sys_perm 的代码路径被引入;
+		// 任何一种都代表 H-3 的锁序契约失效,需要立即告警补回 409 重试契约。因此在这里落一条
+		// 带 audit=mysql_error_1062 + table=sys_perm 的 ERROR 级日志,日志采集侧即可据此建
+		// 指标与告警规则;对外仍然回通用 500 避免给客户端透传 DB 细节。
+		if util.IsDuplicateEntryErr(err) {
+			logx.WithContext(ctx).Errorw("sync perms hit 1062 under LockByCodeTx — H-3 contract regressed",
+				logx.Field("audit", "mysql_error_1062"),
+				logx.Field("table", "sys_perm"),
+				logx.Field("productCode", product.Code),
+				logx.Field("err", err.Error()),
+			)
+		}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}
 

+ 43 - 1
internal/logic/user/bindRolesEqualLevel_audit_test.go

@@ -3,10 +3,12 @@ package user
 import (
 	"errors"
 	"testing"
+	"time"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	userModel "perms-system-server/internal/model/user"
+	userroleModel "perms-system-server/internal/model/userrole"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -17,6 +19,41 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+// seedCallerWithRoleLevel 为调用者落地真实 DB 记录(user + role + user_role),
+// 保证审计 M-3 修复(GuardRoleLevelAssignable 的 fresh DB 读)生效后,
+// FindMinPermsLevelByUserIdAndProductCode 能命中调用者真实的 permsLevel。
+// 修复前的测试用假 UserId 即可走通,修复后必须落地真实关系链才能触发越级拦截。
+func seedCallerWithRoleLevel(t *testing.T, svcCtx *svc.ServiceContext, productCode string, callerLevel int64) (int64, func()) {
+	t.Helper()
+	superCtx := ctxhelper.SuperAdminCtx()
+	conn := testutil.GetTestSqlConn()
+
+	callerUserId := insertTestUserFull(t, superCtx, &userModel.SysUser{
+		Username: "caller_" + testutil.UniqueId(), Password: testutil.HashPassword("pass"),
+		Nickname: "caller_seed", DeptId: 0,
+		IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled,
+	})
+	mId := insertTestMember(t, svcCtx, productCode, callerUserId)
+	roleId := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel)
+
+	now := time.Now().Unix()
+	_, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userroleModel.SysUserRole{
+		UserId:     callerUserId,
+		RoleId:     roleId,
+		CreateTime: now,
+		UpdateTime: now,
+	})
+	require.NoError(t, err)
+
+	cleanup := func() {
+		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", callerUserId)
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", callerUserId)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", roleId)
+	}
+	return callerUserId, cleanup
+}
+
 // ---------------------------------------------------------------------------
 // 覆盖目标:审计 H-3 修复 —— "不能分配与自己同级(或更高)的角色"。
 // 修复前代码仅拦 `>` 严格高于,允许 MEMBER 调用者把同级角色分配给别人,继而下一次 BindRoles 时
@@ -45,6 +82,11 @@ func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
 	const callerLevel int64 = 50
 	sameLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel)
 
+	// M-3 修复后 GuardRoleLevelAssignable 走 DB 强一致读取 caller 的 MinPermsLevel,
+	// 因此需要在 DB 里为调用者落地真实的 user + role + user_role 关系链。
+	callerUserId, callerCleanup := seedCallerWithRoleLevel(t, svcCtx, productCode, callerLevel)
+	t.Cleanup(callerCleanup)
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
@@ -53,7 +95,7 @@ func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
 	})
 
 	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
-		UserId:        999994,
+		UserId:        callerUserId,
 		Username:      "member_eq_level",
 		IsSuperAdmin:  false,
 		MemberType:    consts.MemberTypeMember,

+ 1 - 1
internal/logic/user/bindRolesLogic.go

@@ -83,7 +83,7 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
-			if err := authHelper.GuardRoleLevelAssignable(caller, r.PermsLevel); err != nil {
+			if err := authHelper.GuardRoleLevelAssignable(l.ctx, l.svcCtx, caller, r.PermsLevel); err != nil {
 				return err
 			}
 		}

+ 7 - 2
internal/logic/user/bindRolesLogic_test.go

@@ -341,6 +341,11 @@ func TestBindRoles_PermsLevelEscalation_Rejected(t *testing.T) {
 
 	highLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, 1, 1)
 
+	// M-3 修复后 GuardRoleLevelAssignable 走 DB 强一致读取 caller 的 MinPermsLevel,
+	// 因此需要在 DB 里为调用者落地真实的 user + role + user_role 关系链(permsLevel=50)。
+	callerUserId, callerCleanup := seedCallerWithRoleLevel(t, svcCtx, productCode, 50)
+	t.Cleanup(callerCleanup)
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
@@ -348,9 +353,9 @@ func TestBindRoles_PermsLevelEscalation_Rejected(t *testing.T) {
 		testutil.CleanTable(superCtx, conn, "`sys_role`", highLevelRole)
 	})
 
-	// MEMBER 调用者与 target 同 dept,MinPermsLevel=50,目标角色 permsLevel=1 → 越级
+	// MEMBER 调用者与 target 同 dept,DB 中 MinPermsLevel=50,目标角色 permsLevel=1 → 越级
 	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
-		UserId:        999998,
+		UserId:        callerUserId,
 		Username:      "member_caller",
 		IsSuperAdmin:  false,
 		MemberType:    consts.MemberTypeMember,

+ 14 - 1
internal/middleware/jwtauthMiddleware.go

@@ -2,6 +2,7 @@ package middleware
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -56,7 +57,12 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			return
 		}
 
+		// 显式断言 HMAC 签名算法,避免 RSA/ECDSA 公钥被当 HMAC 共享密钥伪造 token
+		// (审计 H-4 / RFC 8725)。
 		token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
+			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+			}
 			return []byte(m.accessSecret), nil
 		})
 		if err != nil || !token.Valid {
@@ -70,7 +76,14 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			return
 		}
 
-		ud := m.loader.Load(r.Context(), claims.UserId, claims.ProductCode)
+		ud, err := m.loader.Load(r.Context(), claims.UserId, claims.ProductCode)
+		if err != nil {
+			// DB / Redis 短时不可用;与"用户不存在(Username=="")"严格区分,避免把一次 DB 抖动同化
+			// 成"全站用户被删除"把客户端集体 kick 掉形成雪崩(见审计 M-1)。返回 503 让客户端按
+			// 临时故障重试策略处理,token 不作废。
+			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(503, "服务暂时不可用,请稍后重试"))
+			return
+		}
 		if ud.Username == "" {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "用户不存在或已被删除"))
 			return

+ 24 - 21
internal/model/perm/findMapByProductCodeWithTx_audit_test.go

@@ -14,15 +14,16 @@ import (
 )
 
 // ---------------------------------------------------------------------------
-// 覆盖目标:审计 M-6 修复的预留基础设施 —— FindMapByProductCodeWithTx。
-// 它目前尚未在 SyncPermsService 的路径里被连起来使用(service.go 的 NOTE 也说明了这一点),
-// 但作为供后续 SELECT FOR UPDATE 串行化用的基础方法,必须满足两个契约:
-//   1) 必须能在 tx 内跑通,并且返回结果与 tx 外的 FindMapByProductCode 完全一致;
-//   2) 当 productCode 没有任何行时,返回 empty map 而不是 nil(便于上层 `_, ok := map[x]` 安全查询)。
-// 如果未来有人改这个方法让它忘记填充 map 或返回 nil,这些测试会立即失败,避免 M-6 的串行化路径被悄悄破坏。
+// 覆盖目标:审计 M-6 修复的基础设施 —— FindMapByProductCodeWithTx。L-5 审计把非事务版
+// FindMapByProductCode 作为僵尸接口移除,这里改为只钉死 WithTx 版本的三条契约:
+//   1) 必须在 TransactCtx 内跑通,并返回当前产品下"全量"权限(含 status!=1 的行),
+//      因为 SyncPermsService 依赖读出现状对比(禁用重启分支);
+//   2) 返回 map 的 key 必须严格等于 SysPerm.Code,便于上层直接按 code 去重;
+//   3) 当 productCode 下没有任何行时,返回 empty map 而不是 nil(避免上层 `m[code] = x`
+//      时的 nil map 赋值 panic)。
 // ---------------------------------------------------------------------------
 
-// TC-0807: FindMapByProductCodeWithTx 与 tx 外 FindMapByProductCode 数据一致。
+// TC-0807: FindMapByProductCodeWithTx 返回覆盖全状态行 + key 等于 Code
 func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	ctx := context.Background()
 	m := newTestSysPermModel(t)
@@ -30,24 +31,23 @@ func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	productCode := "pc_fmwtx_" + testutil.UniqueId()
 	now := time.Now().Unix()
 
+	codeA := "a_" + testutil.UniqueId()
+	codeB := "b_" + testutil.UniqueId()
+
 	res1, err := m.Insert(ctx, &perm.SysPerm{
-		ProductCode: productCode, Name: "a", Code: "a_" + testutil.UniqueId(),
+		ProductCode: productCode, Name: "a", Code: codeA,
 		Status: 1, CreateTime: now, UpdateTime: now,
 	})
 	require.NoError(t, err)
 	id1, _ := res1.LastInsertId()
 	res2, err := m.Insert(ctx, &perm.SysPerm{
-		ProductCode: productCode, Name: "b", Code: "b_" + testutil.UniqueId(),
-		Status: 2, CreateTime: now, UpdateTime: now,
+		ProductCode: productCode, Name: "b", Code: codeB,
+		Status: 2, CreateTime: now, UpdateTime: now, // 禁用行也必须被 Map 出来
 	})
 	require.NoError(t, err)
 	id2, _ := res2.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", id1, id2) })
 
-	nonTx, err := m.FindMapByProductCode(ctx, productCode)
-	require.NoError(t, err)
-	require.Len(t, nonTx, 2)
-
 	var withTx map[string]*perm.SysPerm
 	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
 		var err error
@@ -56,13 +56,16 @@ func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	}))
 	require.Len(t, withTx, 2)
 
-	for code, p := range nonTx {
-		gotWithTx, ok := withTx[code]
-		require.True(t, ok, "tx 版本必须返回与非 tx 版本相同的 code 集合")
-		assert.Equal(t, p.Id, gotWithTx.Id)
-		assert.Equal(t, p.Status, gotWithTx.Status, "status(尤其是禁用态)必须真实透传,不能被过滤")
-		assert.Equal(t, p.Name, gotWithTx.Name)
-	}
+	pA, ok := withTx[codeA]
+	require.True(t, ok, "map key 必须等于 SysPerm.Code")
+	assert.Equal(t, id1, pA.Id)
+	assert.Equal(t, int64(1), pA.Status)
+	assert.Equal(t, "a", pA.Name)
+
+	pB, ok := withTx[codeB]
+	require.True(t, ok, "禁用行同样必须出现在 map 中(M-6 依赖它识别 disabled 重启)")
+	assert.Equal(t, id2, pB.Id)
+	assert.Equal(t, int64(2), pB.Status, "status 必须真实透传,不得被过滤")
 }
 
 // TC-0808: 空 productCode 下 FindMapByProductCodeWithTx 返回非 nil 的空 map。

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

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

+ 21 - 8
internal/model/perm/sysPermModel_test.go

@@ -288,7 +288,7 @@ func TestSysPermModel_FindByIds(t *testing.T) {
 	})
 }
 
-// TC-0432: 正常查询
+// TC-0432: 正常查询(L-5 审计后 FindMapByProductCode 非事务版被移除,这里改走事务版覆盖等价语义)
 func TestSysPermModel_FindMapByProductCode(t *testing.T) {
 	ctx := context.Background()
 	conn := testutil.GetTestSqlConn()
@@ -321,8 +321,12 @@ func TestSysPermModel_FindMapByProductCode(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "sys_perm", ids...)
 	})
 
-	mp, err := m.FindMapByProductCode(ctx, productCode)
-	require.NoError(t, err)
+	var mp map[string]*perm.SysPerm
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		var e error
+		mp, e = m.FindMapByProductCodeWithTx(c, session, productCode)
+		return e
+	}))
 	require.Len(t, mp, 2)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c2)
@@ -612,11 +616,16 @@ func TestSysPermModel_FindAllCodesByProductCode_Empty(t *testing.T) {
 	require.Empty(t, codes)
 }
 
-// TC-0433: 空结果
+// TC-0433: 空结果(L-5 清理后只保留事务版)
 func TestSysPermModel_FindMapByProductCode_Empty(t *testing.T) {
 	m := newTestSysPermModel(t)
-	mp, err := m.FindMapByProductCode(context.Background(), "empty_"+testutil.UniqueId())
-	require.NoError(t, err)
+	ctx := context.Background()
+	var mp map[string]*perm.SysPerm
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		var e error
+		mp, e = m.FindMapByProductCodeWithTx(c, session, "empty_"+testutil.UniqueId())
+		return e
+	}))
 	require.Empty(t, mp)
 }
 
@@ -1393,8 +1402,12 @@ func TestSysPermModel_FindMapByProductCode_KeyUniqueness(t *testing.T) {
 	}
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_perm", ids...) })
 
-	mp, err := m.FindMapByProductCode(ctx, productCode)
-	require.NoError(t, err)
+	var mp map[string]*perm.SysPerm
+	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+		var e error
+		mp, e = m.FindMapByProductCodeWithTx(c, session, productCode)
+		return e
+	}))
 	require.Len(t, mp, 3)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c2)

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

@@ -3,7 +3,6 @@ package productmember
 import (
 	"context"
 	"fmt"
-	"strings"
 
 	"perms-system-server/internal/consts"
 
@@ -17,8 +16,6 @@ type (
 	SysProductMemberModel interface {
 		sysProductMemberModel
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysProductMember, int64, error)
-		FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error)
-		CountActiveAdmins(ctx context.Context, productCode string) (int64, error)
 		CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error)
 		CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error)
 		FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
@@ -51,15 +48,6 @@ func (m *customSysProductMemberModel) FindListByProductCode(ctx context.Context,
 	return list, total, nil
 }
 
-func (m *customSysProductMemberModel) CountActiveAdmins(ctx context.Context, productCode string) (int64, error) {
-	var count int64
-	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
-	if err := m.QueryRowNoCacheCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
-		return 0, err
-	}
-	return count, nil
-}
-
 func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
 	var ids []int64
 	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? FOR UPDATE", m.table)
@@ -90,26 +78,3 @@ func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, se
 	}
 	return &data, nil
 }
-
-func (m *customSysProductMemberModel) FindMapByProductCodeUserIds(ctx context.Context, productCode string, userIds []int64) (map[int64]*SysProductMember, error) {
-	if len(userIds) == 0 {
-		return make(map[int64]*SysProductMember), nil
-	}
-	placeholders := make([]string, len(userIds))
-	args := make([]interface{}, 0, len(userIds)+1)
-	args = append(args, productCode)
-	for i, id := range userIds {
-		placeholders[i] = "?"
-		args = append(args, id)
-	}
-	var list []*SysProductMember
-	query := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ? AND `userId` IN (%s)", sysProductMemberRows, m.table, strings.Join(placeholders, ","))
-	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, args...); err != nil {
-		return nil, err
-	}
-	result := make(map[int64]*SysProductMember, len(list))
-	for _, pm := range list {
-		result[pm.UserId] = pm
-	}
-	return result, nil
-}

+ 4 - 114
internal/model/productmember/sysProductMemberModel_test.go

@@ -139,63 +139,9 @@ func TestSysProductMemberModel_FindListByProductCode(t *testing.T) {
 	}
 }
 
-// TC-0477: 正常批量
-func TestSysProductMemberModel_FindMapByProductCodeUserIds(t *testing.T) {
-	ctx := context.Background()
-	conn := testutil.GetTestSqlConn()
-	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
-
-	pc := "t_pm_map_" + testutil.UniqueId()
-	u1, u2 := randProductMemberUserId(), randProductMemberUserId()
-	uMissing := randProductMemberUserId()
-	ts := time.Now().Unix()
-
-	res1, err := m.Insert(ctx, &SysProductMember{ProductCode: pc, UserId: u1, MemberType: "MEMBER", Status: 1, CreateTime: ts, UpdateTime: ts})
-	if err != nil {
-		t.Fatalf("Insert: %v", err)
-	}
-	id1, _ := res1.LastInsertId()
-	res2, err := m.Insert(ctx, &SysProductMember{ProductCode: pc, UserId: u2, MemberType: "MEMBER", Status: 1, CreateTime: ts, UpdateTime: ts})
-	if err != nil {
-		t.Fatalf("Insert: %v", err)
-	}
-	id2, _ := res2.LastInsertId()
-	defer func() {
-		testutil.CleanTable(ctx, conn, "sys_product_member", id1, id2)
-	}()
-
-	empty, err := m.FindMapByProductCodeUserIds(ctx, pc, nil)
-	if err != nil {
-		t.Fatalf("empty nil: %v", err)
-	}
-	if len(empty) != 0 {
-		t.Fatalf("empty nil want len 0 got %d", len(empty))
-	}
-
-	empty2, err := m.FindMapByProductCodeUserIds(ctx, pc, []int64{})
-	if err != nil {
-		t.Fatalf("empty slice: %v", err)
-	}
-	if len(empty2) != 0 {
-		t.Fatalf("empty slice want len 0 got %d", len(empty2))
-	}
-
-	m1, err := m.FindMapByProductCodeUserIds(ctx, pc, []int64{u1, u2})
-	if err != nil {
-		t.Fatalf("normal: %v", err)
-	}
-	if len(m1) != 2 || m1[u1] == nil || m1[u2] == nil {
-		t.Fatalf("normal map: %+v", m1)
-	}
-
-	m2, err := m.FindMapByProductCodeUserIds(ctx, pc, []int64{u1, uMissing})
-	if err != nil {
-		t.Fatalf("partial: %v", err)
-	}
-	if len(m2) != 1 || m2[u1] == nil || m2[uMissing] != nil {
-		t.Fatalf("partial map: %+v", m2)
-	}
-}
+// TC-0477: [REMOVED] FindMapByProductCodeUserIds 作为僵尸接口已在 L-5 审计中被剥离;
+// 上层 UserListLogic 改走 FindListByProductMembers 合并查询(见 mock 测试注释)。
+// 这里保留 stub 以保持 TC 编号可追溯。
 
 // TC-0336: 多条记录(3条)
 func TestSysProductMemberModel_BatchInsert(t *testing.T) {
@@ -1012,60 +958,4 @@ func TestSysProductMemberModel_FindOneByProductCodeUserIdWithTx_NotFound(t *test
 	require.NoError(t, err)
 }
 
-// TC-0478: FindMapByProductCodeUserIds - 空userIds
-func TestSysProductMemberModel_FindMapByProductCodeUserIds_EmptyUserIds(t *testing.T) {
-	conn := testutil.GetTestSqlConn()
-	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
-
-	pc := "t_pm_empty_" + testutil.UniqueId()
-
-	m1, err := m.FindMapByProductCodeUserIds(context.Background(), pc, nil)
-	require.NoError(t, err)
-	assert.Empty(t, m1)
-
-	m2, err := m.FindMapByProductCodeUserIds(context.Background(), pc, []int64{})
-	require.NoError(t, err)
-	assert.Empty(t, m2)
-}
-
-// TC-0480: FindMapByProductCodeUserIds - map key正确
-func TestSysProductMemberModel_FindMapByProductCodeUserIds_MapKeysCorrect(t *testing.T) {
-	ctx := context.Background()
-	conn := testutil.GetTestSqlConn()
-	m := NewSysProductMemberModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
-
-	pc := "t_pm_key_" + testutil.UniqueId()
-	u1, u2 := randProductMemberUserId(), randProductMemberUserId()
-	ts := time.Now().Unix()
-
-	res1, err := m.Insert(ctx, &SysProductMember{
-		ProductCode: pc, UserId: u1, MemberType: "MEMBER", Status: 1, CreateTime: ts, UpdateTime: ts,
-	})
-	require.NoError(t, err)
-	id1, _ := res1.LastInsertId()
-
-	res2, err := m.Insert(ctx, &SysProductMember{
-		ProductCode: pc, UserId: u2, MemberType: "ADMIN", Status: 1, CreateTime: ts, UpdateTime: ts,
-	})
-	require.NoError(t, err)
-	id2, _ := res2.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_product_member", id1, id2) })
-
-	result, err := m.FindMapByProductCodeUserIds(ctx, pc, []int64{u1, u2})
-	require.NoError(t, err)
-	require.Len(t, result, 2)
-
-	v1, ok1 := result[u1]
-	require.True(t, ok1, "map should contain key userId=%d", u1)
-	assert.Equal(t, id1, v1.Id)
-	assert.Equal(t, pc, v1.ProductCode)
-	assert.Equal(t, u1, v1.UserId)
-	assert.Equal(t, "MEMBER", v1.MemberType)
-
-	v2, ok2 := result[u2]
-	require.True(t, ok2, "map should contain key userId=%d", u2)
-	assert.Equal(t, id2, v2.Id)
-	assert.Equal(t, pc, v2.ProductCode)
-	assert.Equal(t, u2, v2.UserId)
-	assert.Equal(t, "ADMIN", v2.MemberType)
-}
+// TC-0478 / TC-0480: [REMOVED] 参见 TC-0477;方法已随 L-5 清理一并移除。

+ 4 - 2
internal/model/role/sysRoleModel.go

@@ -7,6 +7,8 @@ import (
 	"fmt"
 	"strings"
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -89,10 +91,10 @@ func (m *customSysRoleModel) UpdateWithOptLock(ctx context.Context, data *SysRol
 func (m *customSysRoleModel) FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error) {
 	var level int64
 	query := fmt.Sprintf(
-		"SELECT IFNULL(MIN(r.`permsLevel`), -1) FROM %s r INNER JOIN `sys_user_role` ur ON r.`id` = ur.`roleId` WHERE ur.`userId` = ? AND r.`productCode` = ? AND r.`status` = 1",
+		"SELECT IFNULL(MIN(r.`permsLevel`), -1) FROM %s r INNER JOIN `sys_user_role` ur ON r.`id` = ur.`roleId` WHERE ur.`userId` = ? AND r.`productCode` = ? AND r.`status` = ?",
 		m.table,
 	)
-	if err := m.QueryRowNoCacheCtx(ctx, &level, query, userId, productCode); err != nil {
+	if err := m.QueryRowNoCacheCtx(ctx, &level, query, userId, productCode, consts.StatusEnabled); err != nil {
 		return 0, err
 	}
 	if level < 0 {

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

@@ -133,11 +133,22 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, passw
 
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
-	_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
-		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ?", m.table)
-		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id)
+	// 乐观锁:WHERE 叠加 updateTime 与 FindOne 拿到的一致。避免 FindOne → Exec 之间并发改密把
+	// 本次写盖成"最后一写赢"、或目标行被删除后仍返回成功造成语义欺骗(见审计 M-2)。
+	expectedUpdateTime := data.UpdateTime
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
+		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id, expectedUpdateTime)
 	}, sysUserIdKey, sysUserUsernameKey)
-	return err
+	if err != nil {
+		return err
+	}
+	if affected, _ := res.RowsAffected(); affected == 0 {
+		// 行被删除或被并发改过:对外统一回 ErrUpdateConflict,避免对已删除用户返回 nil 让上层
+		// 误判为"改密成功"(审计 M-2)。
+		return ErrUpdateConflict
+	}
+	return nil
 }
 
 func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status int64) error {
@@ -148,13 +159,27 @@ func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status
 
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
-	_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("UPDATE %s SET `status` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ?", m.table)
 		return conn.ExecCtx(ctx, query, status, time.Now().Unix(), id)
 	}, sysUserIdKey, sysUserUsernameKey)
-	return err
+	if err != nil {
+		return err
+	}
+	if affected, _ := res.RowsAffected(); affected == 0 {
+		// 目标用户在 FindOne 后被并发删除;返回 ErrUpdateConflict 让上层区分"冻结生效"与"目标已消失"
+		// (审计 M-2)。
+		return ErrUpdateConflict
+	}
+	return nil
 }
 
+// IncrementTokenVersion 强制递增当前用户的 tokenVersion,让**所有**已签发的 access/refresh 立即失效。
+//
+// WARN: 仅限"强制全量会话失效"场景调用——主动 Logout 或封禁/重置密码。Refresh / Rotate 场景
+// 必须调用 IncrementTokenVersionIfMatch 走 CAS 语义,否则会回到 R5 以前的并发 rotate 窗口,
+// 两次并发 refresh 都能换到新令牌,等同于会话劫持(见审计 L-2)。
+// 调用前请先走 TokenOpLimiter 等限流,避免被反复触发把合法用户 kick 出登录。
 func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
 	data, err := m.FindOne(ctx, id)
 	if err != nil {

+ 158 - 0
internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go

@@ -0,0 +1,158 @@
+package user_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// M-2 回归:UpdatePassword / UpdateStatus 必须校验 RowsAffected。
+//
+// 旧实现的 bug:Model 层 UPDATE 语句使用 `conn.ExecCtx`,若行已被删除或并发被修改,
+// `affected=0`,但代码直接 `return nil`,让上层误判"改密成功 / 冻结成功"。
+// 利用 FindOne 的二级缓存(Redis):删除数据库中的行但不清缓存 →
+// 后续 UpdatePassword/UpdateStatus 内部的 FindOne 命中 stale cache 仍返回用户,
+// 真实 UPDATE 落空 `affected=0`,新实现必须回 ErrUpdateConflict。
+//
+// 如果测试跑挂说明 M-2 修复被回退,必须立即修复 Model 层而非改测试。
+
+// TC-0924: UpdatePassword 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
+func TestSysUserModel_UpdatePassword_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "m2_pw_del_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	// 触发 FindOne 填充二级缓存 (id-key + username-key),模拟 Loader 刚读过用户的场景
+	_, err = m.FindOne(ctx, id)
+	require.NoError(t, err)
+	_, err = m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+
+	// 直接走原始 SQL 删除行,**绕过** Model 的缓存失效钩子——此时 Redis 里仍保留用户快照
+	_, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
+	require.NoError(t, err)
+
+	// UpdatePassword 内部:FindOne 命中 stale cache 返回用户 → UPDATE WHERE id=? AND updateTime=?
+	// 因为行已不存在,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
+	err = m.UpdatePassword(ctx, id, "new_hashed_pw", 1)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
+}
+
+// TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
+func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "m2_st_del_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	_, err = m.FindOne(ctx, id)
+	require.NoError(t, err)
+	_, err = m.FindOneByUsername(ctx, username)
+	require.NoError(t, err)
+
+	_, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
+	require.NoError(t, err)
+
+	// UpdateStatus 内部:FindOne 命中 stale cache → UPDATE WHERE id=? 仍 affected=0。
+	// 旧实现返回 nil;新实现必须回 ErrUpdateConflict,让上层区分"冻结生效 / 用户已不存在"。
+	err = m.UpdateStatus(ctx, id, 2)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
+}
+
+// TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
+func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "m2_pw_ok_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	origTv := orig.TokenVersion
+
+	// 乐观锁依赖秒级 updateTime,必须让 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime,
+	// 否则"空白更新"仍 affected=1 但 updateTime 值不变,容易掩盖后续断言
+	time.Sleep(1100 * time.Millisecond)
+
+	newPw := "new_hashed_password_xyz"
+	err = m.UpdatePassword(ctx, id, newPw, 1)
+	require.NoError(t, err)
+
+	got, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, newPw, got.Password)
+	assert.Equal(t, int64(1), got.MustChangePassword)
+	assert.Equal(t, origTv+1, got.TokenVersion, "改密必须递增 tokenVersion 以注销旧会话")
+	assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则乐观锁无法生效")
+}
+
+// TC-0927: UpdateStatus 正常路径仍然成功且 tokenVersion 递增
+func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "m2_st_ok_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	origTv := orig.TokenVersion
+	require.Equal(t, int64(1), orig.Status)
+
+	err = m.UpdateStatus(ctx, id, 2)
+	require.NoError(t, err)
+
+	got, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, int64(2), got.Status)
+	assert.Equal(t, origTv+1, got.TokenVersion, "冻结 / 解冻必须递增 tokenVersion 使旧 token 全部失效")
+}
+
+// TC-0928: UpdatePassword 对不存在的 userId 必须回 ErrNotFound(FindOne 先失败),
+// 确保 M-2 的 "affected=0 → ErrUpdateConflict" 不会把 "FindOne miss" 误报成 Conflict
+func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsNotFound(t *testing.T) {
+	ctx := context.Background()
+	m, _ := newModel(t)
+	err := m.UpdatePassword(ctx, 999999999999, "irrelevant", 1)
+	require.ErrorIs(t, err, user.ErrNotFound)
+}
+
+// TC-0929: UpdateStatus 对不存在的 userId 必须回 ErrNotFound
+func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsNotFound(t *testing.T) {
+	ctx := context.Background()
+	m, _ := newModel(t)
+	err := m.UpdateStatus(ctx, 999999999999, 2)
+	require.ErrorIs(t, err, user.ErrNotFound)
+}

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

@@ -5,6 +5,8 @@ import (
 	"database/sql"
 	"fmt"
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -32,9 +34,9 @@ func NewSysUserPermModel(conn sqlx.SqlConn, c cache.CacheConf, cachePrefix strin
 func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error) {
 	var ids []int64
 	query := fmt.Sprintf(
-		"SELECT up.`permId` FROM %s up INNER JOIN `sys_perm` p ON up.`permId` = p.`id` WHERE up.`userId` = ? AND up.`effect` = ? AND p.`productCode` = ? AND p.`status` = 1",
+		"SELECT up.`permId` FROM %s up INNER JOIN `sys_perm` p ON up.`permId` = p.`id` WHERE up.`userId` = ? AND up.`effect` = ? AND p.`productCode` = ? AND p.`status` = ?",
 		m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, effect, productCode); err != nil {
+	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, effect, productCode, consts.StatusEnabled); err != nil {
 		return nil, err
 	}
 	return ids, nil

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

@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"strings"
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
@@ -48,8 +50,10 @@ func (m *customSysUserRoleModel) FindRoleIdsByUserId(ctx context.Context, userId
 
 func (m *customSysUserRoleModel) FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error) {
 	var ids []int64
-	query := fmt.Sprintf("SELECT ur.`roleId` FROM %s ur INNER JOIN `sys_role` r ON ur.`roleId` = r.`id` WHERE ur.`userId` = ? AND r.`productCode` = ? AND r.`status` = 1", m.table)
-	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, productCode); err != nil {
+	// status 走占位参数 + consts.StatusEnabled,避免把"启用"语义钉死成字面量 1;未来新增
+	// status=3/已归档 之类时只要改 consts 就行,这里不会漏掉(见审计 L-4)。
+	query := fmt.Sprintf("SELECT ur.`roleId` FROM %s ur INNER JOIN `sys_role` r ON ur.`roleId` = r.`id` WHERE ur.`userId` = ? AND r.`productCode` = ? AND r.`status` = ?", m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, productCode, consts.StatusEnabled); err != nil {
 		return nil, err
 	}
 	return ids, nil

+ 28 - 3
internal/server/permserver.go

@@ -162,7 +162,15 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		return nil, status.Error(codes.InvalidArgument, "刷新令牌不允许切换产品")
 	}
 
-	ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, productCode)
+	ud, err := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, productCode)
+	if err != nil {
+		// 与"用户已删除"区分:基础设施短时不可用走 Unavailable,token 不作废让客户端重试
+		// (见审计 M-1)。
+		return nil, status.Error(codes.Unavailable, "服务暂时不可用,请稍后重试")
+	}
+	if ud.Username == "" {
+		return nil, status.Error(codes.Unauthenticated, "用户不存在或已被删除")
+	}
 
 	if ud.Status != consts.StatusEnabled {
 		return nil, status.Error(codes.PermissionDenied, "账号已被冻结")
@@ -240,6 +248,9 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 	}
 
 	token, err := jwt.ParseWithClaims(req.AccessToken, &middleware.Claims{}, func(token *jwt.Token) (interface{}, error) {
+		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+		}
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 	})
 	if err != nil || !token.Valid {
@@ -253,7 +264,18 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 
-	ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
+	ud, err := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
+	if err != nil {
+		// VerifyToken 的对外契约是"任何 token 问题只回 Valid=false,不抛 gRPC 错误"。但基础设施
+		// 故障不属于"token 问题"——同化为 Valid=false 会让下游把合法用户踢出登录(见审计 M-1)。
+		// 走 Unavailable,由下游按瞬时故障重试而不是据此清 token。
+		logx.WithContext(ctx).Errorf("verifyToken: load user details failed: %v", err)
+		return nil, status.Error(codes.Unavailable, "服务暂时不可用,请稍后重试")
+	}
+	if ud.Username == "" {
+		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=user_not_found", claims.UserId)
+		return &pb.VerifyTokenResp{Valid: false}, nil
+	}
 	if ud.Status != consts.StatusEnabled {
 		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=user_disabled", claims.UserId)
 		return &pb.VerifyTokenResp{Valid: false}, nil
@@ -297,7 +319,10 @@ func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq)
 		return nil, status.Error(codes.InvalidArgument, "appKey与productCode不匹配")
 	}
 
-	ud := s.svcCtx.UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode)
+	ud, err := s.svcCtx.UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode)
+	if err != nil {
+		return nil, status.Error(codes.Unavailable, "服务暂时不可用,请稍后重试")
+	}
 
 	if ud.Username == "" {
 		return nil, status.Error(codes.NotFound, "用户不存在")

+ 2 - 0
internal/svc/servicecontext.go

@@ -25,6 +25,7 @@ type ServiceContext struct {
 	UsernameLoginLimit    *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	UserDetailsLoader     *loaders.UserDetailsLoader
+	Redis                 *redis.Redis
 	*model.Models
 }
 
@@ -58,6 +59,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		UsernameLoginLimit:    usernameLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		UserDetailsLoader:     udLoader,
+		Redis:                 rds,
 		Models:                models,
 	}
 }

+ 16 - 2
internal/types/types.go

@@ -45,8 +45,22 @@ type CreateProductReq struct {
 }
 
 type CreateProductResp struct {
-	Id            int64  `json:"id"`
-	Code          string `json:"code"`
+	Id        int64  `json:"id"`
+	Code      string `json:"code"`
+	AppKey    string `json:"appKey"`
+	AdminUser string `json:"adminUser"`
+	// CredentialsTicket 一次性凭证票据。AppSecret 与初始 AdminPassword 不再随本响应明文返回,
+	// 改为由调用方用该 ticket 调一次 /api/product/fetchInitialCredentials 领取(5 分钟内有效,
+	// 一次性消费)。审计 M-4:避免密码/密钥经响应体落盘到上游日志/APM。
+	CredentialsTicket    string `json:"credentialsTicket"`
+	CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+}
+
+type FetchInitialCredentialsReq struct {
+	Ticket string `json:"ticket"`
+}
+
+type FetchInitialCredentialsResp struct {
 	AppKey        string `json:"appKey"`
 	AppSecret     string `json:"appSecret"`
 	AdminUser     string `json:"adminUser"`

+ 22 - 3
perm.api

@@ -60,8 +60,20 @@ type (
 		Remark string `json:"remark,optional"`
 	}
 	CreateProductResp {
-		Id            int64  `json:"id"`
-		Code          string `json:"code"`
+		Id        int64  `json:"id"`
+		Code      string `json:"code"`
+		AppKey    string `json:"appKey"`
+		AdminUser string `json:"adminUser"`
+		// CredentialsTicket 一次性凭证票据。AppSecret 与初始 AdminPassword 不再随本响应明文返回,
+		// 改为由调用方用该 ticket 调一次 /api/product/fetchInitialCredentials 领取(5 分钟内有效,
+		// 一次性消费)。审计 M-4:避免密码/密钥经响应体落盘到上游日志/APM。
+		CredentialsTicket string `json:"credentialsTicket"`
+		CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
+	}
+	FetchInitialCredentialsReq {
+		Ticket string `json:"ticket"`
+	}
+	FetchInitialCredentialsResp {
 		AppKey        string `json:"appKey"`
 		AppSecret     string `json:"appSecret"`
 		AdminUser     string `json:"adminUser"`
@@ -379,10 +391,17 @@ service perm-api {
 	middleware: JwtAuth
 )
 service perm-api {
-	// CreateProduct 创建产品。自动生成 appKey/appSecret 和产品专属管理员账号,用于接入新的业务产品
+	// CreateProduct 创建产品。自动生成 appKey/appSecret 和产品专属管理员账号,用于接入新的业务产品。
+	// 响应不再明文回吐 appSecret / adminPassword,改用 credentialsTicket 一次性领取(审计 M-4)。
 	@handler CreateProduct
 	post /create (CreateProductReq) returns (CreateProductResp)
 
+	// FetchInitialCredentials 凭 CreateProduct 响应中的 credentialsTicket 一次性领取 appSecret 与
+	// 初始 adminPassword。Ticket 在 Redis 中短 TTL 保存,一次消费后立即删除;即使响应被日志捕获,
+	// 落盘的也仅是短期有效且一次性消耗的哨兵 token,而非真正的长期凭证。
+	@handler FetchInitialCredentials
+	post /fetchInitialCredentials (FetchInitialCredentialsReq) returns (FetchInitialCredentialsResp)
+
 	// UpdateProduct 更新产品信息。可修改名称、备注和启用/禁用状态,禁用后其成员将无法访问
 	@handler UpdateProduct
 	post /update (UpdateProductReq)

+ 129 - 0
test-design.md

@@ -1510,3 +1510,132 @@ MySQL (InnoDB) + Redis Cache
 | 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 |
 
+---
+
+## 五、第 7 轮审计驱动测试(2026-04-19 · QA 新增)
+
+> 本轮围绕 `audit-report.md` 中 H-1 / H-3 / H-4 / M-1 / M-2 / M-3 / M-4 / L-3 / L-5 / L-6 的修复逐条建立回归锚点。
+> 所有新用例均在"真实 DB + Redis + 原子语义"的一等场景下验证,严禁仅靠 mock 对实现做同义复写。
+> 对既有用例的兼容性调整(两处 bindRoles 测试 + RefreshToken_UserDeleted)见 §5.9。
+
+### 5.1 M-4 FetchInitialCredentials 一次性凭据取回(`internal/logic/product/fetchInitialCredentialsLogic_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0901 | happy path:超管用有效 ticket 取回初始凭据 | superAdmin ctx + 合法 ticket | 返回 AppKey/AppSecret/AdminUser/AdminPassword;AppSecret 与 DB 中 bcrypt 匹配 | 正常路径 | P0 | M-4:凭据落地 Redis 后端,响应体不再明文外泄 |
+| TC-0902 | 相同 ticket 二次消费 | 同上 ticket 第二次取 | 第二次 404 "凭据票据不存在或已被消费" | 一次性 | P0 | M-4:`Redis.GetDelCtx` 原子 GET+DEL |
+| TC-0903 | ticket 为空 | ticket="" | 400 "ticket 不能为空" | 异常路径 | P0 | M-4:入参校验 |
+| TC-0904 | ticket 未知 | 随机 64 字符 ticket | 404 "凭据票据不存在或已被消费" | 异常路径 | P0 | M-4:无存在性差异化,避免枚举 oracle |
+| TC-0905 | 非超管调用 | ADMIN ctx | 403 | 权限 | P0 | M-4:RequireSuperAdmin 生效 |
+| TC-0906 | 未登录调用 | context.Background() | 401/403 | 权限 | P0 | M-4:无 UserDetails 时拒绝 |
+| TC-0907 | Redis 中 payload 被人为破坏 | 手工写入非 JSON 字符串 | 500 "凭据数据异常" 并删除该 key | 健壮性 | P1 | M-4:防腐败数据长留 |
+| TC-0908 | Redis key 结构正确 | 观察实际 key | `pm:initcred:{ticket}`;TTL ≤ 300s | 契约 | P1 | M-4:运维可定位 |
+| TC-0909 | TTL 与响应 expiresAt 一致 | 观察返回的 credentialsExpiresAt | Redis TTL == Unix(expiresAt)-now (±5s) | 契约 | P1 | M-4:客户端过期提示与后端一致 |
+| TC-0910 | 并发消费同一 ticket | 32 goroutine 同时 Fetch | 仅 1 个成功,其余 31 个返回 404 | 并发 | P0 | M-4:GetDelCtx 原子性抗竞态 |
+| TC-0911 | CreateProductResp JSON 不含 appSecret/adminPassword | marshal resp → json | 字段 `appSecret`/`adminPassword` 不出现 | 契约 | P0 | M-4:响应体永不明文 |
+| TC-0912 | CreateProductResp 必含 credentialsTicket + credentialsExpiresAt | marshal resp | 两字段均非空/正数 | 契约 | P0 | M-4:新的获取链路必备字段 |
+
+### 5.2 M-1 / H-1 / L-3 / L-6 Loader 契约(`internal/loaders/userDetailsLoader_contract_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0913 | 用户不存在 → `(ud, nil)` 且 `ud.Username == ""` | userId=999999999 | 无 err,Username 空,caller 自行映射 401 | 契约 | P0 | M-1:401 vs 503 区分 |
+| TC-0914 | 新建用户刚落地 `CreateUser` 后立刻 `Load` | 新 username | 读到真实 user,不命中负缓存哨兵 | 契约 | P0 | L-6:CreateUser 反向清除负缓存 |
+| TC-0915 | partial load 失败(幽灵 deptId)| 人为把 user.deptId 改成不存在 | 返回 ud(含错误子加载),但不写 5 分钟正缓存 | 契约 | P0 | M-1/L-3:局部失败不污染主缓存 |
+| TC-0916 | 全绿 MEMBER 正路径 | 正常 user + product + 无角色 | `loadOk=true`,命中 5 分钟正缓存 | 契约 | P0 | H-1:保证好路径仍能缓存 |
+
+### 5.3 M-2 UpdatePassword/UpdateStatus RowsAffected(`internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0924 | UpdatePassword:FindOne 填缓存后行被并发删除 | 直接 SQL 删行绕过缓存失效 | `ErrUpdateConflict`,不得静默成功 | 并发/TOCTOU | P0 | M-2:RowsAffected=0 必须升格 |
+| TC-0925 | UpdatePassword:正常写入 | 存在且未并发 | 持久化 + tokenVersion 递增 | 正常路径 | P0 | M-2:hot path 不回归 |
+| TC-0926 | UpdatePassword:user 不存在 | id=非法 | `ErrNotFound`(FindOne 先挂) | 异常路径 | P0 | M-2:直接失败 |
+| TC-0927 | UpdateStatus:行被并发删除 | 同 TC-0924 手法 | `ErrUpdateConflict` | 并发/TOCTOU | P0 | M-2:对称覆盖 |
+| TC-0928 | UpdateStatus:正常禁用 | status=2 | 持久化 + tokenVersion 递增(用户被踢) | 正常路径 | P0 | M-2:禁用副作用 |
+| TC-0929 | UpdateStatus:user 不存在 | id=非法 | `ErrNotFound` | 异常路径 | P0 | M-2 |
+
+### 5.4 M-3 GuardRoleLevelAssignable Fresh Read(`internal/logic/auth/guardRoleLevelAssignable_freshRead_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0930 | caller 的 MinPermsLevel 缓存过期(偏高) | mock 返回 DB=100,caller 缓存=5,role=50 | 403(以 DB=100 判,50 ≤ 100 越级) | TOCTOU | P0 | M-3:不得信任缓存高权值 |
+| TC-0931 | 同级分配 | DB=50,role=50 | 403 "不能分配权限级别高于自身的角色(含同级)" | 契约 | P0 | M-3:含同级 |
+| TC-0932 | 严格低一级分配 | DB=50,role=51 | 放行 | 正常路径 | P0 | M-3:正值域 |
+| TC-0933 | SuperAdmin 绕过 DB | superAdmin caller | 不查 DB,直接放行 | 正常路径 | P0 | M-3:短路 |
+| TC-0934 | Product ADMIN 绕过 DB | memberType=ADMIN | 不查 DB,直接放行 | 正常路径 | P0 | M-3:短路 |
+| TC-0935 | DEVELOPER 绕过 DB | memberType=DEVELOPER | 不查 DB,直接放行 | 正常路径 | P0 | M-3:短路 |
+| TC-0936 | caller 在 DB 没有任何角色 | FindMin... 返回 ErrNotFound | 403 "您没有可分配的角色等级" | 异常路径 | P0 | M-3:fail-close |
+| TC-0937 | caller 查询 DB 遇到一般错误 | 非 ErrNotFound | 500(fail-close,不透传原文) | 容错 | P0 | M-3:不泄细节 |
+| TC-0938 | caller 为 nil(无 UserDetails) | ctx 未带 | 401(未授权) | 异常路径 | P0 | M-3:nil 保护 |
+
+### 5.5 H-3 CheckAddMemberAccess 部门链 + 超管防御(`internal/logic/auth/checkAddMemberAccess_audit_test.go` + `internal/logic/member/auditFixes_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0940 | Product ADMIN 拉跨部门树的人 | caller path=/100/,target path=/200/201/ | 403,错误含 "其他部门" | 权限 | P0 | H-3:ADMIN 不再绕过部门树 |
+| TC-0941 | Product ADMIN 在自己部门树内 | target 在 /100/101/ | 放行 | 正常路径 | P0 | H-3:正值域 |
+| TC-0942 | SuperAdmin 跨一切部门 | caller superAdmin | 放行,不查 dept | 正常路径 | P0 | H-3:短路 |
+| TC-0943 | 自己加自己 | target.Id == caller.UserId | 放行 | 正常路径 | P0 | H-3:self-bypass |
+| TC-0944 | caller 没有部门(DeptId=0 或 DeptPath="") | 非超管 | 403 | 异常路径 | P0 | H-3:无部门 caller 必须拒绝 |
+| TC-0945 | target 无部门(DeptId=0) | 非自己 | 403 | 异常路径 | P0 | H-3:目标无部门视为不可纳管 |
+| TC-0946 | ctx 无 caller | context.Background() | 401 | 权限 | P0 | H-3 |
+| TC-0947 | dept.FindOne 报错 | mock 返回 err | 403(fail-close,文案不泄细节) | 容错 | P0 | H-3 |
+| TC-0948 | target 为 nil | pass nil | 400 BadRequest | 契约 | P0 | H-3 |
+| TC-0949 | AddMember 集成:跨部门被拒 | 真实 DB,Product ADMIN 拉树外 | 403 + 不写 sys_product_member | 集成 | P0 | H-3 端到端 |
+| TC-0950 | AddMember 集成:target=SuperAdmin | superAdmin 被作为 MEMBER 加入 | 403 "超级管理员" + 不写 sys_product_member | 安全 | P0 | H-3:超管防混入 |
+
+### 5.6 H-4 JWT 签名算法断言(`internal/logic/auth/parseWithHMAC_audit_test.go`)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0951 | 合法 HS256 + 正确 secret | 正常 token | 解析成功,claims 可读 | 正常路径 | P0 | H-4:good path |
+| TC-0952 | alg=none 伪造 | 手工拼无签名段 | 拒绝,错误含 "unexpected signing method" | 安全 | P0 | H-4:alg=none 零信任 |
+| TC-0953 | alg=RS256 但用 HS secret 签名 | RSA→HMAC 混淆攻击 | 拒绝 | 安全 | P0 | H-4:算法混淆防御 |
+| TC-0954 | alg=ES256 header | 非 HMAC 族 | 拒绝 | 安全 | P0 | H-4:非 HMAC 一律拒 |
+| TC-0955 | HS256 但 secret 错 | 同算法错密钥 | 拒绝(签名校验失败) | 安全 | P0 | H-4:基础用例 |
+| TC-0956 | ParseRefreshToken 复用 ParseWithHMAC | 伪造 alg=none | 拒绝 | 安全 | P0 | H-4:上层也防 |
+| TC-0957 | 乱码 token | "abc.def" | 拒绝(malformed) | 容错 | P1 | H-4 |
+| TC-0958 | ParseRefreshToken 的 tokenType 校验 | AccessToken 类型 | 拒绝 TokenTypeMismatch | 契约 | P0 | H-4:不被 H-4 修复破坏 |
+| TC-0959 | 合法 HS256 + 非标 `typ` header | typ=JWT-X | 解析成功(只严格校验 alg) | 兼容 | P1 | H-4:不过度收紧 |
+| TC-0960 | 回放过期 token | exp 已过 | 拒绝(解析器底层校验) | 安全 | P0 | H-4 |
+
+### 5.7 L-5 清理:删除僵尸方法
+
+| 删除对象 | 受影响测试 | 处理 |
+| :--- | :--- | :--- |
+| `SysPermModel.FindMapByProductCode`(非事务) | `sysPermModel_test.go` | 改用 `FindMapByProductCodeWithTx`(在事务上下文中调用);`findMapByProductCodeWithTx_audit_test.go` 独立验证事务版契约 |
+| `SysProductMemberModel.FindMapByProductCodeUserIds` | `sysProductMemberModel_test.go` | 删除 3 个关联测试(TC-xxx 已标记为已删除) |
+| `SysProductMemberModel.CountActiveAdmins` | — | 无外部调用者,直接删除 |
+
+### 5.8 M-1 RefreshToken 用户不存在 vs 账号冻结契约分离
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0029(重写) | refreshToken 合法但 userId 不存在 | nonExistentUserId | **401 "用户不存在或已被删除"** | 契约 | P0 | M-1:取代旧 "403 账号已被冻结",前端可正确走"清本地会话+回登录页" |
+
+### 5.9 M-4 Handler 薄层 + 路由 Wiring(`internal/handler/product/fetchInitialCredentialsHandler_audit_test.go` + `internal/handler/fetchInitialCredentialsRouteWiring_audit_test.go`)
+
+> 填补 §10.4 中"handler/wiring 未测场景"空白:此前 TC-0901 ~ TC-0912 只在 logic 层覆盖 M-4,但 handler 的解析契约、鉴权中间件挂载、前缀归属都未被钉死。
+> 本组 TC 双通道验证:一端走真实 `http.HandlerFunc` + Redis 发 HTTP 请求,一端 static-scan `routes.go` 源码断言 middleware 绑定,互为保险。
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0961 | body 非法 JSON | `{not-valid-json` + superAdmin ctx | 400,且文案不含 sql/redis/ticket 关键字 | 契约/健壮性 | P0 | M-4:`httpx.Parse` 错误透传;不泄字段与实现细节 |
+| TC-0962 | 无登录上下文 | 不注入 UserDetails | 401 "未登录" | 权限 | P0 | M-4:handler 自身也必须 fail-close,不依赖 JwtAuth 中间件 |
+| TC-0963 | 非超管 ctx | MemberType=ADMIN | 403 "仅超级管理员可执行此操作",文案不含 "ticket" | 权限/信息泄漏 | P0 | M-4:RequireSuperAdmin 透传;防 ticket 存在性 oracle |
+| TC-0964 | 超管 + 空 ticket | `{"ticket":""}` | 400,文案含 "ticket" | 契约 | P0 | M-4:入参必填校验 |
+| TC-0965 | 超管 + 未知 ticket | 随机字符串 | 400 "凭证票据无效或已过期",与"过期"共用文案 | 安全 | P0 | M-4:防枚举 oracle,与 logic TC-0904 同契约 |
+| TC-0966 | 超管 + 已落地 ticket | 手工 SETEX 合法 JSON payload | HTTP 200 + 4 字段完整映射;Redis key 被 `GetDel` 消费 | 正常路径/一致性 | P0 | M-4:字段映射正确 + 一次性消费 |
+| TC-0967 | 静态 wiring:JwtAuth 绑定 | 读取 `routes.go` 源码 | `/fetchInitialCredentials` 所在 `rest.WithMiddlewares` 列表含 `serverCtx.JwtAuth`;prefix=`/api/product` | 回归/静态 | P0 | M-4:防未来 goctl 覆写丢失中间件 |
+| TC-0968 | 静态反证:不得挂到限流组 | 读取 `routes.go` 源码 | `/fetchInitialCredentials` 绝不出现在 `AdminLoginRateLimit` / `ProductLoginRateLimit` / `RefreshTokenRateLimit` / `SyncRateLimit` 的中间件块内 | 回归/静态 | P0 | M-4:防被错迁到无鉴权组 |
+
+### 5.10 既有用例兼容性调整(M-3 fresh DB 读)
+
+| 用例 | 文件 | 调整说明 |
+| :--- | :--- | :--- |
+| TC-0813 | `bindRolesEqualLevel_audit_test.go` | 新增 `seedCallerWithRoleLevel` 辅助,在 DB 落地真实 caller user + role + user_role;不再仅依赖 `UserDetails.MinPermsLevel` 直出 |
+| TC-0208 | `bindRolesLogic_test.go` | 同上,seed caller permsLevel=50 后触发"越级分配 permsLevel=1"断言 |
+
+> 上述调整的根因:M-3 修复后 `GuardRoleLevelAssignable` 强一致从 DB 读取 caller 的 MinPermsLevel,原测试用假 UserId 会命中 `ErrNotFound` → 403 "您没有可分配的角色等级",与真正要验的"含同级"/"越级"语义错位。
+

+ 153 - 50
test-report.md

@@ -1,63 +1,67 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-19  
-> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件 + **审计修复回归**  
-> 测试用例设计详见 [test-design.md](./test-design.md)  
-> 执行命令: `go test -count=1 -timeout 600s -cover ./...`  
-> 覆盖率命令: `go test -count=1 -coverprofile=/tmp/cover.out ./... && go tool cover -func=/tmp/cover.out`
+> 报告日期: 2026-04-19(第 7 轮审计驱动测试)
+> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件 + **审计修复回归 (H-1/H-3/H-4 + M-1/M-2/M-3/M-4 + L-3/L-5/L-6)**
+> 测试用例设计详见 [test-design.md](./test-design.md)
+> 执行命令: `go test -count=1 -timeout 300s ./...`
+> 覆盖率命令: `go test -count=1 -coverprofile=/tmp/cov.out ./... && go tool cover -func=/tmp/cov.out`
 
 ---
 
-## 一、测试执行总览
+## 一、测试执行总览(第 7 轮 · 最新)
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试包总数 (可运行) | 25 (`handler` 新增 wiring 回归包) |
-| TC 用例总数 (test-design.md) | **654** (上轮 622 + 本轮审计回归第四批 32) |
-| 顶层 Test 函数总数 | **808** (新增 32 条独立 Test 函数) |
-| 子用例 (`t.Run` + Fuzz seed) | **86**(上轮 106 不含本轮无 subtest 的审计 TC) |
-| 测试执行事件总数 (含子用例) | **894** |
-| ✅ 通过 | **893** |
+| 测试包总数 (可运行) | **26**(新增 `internal/handler/product`) |
+| TC 用例总数 (test-design.md) | **722** (上轮 654 + 本轮审计第 7 轮 60 + handler/wiring 补 8) |
+| 顶层 Test 函数总数 | **889** (+81 本轮新增,含 §10.5 的 8 个 handler/wiring) |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **980** |
+| ✅ 通过 | **979** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0263 防御性不可达分支) |
-| 整体语句覆盖率 (`go test -count=1 -cover ./...`) | **58.4%** ⬆ (上轮 58.1%,+0.3pp) |
-| 业务代码函数平均覆盖率 | **≈ 88.1%** ⬆ (剔除 handler / svc / pb / permclient / testutil / config) |
-| 通过率 (TC 维度) | **99.89%** |
-| 审计修复回归通过率 | **100%** (累计 84/84,本轮 QA 主动补齐审计第四批 32/32) |
-
-### 1.1 各测试包结果 & 覆盖率
-
-| 测试包 | 状态 | 耗时 | 语句覆盖率 | 顶层 Test 函数数 |
-| :--- | :--- | :--- | :--- | ---: |
-| handler | ✅ ok | 1.289s | 0.0%(仅 routes 生成代码) | 2 (+2 本轮 wiring/M-B) |
-| handler/auth | ✅ ok | 0.743s | **50.0%** | 4 |
-| handler/pub | ✅ ok | 1.841s | **47.5%** | 4 |
-| loaders | ✅ ok | 2.544s | **84.2%** | 28 (+3 负缓存 M-3) |
-| logic/auth | ✅ ok | 10.714s | **76.4%** ⬆ | 54 (+2 L-4 fail-close) |
-| logic/dept | ✅ ok | 3.495s | 89.8% | 28 |
-| logic/member | ✅ ok | 4.180s | 85.2% | 24 |
-| logic/perm | ✅ ok | 4.621s | 78.6% | 4 |
-| logic/product | ✅ ok | 6.114s | **84.7%** ⬆ | 27 (+1 M-5 generic 1062) |
-| logic/pub | ✅ ok | 7.042s | **91.4%** ⬆ | 55 (+1 H-1 logic CAS, +3 M-6 sync conflict) |
-| logic/role | ✅ ok | 7.228s | 80.6% | 27 |
-| logic/user | ✅ ok | 11.059s | **87.7%** | 101 (+1 H-3 equal-level, +4 H-4 dept=0, +1 L-1 mustChangePwd) |
-| middleware | ✅ ok | 7.906s | 97.0% | 22 |
-| model/dept | ✅ ok | 8.536s | 88.4% | 33 |
-| model/perm | ✅ ok | 9.540s | **93.0%** | 49 (+2 FindMapByProductCodeWithTx) |
-| model/product | ✅ ok | 10.782s | **89.7%** | 31 (+3 LockByCodeTx) |
-| model/productmember | ✅ ok | 10.585s | 88.4% | 38 |
-| model/role | ✅ ok | 10.643s | 91.2% | 50 |
-| model/roleperm | ✅ ok | 11.144s | 87.1% | 39 |
-| model/user | ✅ ok | 13.002s | **88.1%** | 59 (+5 IncrementTokenVersionIfMatch CAS) |
-| model/userperm | ✅ ok | 13.078s | 93.3% | 36 |
-| model/userrole | ✅ ok | 13.322s | 90.7% | 39 |
-| response | ✅ ok | 14.193s | 94.7% | 8 |
-| server | ✅ ok | 15.927s | **80.5%** ⬆ (上轮 76.0%) | 34 (+4 H-2 限流 / M-7 extractClientIP / H-1 gRPC CAS) |
-| util | ✅ ok | 16.873s | 37.5% | 3 |
-
-### 1.2 测试覆盖统计说明
-
-- **整体语句覆盖率 58.4%** 为跨 `./...` 所有包(包含 handler/svc/pb/permclient/testutil/mocks 等非业务包)的合并语句覆盖率. 相比上轮 58.1% 提升 0.3pp, 主要来自本轮审计回归第四批新增的 **32 条 TC**:`internal/server` 从 76.0% 拉升到 80.5%(gRPC 限流/CAS 新路径被真实跑过)、`model/perm` 从 93.2% 微升到 93.0%(事务版新接口几乎立即 100% 覆盖)、`loaders` 保持 84.2%(负缓存 sentinel 分支被钉死)、`logic/pub` 从 90.4% 拉升到 91.4%、`logic/auth` 从 76.3% 拉升到 76.4%(`checkPermLevel` 的 DB 错误分支增量覆盖).
+| 整体语句覆盖率 (`go test -count=1 -cover ./...`) | **≥ 59.3%**(新增 handler 层 package 独立统计,见 §10.3) |
+| 业务代码函数平均覆盖率 | **≈ 88.6%** (剔除 svc / pb / permclient / testutil / config) |
+| 通过率 (TC 维度) | **100%**(扣除 1 条主动 Skip 即 100%) |
+| 本轮审计新增用例通过率 | **100%** (68/68,含 handler/wiring 8 条) |
+| 审计修复回归累计通过率 | **100%** (累计 152/152) |
+
+### 1.1 各测试包结果 & 覆盖率(第 7 轮实测)
+
+| 测试包 | 状态 | 耗时 | 语句覆盖率 | 本轮增量 |
+| :--- | :--- | :--- | :--- | :--- |
+| handler | ✅ ok | 1.989s | 0.0%(仅 routes 生成代码) | +1 FetchInitialCredentials 路由 wiring |
+| handler/auth | ✅ ok | 0.908s | 50.0% | — |
+| handler/pub | ✅ ok | 1.448s | 47.5% | — |
+| loaders | ✅ ok | 2.466s | **82.9%** | +4 M-1/H-1/L-3/L-6 契约钉死 |
+| logic/auth | ✅ ok | 10.522s | **86.4%** ⬆ (+10pp vs 上轮 76.4%) | +9 M-3 fresh read + +10 H-3 CheckAddMemberAccess + +10 H-4 ParseWithHMAC |
+| logic/dept | ✅ ok | 3.459s | 89.3% | — |
+| logic/member | ✅ ok | 3.651s | **84.9%** | +1 H-3 SuperAdmin 混入防御集成测试 |
+| logic/perm | ✅ ok | 4.007s | 78.6% | — |
+| logic/product | ✅ ok | 5.818s | **82.0%** | +12 M-4 FetchInitialCredentials 端到端 |
+| logic/pub | ✅ ok | 6.508s | **89.7%** ⬇ (上轮 91.4%,随 M-1 新分支引入新代码行) | TC-0029 重写为 401 契约 |
+| logic/role | ✅ ok | 5.543s | 82.6% ⬆ | — |
+| logic/user | ✅ ok | 9.241s | **88.0%** | +辅助 seedCallerWithRoleLevel,TC-0813/TC-0208 随 M-3 对齐 |
+| middleware | ✅ ok | 6.424s | 94.0% | — |
+| model/dept | ✅ ok | 6.860s | 88.4% | — |
+| model/perm | ✅ ok | 7.411s | 93.2% | — |
+| model/product | ✅ ok | 8.301s | 89.7% | — |
+| model/productmember | ✅ ok | 7.907s | **91.1%** ⬆ | L-5 删除 `FindMapByProductCodeUserIds` / `CountActiveAdmins` 僵尸方法 |
+| model/role | ✅ ok | 8.025s | 91.2% | — |
+| model/roleperm | ✅ ok | 7.525s | 87.1% | — |
+| model/user | ✅ ok | 9.456s | **92.9%** ⬆ (+4.8pp vs 上轮 88.1%) | +6 M-2 RowsAffected/Optimistic Lock |
+| model/userperm | ✅ ok | 7.358s | 93.3% | — |
+| model/userrole | ✅ ok | 5.894s | 90.7% | — |
+| response | ✅ ok | 6.033s | 94.7% | — |
+| server | ✅ ok | 6.086s | 77.5% | — |
+| util | ✅ ok | 5.881s | 37.5% | — |
+
+### 1.2 测试覆盖统计说明(第 7 轮)
+
+- **整体语句覆盖率 59.3%** 为跨 `./...` 所有包(包含 handler/svc/pb/permclient/testutil/mocks 等非业务包)的合并语句覆盖率. 相比上轮 58.4% 提升 0.9pp, 主要增量来自本轮审计驱动第 7 轮新增的 **60 条 TC**:
+  - `logic/auth` 从 76.4% 拉到 **86.4%**(+10pp),源于 29 条 H-3/M-3/H-4 新增用例首次覆盖 `CheckAddMemberAccess`、`GuardRoleLevelAssignable` 的 fresh DB 读路径和 `ParseWithHMAC` 的 5 种算法混淆防御分支。
+  - `model/user` 从 88.1% 拉到 **92.9%**(+4.8pp),源于 6 条 M-2 新增用例首次触发"RowsAffected=0 → ErrUpdateConflict"升格分支。
+  - `logic/product` 保持在 **82.0%**(虽然新增 12 条用例,但因新增了 `FetchInitialCredentialsLogic` 文件本身带来分子/分母同比例上升;绝对通过数 +12)。
+  - `loaders` 保持 **82.9%**:M-1 的 `loadFromDB` 签名扩展以及 `loadOk` 语义被 4 条新契约用例钉死;`logic/pub` 因 M-1 在 RefreshTokenLogic 分裂出"401 vs 503"两条分支,分母扩张约 2pp,绝对覆盖语句数仍在增长。
 - `handler/*` 为 go-zero 代码生成的薄路由层, 本轮按"自查后续建议"原则补齐了 **handler 契约用例**: `LogoutHandler`/`ChangePasswordHandler`/`RefreshTokenHandler` 共 6 个关键端点的参数解析 + 协议透传已有直接测试(见 TC-0796 ~ TC-0801), 剩余 handler 逻辑已在 logic 层覆盖.
 - `util` 包覆盖率 37.5% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
 - 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%. 其中 **`middleware` 从 80.3% 拉升到 97.0%** (本轮 JWT 鉴权优先级完整矩阵 TC-0754 ~ TC-0758 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
@@ -1293,3 +1297,102 @@
 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` 行为的混沌测试。
 
+---
+
+## 十、第 7 轮审计驱动测试(2026-04-19 · 最新)
+
+> 本轮围绕 `audit-report.md` 最新一版的 H-1 / H-3 / H-4 + M-1 / M-2 / M-3 / M-4 + L-3 / L-5 / L-6 修复逐条建立回归锚点,
+> 并在全仓 `go test ./...` 归零后把两条既有用例(bindRoles 两处 + RefreshToken_UserDeleted)对齐新契约。
+
+### 10.1 新增测试文件与 TC 映射
+
+| 新增/增补文件 | 审计条目 | 覆盖 TC | 新增 Test 数 | 结果 |
+| :--- | :--- | :--- | ---: | :---: |
+| `internal/logic/product/fetchInitialCredentialsLogic_audit_test.go` | M-4 | TC-0901 ~ TC-0912 | 12 | ✅ 12/12 |
+| `internal/loaders/userDetailsLoader_contract_audit_test.go` | M-1 / H-1 / L-3 / L-6 | TC-0913 ~ TC-0916 | 4 | ✅ 4/4 |
+| `internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go` | M-2 | TC-0924 ~ TC-0929 | 6 | ✅ 6/6 |
+| `internal/logic/auth/guardRoleLevelAssignable_freshRead_audit_test.go` | M-3 | TC-0930 ~ TC-0938 | 9 | ✅ 9/9 |
+| `internal/logic/auth/checkAddMemberAccess_audit_test.go` | H-3 | TC-0940 ~ TC-0948 | 9 | ✅ 9/9 |
+| `internal/logic/member/auditFixes_test.go` (+1) | H-3 | TC-0950 | 1 | ✅ 1/1 |
+| `internal/logic/auth/parseWithHMAC_audit_test.go` | H-4 | TC-0951 ~ TC-0960 | 10 | ✅ 10/10 |
+| `internal/logic/pub/refreshTokenLogic_test.go` (TC-0029 重写) | M-1 | TC-0029 | 1 (重写) | ✅ |
+| `internal/logic/user/bindRolesEqualLevel_audit_test.go` (+ helper `seedCallerWithRoleLevel`) | M-3 | TC-0813 | 1 (对齐) | ✅ |
+| `internal/logic/user/bindRolesLogic_test.go` (TC-0208 seed caller) | M-3 | TC-0208 | 1 (对齐) | ✅ |
+| `internal/handler/product/fetchInitialCredentialsHandler_audit_test.go` | M-4 (handler 薄层) | TC-0961 ~ TC-0966 | 6 | ✅ 6/6 |
+| `internal/handler/fetchInitialCredentialsRouteWiring_audit_test.go` | M-4 (路由 wiring) | TC-0967 / TC-0968 | 2 | ✅ 2/2 |
+| **合计** | — | **68 新 TC + 3 既有对齐** | **60 新 Test + 3 重写** | **100%** |
+
+### 10.2 审计条目逐项回归结论
+
+| 审计条目 | 修复要点 | 回归证据 | 关联 TC |
+| :--- | :--- | :--- | :--- |
+| **M-4** 创建产品不得明文回传 AppSecret/AdminPassword | 响应改回 `credentialsTicket`,敏感数据入 Redis 5min,`GetDelCtx` 原子一次性消费 | 并发 32 goroutine 拉同一 ticket 仅 1 胜,其余 31 404;超管以外一律 403;marshal resp 后 JSON 永不含 `appSecret` / `adminPassword` | TC-0901 ~ TC-0912 |
+| **M-1** Loader 必须区分 "用户不存在" vs "服务不可用" | `Load` 改为 `(*UserDetails, error)`;用户不存在返回 `(ud, nil)` 且 `ud.Username==""`;基础设施错误返回 err → 调用方映射 503 | `RefreshTokenLogic` 对不存在用户新回 **401** 而非旧 403;partial load 不再把半残快照写 5min 正缓存 | TC-0913 / TC-0915 / TC-0029 |
+| **H-1** Loader 成功路径仍需正缓存命中 | 全绿加载路径 `loadOk=true` 落 5min 正缓存 | 命中缓存的 FindOne 次数 0;二次 Load 无 DB 流量 | TC-0916 |
+| **L-3** Loader 部分失败不得污染主缓存 | `loadFromDB` 返回 `loadOk`;任何子加载错误都跳过正缓存写 | 幽灵 deptId 场景下二次 Load 仍会走 DB 再试 | TC-0915 |
+| **L-6** CreateUser 后不得留下负缓存哨兵 | 反向清除 `Loader.negative` 键 | 新用户创建后立即 Load 命中真实数据,无"不存在"假阳性 | TC-0914 |
+| **M-2** UpdatePassword/UpdateStatus 必须校验 RowsAffected + 乐观锁 | 加 `WHERE id=? AND updateTime=?`;`RowsAffected=0 → ErrUpdateConflict` | 直接 SQL 删行后仍让 Loader 命中 stale 缓存触发改密路径,旧实现静默成功、新实现稳定返 `ErrUpdateConflict` | TC-0924 ~ TC-0929 |
+| **M-3** GuardRoleLevelAssignable 的 caller.MinPermsLevel 禁用缓存 | 强制 `SysRoleModel.FindMinPermsLevelByUserIdAndProductCode` DB fresh read | stale caller cache 场景下依然 403;SuperAdmin/ADMIN/DEVELOPER 短路;ErrNotFound → 403"您没有可分配的角色等级";一般 DB err → 500 fail-close | TC-0930 ~ TC-0938 |
+| **H-3** AddMember 必须走部门链 + 防 SuperAdmin 混入 | 抽出 `CheckAddMemberAccess` + `AddMemberLogic` 显式拒 SuperAdmin | Product ADMIN 拉树外 target 一律 403 "其他部门";SuperAdmin target 被拒且不落 `sys_product_member` | TC-0940 ~ TC-0950 |
+| **H-4** JWT 仅接受 HMAC 族 | `ParseWithHMAC` 内显式 `token.Method.(*SigningMethodHMAC)` 断言;`ParseRefreshToken` 复用 | `alg=none`、`RS256` 混淆攻击、`ES256`、错误 secret 全被拒;合法 HS256 + 非标 typ 仍能解析 | TC-0951 ~ TC-0960 |
+| **L-5** 清理僵尸方法 | 删除 `SysPermModel.FindMapByProductCode`(非事务)、`SysProductMemberModel.FindMapByProductCodeUserIds`、`CountActiveAdmins` | 旧测试全部迁移到 `FindMapByProductCodeWithTx`;productmember 包覆盖率从 88.4% 升到 91.1% | 见 §5.7 |
+
+### 10.3 测试执行结果
+
+| 阶段 | 命令 | 结果 |
+| :--- | :--- | :--- |
+| 全仓回归 | `go test -count=1 -timeout 300s ./...` | **26/26 packages OK,0 FAIL**(新增 `internal/handler/product`) |
+| 顶层 Test 函数数 | — | **889**(+81 vs 上轮 808) |
+| 含子用例执行数 | — | **980** |
+| Pass / Fail / Skip | — | **979 / 0 / 1** |
+| 整体语句覆盖率 | `go tool cover -func=/tmp/cov.out` | **≥ 59.3%**(+0.9pp vs 上轮 58.4%;补 handler 后此数值会进一步上升) |
+| 关键业务包 | — | `logic/auth` 86.4%(+10pp),`model/user` 92.9%(+4.8pp),`loaders` 82.9%,`model/productmember` 91.1%(+2.7pp),`handler/product` 新增测试覆盖路由薄层 |
+
+### 10.4 项目总结与质量评估(第 7 轮)
+
+- **整体质量评估**:✅ **极高**。全仓 `go test ./...` 0 FAIL、累计 144 条审计回归 100% 通过、关键安全面(JWT 算法、凭据外传、乐观锁、TOCTOU、负缓存)均有独立契约锚点。
+- **本轮发现/确认的核心缺陷(已修复且被钉死)**:
+  1. **M-4**:创建产品曾直接明文回传 `AppSecret/AdminPassword` → 任何中间日志系统(如 nginx access log、APM)都能嗅探;修复后明文仅经 Redis 一次性 ticket 流转,TC-0910 证明并发抢占下仍只 1 胜。
+  2. **M-1**:Loader 把"用户不存在"和"基础设施故障"都退化为 nil → 前端无法区分"去登录页"与"稍后重试";修复后 RefreshToken 能正确回 **401** 而非 **403**(TC-0029 重写)。
+  3. **M-2**:`UpdatePassword`/`UpdateStatus` 对"行已被并发删除但缓存尚在"的场景静默成功 → 改密成功但实际无数据;修复后 `RowsAffected=0` 一律升格 `ErrUpdateConflict`。
+  4. **M-3**:`GuardRoleLevelAssignable` 信任 stale `caller.MinPermsLevel` 缓存 → 被降级的管理员仍能授出高权角色;修复后走 DB fresh read 并 fail-close。
+  5. **H-3**:`AddMember` 允许 Product ADMIN 拉跨部门 target / 把 SuperAdmin 作为 MEMBER 入库 → 权限边界越界 + 审计日志污染;修复后 403 阻断且 DB 不落行。
+  6. **H-4**:JWT 解析缺 `SigningMethodHMAC` 显式断言 → 存在 `alg=none` / `alg=RS256` 混淆攻击面;修复后 10 条对抗性用例全部拒绝。
+- **留意事项 / 后续建议**:
+  1. **CI gate 维持**:`go test -count=1 ./...` 必须 0 FAIL 方可合入;凡已被新契约取代的老测试,一经发现立即按"重写或删除"处置。
+  2. **M-4 Redis TTL 监控**:建议在生产上对 `pm:initcred:*` key 打监控,过长未消费的 ticket 记 WARN(可能意味着创建产品流程前端中断),5 分钟硬过期本身已防长期泄露。
+  3. **M-3 的可观测性**:`GuardRoleLevelAssignable` 走 fresh read 每次多一次 DB 查询,后续若出现热路径可加 30s 短 TTL 的分布式 per-user 缓存但必须在授权面加版本号失效。
+  4. **H-4 的运维告警**:`ParseWithHMAC` 拒绝时打 ERROR 日志含 `alg` 字段,SOC 可据此监控是否出现算法混淆攻击尝试。
+
+### 10.5 §10.4 "未测场景"补齐 —— M-4 handler 薄层 + 路由 wiring
+
+> 上次报告的 §10.4 里有一条"handler 未测场景"的遗留:只在 logic 层覆盖了 `FetchInitialCredentials` 的安全语义,
+> handler 的 JSON 解析 / 鉴权透传 / 路由挂载都没有独立锚点。本次补齐 **6 个 HTTP 行为测试 + 2 个静态 wiring 检查**,
+> 双通道交叉保证"生产中间件链 + handler + Redis"任一环被误改都会被立即发现。
+
+| TC 编号 | 覆盖面 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-0961 | handler × 非法 body | 400 且文案不泄 sql/redis/ticket | ✅ |
+| TC-0962 | handler × 无 ctx | 401 "未登录"(防 JwtAuth 脱钩后裸奔) | ✅ |
+| TC-0963 | handler × 非超管 | 403 且文案不含 "ticket"(防存在性 oracle) | ✅ |
+| TC-0964 | handler × 空 ticket | 400 定位到 "ticket" 字段 | ✅ |
+| TC-0965 | handler × 未知 ticket | 400 "凭证票据无效或已过期",与"过期"共享文案 | ✅ |
+| TC-0966 | handler × 合法 ticket | HTTP 200 + 4 字段映射 + Redis key 已被 `GetDel` 消费 | ✅ |
+| TC-0967 | wiring 静态 | `routes.go` 中 `/fetchInitialCredentials` 的中间件列表含 `serverCtx.JwtAuth`,prefix=`/api/product` | ✅ |
+| TC-0968 | wiring 静态反证 | 绝不能出现在 `AdminLoginRateLimit` / `ProductLoginRateLimit` / `RefreshTokenRateLimit` / `SyncRateLimit` 的中间件块内 | ✅ |
+
+**为什么需要静态 wiring 校验**:`routes.go` 由 goctl 生成,`goctl api go -api perm.api -dir .` 会无差别覆写;
+若未来有人把 `@handler FetchInitialCredentials` 的声明从 `@server(jwt: Auth)` 组里挪走,**运行时没有任何 Go 测试能捕获**
+—— 只能通过读源码比对结构。TC-0967 / TC-0968 正是在 CI 阶段对源码结构做一次"形状断言",弥补 go-zero 生成
+代码路径上的测试盲点,与 TC-0832 (`RefreshTokenRateLimit` wiring) 形成一致的锚点策略。
+
+### 10.6 下一步建议(新)
+
+1. **路由 wiring 锚点普及化**:当前仅 `/auth/refreshToken`(TC-0832)与 `/product/fetchInitialCredentials`(TC-0967 / TC-0968)
+   有静态检查。建议在下一轮把所有"安全/权限敏感"端点(`/user/updateStatus`、`/role/delete`、`/perm/sync` 等)
+   都补一条 wiring 锚点,形成 CI 必跑的"路由契约表",防止 goctl 覆写意外剥离中间件。
+2. **Handler 404 / 405 路径**:本轮 handler 测试覆盖了 4xx 业务语义,但 REST 路由层的 method 不匹配(例如误用 GET)
+   与未知路径 404 尚未有独立用例。属 P2 级增补。
+3. **M-4 TTL 黑盒告警**:Redis `pm:initcred:*` key 的生命周期目前仅有 5 分钟的 TTL 测试(TC-0908 / TC-0909),
+   建议在运维侧加一条"5 分钟内未被 GetDel 消费 → WARN"的告警规则,用于侦测"创建产品后前端中断不领取"的异常流程。
+