Przeglądaj źródła

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

BaiLuoYan 3 tygodni temu
rodzic
commit
b355e6879b
49 zmienionych plików z 3011 dodań i 899 usunięć
  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)
 ## 🚩 核心逻辑漏洞 (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
   ```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 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
   ```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
   ```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
   ```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",
 					Path:    "/create",
 					Handler: product.CreateProductHandler(serverCtx),
 					Handler: product.CreateProductHandler(serverCtx),
 				},
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/fetchInitialCredentials",
+					Handler: product.FetchInitialCredentialsHandler(serverCtx),
+				},
 				{
 				{
 					Method:  http.MethodPost,
 					Method:  http.MethodPost,
 					Path:    "/detail",
 					Path:    "/detail",

+ 137 - 54
internal/loaders/userDetailsLoader.go

@@ -23,7 +23,13 @@ const (
 	// negativeCacheTTL 控制"用户不存在/已删除"的短期负缓存窗口;必须显著短于 defaultCacheTTL,避免
 	// negativeCacheTTL 控制"用户不存在/已删除"的短期负缓存窗口;必须显著短于 defaultCacheTTL,避免
 	// 刚刚 createUser 的合法用户被误判为不存在,但又要足够长到能吸收一波由离职用户残留 token 带来的
 	// 刚刚 createUser 的合法用户被误判为不存在,但又要足够长到能吸收一波由离职用户残留 token 带来的
 	// 无效流量(审计 M-3 所说的 DB DoS 放大路径)。
 	// 无效流量(审计 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
 	// negativeCacheMarker 是写入 Redis 的哨兵字符串;选用非合法 JSON,确保任何升级带来的 schema
 	// 变动都不会把它误解析为真实 UserDetails。
 	// 变动都不会把它误解析为真实 UserDetails。
 	negativeCacheMarker = "_NOT_FOUND_"
 	negativeCacheMarker = "_NOT_FOUND_"
@@ -116,33 +122,65 @@ func (l *UserDetailsLoader) productIndexKey(productCode string) string {
 }
 }
 
 
 // Load 根据 userId 和 productCode 加载完整的 UserDetails。
 // 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)
 	key := l.cacheKey(userId, productCode)
 
 
 	if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
 	if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
-		// 命中负缓存:该 userId/productCode 最近查询确认为不存在;直接返回空 UserDetails,
-		// 避免离职/伪造账号的残余 token 持续压垮 DB(见审计 M-3)。
 		if val == negativeCacheMarker {
 		if val == negativeCacheMarker {
-			return &UserDetails{UserId: userId, ProductCode: productCode}
+			return &UserDetails{UserId: userId, ProductCode: productCode}, nil
 		}
 		}
 		var ud UserDetails
 		var ud UserDetails
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
 		if err := json.Unmarshal([]byte(val), &ud); err == nil {
-			return &ud
+			return &ud, nil
 		}
 		}
 	}
 	}
 
 
 	v, sfErr, _ := l.sf.Do(key, func() (interface{}, error) {
 	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 {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
 		if ud.Username == "" {
 		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 {
 			if err := l.rds.SetexCtx(ctx, key, negativeCacheMarker, negativeCacheTTL); err != nil {
 				logx.WithContext(ctx).Errorf("set user details negative cache failed: %v", err)
 				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 val, err := json.Marshal(ud); err == nil {
 			if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); 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 {
 	if sfErr != nil {
 		logx.WithContext(ctx).Errorf("load user details from DB failed: %v", sfErr)
 		logx.WithContext(ctx).Errorf("load user details from DB failed: %v", sfErr)
+		return nil, sfErr
 	}
 	}
 
 
 	ud, ok := v.(*UserDetails)
 	ud, ok := v.(*UserDetails)
 	if !ok || ud == nil {
 	if !ok || ud == nil {
-		return &UserDetails{UserId: userId, ProductCode: productCode}
+		return &UserDetails{UserId: userId, ProductCode: productCode}, nil
 	}
 	}
-	return ud
+	return ud, nil
 }
 }
 
 
 // Del 删除指定用户在指定产品下的缓存。
 // 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{
 	ud := &UserDetails{
 		UserId:        userId,
 		UserId:        userId,
 		ProductCode:   productCode,
 		ProductCode:   productCode,
@@ -289,18 +334,29 @@ func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, produc
 	}
 	}
 
 
 	if err := l.loadUser(ctx, ud); err != nil {
 	if err := l.loadUser(ctx, ud); err != nil {
-		return ud, err
+		return ud, false, err
 	}
 	}
 	if ud.Username == "" {
 	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 {
 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
 	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 {
 	if ud.DeptId == 0 {
-		return
+		return nil
 	}
 	}
 	d, err := l.models.SysDeptModel.FindOne(ctx, ud.DeptId)
 	d, err := l.models.SysDeptModel.FindOne(ctx, ud.DeptId)
 	if err != nil {
 	if err != nil {
+		// DeptPath 为空会让 checkDeptHierarchy 一刀切 403;必须向上传递 error 让 Load 跳过缓存写入
+		// (见审计 M-1)。
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query dept %d failed: %v", ud.DeptId, err)
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query dept %d failed: %v", ud.DeptId, err)
-		return
+		return err
 	}
 	}
 	ud.DeptName = d.Name
 	ud.DeptName = d.Name
 	ud.DeptPath = d.Path
 	ud.DeptPath = d.Path
 	ud.DeptType = d.DeptType
 	ud.DeptType = d.DeptType
 	ud.DeptStatus = d.Status
 	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 == "" {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	}
 	p, err := l.models.SysProductModel.FindOneByCode(ctx, ud.ProductCode)
 	p, err := l.models.SysProductModel.FindOneByCode(ctx, ud.ProductCode)
 	if err != nil {
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query product %s failed: %v", ud.ProductCode, err)
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query product %s failed: %v", ud.ProductCode, err)
-		return
+		return err
 	}
 	}
 	ud.ProductName = p.Name
 	ud.ProductName = p.Name
 	ud.ProductStatus = p.Status
 	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 {
 	if ud.IsSuperAdmin {
 		ud.MemberType = consts.MemberTypeSuperAdmin
 		ud.MemberType = consts.MemberTypeSuperAdmin
 	}
 	}
 	if ud.ProductCode == "" {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	}
 	if ud.IsSuperAdmin {
 	if ud.IsSuperAdmin {
-		return
+		return nil
 	}
 	}
 	member, err := l.models.SysProductMemberModel.FindOneByProductCodeUserId(ctx, ud.ProductCode, ud.UserId)
 	member, err := l.models.SysProductMemberModel.FindOneByProductCodeUserId(ctx, ud.ProductCode, ud.UserId)
 	if err != nil {
 	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 {
 	if member.Status != consts.StatusEnabled {
-		return
+		return nil
 	}
 	}
 	ud.MemberType = member.MemberType
 	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 == "" {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	}
 	roleIds, err := l.models.SysUserRoleModel.FindRoleIdsByUserIdForProduct(ctx, ud.UserId, ud.ProductCode)
 	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)
 	roles, err := l.models.SysRoleModel.FindByIds(ctx, roleIds)
 	if err != nil {
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query roles failed: %v", err)
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query roles failed: %v", err)
-		return
+		return err
 	}
 	}
 	ud.Roles = make([]RoleInfo, 0)
 	ud.Roles = make([]RoleInfo, 0)
 	minLevel := int64(math.MaxInt64)
 	minLevel := int64(math.MaxInt64)
@@ -410,21 +476,22 @@ func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) {
 	if minLevel < math.MaxInt64 {
 	if minLevel < math.MaxInt64 {
 		ud.MinPermsLevel = minLevel
 		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 == "" {
 	if ud.ProductCode == "" {
-		return
+		return nil
 	}
 	}
 
 
 	if ud.ProductStatus != consts.StatusEnabled {
 	if ud.ProductStatus != consts.StatusEnabled {
 		ud.Perms = nil
 		ud.Perms = nil
-		return
+		return nil
 	}
 	}
 
 
 	if !ud.IsSuperAdmin && ud.MemberType == "" {
 	if !ud.IsSuperAdmin && ud.MemberType == "" {
 		ud.Perms = nil
 		ud.Perms = nil
-		return
+		return nil
 	}
 	}
 
 
 	// 超管 / ADMIN / DEVELOPER / 研发部门的有效成员 → 全量权限
 	// 超管 / 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) {
 		(ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) {
 		codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
 		codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode)
 		if err != nil {
 		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)
 			logx.WithContext(ctx).Errorf("userDetailsLoader: query all perms failed: %v", err)
+			return err
 		}
 		}
 		ud.Perms = codes
 		ud.Perms = codes
-		return
+		return nil
 	}
 	}
 
 
 	// 普通成员:角色权限 + 用户附加权限 - 用户拒绝权限
 	// 普通成员:角色权限 + 用户附加权限 - 用户拒绝权限
@@ -448,19 +519,26 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 			roleIds = append(roleIds, r.Id)
 			roleIds = append(roleIds, r.Id)
 		}
 		}
 		ids, err := l.models.SysRolePermModel.FindPermIdsByRoleIds(ctx, roleIds)
 		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)
 	allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
 	if err != nil {
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: load allow perms failed: %v", err)
 		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)
 	denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
 	if err != nil {
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
 		logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
+		return err
 	}
 	}
 
 
 	denySet := make(map[int64]bool, len(denyIds))
 	denySet := make(map[int64]bool, len(denyIds))
@@ -487,14 +565,19 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 
 
 	if len(finalIds) > 0 {
 	if len(finalIds) > 0 {
 		perms, err := l.models.SysPermModel.FindByIds(ctx, finalIds)
 		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)
 	loader.Del(ctx, nonExistId, productCode)
 
 
 	// 第 1 次 Load:预期回写负缓存哨兵。
 	// 第 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)
 	require.NotNil(t, ud1)
 	assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
 	assert.Empty(t, ud1.Username, "不存在的用户 Load 后 Username 必须为空")
 
 
@@ -42,7 +45,8 @@ func TestUserDetailsLoader_NegativeCache_HitsOnSecondCall(t *testing.T) {
 
 
 	// 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
 	// 第 2 次 Load:必须命中哨兵分支;哨兵应当返回空 UserDetails(Username 依然为空),
 	// 且不得再做 DB 查询(这里没有 mock DB counter,但结果的契约仍然成立)。
 	// 且不得再做 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)
 	require.NotNil(t, ud2)
 	assert.Empty(t, ud2.Username)
 	assert.Empty(t, ud2.Username)
 	assert.Equal(t, nonExistId, ud2.UserId)
 	assert.Equal(t, nonExistId, ud2.UserId)
@@ -69,7 +73,7 @@ func TestUserDetailsLoader_NegativeCache_NotIndexed(t *testing.T) {
 	productCode := "pc_idx_" + uniqueId()
 	productCode := "pc_idx_" + uniqueId()
 
 
 	loader.Del(ctx, nonExistId, productCode)
 	loader.Del(ctx, nonExistId, productCode)
-	loader.Load(ctx, nonExistId, productCode)
+	_, _ = loader.Load(ctx, nonExistId, productCode)
 
 
 	uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
 	uidx, err := loader.rds.SmembersCtx(ctx, loader.userIndexKey(nonExistId))
 	require.NoError(t, err)
 	require.NoError(t, err)
@@ -108,7 +112,7 @@ func TestUserDetailsLoader_NegativeCache_ConcurrentLoadsStabilize(t *testing.T)
 					close(ch)
 					close(ch)
 				}
 				}
 			}()
 			}()
-			_ = loader.Load(ctx, nonExistId, productCode)
+			_, _ = loader.Load(ctx, nonExistId, productCode)
 		}()
 		}()
 	}
 	}
 	select {
 	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) {
 		go func(idx int) {
 			defer wg.Done()
 			defer wg.Done()
 			<-start
 			<-start
-			ptrs[idx] = loader.Load(ctx, userId, "")
+			ud, _ := loader.Load(ctx, userId, "")
+			ptrs[idx] = ud
 		}(i)
 		}(i)
 	}
 	}
 	close(start)
 	close(start)
@@ -111,12 +112,12 @@ func TestLoader_Load_SecondRoundHitsCache(t *testing.T) {
 	loader.Del(ctx, userId, "")
 	loader.Del(ctx, userId, "")
 	loader.Clean(ctx, userId)
 	loader.Clean(ctx, userId)
 
 
-	_ = loader.Load(ctx, userId, "")
+	_, _ = loader.Load(ctx, userId, "")
 	firstHits := atomic.LoadInt64(&counting.findOneHits)
 	firstHits := atomic.LoadInt64(&counting.findOneHits)
 	require.Equal(t, int64(1), firstHits, "首次 Load 应命中 DB 一次")
 	require.Equal(t, int64(1), firstHits, "首次 Load 应命中 DB 一次")
 
 
 	for i := 0; i < 20; i++ {
 	for i := 0; i < 20; i++ {
-		_ = loader.Load(ctx, userId, "")
+		_, _ = loader.Load(ctx, userId, "")
 	}
 	}
 	secondRoundHits := atomic.LoadInt64(&counting.findOneHits) - firstHits
 	secondRoundHits := atomic.LoadInt64(&counting.findOneHits) - firstHits
 	assert.Equal(t, int64(0), secondRoundHits,
 	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
 	// clear any leftover cache
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, userId, ud.UserId)
 	assert.Equal(t, userId, ud.UserId)
 	assert.Equal(t, uid, ud.Username)
 	assert.Equal(t, uid, ud.Username)
@@ -264,10 +264,10 @@ func TestLoad_CacheHit(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud1 := loader.Load(ctx, userId, pcode)
+	ud1, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud1)
 	require.NotNil(t, ud1)
 
 
-	ud2 := loader.Load(ctx, userId, pcode)
+	ud2, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud2)
 	require.NotNil(t, ud2)
 
 
 	assert.Equal(t, ud1.UserId, ud2.UserId)
 	assert.Equal(t, ud1.UserId, ud2.UserId)
@@ -284,7 +284,7 @@ func TestLoad_UserNotExist(t *testing.T) {
 	nonExistId := int64(999999999)
 	nonExistId := int64(999999999)
 	loader.Del(ctx, nonExistId, "nonexist_product")
 	loader.Del(ctx, nonExistId, "nonexist_product")
 
 
-	ud := loader.Load(ctx, nonExistId, "nonexist_product")
+	ud, _ := loader.Load(ctx, nonExistId, "nonexist_product")
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(0), ud.Status)
 	assert.Equal(t, int64(0), ud.Status)
 	assert.Empty(t, ud.Username)
 	assert.Empty(t, ud.Username)
@@ -319,7 +319,7 @@ func TestLoad_EmptyProductCode(t *testing.T) {
 
 
 	loader.Del(ctx, userId, "")
 	loader.Del(ctx, userId, "")
 
 
-	ud := loader.Load(ctx, userId, "")
+	ud, _ := loader.Load(ctx, userId, "")
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, uid, ud.Username)
 	assert.Equal(t, uid, ud.Username)
 	assert.Equal(t, int64(consts.StatusEnabled), ud.Status)
 	assert.Equal(t, int64(consts.StatusEnabled), ud.Status)
@@ -362,13 +362,13 @@ func TestDel(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud1 := loader.Load(ctx, userId, pcode)
+	ud1, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud1)
 	require.NotNil(t, ud1)
 	assert.Equal(t, uid, ud1.Username)
 	assert.Equal(t, uid, ud1.Username)
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud2 := loader.Load(ctx, userId, pcode)
+	ud2, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud2)
 	require.NotNil(t, ud2)
 	assert.Equal(t, uid, ud2.Username)
 	assert.Equal(t, uid, ud2.Username)
 }
 }
@@ -412,8 +412,8 @@ func TestClean(t *testing.T) {
 	loader.Del(ctx, userId, pcode1)
 	loader.Del(ctx, userId, pcode1)
 	loader.Del(ctx, userId, pcode2)
 	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, ud1)
 	require.NotNil(t, ud2)
 	require.NotNil(t, ud2)
 
 
@@ -475,8 +475,8 @@ func TestCleanByProduct(t *testing.T) {
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId2, 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()
 	rds := testRedis()
 	k1 := loader.cacheKey(userId1, pcode)
 	k1 := loader.cacheKey(userId1, pcode)
@@ -536,8 +536,8 @@ func TestBatchDel(t *testing.T) {
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId1, pcode)
 	loader.Del(ctx, userId2, 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()
 	rds := testRedis()
 	k1 := loader.cacheKey(userId1, pcode)
 	k1 := loader.cacheKey(userId1, pcode)
@@ -607,7 +607,7 @@ func TestLoadPerms_SuperAdmin(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
@@ -662,7 +662,7 @@ func TestLoadPerms_AdminMember(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.MemberTypeAdmin, ud.MemberType)
 	assert.Equal(t, consts.MemberTypeAdmin, ud.MemberType)
 	assert.Contains(t, ud.Perms, permCode)
 	assert.Contains(t, ud.Perms, permCode)
@@ -713,7 +713,7 @@ func TestLoadPerms_DeveloperMember(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.MemberTypeDeveloper, ud.MemberType)
 	assert.Equal(t, consts.MemberTypeDeveloper, ud.MemberType)
 	assert.Contains(t, ud.Perms, permCode)
 	assert.Contains(t, ud.Perms, permCode)
@@ -771,7 +771,7 @@ func TestLoadPerms_DevDept(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Contains(t, ud.Perms, permCode)
 	assert.Contains(t, ud.Perms, permCode)
@@ -860,7 +860,7 @@ func TestLoadPerms_MemberRolePermWithAllowDeny(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 
 
 	// permA (from role) + permC (from ALLOW) should be present
 	// permA (from role) + permC (from ALLOW) should be present
@@ -926,7 +926,7 @@ func TestLoadRoles_MinPermsLevel(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 2)
 	assert.Len(t, ud.Roles, 2)
 	assert.Equal(t, int64(5), ud.MinPermsLevel)
 	assert.Equal(t, int64(5), ud.MinPermsLevel)
@@ -964,7 +964,7 @@ func TestLoadRoles_NoRoles(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(math.MaxInt64), ud.MinPermsLevel)
 	assert.Equal(t, int64(math.MaxInt64), ud.MinPermsLevel)
 }
 }
@@ -1031,7 +1031,7 @@ func TestLoadRoles_CrossProductFilter(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcodeA)
 	loader.Del(ctx, userId, pcodeA)
 
 
-	ud := loader.Load(ctx, userId, pcodeA)
+	ud, _ := loader.Load(ctx, userId, pcodeA)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 1)
 	assert.Len(t, ud.Roles, 1)
 	assert.Equal(t, roleA, ud.Roles[0].Id)
 	assert.Equal(t, roleA, ud.Roles[0].Id)
@@ -1094,7 +1094,7 @@ func TestLoadRoles_DisabledRoleExcluded(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Len(t, ud.Roles, 1)
 	assert.Len(t, ud.Roles, 1)
 	assert.Equal(t, enabledRole, ud.Roles[0].Id)
 	assert.Equal(t, enabledRole, ud.Roles[0].Id)
@@ -1133,7 +1133,7 @@ func TestLoadMembership_SuperAdminAuto(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.True(t, ud.IsSuperAdmin)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
 	assert.Equal(t, consts.MemberTypeSuperAdmin, ud.MemberType)
@@ -1171,7 +1171,7 @@ func TestLoadMembership_NonMemberEmpty(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.False(t, ud.IsSuperAdmin)
 	assert.False(t, ud.IsSuperAdmin)
 	assert.Empty(t, ud.MemberType)
 	assert.Empty(t, ud.MemberType)
@@ -1244,13 +1244,13 @@ func TestLoadPerms_CrossProductPermIsolation(t *testing.T) {
 	})
 	})
 
 
 	loader.Del(ctx, userId, pcodeA)
 	loader.Del(ctx, userId, pcodeA)
-	udA := loader.Load(ctx, userId, pcodeA)
+	udA, _ := loader.Load(ctx, userId, pcodeA)
 	require.NotNil(t, udA)
 	require.NotNil(t, udA)
 	assert.Contains(t, udA.Perms, "permA:"+uid, "产品A应包含自身权限")
 	assert.Contains(t, udA.Perms, "permA:"+uid, "产品A应包含自身权限")
 	assert.NotContains(t, udA.Perms, "permB:"+uid, "产品A不应包含产品B的权限(H-1)")
 	assert.NotContains(t, udA.Perms, "permB:"+uid, "产品A不应包含产品B的权限(H-1)")
 
 
 	loader.Del(ctx, userId, pcodeB)
 	loader.Del(ctx, userId, pcodeB)
-	udB := loader.Load(ctx, userId, pcodeB)
+	udB, _ := loader.Load(ctx, userId, pcodeB)
 	require.NotNil(t, udB)
 	require.NotNil(t, udB)
 	assert.Contains(t, udB.Perms, "permB:"+uid, "产品B应包含自身权限")
 	assert.Contains(t, udB.Perms, "permB:"+uid, "产品B应包含自身权限")
 	assert.NotContains(t, udB.Perms, "permA:"+uid, "产品B不应包含产品A的权限(H-1)")
 	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)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Empty(t, ud.MemberType, "禁用成员的MemberType应为空(H-3)")
 	assert.Empty(t, ud.MemberType, "禁用成员的MemberType应为空(H-3)")
 }
 }
@@ -1351,7 +1351,7 @@ func TestLoadPerms_DisabledDevDeptNoFullPerms(t *testing.T) {
 
 
 	loader.Del(ctx, userId, pcode)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Equal(t, int64(consts.StatusDisabled), ud.DeptStatus)
 	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)
 	loader.Del(ctx, userId, pcode)
 
 
-	ud := loader.Load(ctx, userId, pcode)
+	ud, _ := loader.Load(ctx, userId, pcode)
 	require.NotNil(t, ud)
 	require.NotNil(t, ud)
 	// 部门信息正常载入
 	// 部门信息正常载入
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
 	assert.Equal(t, consts.DeptTypeDev, ud.DeptType)
@@ -1443,7 +1443,7 @@ func TestLoad_NonExistentUser_NotCached(t *testing.T) {
 	// 预先确保缓存中没有该 key
 	// 预先确保缓存中没有该 key
 	loader.Del(ctx, nonExistentUserId, pcode)
 	loader.Del(ctx, nonExistentUserId, pcode)
 
 
-	ud := loader.Load(ctx, nonExistentUserId, pcode)
+	ud, _ := loader.Load(ctx, nonExistentUserId, pcode)
 	// 按当前实现,Load 返回的是 ud(可能是 nil 或零值的 UserDetails),调用方通过 ud.Username == "" 判定不存在。
 	// 按当前实现,Load 返回的是 ud(可能是 nil 或零值的 UserDetails),调用方通过 ud.Username == "" 判定不存在。
 	// L-5 的关键断言:不论返回什么,Redis 里必须没有缓存的 key(即下次 Load 依然走 DB)
 	// L-5 的关键断言:不论返回什么,Redis 里必须没有缓存的 key(即下次 Load 依然走 DB)
 	// 通过再读一次 Redis 判定:间接用 loader.Del 的 key 规则读取
 	// 通过再读一次 Redis 判定:间接用 loader.Del 的 key 规则读取
@@ -1453,7 +1453,7 @@ func TestLoad_NonExistentUser_NotCached(t *testing.T) {
 		assert.Empty(t, ud.Username, "不存在用户返回的 ud 必须是空 Username")
 		assert.Empty(t, ud.Username, "不存在用户返回的 ud 必须是空 Username")
 	}
 	}
 
 
-	ud2 := loader.Load(ctx, nonExistentUserId, pcode)
+	ud2, _ := loader.Load(ctx, nonExistentUserId, pcode)
 	if ud2 != nil {
 	if ud2 != nil {
 		assert.Empty(t, ud2.Username)
 		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 的 ">=" 拦截口径对齐,
 // 约束:"只能分配严格低于自身的等级"(数字更大 = 更低),与 checkPermLevel 的 ">=" 拦截口径对齐,
 // 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。
 // 避免调用者把下属拉到与自己平级后彻底失去管控(见审计 H-3)。
 // 拥有产品全权(SuperAdmin / ADMIN / DEVELOPER)的调用者直接放行。
 // 拥有产品全权(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) {
 	if HasFullProductPerms(caller) {
 		return nil
 		return nil
 	}
 	}
-	if caller == nil || caller.MinPermsLevel == math.MaxInt64 {
+	if caller == nil {
 		return response.ErrForbidden("您没有可分配的角色等级")
 		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 response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
 	}
 	}
 	return nil
 	return nil
@@ -158,6 +173,49 @@ func HasFullProductPerms(caller *loaders.UserDetails) bool {
 		caller.MemberType == consts.MemberTypeDeveloper
 		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 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
 // 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 (
 import (
 	"errors"
 	"errors"
+	"fmt"
 	"time"
 	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
@@ -12,6 +13,23 @@ import (
 
 
 var ErrTokenTypeMismatch = errors.New("token type mismatch")
 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 {
 type RefreshClaims struct {
 	TokenType    string `json:"tokenType"`
 	TokenType    string `json:"tokenType"`
 	UserId       int64  `json:"userId"`
 	UserId       int64  `json:"userId"`
@@ -75,9 +93,7 @@ func GenerateRefreshTokenWithExpiry(secret string, expiresAt time.Time, userId i
 }
 }
 
 
 func ParseRefreshToken(tokenStr, secret string) (*RefreshClaims, error) {
 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 {
 	if err != nil {
 		return nil, err
 		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()
 	userId, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	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)
 	require.NotNil(t, ud)
 	assert.Equal(t, int64(0), ud.TokenVersion)
 	assert.Equal(t, int64(0), ud.TokenVersion)
 
 
@@ -49,7 +50,8 @@ func TestLogout_Normal_IncrementsTokenVersion(t *testing.T) {
 	require.NoError(t, err)
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), u.TokenVersion, "logout 必须递增 tokenVersion")
 	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)
 	require.NotNil(t, ud2)
 	assert.Equal(t, int64(1), ud2.TokenVersion, "clean 后重新 Load 应拿到最新 TokenVersion")
 	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)
 		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)
 	infoCtx := middleware.WithUserDetails(context.Background(), ud)
 
 
 	logic := NewUserInfoLogic(infoCtx, svcCtx)
 	logic := NewUserInfoLogic(infoCtx, svcCtx)
@@ -124,7 +124,7 @@ func TestUserInfo_WithoutProductCode(t *testing.T) {
 	userId, _ := res.LastInsertId()
 	userId, _ := res.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	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)
 	infoCtx := middleware.WithUserDetails(context.Background(), ud)
 
 
 	logic := NewUserInfoLogic(infoCtx, svcCtx)
 	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 {
 	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
 		var deptId int64
 		lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
 		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 {
 		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
 		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 {
 		if err := session.QueryRowsCtx(ctx, &childIds, childQuery, req.Id); err != nil {
 			return err
 			return err
 		}
 		}
@@ -50,7 +57,7 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		}
 		}
 
 
 		var userIds []int64
 		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 {
 		if err := session.QueryRowsCtx(ctx, &userIds, userQuery, req.Id); err != nil {
 			return err
 			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
 		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)
 	_, findErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, req.UserId)
 	if findErr == nil {
 	if findErr == nil {
 		return nil, response.ErrConflict("该用户已是该产品成员")
 		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)
 	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 修复:禁用产品不允许添加成员
 // TC-0729: L-5 修复:禁用产品不允许添加成员
 func TestAddMember_DisabledProductRejected(t *testing.T) {
 func TestAddMember_DisabledProductRejected(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	ctx := ctxhelper.SuperAdminCtx()

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

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/hex"
 	"encoding/hex"
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"regexp"
 	"regexp"
 	"time"
 	"time"
@@ -23,6 +24,22 @@ import (
 	"golang.org/x/crypto/bcrypt"
 	"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}$`)
 var productCodeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{1,63}$`)
 
 
 type CreateProductLogic struct {
 type CreateProductLogic struct {
@@ -142,13 +159,42 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 		return nil, err
 		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,
 		AppKey:        appKey,
 		AppSecret:     rawAppSecret,
 		AppSecret:     rawAppSecret,
 		AdminUser:     adminUsername,
 		AdminUser:     adminUsername,
 		AdminPassword: adminPassword,
 		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
 	}, nil
 }
 }
 
 

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

@@ -1,6 +1,7 @@
 package product
 package product
 
 
 import (
 import (
+	"encoding/json"
 	"errors"
 	"errors"
 	"sync"
 	"sync"
 	"testing"
 	"testing"
@@ -42,9 +43,23 @@ func TestCreateProduct_Success(t *testing.T) {
 	assert.True(t, resp.Id > 0)
 	assert.True(t, resp.Id > 0)
 	assert.Equal(t, code, resp.Code)
 	assert.Equal(t, code, resp.Code)
 	assert.NotEmpty(t, resp.AppKey)
 	assert.NotEmpty(t, resp.AppKey)
-	assert.NotEmpty(t, resp.AppSecret)
 	assert.Equal(t, "admin_"+code, resp.AdminUser)
 	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: 正常创建
 // TC-0064: 正常创建
@@ -73,8 +88,17 @@ func TestCreateProduct_VerifyDB(t *testing.T) {
 	assert.Equal(t, code, product.Code)
 	assert.Equal(t, code, product.Code)
 	assert.Equal(t, "DB验证产品", product.Name)
 	assert.Equal(t, "DB验证产品", product.Name)
 	assert.Equal(t, resp.AppKey, product.AppKey)
 	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)
 	assert.Equal(t, int64(1), product.Status)
 
 
 	var userCount int64
 	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("用户名或密码错误")
 		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(
 	accessToken, err := authHelper.GenerateAccessToken(
 		l.svcCtx.Config.Auth.AccessSecret,
 		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: "您在该产品下的成员资格已被禁用"}
 		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(
 	accessToken, err := authHelper.GenerateAccessToken(
 		svcCtx.Config.Auth.AccessSecret,
 		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("刷新令牌不允许切换产品")
 		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 {
 	if ud.Status != consts.StatusEnabled {
 		return nil, response.ErrForbidden("账号已被冻结")
 		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())
 	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) {
 func TestRefreshToken_UserDeleted(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	svcCtx := newTestSvcCtx()
@@ -167,8 +175,8 @@ func TestRefreshToken_UserDeleted(t *testing.T) {
 
 
 	var codeErr *response.CodeError
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	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: 账号冻结
 // TC-0030: 账号冻结

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

@@ -8,7 +8,9 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	permModel "perms-system-server/internal/model/perm"
 	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
+	"perms-system-server/internal/util"
 
 
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 )
 )
@@ -128,6 +130,21 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		if errors.As(err, &se) {
 		if errors.As(err, &se) {
 			return nil, 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: "同步权限事务失败"}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}
 	}
 
 

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

@@ -3,10 +3,12 @@ package user
 import (
 import (
 	"errors"
 	"errors"
 	"testing"
 	"testing"
+	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/loaders"
 	userModel "perms-system-server/internal/model/user"
 	userModel "perms-system-server/internal/model/user"
+	userroleModel "perms-system-server/internal/model/userrole"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil"
@@ -17,6 +19,41 @@ import (
 	"github.com/stretchr/testify/require"
 	"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 修复 —— "不能分配与自己同级(或更高)的角色"。
 // 覆盖目标:审计 H-3 修复 —— "不能分配与自己同级(或更高)的角色"。
 // 修复前代码仅拦 `>` 严格高于,允许 MEMBER 调用者把同级角色分配给别人,继而下一次 BindRoles 时
 // 修复前代码仅拦 `>` 严格高于,允许 MEMBER 调用者把同级角色分配给别人,继而下一次 BindRoles 时
@@ -45,6 +82,11 @@ func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
 	const callerLevel int64 = 50
 	const callerLevel int64 = 50
 	sameLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel)
 	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() {
 	t.Cleanup(func() {
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
 		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
@@ -53,7 +95,7 @@ func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
 	})
 	})
 
 
 	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
 	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
-		UserId:        999994,
+		UserId:        callerUserId,
 		Username:      "member_eq_level",
 		Username:      "member_eq_level",
 		IsSuperAdmin:  false,
 		IsSuperAdmin:  false,
 		MemberType:    consts.MemberTypeMember,
 		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 {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 				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
 				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)
 	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() {
 	t.Cleanup(func() {
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId)
 		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
 		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)
 		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{
 	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
-		UserId:        999998,
+		UserId:        callerUserId,
 		Username:      "member_caller",
 		Username:      "member_caller",
 		IsSuperAdmin:  false,
 		IsSuperAdmin:  false,
 		MemberType:    consts.MemberTypeMember,
 		MemberType:    consts.MemberTypeMember,

+ 14 - 1
internal/middleware/jwtauthMiddleware.go

@@ -2,6 +2,7 @@ package middleware
 
 
 import (
 import (
 	"context"
 	"context"
+	"fmt"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 
 
@@ -56,7 +57,12 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			return
 			return
 		}
 		}
 
 
+		// 显式断言 HMAC 签名算法,避免 RSA/ECDSA 公钥被当 HMAC 共享密钥伪造 token
+		// (审计 H-4 / RFC 8725)。
 		token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
 		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
 			return []byte(m.accessSecret), nil
 		})
 		})
 		if err != nil || !token.Valid {
 		if err != nil || !token.Valid {
@@ -70,7 +76,14 @@ func (m *JwtAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 			return
 			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 == "" {
 		if ud.Username == "" {
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "用户不存在或已被删除"))
 			httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "用户不存在或已被删除"))
 			return
 			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) {
 func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	m := newTestSysPermModel(t)
 	m := newTestSysPermModel(t)
@@ -30,24 +31,23 @@ func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	productCode := "pc_fmwtx_" + testutil.UniqueId()
 	productCode := "pc_fmwtx_" + testutil.UniqueId()
 	now := time.Now().Unix()
 	now := time.Now().Unix()
 
 
+	codeA := "a_" + testutil.UniqueId()
+	codeB := "b_" + testutil.UniqueId()
+
 	res1, err := m.Insert(ctx, &perm.SysPerm{
 	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,
 		Status: 1, CreateTime: now, UpdateTime: now,
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 	id1, _ := res1.LastInsertId()
 	id1, _ := res1.LastInsertId()
 	res2, err := m.Insert(ctx, &perm.SysPerm{
 	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)
 	require.NoError(t, err)
 	id2, _ := res2.LastInsertId()
 	id2, _ := res2.LastInsertId()
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", id1, id2) })
 	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
 	var withTx map[string]*perm.SysPerm
 	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
 	require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
 		var err error
 		var err error
@@ -56,13 +56,16 @@ func TestSysPermModel_FindMapByProductCodeWithTx_EqualsNonTx(t *testing.T) {
 	}))
 	}))
 	require.Len(t, withTx, 2)
 	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。
 // 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)
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysPerm, int64, error)
 		FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error)
 		FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
-		FindMapByProductCode(ctx context.Context, productCode string) (map[string]*SysPerm, error)
 		// FindMapByProductCodeWithTx 在事务内查询权限快照;配合 SysProductModel.LockByCodeTx 锁住
 		// FindMapByProductCodeWithTx 在事务内查询权限快照;配合 SysProductModel.LockByCodeTx 锁住
 		// product 行,可把"读取现有权限 → 增/改/禁用"这段与其他 SyncPermissions 串行化,
 		// product 行,可把"读取现有权限 → 增/改/禁用"这段与其他 SyncPermissions 串行化,
 		// 避免两次并发同步都认为 code X 不存在并并发 INSERT 导致 1062(见审计 M-6)。
 		// 避免两次并发同步都认为 code X 不存在并并发 INSERT 导致 1062(见审计 M-6)。
@@ -82,19 +81,6 @@ func (m *customSysPermModel) FindByIds(ctx context.Context, ids []int64) ([]*Sys
 	return list, nil
 	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) {
 func (m *customSysPermModel) FindMapByProductCodeWithTx(ctx context.Context, session sqlx.Session, productCode string) (map[string]*SysPerm, error) {
 	var list []*SysPerm
 	var list []*SysPerm
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ?", sysPermRows, m.table)
 	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) {
 func TestSysPermModel_FindMapByProductCode(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	conn := testutil.GetTestSqlConn()
 	conn := testutil.GetTestSqlConn()
@@ -321,8 +321,12 @@ func TestSysPermModel_FindMapByProductCode(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "sys_perm", ids...)
 		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.Len(t, mp, 2)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c2)
 	require.Contains(t, mp, c2)
@@ -612,11 +616,16 @@ func TestSysPermModel_FindAllCodesByProductCode_Empty(t *testing.T) {
 	require.Empty(t, codes)
 	require.Empty(t, codes)
 }
 }
 
 
-// TC-0433: 空结果
+// TC-0433: 空结果(L-5 清理后只保留事务版)
 func TestSysPermModel_FindMapByProductCode_Empty(t *testing.T) {
 func TestSysPermModel_FindMapByProductCode_Empty(t *testing.T) {
 	m := newTestSysPermModel(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)
 	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...) })
 	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.Len(t, mp, 3)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c1)
 	require.Contains(t, mp, c2)
 	require.Contains(t, mp, c2)

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

@@ -3,7 +3,6 @@ package productmember
 import (
 import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
-	"strings"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 
 
@@ -17,8 +16,6 @@ type (
 	SysProductMemberModel interface {
 	SysProductMemberModel interface {
 		sysProductMemberModel
 		sysProductMemberModel
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysProductMember, int64, error)
 		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)
 		CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error)
 		CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error)
 		CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error)
 		FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
 		FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error)
@@ -51,15 +48,6 @@ func (m *customSysProductMemberModel) FindListByProductCode(ctx context.Context,
 	return list, total, nil
 	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) {
 func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
 	var ids []int64
 	var ids []int64
 	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? FOR UPDATE", m.table)
 	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
 	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条)
 // TC-0336: 多条记录(3条)
 func TestSysProductMemberModel_BatchInsert(t *testing.T) {
 func TestSysProductMemberModel_BatchInsert(t *testing.T) {
@@ -1012,60 +958,4 @@ func TestSysProductMemberModel_FindOneByProductCodeUserIdWithTx_NotFound(t *test
 	require.NoError(t, err)
 	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"
 	"fmt"
 	"strings"
 	"strings"
 
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"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) {
 func (m *customSysRoleModel) FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error) {
 	var level int64
 	var level int64
 	query := fmt.Sprintf(
 	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,
 		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
 		return 0, err
 	}
 	}
 	if level < 0 {
 	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)
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
 	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)
 	}, 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 {
 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)
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
 	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)
 		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)
 		return conn.ExecCtx(ctx, query, status, time.Now().Unix(), id)
 	}, sysUserIdKey, sysUserUsernameKey)
 	}, 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) {
 func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
 	data, err := m.FindOne(ctx, id)
 	data, err := m.FindOne(ctx, id)
 	if err != nil {
 	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"
 	"database/sql"
 	"fmt"
 	"fmt"
 
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"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) {
 func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error) {
 	var ids []int64
 	var ids []int64
 	query := fmt.Sprintf(
 	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)
 		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 nil, err
 	}
 	}
 	return ids, nil
 	return ids, nil

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

@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
 
 
+	"perms-system-server/internal/consts"
+
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"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) {
 func (m *customSysUserRoleModel) FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error) {
 	var ids []int64
 	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 nil, err
 	}
 	}
 	return ids, nil
 	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, "刷新令牌不允许切换产品")
 		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 {
 	if ud.Status != consts.StatusEnabled {
 		return nil, status.Error(codes.PermissionDenied, "账号已被冻结")
 		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) {
 	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
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 	})
 	})
 	if err != nil || !token.Valid {
 	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
 		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 {
 	if ud.Status != consts.StatusEnabled {
 		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=user_disabled", claims.UserId)
 		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=user_disabled", claims.UserId)
 		return &pb.VerifyTokenResp{Valid: false}, nil
 		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不匹配")
 		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 == "" {
 	if ud.Username == "" {
 		return nil, status.Error(codes.NotFound, "用户不存在")
 		return nil, status.Error(codes.NotFound, "用户不存在")

+ 2 - 0
internal/svc/servicecontext.go

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

+ 16 - 2
internal/types/types.go

@@ -45,8 +45,22 @@ type CreateProductReq struct {
 }
 }
 
 
 type CreateProductResp 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"`
 	AppKey        string `json:"appKey"`
 	AppSecret     string `json:"appSecret"`
 	AppSecret     string `json:"appSecret"`
 	AdminUser     string `json:"adminUser"`
 	AdminUser     string `json:"adminUser"`

+ 22 - 3
perm.api

@@ -60,8 +60,20 @@ type (
 		Remark string `json:"remark,optional"`
 		Remark string `json:"remark,optional"`
 	}
 	}
 	CreateProductResp {
 	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"`
 		AppKey        string `json:"appKey"`
 		AppSecret     string `json:"appSecret"`
 		AppSecret     string `json:"appSecret"`
 		AdminUser     string `json:"adminUser"`
 		AdminUser     string `json:"adminUser"`
@@ -379,10 +391,17 @@ service perm-api {
 	middleware: JwtAuth
 	middleware: JwtAuth
 )
 )
 service perm-api {
 service perm-api {
-	// CreateProduct 创建产品。自动生成 appKey/appSecret 和产品专属管理员账号,用于接入新的业务产品
+	// CreateProduct 创建产品。自动生成 appKey/appSecret 和产品专属管理员账号,用于接入新的业务产品。
+	// 响应不再明文回吐 appSecret / adminPassword,改用 credentialsTicket 一次性领取(审计 M-4)。
 	@handler CreateProduct
 	@handler CreateProduct
 	post /create (CreateProductReq) returns (CreateProductResp)
 	post /create (CreateProductReq) returns (CreateProductResp)
 
 
+	// FetchInitialCredentials 凭 CreateProduct 响应中的 credentialsTicket 一次性领取 appSecret 与
+	// 初始 adminPassword。Ticket 在 Redis 中短 TTL 保存,一次消费后立即删除;即使响应被日志捕获,
+	// 落盘的也仅是短期有效且一次性消耗的哨兵 token,而非真正的长期凭证。
+	@handler FetchInitialCredentials
+	post /fetchInitialCredentials (FetchInitialCredentialsReq) returns (FetchInitialCredentialsResp)
+
 	// UpdateProduct 更新产品信息。可修改名称、备注和启用/禁用状态,禁用后其成员将无法访问
 	// UpdateProduct 更新产品信息。可修改名称、备注和启用/禁用状态,禁用后其成员将无法访问
 	@handler UpdateProduct
 	@handler UpdateProduct
 	post /update (UpdateProductReq)
 	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-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 |
 | 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) — 测试报告
 # 权限管理系统 (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** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0263 防御性不可达分支) |
 | ⏭️ 跳过 | **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 层覆盖.
 - `handler/*` 为 go-zero 代码生成的薄路由层, 本轮按"自查后续建议"原则补齐了 **handler 契约用例**: `LogoutHandler`/`ChangePasswordHandler`/`RefreshTokenHandler` 共 6 个关键端点的参数解析 + 协议透传已有直接测试(见 TC-0796 ~ TC-0801), 剩余 handler 逻辑已在 logic 层覆盖.
 - `util` 包覆盖率 37.5% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
 - `util` 包覆盖率 37.5% 因 `util` 包内存在大量 string/path 辅助函数未在生产代码使用, 仅 `NormalizePage` / `IsValidEmail` / `IsValidPhone` 等对外暴露方法被测试覆盖.
 - 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%. 其中 **`middleware` 从 80.3% 拉升到 97.0%** (本轮 JWT 鉴权优先级完整矩阵 TC-0754 ~ TC-0758 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
 - 核心业务包 (logic/*, model/*, loaders, middleware, server) 语句覆盖率均 ≥ 74.2%. 其中 **`middleware` 从 80.3% 拉升到 97.0%** (本轮 JWT 鉴权优先级完整矩阵 TC-0754 ~ TC-0758 把之前未覆盖的"多因素同时失败"分支全部触达), 是本轮最大单点提升.
@@ -1293,3 +1297,102 @@
 3. **H-3 的 1062 监控**:LockByCodeTx 串行化后 1062 理论不可达;建议在 DB 层加一条 `mysql_error_1062{table="sys_perm"}` 的 metric + 告警,一旦出现代表 H-3 失效,需要补回 409 重试契约。
 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` 行为的混沌测试。
 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"的告警规则,用于侦测"创建产品后前端中断不领取"的异常流程。
+