Procházet zdrojové kódy

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

BaiLuoYan před 4 týdny
rodič
revize
47667fe521
31 změnil soubory, kde provedl 839 přidání a 722 odebrání
  1. 403 550
      audit-report.md
  2. 15 0
      internal/logic/auth/access.go
  3. 15 0
      internal/logic/auth/changePasswordLogic.go
  4. 7 0
      internal/logic/dept/createDeptLogic.go
  5. 8 9
      internal/logic/dept/deleteDeptLogic.go
  6. 2 2
      internal/logic/member/addMemberLogic.go
  7. 3 2
      internal/logic/member/removeMemberLogic.go
  8. 13 9
      internal/logic/member/updateMemberLogic.go
  9. 9 8
      internal/logic/product/createProductLogic.go
  10. 7 1
      internal/logic/product/updateProductLogic.go
  11. 5 4
      internal/logic/pub/adminLoginLogic.go
  12. 4 4
      internal/logic/pub/adminLoginLogic_test.go
  13. 17 12
      internal/logic/role/bindRolePermsLogic.go
  14. 2 2
      internal/logic/role/createRoleLogic.go
  15. 12 2
      internal/logic/role/updateRoleLogic.go
  16. 13 14
      internal/logic/user/bindRolesLogic.go
  17. 1 2
      internal/logic/user/createUserLogic.go
  18. 15 14
      internal/logic/user/setUserPermsLogic.go
  19. 9 0
      internal/logic/user/updateUserStatusLogic.go
  20. 0 22
      internal/middleware/adminloginratelimitMiddleware.go
  21. 0 22
      internal/middleware/productloginratelimitMiddleware.go
  22. 0 22
      internal/middleware/syncratelimitMiddleware.go
  23. 23 0
      internal/model/product/sysProductModel.go
  24. 4 4
      internal/model/productmember/sysProductMemberModel.go
  25. 22 0
      internal/model/role/sysRoleModel.go
  26. 17 8
      internal/server/permserver.go
  27. 14 0
      internal/testutil/mocks/mock_product_model.go
  28. 14 0
      internal/testutil/mocks/mock_role_model.go
  29. 9 0
      internal/util/validate.go
  30. 114 3
      test-design.md
  31. 62 6
      test-report.md

+ 403 - 550
audit-report.md

@@ -1,671 +1,524 @@
-# 权限管理系统 - 深度代码审计报告(第 3 轮)
+# 权限管理系统 - 深度代码审计报告(第 4 轮)
 
-> 审计范围:`/internal` 下全部非测试生产代码(logic、model、middleware、handler、loaders、server、svc、consts、response、util)及入口文件 `perm.go`、`perm.api`。
+> 审计范围:`/internal` 下全部非测试、非 `_gen.go` 生产代码(logic、loaders、model 的 custom 层、middleware、handler、server、svc、consts、response、util)+ 入口 `perm.go` + 接口定义 `perm.api`。
 > 审计时间:2026-04-19
-> 审计重点:自我越权、并发竞态、接口 DoS、事务内外读写分裂、缓存一致性、接口契约完整性。
-> 相对上一轮修复情况:
-> - **已修复**:H-1(产品禁用联动)、H-2(JwtAuth MemberType 校验)、H-3(ManagementKey 前置)、H-4(最后一个 ADMIN 保护)、M-1(/auth/logout 路由)、M-2(refreshToken 轮转递增 tokenVersion)、M-9(BindRolePerms 事务外用户集查询已改为事务后)、M-11(`FindByPathPrefix`/`FindByParentId` 已清理)、M-12(UpdateUserStatus 重复校验已清理)、M-14(setUserPerms 校验产品启用)、M-15(BindRoles 无 diff 也清缓存)、M-16(用户 perm 查询已加 `p.status=1`)、L-3(非超管禁止下调 permsLevel)、L-4(CreateRole 校验产品启用)、L-5(AddMember 校验产品启用)、L-6(UserDetail 分支语义已明确)。
-> - **未修复且仍存在**:M-3(`generateRandomHex` 截断 bug)、M-4(`DeptTree` 权限过滤)、M-5(`ProductList`/`ProductDetail` 权限过滤)、M-7(`X-Real-IP` 信任 + 无 XFF)、M-8(缓存失效非原子)、L-1/L-2/L-7~L-10。
-> - **新增 P0/P1 问题**:H-A(`SetUserPerms` 自我权限越权)、H-B(`IncrementTokenVersion` 读缓存导致返回值错位)、M-A("最后 ADMIN" 校验存在 TOCTOU)、M-B(`/auth/refreshToken` 无限流,DB 热点写 DoS)、M-C(产品登录用户枚举 + 选择性锁定)、M-D(`DeleteRole` 事务内但用非事务连接读用户集)、M-E(多个 `Delete*ForProductTx` 的 "先 SELECT 再 DELETE" 非原子)、M-F(`CountActiveAdmins` 硬编码 'ADMIN' 字面量)。
+> 审计重点:
+>   - 并发场景下"最后一个 ADMIN"保护的真实可打破性(跨行 TOCTOU、事务内外数据脱钩)
+>   - 状态变更接口的"无变化也强制递增 tokenVersion"导致的不必要踢出
+>   - 级联删除的 TOCTOU(父部门删除 vs 子部门/用户插入)
+>   - 高频写接口的限流盲区(changePassword 等)
+>   - 负缓存缺失 / 缓存索引集合与数据 key 的非原子 SADD/SetEx 导致的漏清理
+>   - 依赖 `strings.Contains(err, "1062")` 的脆弱错误分类
+>   - 僵尸 scaffolded 中间件
+>
+> 相对上一轮:本轮新发现一批**未被上一轮覆盖**的 P0/P1 问题,都涉及生产环境真实可触发的业务逻辑/数据完整性破坏路径。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H-A. `SetUserPerms` 对"自己调用自己"直接放行,普通 MEMBER 可自我授予产品内任意权限(自我越权
+### H-1. `UpdateMember` 的"最后一个 ADMIN"保护只看 `memberType` 变化,不看 `status` 变化 → **可把最后一个 ADMIN 直接禁用**(产品瞬间"无人管理"
 
-- **位置**:
-  - `internal/logic/user/setUserPermsLogic.go:50`
-  - `internal/logic/auth/access.go:47`(`CheckManageAccess` 内的 `if caller.UserId == targetUserId { return nil }`)
+- **位置**:`internal/logic/member/updateMemberLogic.go:51,54-59,66-73`
 - **描述**:
-  - `SetUserPerms` 使用 `CheckManageAccess(ctx, svcCtx, req.UserId, productCode)` 作为唯一的访问控制。
-  - `CheckManageAccess` 中"自己操作自己"是无条件放行的短路:
-    ```go
-    if caller.UserId == targetUserId {
-        return nil
-    }
-    ```
-  - 随后逻辑只校验:
-    1. 目标是当前产品的有效成员(调用者自己当然是);
-    2. 传入的 permIds 都属于当前产品、且 `status=1`;
-    3. DELETE 现有 `sys_user_perm`(userId+productCode),然后 `BatchInsert` 新的 (permId, effect) 对。
-  - 没有校验"调用者本身是否有权授予该 perm"。
-
-  攻击路径(任意 MEMBER 都可完成):
+  ```go
+  needAdminCheck := member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin
 
-  ```http
-  POST /api/user/setPerms
-  Authorization: Bearer <自己的 access token>
-  {
-    "userId": <自己的 userId>,
-    "perms": [
-      {"permId": 1, "effect": "ALLOW"},
-      {"permId": 2, "effect": "ALLOW"},
-      ...所有 permId...
-    ]
+  member.MemberType = req.MemberType
+  if req.Status != 0 {
+      if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+          return response.ErrBadRequest("状态值无效...")
+      }
+      member.Status = req.Status
+  }
+  ...
+  if needAdminCheck {
+      adminCount, _ := ...CountActiveAdminsTx(...)
+      if adminCount <= 1 {
+          return response.ErrBadRequest("不能降级该产品的最后一个管理员")
+      }
   }
   ```
+  `needAdminCheck` 只在 **memberType 从 ADMIN 改成非 ADMIN** 时为 `true`。如果请求保持 `memberType=ADMIN` 但 **`status` 改为 `StatusDisabled`**,`needAdminCheck=false`,直接跳过计数检查。`CountActiveAdminsTx` 只计 `status=1` 的 ADMIN,所以"禁用最后一个 ADMIN"是在绕过该保护之下合法完成的。
 
-  下一次 `loadPerms` 会走"普通成员"分支:`rolePerms ∪ userAllow − userDeny`。用户自己塞进去的 `userAllow` 全部生效(第 427-431 行),直接获得整个产品的全部 `perms` 集合。中间件侧的 `ud.Perms` 也会包含这些 code;下游产品服务通过 `GetUserPerms` gRPC 拿到的权限同样被污染。
+  攻击/误操作路径(任何持有该产品 ADMIN 令牌的账号都可做):
 
-  该越权对 ADMIN/DEVELOPER/SUPER_ADMIN 无新增危害(这三类本来就有全产品权限),但对 **MEMBER 类型是完全的权限集逃逸**。
-
-- **影响**:
-  - 产品端的"最小权限"模型**彻底失效**:任何通过 `/auth/login` 登录的普通成员都能自我提权到"全权限"(等价于 ADMIN 级 perms,但不改 `memberType`、不触发 `checkDeptHierarchy` 等 ADMIN 后续护栏——也就是说**绕过权限级别校验、绕过部门护栏**,仅对自身 perms 集合生效)。
-  - 与 `BindRoles` 形成对比:`BindRoles` 的"自己操作自己"在 `caller.MemberType == MEMBER` 时仍会被 `r.PermsLevel < caller.MinPermsLevel` 拦截(`bindRolesLogic.go:82-88`),避免了自我绑高等级角色;但 `SetUserPerms` **没有任何对应的自我越权护栏**。
-  - 由于 `ud.Perms` 会被注入到 access token 的 `claims` 之外的 DB 查询结果中(走的是 loader,不是 JWT 内联 perms),攻击者不需要再刷 token,下一次请求就生效。
-
-- **修复方案**(二选一或叠加):
-
-  **方案 A(最小侵入,直接禁止自我 set):**
-
-  ```go
-  // internal/logic/user/setUserPermsLogic.go
-  caller := middleware.GetUserDetails(l.ctx)
-  if caller != nil && caller.UserId == req.UserId && !caller.IsSuperAdmin {
-      return response.ErrForbidden("不能为自己设置权限")
-  }
+  ```http
+  POST /api/member/update
+  { "id": <最后一个 ADMIN 的 member 记录 id>,
+    "memberType": "ADMIN",
+    "status": 2 }
   ```
 
-  **方案 B(对齐"高危写操作需 ADMIN"语义,推荐):**
+  执行结果:DB 里这条 ADMIN 的 `status=2`,`CountActiveAdmins==0`。此后:
+  - 该产品下无人能通过 `RequireProductAdminFor` 的 Admin 校验(除了超管);
+  - 所有需要 ADMIN 的接口(CreateRole/BindRolePerms/SetUserPerms/AddMember/…)都失锁,只能走超管通道;
+  - 该产品上的 ADMIN 自助救援路径不存在,需要联系平台管理员。
 
-  ```go
-  // 全部调用者限定为超管或该产品 ADMIN
-  if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
-      return err
-  }
-  ```
+- **影响**:
+  - 直接破坏"产品始终保留至少一个可用 ADMIN"的业务不变式;
+  - 非超管的恶意 ADMIN 可以"离职前锁死产品";
+  - 普通运维失误也可能造成同样结果,且没有任何回滚机制。
 
-  方案 B 更贴近 API 注释"个性化权限"的管理语义(默认只有管理员能做精细化调权)。保留 `CheckManageAccess` 的层级校验作为纵深:
+- **修复方案**:
+  把 `needAdminCheck` 扩展为"任何会让这条 ADMIN 记录变为非活跃的写入":
 
   ```go
-  if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
-      return err
-  }
-  if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
-      return err
+  // internal/logic/member/updateMemberLogic.go
+  nextType := req.MemberType
+  nextStatus := member.Status
+  if req.Status != 0 {
+      nextStatus = req.Status
   }
-  ```
 
-  - 同步排查 `BindRoles`:对 MEMBER 目前通过 permsLevel 拦截了"自我绑低等级角色",这条逻辑是护栏;但 `caller.MemberType == DEVELOPER` 自己绑自己任意角色的路径**仍能过**(DEVELOPER 本身就全权,无新增危害,保留即可)。保留现状是合理的。
-
-- **优先级**:P0(立即修复)
+  willBecomeInactiveAdmin :=
+      member.MemberType == consts.MemberTypeAdmin &&
+      member.Status == consts.StatusEnabled &&
+      (nextType != consts.MemberTypeAdmin || nextStatus != consts.StatusEnabled)
+  ```
+  并把后续赋值/校验改成基于 `nextType`/`nextStatus`。同时建议把 `needAdminCheck`/"最后一个 ADMIN" 同时在 `RemoveMember` 和 `UpdateMember` 里抽成一个 helper `guardLastAdminTx(session, productCode, excludingId)`,统一用 **行锁 + FOR UPDATE 遍历** 方式做。参考 H-3 的修复方案。
 
 ---
 
-### H-B. `SysUserModel.IncrementTokenVersion` 返回基于**缓存读**的新版本号,并发 refresh/logout 会签发"DB 已作废"的新 token
+### H-2. `RemoveMember` 在"是不是 ADMIN"判断上使用了**事务外读到的** `member.MemberType`,而把事务内 `FindOneForUpdateTx` 的返回值扔掉 → 并发下最后一个 ADMIN 会被删掉
 
-- **位置**:`internal/model/user/sysUserModel.go:140-156`
+- **位置**:`internal/logic/member/removeMemberLogic.go:32,42-53`
 - **描述**:
   ```go
-  func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
-      data, err := m.FindOne(ctx, id)  // ← 从缓存读
-      ...
-      _, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
-          query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ?", m.table)
-          return conn.ExecCtx(ctx, query, time.Now().Unix(), id)
-      }, sysUserIdKey, sysUserUsernameKey)
-      if err != nil {
-          return 0, err
+  member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id) // 事务外
+  ...
+  if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil { // <-- 返回值被丢弃
+          return response.ErrNotFound("成员不存在")
       }
-      return data.TokenVersion + 1, nil  // ← 基于旧缓存 + 1
-  }
+      if member.MemberType == consts.MemberTypeAdmin { // <-- 用的是事务外的 member
+          adminCount, err := ...CountActiveAdminsTx(...)
+          ...
+      }
+      ...
+  })
   ```
+  `FindOneForUpdateTx` 仅用来加行锁,但返回的最新值**直接丢掉**,后续判断"这条是不是 ADMIN"仍然走事务外的 `member`。
 
-  两个严重问题:
+  攻击/误操作路径:
+  - 初始:`productA` 有 ADMIN = [A1, A2];`M` 是 MEMBER。
+  - `T1`:调用 `RemoveMember(member.Id = M)`:
+    - 事务外 FindOne → `member.MemberType = MEMBER`。
+    - `T2` 几乎同时:`UpdateMember` 把 `M` 提升成 ADMIN(事务提交)。
+    - `T1`:进入事务,`FindOneForUpdateTx(M)` 返回 ADMIN(被丢弃)。
+    - `T1`:`if member.MemberType == ADMIN` 用的是旧 MEMBER → **跳过 count 检查**。
+    - `T1`:删除 `M`。
+  - 如果 T2 是因为 A1/A2 之一被先移除而把 `M` 提到 ADMIN 补位(运营流程),T1 就在恢复期内删掉了新晋 ADMIN;极端下可导致 0 active admin。
 
-  1. **缓存陈旧**:`data.TokenVersion` 来自 `FindOne` 的缓存层(`sqlc.CachedConn.FindOne`)。如果在另一条写链路(`UpdatePassword` / `UpdateStatus` / 另一次 `IncrementTokenVersion`)**DB 已 +1 但 Redis 缓存尚未失效**(M-8 窗口),缓存里读到的 `TokenVersion` 仍是旧值 X;`UPDATE tokenVersion+1` 把 DB 从 X+1 推到 X+2;函数却返回 X+1。随后签发的 access token 里 `tokenVersion=X+1` 与 DB 的 X+2 **不匹配**,**下一次请求直接被 `jwtauthMiddleware` 以 "登录状态已失效" 拒掉**。
-  2. **并发自身**:即使不存在外部写入,两条并发 `RefreshToken` 也会命中同一现象——两条流程同时读缓存得 X,`UPDATE x+1` 让 DB 变成 X+2;两条流程都返回 X+1 签发了 access+refresh token 对,两张 refresh token 下一次使用都会因为 `TokenVersion != ud.TokenVersion` 被拒。
+  即使不考虑跨线程竞态,这也违背了"同一事务内一次读到一次写之间必须**用事务内的**视图来决策"的原则。
 
-  调用方均依赖**返回值**作为新签 token 的 version:
-  - `internal/logic/pub/refreshTokenLogic.go:66-75`(生成新 access/refresh)
-  - `internal/server/permserver.go:141-148`(gRPC RefreshToken)
-
-  这不只是理论问题——`RefreshToken` 现在每次都轮转 `tokenVersion`(上一轮 M-2 的修复要求),配合前端"多 tab / SW 并发 / 失败重试 / 双请求"等真实场景,用户会规律性遭遇"刷新一次就全家桶失效、需要完全重新登录"。
-
-- **影响**:
-  - 正常用户会看到**偶发的"刚刷新完 token 就被登出"**(灰度回溯难度高,属于典型的 TOCTOU 竞态)。
-  - `Logout` 本身对此问题免疫(不读 `data.TokenVersion` 作为返回值),但 `RefreshToken` 严重受影响。
-  - 间接让 "被盗 refresh token 立即失效" 的安全语义在正常用户身上误伤自己。
+- **影响**:与 H-1 同类,破坏 "至少一个 ADMIN" 不变式。
 
 - **修复方案**:
 
-  **方案 A(推荐,MySQL 原子自增 + 读取):**
-
   ```go
-  func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
-      var newVersion int64
-      sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
-      // 先查出 username(仅用于缓存 key)
-      data, err := m.FindOne(ctx, id)
+  if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
       if err != nil {
-          return 0, err
+          return response.ErrNotFound("成员不存在")
       }
-      sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
-      err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
-          // LAST_INSERT_ID trick:UPDATE 的同时把新值写入 LAST_INSERT_ID()
-          upd := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
-          if _, err := session.ExecCtx(ctx, upd, time.Now().Unix(), id); err != nil {
+      if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
+          // 只有当前是"活跃 ADMIN"才需要计数
+          adminCount, err := ...CountActiveAdminsTx(...)
+          if err != nil {
               return err
           }
-          return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
-      })
-      if err != nil {
-          return 0, err
-      }
-      // 事务外再 purge 两个缓存 key
-      _, _ = m.DelCacheCtx(ctx, sysUserIdKey, sysUserUsernameKey)
-      return newVersion, nil
-  }
-  ```
-
-  (`DelCacheCtx` 是 `sqlc.CachedConn` 公开接口;若 go-zero 当前版本未暴露,可在事务成功后通过 `m.ExecCtx` 触发一次空 UPDATE 以复用其自动 invalidation,或直接用 `m.rds.Del` 删 Redis key。)
-
-  **方案 B(最小改动,用 `SELECT ... FOR UPDATE`):**
-
-  ```go
-  err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
-      var cur int64
-      if err := session.QueryRowCtx(ctx, &cur,
-          fmt.Sprintf("SELECT `tokenVersion` FROM %s WHERE `id` = ? FOR UPDATE", m.table), id); err != nil {
-          return err
+          if adminCount <= 1 {
+              return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+          }
       }
-      newVersion = cur + 1
-      _, err := session.ExecCtx(ctx,
-          fmt.Sprintf("UPDATE %s SET `tokenVersion` = ?, `updateTime` = ? WHERE `id` = ?", m.table),
-          newVersion, time.Now().Unix(), id)
-      return err
+      ...
   })
   ```
-
-  修复后需同时确认 `ChangePassword` / `UpdateStatus` / `UpdateProfile`(status 变更分支)等"递增"路径都走同一实现,避免一致性漂移。
-
-- **优先级**:P0(立即修复,生产将周期性出现自伤)
+  外层的 `member` 只用来取 `UserId`/`ProductCode` 做 `CheckManageAccess` 和事后清缓存。
 
 ---
 
-## ⚠️ 健壮性与安全建议 (Medium)
-
-### M-A. `CountActiveAdmins` 校验在事务外,`RemoveMember` / `UpdateMember` "最后 ADMIN" 检查存在 TOCTOU
+### H-3. "最后一个 ADMIN" 保护基于 `SELECT COUNT(*)` 的快照读 → 并发移除/降级两个不同 ADMIN 会同时通过检查(跨行 TOCTOU,业务不变式被打破)
 
 - **位置**:
-  - `internal/logic/member/removeMemberLogic.go:41-49`
-  - `internal/logic/member/updateMemberLogic.go:50-58`
-  - `internal/model/productmember/sysProductMemberModel.go:49-56`(`CountActiveAdmins`)
-- **描述**:新加入的"最后一个 ADMIN 保护"机制是:
+  - `internal/model/productmember/sysProductMemberModel.go:62-69` (`CountActiveAdminsTx`)
+  - `internal/logic/member/updateMemberLogic.go:66-73`
+  - `internal/logic/member/removeMemberLogic.go:45-52`
+
+- **描述**:
   ```go
-  if member.MemberType == consts.MemberTypeAdmin {
-      adminCount, _ := svcCtx.SysProductMemberModel.CountActiveAdmins(ctx, member.ProductCode)
-      if adminCount <= 1 {
-          return response.ErrBadRequest("不能移除该产品的最后一个管理员")
+  func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
+      var count int64
+      query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
+      if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
+          return 0, err
       }
+      return count, nil
   }
-  // …然后进入事务 DELETE
   ```
+  - 非锁定读(`session.QueryRowCtx` 不附带 `FOR UPDATE` / `LOCK IN SHARE MODE`)。在 MySQL InnoDB 默认 `REPEATABLE READ` 下,这是 MVCC 快照读,**不会**阻塞也**不会**被其他事务的 INSERT/UPDATE 阻塞。
+  - 事务 T1 锁定并计划降级/移除 ADMIN `m1`;事务 T2 几乎同时锁定并计划降级/移除 ADMIN `m2`(`m1 ≠ m2`,所以行锁不互斥)。
+  - 两个事务同时做 `CountActiveAdmins`,都读到 snapshot=2,都通过 `<=1` 检查,都 commit → 活跃 ADMIN 数变成 0。
 
-  `CountActiveAdmins` 用 `QueryRowNoCacheCtx` 读 DB,**但不加锁**,且整个"判断 + DELETE"**不在同一个事务里**。
-
-  并发路径:
+  这是一个真实存在的**跨行 TOCTOU**,经典情景:
+  - 两位超管同时对不同 ADMIN 做"降级为 DEVELOPER";
+  - 一个 ADMIN 并行点了"退出产品"(自己 RemoveMember)和"降级自己为 MEMBER";
+  - 批处理脚本 + 人工操作的并发。
 
-  | 时序 | 请求 A(移除 admin_P) | 请求 B(移除 admin_Q) |
-  |------|------------------------|------------------------|
-  | T1   | `CountActiveAdmins` = 2 |                        |
-  | T2   |                        | `CountActiveAdmins` = 2 |
-  | T3   | 进入 Tx → DELETE admin_P |                        |
-  | T4   |                        | 进入 Tx → DELETE admin_Q |
-  | T5   | 产品剩余 ADMIN = 0      | 产品剩余 ADMIN = 0      |
+- **影响**:与 H-1/H-2 累积。"至少一个 ADMIN"是系统的关键不变式,它被三条独立路径共同破坏:
+  - H-1:通过 disable 绕过;
+  - H-2:通过事务内外视图不一致绕过;
+  - H-3:通过快照读+跨行并发绕过。
 
-  `UpdateMember`(ADMIN 降级为 MEMBER)存在同样的 TOCTOU。真实触发场景
+- **修复方案**(推荐方案 B):
 
-  - 超管在管理后台"批量移除"两个 ADMIN;
-  - 两个 ADMIN 同时在前端点"移除对方";
-  - 自动化脚本一次性下发两条变更。
-
-  虽然概率不高,但"产品永久无 ADMIN 需平台救援"的代价高。
-
-- **影响**:绕过了上一轮 H-4 的"最后一个 ADMIN"保护,导致产品进入无人管理态。
-
-- **修复方案**:把 count 检查挪到事务内、并对要变更/删除的那行加行锁即可;对其他 ADMIN 行不需要锁(因为 count 查询会用到 InnoDB 的当前读规则,配合事务隔离级别足够)。
+  **方案 A(局部:对所有活跃 ADMIN 加共享锁)**:
 
   ```go
-  // RemoveMember 示例(UpdateMember 同理)
-  return l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      // 1) 锁定当前要删的行,避免重复删
-      var lockedId int64
-      lockQ := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysProductMemberModel.TableName())
-      if err := session.QueryRowCtx(ctx, &lockedId, lockQ, req.Id); err != nil {
-          return response.ErrNotFound("成员不存在")
-      }
-      // 2) 最后一个 ADMIN 校验在事务内
-      if member.MemberType == consts.MemberTypeAdmin {
-          var cnt int64
-          cntQ := fmt.Sprintf(
-              "SELECT COUNT(*) FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR SHARE",
-              l.svcCtx.SysProductMemberModel.TableName(),
-          )
-          if err := session.QueryRowCtx(ctx, &cnt, cntQ, member.ProductCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
-              return err
-          }
-          if cnt <= 1 {
-              return response.ErrBadRequest("不能移除该产品的最后一个管理员")
-          }
+  func (m *customSysProductMemberModel) LockActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
+      var ids []int64
+      // 锁定该产品下所有当前活跃的 ADMIN 行;任何其它事务想把这些行变更,都必须等我
+      q := fmt.Sprintf(
+          "SELECT `id` FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR UPDATE",
+          m.table)
+      if err := session.QueryRowsCtx(ctx, &ids, q, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
+          return 0, err
       }
-      // 3) 既有的 DeleteByUserIdForProductTx / DeleteWithTx
-      ...
-  })
+      return int64(len(ids)), nil
+  }
   ```
+  在所有会让一条 ADMIN 变为非活跃的路径(`UpdateMember` 的 disable/降级 分支、`RemoveMember`)上:**先**调用 `LockActiveAdminsTx`,再判断 `count <= 1`。这样两个并发事务都会尝试锁定 **相同的一组 ADMIN 行**,其中一个会被阻塞;赢的那个先计数 → 允许或拒绝 → 提交后释放锁;输的那个获得锁后再看到准确的 count-1。
 
-  备选:保留现有 `CountActiveAdmins` 调用顺序,但在 DELETE 成功**之后**再 count 一次,若 `=0` 则显式 `return fmt.Errorf("rollback: last admin")` 触发回滚。这个实现更简单。
-
-- **优先级**:P1
+  **方案 B(更稳:对产品行做粗粒度互斥)**:
+  在所有"可能影响 ADMIN 数量"的事务入口先 `SELECT ... FOR UPDATE` 产品行(或建一张 `sys_product_mutex` 表),让对同一产品的 ADMIN 集合变更天然串行化。业务上这些操作并发率极低(手动运营动作),粗粒度锁不会成为性能瓶颈。
 
 ---
 
-### M-B. `/auth/refreshToken` 路由无任何限流,可对单用户发起 DB 写热点 DoS 并清空其所有缓存
+### H-4. `DeleteDept` 子部门/用户计数用非锁定快照读 → `CreateDept` / `CreateUser` / `UpdateUser` 能在 Delete 事务中"偷偷插入"→ 产生孤儿引用
 
-- **位置**:`internal/handler/routes.go:176-185`(`/auth/refreshToken` 无中间件)、`internal/logic/pub/refreshTokenLogic.go:66-70`
+- **位置**:`internal/logic/dept/deleteDeptLogic.go:36-63`
 - **描述**:
-  - 上一轮 M-2 修复后,`RefreshToken` 每次都会:
-    1. `IncrementTokenVersion`:`UPDATE sys_user SET tokenVersion=tokenVersion+1` + 清两个 cache key;
-    2. `UserDetailsLoader.Clean(userId)`:`SMEMBERS` 该用户 index → `DEL` 所有 cache key → `DEL` index key。
-  - 路由挂载处(`routes.go:176-185`)**没有 `JwtAuth`、没有 IP 限流**,只有签名 + `tokenVersion` 校验。
-  - 只要攻击者曾经拿到过任意一张有效 refreshToken(或者就是合法用户自己被攻破),就能每秒反复调用:
-    - 每次一条 `UPDATE sys_user`(写热点,命中行锁);
-    - 每次一轮 Redis `SMEMBERS` + `DEL`(用户缓存整体失效,后续所有请求都 miss 并穿透到 DB 的 `loadFromDB`,进而扇出 6 条 DB 查询)。
-  - 单用户持续被刷,等价于"该用户的每次业务请求都穿透 loader,全部冷路径查 DB",且对 `sys_user` 表形成写热点。如果该用户是 ADMIN/超管,其 `loadFromDB` 里 `loadPerms` 会扫 `sys_perm` 全表拉"该产品全量 code",代价更高。
-
-  关键是:**刷新是发出 token 后就能做的,没有任何 IP/账号层面的限流**。`AdminLoginRateLimit` / `ProductLoginRateLimit` 只防登录入口,`SyncRateLimit` 只防 `/perm/sync`。
-
-- **影响**:单个凭据即可对权限系统制造持续的 DB 写 + 缓存穿透;且由于每次刷新都轮转 refresh token,攻击者总能拿到下一张有效凭据继续刷。
-- **修复方案**:
-  - 在 `/auth/refreshToken` 前挂一条 `refreshLimiter`,key = `user:<userId>` 或 `ip+user`,窗口建议 60s/6 次(用户实际只会在 access 过期前偶发刷新,多端也不太会超过 3 次)。
-  - 或者给 `refreshTokenLogic` 内部加一层"Redis 计数器":以 `rl:refresh:<userId>` 为 key 做自增 + TTL。
-  - 超额后不应无条件 429,可选择降级为"返回同一张已经签发的 access token",但这又引入复杂度,保持 429 最简单。
+  ```go
+  return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+      // 仅锁定要删的那一行
+      lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", ...)
+      ...
+      var childCount int64
+      countChildQuery := "SELECT COUNT(*) FROM sys_dept WHERE parentId = ?"
+      session.QueryRowCtx(..., countChildQuery, req.Id) // 快照读,无锁
 
-- **优先级**:P1
+      var userCount int64
+      countUserQuery := "SELECT COUNT(*) FROM sys_user WHERE deptId = ?"
+      session.QueryRowCtx(..., countUserQuery, req.Id) // 快照读,无锁
 
----
+      return l.svcCtx.SysDeptModel.DeleteWithTx(...)
+  })
+  ```
+  - 父部门加了 `FOR UPDATE`,但这只是对 `sys_dept.id=X` 这一行的 X 锁。
+  - 子部门计数走 `sys_dept.parentId=X`。父部门行 X 锁**不会**传递给"引用这个父"的子行,也**不会**在 `parentId` 索引上建立 gap lock(因为 COUNT(*) 不是锁定读)。
+  - 用户计数更离谱:`sys_user.deptId=X` 分属另一张表,跟 `sys_dept` 的行锁没有任何关系。
+  - `CreateDept`(`internal/logic/dept/createDeptLogic.go:46-52`)在事务外 `FindOne` 读父部门,**不加任何锁**,随后 InsertWithTx 写子行。同样 `CreateUser`、`UpdateUser`(改 deptId)都不会锁父部门。
+
+  结果:
+  - T1 开始 Delete dept=5:父行加锁,childCount=0、userCount=0,通过。
+  - T2 `CreateDept(parentId=5)`:不需要任何锁,Insert 成功,commit。
+  - T1:DELETE parent,commit。
+  - 数据库里有 `sys_dept` 子行挂在一个**已不存在**的 parentId=5 → `DeptTreeLogic` 的 "parent 不存在则视为 root" 兜底 (`deptTreeLogic.go:60-62`) 只是把异常用户吞掉,组织架构彻底错乱。
+  - 对 `sys_user` 同理:可能生产出 `deptId=5` 但 dept 已删的孤儿用户,`loadDept`→`FindOne` 会失败静默,`DeptPath=""`,进而触发 `CheckManageAccess → checkDeptHierarchy` 的"您的部门信息异常"分支,用户相当于完全失去权限。
 
-### M-C. 产品端登录 `ValidateProductLogin`:存在用户名枚举 & 选择性账号锁定
+- **影响**:
+  - 组织树出现悬空父指针,`DeptTree` 展示异常;
+  - 用户的 deptId 成孤儿,`loadDept` 静默失败,用户被持续推到"无权操作"分支;
+  - 如果外部系统以 `deptPath` 鉴权,会出现"应该有权限但系统说没"或反之的权限紊乱。
+
+- **修复方案**:
+  在 `DeleteDept` 事务内,对 **parentId 索引** 和 **deptId 索引** 做"存在性锁定读":
 
-- **位置**:`internal/logic/pub/loginService.go:32-46`
-- **描述**:当前顺序是:
   ```go
-  u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
-  if err != nil { // 不存在 → 401
-      return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+  // 子部门:如果有任意一行,就失败;并在 parentId 索引上加 gap/next-key 锁,阻塞并发 INSERT
+  var tmp int64
+  q := "SELECT 1 FROM sys_dept WHERE parentId=? LIMIT 1 FOR UPDATE"
+  err := session.QueryRowCtx(ctx, &tmp, q, req.Id)
+  if err == nil {
+      return response.ErrBadRequest("该部门下存在子部门,无法删除")
   }
-  if svcCtx.UsernameLoginLimit != nil {
-      code, _ := svcCtx.UsernameLoginLimit.Take(username)
-      if code == limit.OverQuota {
-          return nil, &LoginError{Code: 429, Message: ...}
-      }
+  if !errors.Is(err, sql.ErrNoRows) {
+      return err
   }
-  if u.Status != consts.StatusEnabled { ... }
-  if err := bcrypt.CompareHashAndPassword(...); err != nil { return 401 }
+  // 用户引用:同样用锁定读
+  q = "SELECT 1 FROM sys_user WHERE deptId=? LIMIT 1 FOR UPDATE"
+  err = session.QueryRowCtx(ctx, &tmp, q, req.Id)
+  ...
   ```
+  `CreateDept` / `CreateUser` / `UpdateUser` 在写 `parentId` / `deptId` 之前,也要 `SELECT 1 FROM sys_dept WHERE id=? FOR SHARE`(S-lock 父行),这样 `DeleteDept` 的 X-lock 会阻塞这些插入,TOCTOU 被彻底消除。
 
-  两个衍生问题:
-
-  1. **用户名枚举**:不存在的 username 直接返回 401、**不消耗任何限流配额**,响应时间也很短(不走 bcrypt);存在的 username 则可能返回 401(密码错)或 429(锁定)或 403(冻结)。攻击者通过"时间 + 响应码"组合能稳定区分 username 是否存在。
-  2. **选择性锁定**:攻击者探测出真实 username 后,对该 username 连打 10 次,该 username 立即被 `UsernameLoginLimit`(5min/10 次,key 纯 username)锁定 5 分钟,真正用户同期无法登录。配合 `AdminLoginRateLimit`(IP 60s/30)毫无阻塞。
-
-  `/auth/adminLogin` 已经把 `UsernameLoginLimit.Take` 移到 `ManagementKey` 校验之后,享受了一定保护(攻击者必须知道 ManagementKey 才能触发)。但 `/auth/login` 没有这种护栏。
-
-- **影响**:外部攻击者可在不触发限流的前提下批量探测有效 username;对已知用户可周期性(每 5 分钟一轮)持续锁定。
-- **修复方案**:
-  - **限流 key 加 IP 维度**:`UsernameLoginLimit` 的 key 改为 `fmt.Sprintf("%s:%s", ip, username)`,同时保留一个更宽松的"纯 IP 限流"(已有 `ProductLoginRateLimit`),这样攻击者无法通过廉价 IP 锁定目标账号。
-  - **`FindOneByUsername` 之前先 `Take`**:让 Take 对无效 username 也消耗配额(这样无效 username 也会得 429,不再区分);或者在 username 不存在时直接 `time.Sleep` 一个近似 bcrypt 的耗时,避免时间侧信道。
-  - **成功登录时重置计数**(go-zero `PeriodLimit` 不支持 reset,可以改用 Redis 自增 + 登录成功 `DEL` key)。
-
-- **优先级**:P1
+  对长期工程:建议直接加 MySQL FK `RESTRICT`;但即使不加 FK,应用层也必须把"存在性检查 + 删除"放在同一事务的锁定读语义下。
 
 ---
 
-### M-D. `DeleteRoleLogic` 的 `FindUserIdsByRoleId` 写在事务回调内,却**没有用 session**,仍是旧连接读
+### H-5. `ChangePasswordLogic` 无任何限流,JWT 被盗/会话仍在场景下可**暴力爆破当前密码**
 
-- **位置**:
-  - `internal/logic/role/deleteRoleLogic.go:40-50`
-  - `internal/model/userrole/sysUserRoleModel.go:55-62`(`FindUserIdsByRoleId` 用的是 `m.QueryRowsNoCacheCtx`,不接 `session`)
+- **位置**:`internal/logic/auth/changePasswordLogic.go:32-62`
 - **描述**:
   ```go
-  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      affectedUserIds, _ = l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(ctx, req.Id) // ← 这里没传 session
+  func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error {
+      if msg := util.ValidatePassword(req.NewPassword); msg != "" { ... }
+      userId := middleware.GetUserId(l.ctx)
+      user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId)
+      ...
+      if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
+          return response.ErrBadRequest("原密码错误")
+      }
       ...
-      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
-  })
-  ```
-
-  `FindUserIdsByRoleId` 直接用 `m.QueryRowsNoCacheCtx`(非事务连接、非当前 session)查询。结果:
-
-  - 这一行读位于事务**之外**,本事务内尚未提交的变更看不到;
-  - 反之,事务**之外**并发提交的 `BindRoles`(另一 goroutine 给这个 roleId 插入新行)会被这条 SELECT **看到,或看不到**,取决于提交时序;
-  - 真正的问题是:**在当前事务 COMMIT 之前**,如果另一条已提交的并发事务给 roleId 加了新用户 U',本事务的 DELETE(`DeleteByRoleIdTx`)**会把 U' 也删掉**(因为它用 `DELETE ... WHERE roleId=?`),但 `affectedUserIds` 中不包含 U',所以**U' 的 UserDetails 缓存不会被清理**。
-
-  同样的问题在 `BindRolePermsLogic`(`bindRolePermsLogic.go:127`)已经通过"事务提交后再查"规避;`UpdateRoleLogic`(`updateRoleLogic.go:73`)也是事务外后查。只剩 `DeleteRole` 这一条路径仍然错位。
-
-- **影响**:`DeleteRole` 并发 `BindRoles` 的小概率场景下,漏清一批用户的缓存;这些用户会继续持有"引用已删角色"的权限集合,直到 300s TTL 自然过期。
-- **修复方案**:把 `FindUserIdsByRoleId` 挪到事务提交**之后**:
-
-  ```go
-  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdTx(ctx, session, req.Id); err != nil { return err }
-      if err := l.svcCtx.SysUserRoleModel.DeleteByRoleIdTx(ctx, session, req.Id); err != nil { return err }
-      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
-  }); err != nil {
-      return err
   }
-  // 提交之后再读(此时该 roleId 已无任何关联行,但仍能查到曾经的 userId——需要在 DELETE 之前保留)
   ```
+  - 路由层:`/api/auth/changePassword` 只有 `JwtAuth` 中间件,**无 RateLimitMiddleware**。
+  - 逻辑层:`LogoutLogic` 和 `RefreshTokenLogic` 都挂了 `svcCtx.TokenOpLimiter`,但 `ChangePasswordLogic` 没有;也没有 `UsernameLoginLimit` 类的每用户限流。
+  - `bcrypt.CompareHashAndPassword` 大约 ~100ms,已登录用户可以串行每秒 ~10 次对自己当前密码做爆破。
+  - 威胁模型:
+    1. 攻击者偷到 access token(XSS/设备共用),想"把密码改成自己的"以实现持久化。由于必须知道旧密码,于是他用盗来的 token 不停调 `changePassword` 枚举旧密码。虽然 bcrypt 慢,但没有限流意味着**可以一直尝试**——被害人直到 access token 过期(默认 `AccessExpire`,通常 2h~1d)都无法自救。
+    2. 更现实:同一台办公机、同一设备同一账号,未离开时间段内 malicious script 执行百万次尝试;每次失败 `ErrBadRequest("原密码错误")`,既不记录日志也不递增 tokenVersion,失败完全"无痕"。
+  - 对比:`LogoutLogic` 做了 10/60s 限流,`RefreshTokenLogic` 也做了;`ChangePasswordLogic` 显然遗漏。
+
+- **影响**:
+  - 提供了"持有短期 access token 后可以 brute-force 当前密码,命中后改密持久化"的攻击路径;
+  - 一旦命中,新密码会触发 `tokenVersion+1`(`UpdatePassword` 里),**踢掉真正的用户**,攻击者用新密码重新登录即可接管(管理后台也同理,因为它用同一套 bcrypt)。
 
-  **注意**:`DELETE ... WHERE roleId=?` 执行后,`sys_user_role` 里 `roleId=?` 的行已没了,提交后再查 `FindUserIdsByRoleId` 得空集。正确做法是在事务内(用 session)先读一次,然后再 DELETE:
+- **修复方案**:
 
   ```go
-  if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-      // 在事务内用 session 读,锁住这些关系行
-      query := fmt.Sprintf("SELECT `userId` FROM `sys_user_role` WHERE `roleId` = ? FOR UPDATE")
-      if err := session.QueryRowsCtx(ctx, &affectedUserIds, query, req.Id); err != nil {
-          return err
+  // internal/logic/auth/changePasswordLogic.go
+  if l.svcCtx.TokenOpLimiter != nil {
+      code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId))
+      if code == limit.OverQuota {
+          return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
       }
-      ...DELETE...
-      return l.svcCtx.SysRoleModel.DeleteWithTx(ctx, session, req.Id)
-  })
+  }
   ```
-
-  `FOR UPDATE` 让并发 `BindRoles` 的 `INSERT sys_user_role` 被挂起,直到当前事务提交。这样 affectedUserIds 覆盖所有实际被删的行。
-
-  需要在 `SysUserRoleModel` 接口新增一个 `FindUserIdsByRoleIdForUpdateTx(ctx, session, roleId)`,或者 DeleteRole 内直接拼 SQL(如上)。
-
-- **优先级**:P2(低概率,但修复成本很小)
+  建议在 `ValidatePassword` 之后、`FindOne` 之前立即做(失败也计数,避免 "先写合法新密码才触发限流" 的绕过)。同时把失败做一条 `logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)`,给 SOC 提供可观测性。
 
 ---
 
-### M-E. 所有 `Delete*ForProductTx` / `DeleteByRoleIdTx` / `DeleteByUserIdForProductTx` 的"先 SELECT 再 DELETE"模式非原子
+## ⚠️ 健壮性与性能建议 (Medium / Low)
 
-- **位置**:
-  - `internal/model/userrole/sysUserRoleModel.go:75-107,109-136`
-  - `internal/model/roleperm/sysRolePermModel.go:73-117`
-  - `internal/model/userperm/sysUserPermModel.go:43-63`
-  - `internal/model/perm/sysPermModel.go:94-154`(`DisableNotInCodesWithTx`)
-- **描述**:统一套路是:
+### M-1. `UpdateUserStatus` 对"状态无变化"的请求仍然强制递增 `tokenVersion`,把被操作用户不必要地踢下线
+- **位置**:`internal/logic/user/updateUserStatusLogic.go:31-52`、`internal/model/user/sysUserModel.go:137-150`
+- **描述**:`UpdateUserStatus` 没有对比 `user.Status` 与 `req.Status`,不管是否真的变更,都调用 `SysUserModel.UpdateStatus`;模型层 SQL 为 `UPDATE … SET status=?, tokenVersion=tokenVersion+1 …` **无条件**递增。结果:管理员点一次"启用"按钮(用户原本就是启用),所有该用户在场的会话全部下线。
+  - 对比 `UpdateUserLogic`(`updateUserLogic.go:122-135`):显式 `if user.Status != req.Status { statusChanged = true }`,只在真正变化时才递增。两处逻辑不一致。
+- **修复**:进入 logic 时先读当前值,仅当 `user.Status != req.Status` 时调 UpdateStatus;或者在 SQL 上加 `WHERE id=? AND status<>?` 并仅当 `RowsAffected>0` 时认为"确实变更"。
+
+### M-2. `generateRandomHex` 长度截断导致 `appSecret` / `adminPassword` 熵减半(上一轮 M-3,仍未修复)
+- **位置**:`internal/logic/product/createProductLogic.go:158-164`
+- **描述**:
   ```go
-  // 1) 先用 QueryRowsNoCacheCtx 读出要删的行,拼 cache keys
-  if err := m.QueryRowsNoCacheCtx(ctx, &list, findQuery, ...); err != nil { return err }
-  keys := buildCacheKeys(list)
-  // 2) 再 m.ExecCtx 包装 session.ExecCtx,借助 ExecCtx 的 cache-aside invalidate
-  _, err := m.ExecCtx(ctx, func(ctx, conn) {
-      return session.ExecCtx(ctx, deleteQuery, ...)
-  }, keys...)
+  func generateRandomHex(length int) (string, error) {
+      b := make([]byte, length)
+      rand.Read(b)
+      return hex.EncodeToString(b)[:length], nil // 😱 截断
+  }
   ```
+  - `hex.EncodeToString(N bytes) => 2N chars`;`[:length]` 拿到 `length` 个 hex 字符 = `length/2` 字节随机性。
+  - `generateRandomHex(64)` 生成 `appSecret` → **32 字节**熵,不是 64;
+  - `generateRandomHex(32)` 生成 `appKey` → **16 字节** / 128 bit,勉强;
+  - `generateRandomHex(8)` 生成首任管理员初始密码 → **4 字节 = 32 bit**,可在 10 分钟内离线爆破(就算走 bcrypt,超管持有明文返回值且要邮件/IM 明文传给运营方——泄漏风险真实存在)。
+- **修复**:
+  ```go
+  func generateRandomHex(byteLen int) (string, error) {
+      b := make([]byte, byteLen)
+      if _, err := rand.Read(b); err != nil { return "", err }
+      return hex.EncodeToString(b), nil  // 返回 2*byteLen 个 hex 字符
+  }
+  ```
+  调用方按"字节数"来传;初始管理员密码至少 12 字节随机 → 96 bit 熵,或者直接改用人类可读的 passphrase。
 
-  问题:
-  1. **SELECT 用的是 `m.QueryRowsNoCacheCtx`(主库默认连接,读未提交前的数据)**,它既不是 `session`,也没有加锁。如果此刻有并发 INSERT 提交(例如 BindRoles 插入新 `sys_user_role`),那条新行的 cache key 不会进入 `keys` 列表。
-  2. 紧接着 `DELETE ... WHERE roleId=?` 会一并删掉该新行;新行的 `cacheSysUserRoleIdPrefix:<newId>` 和 `cacheSysUserRoleUserIdRoleIdPrefix:<userId>:<roleId>` 缓存**不会被 invalidate**。
-  3. 一般情况下,新行刚插入缓存也没写过(只有 `FindOne` 会写缓存,insert 不写),所以大部分时候这些 key 本来就不存在,问题不显现。但一旦有其它读路径(例如 `FindOne`、或 `DeleteByUserIdAndRoleIdsTx` 里的 list 查询本身)在极短窗口内 cache miss + 回填,就会留下**已 DELETE 但仍在 Redis 的幽灵缓存**,直到 TTL 自然过期。
-
-  同时 `perm/sysPermModel.go:DisableNotInCodesWithTx` 走的路径里,SELECT 是非事务读,之后的 UPDATE 是 session.ExecCtx,如果并发 `SyncPerms` 插入了新 perm code(不在 `codes` 列表),第二轮 SELECT 不会看到,但 UPDATE 的 `WHERE NOT IN (codes)` **会禁用**它——该 perm 的缓存键不会被清理。
-
-  这是一类普遍的"缓存 keys ≠ 实际被影响行"的问题。
-
+### M-3. `UserDetailsLoader` 对 "不存在的用户 / 错误的 (userId, productCode)" 没有负缓存 → 每次请求都穿透到 DB
+- **位置**:`internal/loaders/userDetailsLoader.go:119-144`
+- **描述**:`loadFromDB` 若 `ud.Username == ""`(用户不存在或已被删),`sf.Do` 返回 `(nil, nil)`,**不写任何缓存**。`Load` 返回一个空 UserDetails。下一次同样请求仍然走一次完整 DB 链路(`FindOne` → 命中模型层 cache miss → DB)。
 - **影响**:
-  - 罕见但可触发的"幽灵缓存"问题,表现为"某用户/角色/权限在 DB 已删但 Redis 仍返回老值",最多持续 300s(默认 TTL)+ loader.Load 的 singleflight 覆盖。
-  - 实际业务影响通常被 `UserDetailsLoader` 的 300s TTL 吸收——`loader.Load` 是以用户维度缓存 `UserDetails`,不直接依赖 `sys_user_role.id` 级 cache key。所以**实际暴露面主要在直接调 `SysXxxModel.FindOne(id)` 的路径**。
-  - 风险较低,但长期看会积累数据不一致。
-
-- **修复方案**:
-  - **方案一(推荐)**:把 SELECT 改为在 session 内执行,并且 `FOR UPDATE`:
-
-    ```go
-    findQuery := fmt.Sprintf("SELECT ... WHERE `roleId` = ? FOR UPDATE")
-    if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil { return err }
-    ```
-
-    这样在事务提交前,其他事务无法插入新行到同一 roleId 范围内;SELECT 和 DELETE 看到的是完全一致的集合。
-
-  - **方案二**:事务提交后再做一次"宽清理"——直接 `SREM` 掉该 `roleId` / `productCode` 下的全部 cache 键;粒度粗但绝不会漏。
-  - **方案三(最小成本)**:既然缓存不一致窗口 ≤ 300s 且 `UserDetailsLoader` 层做主业务隔离,可将这类 cache key 的 TTL 降到 60s,限制风险窗口。
-
-- **优先级**:P2(风险低、影响面小,但建议与 M-D 合并一次性收敛)
-
----
-
-### M-F. `CountActiveAdmins` SQL 字面量 `'ADMIN'` 与 `consts.MemberTypeAdmin` 解耦,僵尸常量同步风险
-
-- **位置**:`internal/model/productmember/sysProductMemberModel.go:51`
+  - 账号被删除后,旧 access token 的每次请求都会触发 1 次 `sys_user.FindOne` 未命中的 DB 查询(直到 token 过期,默认可达 1h~24h)。
+  - 攻击者发送垃圾 access token(签名正确但 `userId` 为不存在 id)即可放大 DB 压力;虽然签名伪造需要 secret,但一个离职用户保留的 token 就能让内网攻击者制造 DB 噪声。
+- **修复**:在 `loadFromDB` 识别到 "用户不存在 / 已删除" 时,写一条 TTL 很短(15~60s)的负缓存标记(例如 cache 一个 `ud.Username=="_not_found_"`),`Load` 检测到该标记直接返回"不存在",无需再次查库。
+
+### M-4. `UserDetailsLoader` 的 "index set 添加 (SADD) 与数据 key 写入 (SETEX)" 非原子 → 并发下 `Clean` 可能漏清
+- **位置**:`internal/loaders/userDetailsLoader.go:127-132,185-199,201-219`
+- **描述**:写链路顺序是 `SETEX(key, json)` → `SADD(userIdxKey, key)` → `EXPIRE(userIdxKey)`;清链路是 `SMEMBERS(userIdxKey)` → `DEL(keys...)` → `DEL(userIdxKey)`。
+  交错:
+  1. Thread A 写入:`SETEX` 完成。
+  2. Thread B 触发 `Clean(userId)`:`SMEMBERS` 尚未看到新 key → `DEL` 清单中不含它 → `DEL idxKey` 清掉索引。
+  3. Thread A 继续:`SADD` 把 key 加回到(已被删除的)索引 → 再 `EXPIRE`(这步会重新创建 set,于是孤儿索引);同时数据 key 仍在 Redis 里。
+  4. 结果:**数据 key 实际仍在**,`Clean` 却已经认为"清完了"。stale 数据持续到 `ttl=300s` 才自然过期。
+  这在 `BindRoles → Clean`、`UpdateUser → Clean`、`UpdateRole → BatchDel` 等高频写入路径都可复现。
+- **修复**:
+  - 用 Redis 事务 / 脚本把 `SETEX + SADD + EXPIRE` 打包成单次 `EVAL`;
+  - 清理链路也用 Lua 把 `SMEMBERS + DEL keys + DEL idxKey` 原子化;
+  - 更简洁的替代:弱一致保证下,在每次 `Del/Clean` 后发一条 "延迟二次清除" 消息(比如 `DEL after 1s`),补偿这一窄竞态。
+
+### M-5. `UpdateRole` / `BindRolePerms` 在事务提交后读 `FindUserIdsByRoleId` 时**忽略错误**,Redis 抖动时会漏清缓存
+- **位置**:`internal/logic/role/updateRoleLogic.go:73-75`、`internal/logic/role/bindRolePermsLogic.go:127-128`
 - **描述**:
   ```go
-  query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = 'ADMIN' AND `status` = 1", m.table)
+  affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
+  l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
   ```
+  `_,_` 丢 err,`affectedUserIds` 为空即 `BatchDel` 无作为。底层 DB 任何抖动都会让这次变更的缓存失效丢失;用户最多需要 5 分钟才拿到新权限。
+- **修复**:`return err` 并在调用链把这个错误归为 "已更新但缓存未清" 的 500;或者失败时写进 retry 队列,由后台任务重做。
 
-  `'ADMIN'` 和 `1`(StatusEnabled)都是裸字面量,没有引用 `consts.MemberTypeAdmin` / `consts.StatusEnabled`。一旦常量值调整(例如未来新增 `OWNER` 或 `ADMIN` 更名为 `PRODUCT_ADMIN`),Go 调用方的判断和 SQL 查询会**悄悄脱钩**——不会编译报错、不会测试失败(除非有专门的覆盖测试),但"最后一个 ADMIN"保护**逻辑失效**(SQL 找不到任何 ADMIN,count 永远 = 0,所有移除都被拒)。
-
-- **影响**:维护风险(契约隐性约定);也违反"同一信息不写两遍"原则,与 `FindAllCodesByProductCode`(`perm/sysPermModel.go:56`)等现有做法不一致(那边用了 `consts.StatusEnabled` 拼到 SQL)。
-- **修复方案**:
-
+### M-6. `UpdateRole` / `UpdateProduct` / `UpdateMember` 没有乐观锁(与 `UpdateUser` / `UpdateDept` 的策略不一致)
+- **位置**:
+  - `internal/logic/role/updateRoleLogic.go:69`(`SysRoleModel.Update(ctx, role)`)
+  - `internal/logic/product/updateProductLogic.go:58`(`SysProductModel.Update(...)`)
+  - `internal/logic/member/updateMemberLogic.go:75`(`SysProductMemberModel.UpdateWithTx(...)`)
+- **描述**:`UpdateUserLogic` 和 `UpdateDeptLogic` 用了 `UpdateWithOptLock(expectedUpdateTime)` 防并发覆盖;但相同模式的 `UpdateRole` / `UpdateProduct` / `UpdateMember` 仍是无条件 `UPDATE`:
+  - 两位管理员同时修改同一角色(A 改名字,B 改 permsLevel),后提交者会全字段覆盖先提交者的改动,**丢失字段**。
+- **修复**:统一给三者加 `UpdateWithOptLock(expectedUpdateTime)` / 或在 SQL WHERE 子句中加 `updateTime = ?`,并在受影响行数为 0 时返回 `ErrUpdateConflict`。
+
+### M-7. `AdminLoginLogic` 缺少"不存在账号的 dummy bcrypt",响应时间可用于 username 枚举
+- **位置**:`internal/logic/pub/adminLoginLogic.go:48-66`
+- **描述**:`ValidateProductLogin` 在用户不存在时跑一次 `dummyBcryptHash` 做恒时对齐(`loginService.go:53`),但 `AdminLoginLogic` 没有——直接 `return "用户名或密码错误"`。结合错误信息差异("账号已被冻结" vs "仅超级管理员可通过管理后台登录")和响应时间差异(bcrypt ~100ms vs 不调用 bcrypt ~1ms),攻击者可在 `AdminLoginRateLimit(20/min)`、`UsernameLoginLimit(10/5min)` 限额内做较精确枚举。
+- **修复**:与 `ValidateProductLogin` 对齐:
   ```go
-  query := fmt.Sprintf(
-      "SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?",
-      m.table,
-  )
-  return m.QueryRowNoCacheCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled)
+  if errors.Is(err, user.ErrNotFound) {
+      bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
+      return nil, response.ErrUnauthorized("用户名或密码错误")
+  }
   ```
+  并把"账号已被冻结" / "仅超级管理员可通过管理后台登录" 这两个分支统一归到 `"用户名或密码错误"`(管理后台侧不暴露任何有效账号的状态信息),让登录失败无法区分。
 
-- **优先级**:P2
-
----
-
-### M-G. `UserList` 在有 `productCode` 分支时,JOIN 后又多做了一次 `FindMapByProductCodeUserIds`
-
+### M-8. `ProductList` / `ProductDetail` / `DeptTree` 无访问控制,普通成员能看全量产品名 / 组织架构(上一轮 M-4/M-5,仍未修复)
 - **位置**:
-  - `internal/logic/user/userListLogic.go:51-76`
-  - `internal/model/user/sysUserModel.go:59-76`(`FindListByProductMembers` 已 INNER JOIN `sys_product_member`)
-- **描述**:当 `req.ProductCode != ""` 时:
-  1. `FindListByProductMembers` 已经 `INNER JOIN sys_product_member pm ON u.id=pm.userId WHERE pm.productCode=?`——此时每行都有对应的 `pm.memberType`;
-  2. 代码又发起一次 `FindMapByProductCodeUserIds` 把 `userIds` IN 回 `sys_product_member` 取 `memberType`。
-
-  两次查询做了完全一样的事。这是一个纯粹的冗余读——第一次 JOIN 丢弃了 `pm.*`,第二次再 IN 取回。
-- **影响**:产品端用户列表多一次全表/IN 扫描,无功能性问题。
-- **修复方案**:扩展 `FindListByProductMembers` 返回 `[]struct{ *SysUser; MemberType string }`(或返回 `memberType` 数组),替掉第二次查询。
-
-- **优先级**:P2
-
----
-
-### M-3(遗留). 产品初始管理员随机密码熵仍为 32 bits
-
-- **位置**:`internal/logic/product/createProductLogic.go:80-81, 158-164`
-- **状态**:未修复。`generateRandomHex(8)` 依然使用了截断 bug 的路径:
+  - `internal/logic/product/productListLogic.go:31-58`
+  - `internal/logic/product/productDetailLogic.go:29-48`
+  - `internal/logic/dept/deptTreeLogic.go:27-67`
+- **描述**:都只有 `JwtAuth`,没有任何"是当前产品成员 / 当前部门管辖"过滤。`ProductList` 只是把 `appKey` 在非超管时置空,名称/备注仍返回;`ProductDetail` 给任何 id 都能读。`DeptTree` 更是一次性返回所有部门——对外泄漏组织结构和产品清单。
+- **修复**:
+  - `ProductList` 改为"按 `sys_product_member` inner join `productCode` 过滤成当前调用者所属的产品集合";超管才能看全量;
+  - `ProductDetail` 对非超管校验 `caller.ProductCode == product.Code`;
+  - `DeptTree` 至少要求 `RequireSuperAdmin` 或返回以调用者部门为根的子树。
+
+### M-9. `SetUserPermsLogic` 重复执行了同一条 `FindOneByProductCodeUserId` 查询(`CheckManageAccess → checkPermLevel` 内部做一次,随后 `SetUserPerms` 里又做一次)
+- **位置**:`internal/logic/user/setUserPermsLogic.go:54-64` + `internal/logic/auth/access.go:148-157`
+- **描述**:`CheckManageAccess(req.UserId, productCode)` 内部 `checkPermLevel` 已经 `FindOneByProductCodeUserId(productCode, targetUserId)`;紧随其后 `SetUserPerms` 又做一次同样的读,再加上一次 `FindOne(user)`、一次 `FindOneByCode(product)`、一次 `FindByIds(perms)`——在写路径上累计 5~6 次 DB read。考虑到模型层都有 `CachedConn`,多数命中缓存,但 `FindMinPermsLevelByUserIdAndProductCode` 是无缓存查询,每次接口调用都会实际打一次 DB。
+- **修复**:
+  - 让 `CheckManageAccess` 把解析出来的 `targetMember` / `targetRoleLevel` 通过返回值或 context 透出,SetUserPerms 直接复用;
+  - 或把整个权限校验前置到单独的 "AuthContext" 对象,一次加载。
+
+### M-10. `VerifyToken` gRPC 对"无效 token"完全不记录日志,也没有调用方识别字段(appKey/serviceName)→ 生产上不可观测
+- **位置**:`internal/server/permserver.go:173-208`
+- **描述**:所有失败分支都 `return &pb.VerifyTokenResp{Valid: false}, nil`,不分类、不打日志。线上"用户反馈总是 401"时没法判断是 `TokenVersion` 不符还是 `ProductStatus` 禁用还是别的;更没有"该产品服务在过去 10 分钟发生了 10k 次 VerifyToken 失败"这种告警能力。
+- **修复**:至少保留 `logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=%s", claims.UserId, reason)`;`reason` 单独落字段方便日志聚合。
+
+### M-11. gRPC `PermServer.Login` 在 `peer.FromContext` 失败时静默跳过限流(fail-open)
+- **位置**:`internal/server/permserver.go:62-76`
+- **描述**:`if s.svcCtx.GrpcLoginLimiter != nil { if p, ok := peer.FromContext(ctx); ok { … rate limit … } }`——如果 `ok == false`(走 in-process / socket 无 peer 信息的场景)就**不限流**。配合 ExecuteSyncPerms 同样逻辑(gRPC 不限流,HTTP 层才有),理论上内部调用或错误配置下会绕过保护。
+- **修复**:`ok == false` 时把 key 当作 `"grpc:login:unknown"` 走限流,更严苛的做法是"没 peer → 直接拒绝"(因为生产环境都在 gRPC over TCP)。
+
+### M-12. 三个 scaffolded 中间件文件是僵尸代码,从未注册也没被引用
+- **位置**:
+  - `internal/middleware/adminloginratelimitMiddleware.go`
+  - `internal/middleware/productloginratelimitMiddleware.go`
+  - `internal/middleware/syncratelimitMiddleware.go`
+- **描述**:三个文件各自声明一个空壳类型和 `Handle` 方法(直接 passthrough),路由里实际使用的是 `svc.ServiceContext` 里基于 `RateLimitMiddleware` 构造出来的实例。两边同名但互不相干,静态分析工具看来这些 `*Middleware` 类型无任何引用。
+- **修复**:直接删除这三个文件;或者把它们改成 `// Deprecated. Use ...` 并在下次发版一起删。
+
+### M-13. `bindRolesLogic` 的 "role level" 检查对 DEVELOPER 无条件放行
+- **位置**:`internal/logic/user/bindRolesLogic.go:85-91`
+- **描述**:
   ```go
-  b := make([]byte, 8)              // 8 字节 = 64 bits
-  rand.Read(b)
-  return hex.EncodeToString(b)[:8]  // 取前 8 hex 字符 = 4 字节 = 32 bits
+  if !caller.IsSuperAdmin &&
+      caller.MemberType != consts.MemberTypeAdmin &&
+      caller.MemberType != consts.MemberTypeDeveloper {
+      if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
+          return response.ErrForbidden("不能分配权限级别高于自身的角色")
+      }
+  }
   ```
-- **影响**:见上一轮审计。虽然 `MustChangePassword=Yes`,但一次性密码暴力破解窗口依然存在。
-- **修复方案**:同上一轮方案——修正截断边界或直接把 adminPassword 调大到 16 字符(64 bits)。
-- **优先级**:P1
-
----
-
-### M-4(遗留). `/api/dept/tree` 对所有已登录用户开放,暴露完整组织架构
-
-- **位置**:`internal/logic/dept/deptTreeLogic.go:27-66`
-- **状态**:未修复。
-- **建议**:仅超管可拉全量;其他用户按 `caller.DeptPath` 过滤(只返回自身部门及其子树)。
-
----
-
-### M-5(遗留). `ProductList` / `ProductDetail` 对所有已登录用户返回系统级元数据
-
-- **位置**:`internal/logic/product/productListLogic.go`、`productDetailLogic.go`
-- **状态**:未修复。
-- **建议**:非超管只能看到自己有有效成员资格的产品。
-
----
-
-### M-7(遗留). `ExtractClientIP` 仅识别 `X-Real-IP`,未识别 `X-Forwarded-For`,且无可信代理白名单
-
-- **位置**:`internal/middleware/ratelimitMiddleware.go:41-52`
-- **状态**:未修复。
-- **建议**:同上一轮——按"可信代理 CIDR 列表 + XFF 取最右可信值 > X-Real-IP > RemoteAddr"。
+  DEVELOPER 绕过 perms-level 校验——**语义正确前提**是"DEVELOPER 已经拿到产品全部权限(`loadPerms` 里走全权分支)",因此让 TA 给 MEMBER 分高阶角色不算越权。但这条业务语义并不显式写在代码里,任何未来把 DEVELOPER 权限收窄的改动都会立刻让这里成为漏洞。
+- **修复**:在 `access.go` 里统一写一个 `callerIsFullPermInProduct(ud) bool`(条件:SuperAdmin / ADMIN / DEVELOPER / 或 `DeptType==DEV` 且成员启用),所有依赖"caller 已拥有全权"做的短路都复用它,变更只需改一处。`loadPerms` 的判定也统一走它。
 
----
-
-### M-8(遗留). 缓存失效为 fire-and-forget,"收紧类"安全动作 + Redis 抖动 = 5 分钟内旧视图生效
-
-- **位置**:所有 `Del/Clean/BatchDel/CleanByProduct` 的调用点。
-- **状态**:未修复。
-- **建议**:
-  - 对 `UpdateUserStatus(Disabled)`、`ChangePassword`、`RemoveMember`、`Logout`、`UpdateMember(Status=Disabled)` 等"收紧"类动作,缓存清理失败必须返回 5xx / 重试;
-  - 或者在这些路径上同步走 "DB tx 提交 → 消息队列发事件 → 消费方确保删干净",把缓存失效转为幂等补偿。
-  - 对新加入的 `Logout`,`Clean` 失败目前只打日志,用户虽然 DB 的 `tokenVersion+1` 生效、但缓存里旧 ud.TokenVersion 仍在——中间件对比 claim 里的旧 version vs cache 里的旧 version,**会继续放行** 5 分钟,这和 `Logout` 的"立即失效"语义严重冲突。此项建议升到 P1。
-
----
-
-## 📝 低风险 / 遗留问题 (Low)
+### M-14. `strings.Contains(err.Error(), "1062")` 脆弱的错误识别(多处)
+- **位置**:
+  - `internal/logic/user/createUserLogic.go:92-96`
+  - `internal/logic/role/createRoleLogic.go:67-71`
+  - `internal/logic/member/addMemberLogic.go:76-80`
+  - `internal/logic/product/createProductLogic.go:134-144`
+- **描述**:所有冲突检测都走字符串匹配 `"1062"` / `"Duplicate entry"`。一旦换 driver 版本或 MySQL 升级导致文案变化,这些检测直接失效,逻辑全部变成 500。产品/用户的唯一索引冲突会被当成内部错误吃掉。
+- **修复**:换成 `mysql.MySQLError` 类型判断:
+  ```go
+  import "github.com/go-sql-driver/mysql"
+  var me *mysql.MySQLError
+  if errors.As(err, &me) && me.Number == 1062 {
+      return response.ErrConflict(...)
+  }
+  ```
 
-### L-A. `CreateUser` 注释写"仅超管可调用",实际却允许产品 ADMIN 调用
+### M-15. `ChangePasswordLogic` 在 bcrypt 校验之前不检查 `user.Status` 冻结状态
+- **位置**:`internal/logic/auth/changePasswordLogic.go:37-45`
+- **描述**:`FindOne → bcrypt.CompareHashAndPassword`,没有 `if user.Status != StatusEnabled`。JWT 中间件已经对冻结用户拦截,但 (a) 冻结后中间件 loader cache 未及时失效的 race 窗口;(b) 冻结状态的用户理论上仍可调 changePassword(虽然此 JWT 已被拦截)。防御纵深建议双保险。
+- **修复**:
+  ```go
+  if user.Status != consts.StatusEnabled {
+      return response.ErrForbidden("账号已被冻结")
+  }
+  ```
 
-- **位置**:`internal/logic/user/createUserLogic.go:38-43`
+### L-1. `SetUserPerms` / `BindRoles` / `BindRolePerms` 的去重写回到了 `req`(副作用入参)
+- **位置**:
+  - `setUserPermsLogic.go:72-86`
+  - `bindRolesLogic.go:58-68`
+  - `bindRolePermsLogic.go:44-54`
 - **描述**:
   ```go
-  // CreateUser 创建用户。新建系统用户账号,可指定部门归属。仅超管可调用。
-  func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (...) {
-      productCode := middleware.GetProductCode(l.ctx)
-      if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil { ... }
+  uniquePerms := make([]types.UserPermItem, 0, len(req.Perms))
+  ...
+  req.Perms = uniquePerms
   ```
-  产品 ADMIN(登录时带 productCode)可以通过该接口创建**系统级用户**(没有任何产品归属)。该用户不会自动加入任何产品,ADMIN 也只能把他加入自己这一个产品;但**文档契约与行为不一致**,容易在后续变更时导致误解
-- **建议**:若意图是"超管专用",改为 `RequireSuperAdmin`;若意图是"产品 ADMIN 也可以",改注释并在 `perm.api` 的接口注释中说明
+  对外部传入的 `req` 做原地写入会让上层(例如 handler、中间件、单元测试)读到被改过的结构,违背"logic 不修改入参"原则
+- **修复**:改成局部变量 `perms := uniquePerms`;后续所有流程使用 `perms`,不再碰 `req.Perms`
 
----
-
-### L-B. `jwtauthMiddleware` 校验顺序:`tokenVersion` 在 `ProductStatus/MemberType` 之后
-
-- **位置**:`internal/middleware/jwtauthMiddleware.go:78-93`
-- **描述**:顺序是 `Status → ProductStatus → MemberType → TokenVersion`。当用户已 `Logout` 或 `ChangePassword`(`tokenVersion` 应失效),但产品被同步禁用了,会优先返回 "该产品已被禁用" 而不是 "登录已失效"。这会给前端错误的 UX 分支(比如前端见到 "产品禁用" 可能不会清本地 token,而是提示用户"联系管理员恢复产品";实际上该 token 已经作废了)。
-- **建议**:把 `tokenVersion` 检查提到最前(或仅次于 `Status != Enabled`)。这也符合 "优先拒绝无效凭据,再拒绝业务层禁用" 的错误码语义。
-
----
-
-### L-C. `Logout` 无并发保护,一条 token 可不断自增 `tokenVersion`
-
-- **位置**:`internal/logic/auth/logoutLogic.go:30-43`
-- **描述**:`Logout` 虽然是幂等(多次 +1 不伤害业务),但每次都会:
-  1. 发 `UPDATE sys_user tokenVersion+1`(行锁);
-  2. 清所有 UserDetails 缓存。
-  攻击者拿到一次有效 access token,就能反复调 `/auth/logout`——每次都是 **DB 写 + 全缓存清**,比 M-B 的 refresh 路径更轻量。
-- **建议**:对 `Logout` 同步加一条 "60s / 6 次" 的 userId 级限流;或者在中间件层面对"高写入路径"统一限流。
+### L-2. `memberTypePriority("")` 返回 `MaxInt32`,在 `CheckMemberTypeAssignment` 下会与未知类型保持等价判定
+- **位置**:`internal/logic/auth/access.go:15-28,67-69`
+- **描述**:若 `caller.MemberType == ""`(例如产品禁用后被 loader 清空),`memberTypePriority("")==MaxInt32`;`if MaxInt32 >= priority(assigned)` 恒真 → `CheckMemberTypeAssignment` 直接拒绝。这里"fail-closed"是正确行为,但逻辑靠的是 sentinel 值而非显式分支,容易在将来扩展 MemberType 时出错。
+- **修复**:显式判断 `caller.MemberType == "" → return ErrForbidden("缺少产品成员上下文")`。
 
----
+### L-3. `UserDetailsLoader.Clean` 的错误忽略会扩散
+- **位置**:`internal/loaders/userDetailsLoader.go:150-153,177-179,186-198`
+- **描述**:Redis 所有操作失败都只 `Errorf` 记日志,然后继续。当 Redis 间歇不可用时,管理员以为"清完了",实际上旧缓存可能至少 5 分钟内生效。
+- **修复**:对关键变更路径(角色授权、状态变更)做"两次清理 + 失败回退":第一次 `Del` 失败 → 在队列里起一次 retry;或者把 UserDetails 的缓存层 TTL 缩短到 60s 并接受 DB 压力(负载允许前提下)。
 
-### L-D. `SyncPerms` 通过 appKey 查询,appKey 错误 vs appSecret 错误返回同一文案,但**响应时间**差异明显
+### L-4. `DeptTree` 对"孤儿 parent"只打日志即当 root 并继续返回 → 静默数据异常
+- **位置**:`internal/logic/dept/deptTreeLogic.go:55-63`
+- **描述**:如果发生 H-4 的 TOCTOU,树里会出现 "parentId 指向不存在的 id" 的部门,代码会把它"视作 root"继续放回前端。**数据已损坏但用户/管理员无感知**,管理员很可能在之后一通操作里把 children 移到别处却不清楚真正丢失的是哪棵子树。
+- **修复**:同一批异常记录直接在响应里带一个 `warnings` 字段或在 HTTP 头里 flag,让前端报警;后台做一条 alerting 指标 `dept_orphan_count > 0`。
 
-- **位置**:`internal/logic/pub/syncPermsService.go:37-43`
-- **描述**:appKey 不存在 → 401,不走 bcrypt(快);appKey 存在但 appSecret 错 → 401,走 bcrypt(慢)。攻击者可据此枚举有效 appKey。
-- **影响**:低——appKey 对外不暴露,枚举难度大;但如果将来 `/api/perm/sync` 暴露在公网,这是信息泄露。
-- **建议**:appKey 不存在时也跑一次 dummy bcrypt,或对该接口限流。
+### L-5. 多数接口在 DB 错误时直接 `return err` 而不包装为统一 500,生产会透出底层 sqlx/gorm/bcrypt 错误文本
+- **位置**:几乎所有 logic 文件的尾端
+- **描述**:`response.Setup` 会把非 `*CodeError` 的错误映射成 `{code: 500, msg: "服务器内部错误"}` 并 `logx.Errorf`。这本身没问题,但很多 logic 在上下文敏感的位置(比如 bcrypt 生成失败)返回原始 err 到调用栈,日志里出现原始 bcrypt 错误文本也是一种信息披露。建议所有 logic 统一包装成 `response.ErrInternal("xxx 失败")` 并把原 err 放入 `logx.Errorf` 里,避免上下层关心如何转换。
 
 ---
 
-### L-E. 保留的 `FindRoleIdsByUserId`(不按 productCode 过滤)在当前业务中几乎不可达
-
-- **位置**:`internal/model/userrole/sysUserRoleModel.go:37-44`、`internal/logic/user/userDetailLogic.go:53`
-- **描述**:仅在 `UserDetail` 的 `productCode == ""` 分支调用。该分支只有超管未带产品上下文时才进入(超管通过 `adminLogin` + 查用户详情页)。虽可达但使用面极窄,且返回**跨全部产品**的 roleIds,对 API 消费方语义模糊(前端只拿到 roleIds 无 productCode 区分,容易误用)。
-- **建议**:保留但在接口文档里明确"此分支的 roleIds 是跨产品聚合",或改为按 "caller 的产品上下文"(不等价,但更可预测)。
-
----
-
-### L-F. `UpdateUser` 允许他人调用者修改目标的 `deptId`,但未校验新 `deptId` 是否在调用者可管理的子树中
-
-- **位置**:`internal/logic/user/updateUserLogic.go:99-106`
-- **描述**:`CheckManageAccess` 只校验"可管理目标用户",不校验"新目标部门"在 caller 的子树中。实际上 `checkDeptHierarchy` 对 ADMIN 直接放行(第 101-103 行),但对 DEVELOPER 会先走 `callerDeptPath` 前缀匹配;DEVELOPER 如果能管理到跨部门的用户(极少数配置下),就能把他调到任意部门。由于 `CheckManageAccess` 的部门校验只限制"目标当前部门",不限制"目标的新部门",DEVELOPER 可以把下属挪出自己的子树。
-- **建议**:更新 `deptId` 时,新 deptId 若非空,校验 `caller.IsSuperAdmin || caller.MemberType == ADMIN || strings.HasPrefix(newDept.Path, caller.DeptPath)`。
-
----
-
-### L-G. `loginService.ValidateProductLogin` 对已冻结用户的密码仍做 bcrypt 比对
-
-- **位置**:`internal/logic/pub/loginService.go:48-54`
-- **描述**:Status 检查在 bcrypt 之前,但中间多了一步 `UsernameLoginLimit.Take` → 已冻结用户仍会消耗配额。累积消耗后,**真正解冻后用户 5 分钟内无法登录**。
-- **建议**:Status 检查前置到 FindOneByUsername 之后、`UsernameLoginLimit.Take` 之前,冻结即返回 403,不消耗配额。
-
----
-
-### L-H. `ValidateProductLogin` 成功登录后没有重置 `UsernameLoginLimit`
-
-- **位置**:同上
-- **描述**:go-zero 的 `PeriodLimit` 没有 reset API,因此"登录成功"不会归零失败计数。连续多次失败(例如输错 3 次、然后登录成功)后 24 小时内的窗口内若再输错 7 次仍会触发锁定——这对用户体验不友好,也让攻击者可以通过合法登录不打破锁定(因为锁定只和失败计数相关)。
-- **建议**:改用 Redis 自增 + 登录成功时 `DEL` key,或只在 `bcrypt.CompareHashAndPassword` 失败时才计数。
-
----
-
-### L-I. `AddMember` 不校验目标用户 `Status`
-
-- **位置**:`internal/logic/member/addMemberLogic.go:41-43`
-- **描述**:只校验用户存在、不校验 `u.Status == Enabled`。可以把已冻结用户加成员;他们被解冻的瞬间就拥有产品访问权。
-- **建议**:`if u.Status != consts.StatusEnabled { return ErrBadRequest("用户已被冻结,无法添加为成员") }`。
-
----
-
-### L-J. `UpdateUser` 允许他人修改**目标用户的 `Status` 值**,与 `UpdateUserStatus` 职责重叠
-
-- **位置**:`internal/logic/user/updateUserLogic.go:108-125`、`internal/logic/user/updateUserStatusLogic.go`
-- **描述**:两条接口都能改 `Status`;但 `UpdateUser` 的路径只在"用户维度"`Clean`,而 `UpdateUserStatus` 显式通过 `UpdateStatus`(tokenVersion+1)。`UpdateUser` 里的 `UpdateProfile(statusChanged=true)` 模型代码**也** `tokenVersion+1`,行为一致,所以功能等价;但两条路径分别做校验、很容易在一侧加了新防御一侧漏掉(比如 `UpdateUserStatus` 已经禁止自改自己、不允许改超管;`UpdateUser` 只在 `caller.UserId == req.Id` 时禁 status,漏掉了"不允许把超管冻结"——实际上走 `UpdateUser` 修改超管 Status 会被第 56-60 行的"不能通过此接口修改其他超级管理员的状态和部门"拦下,OK)。
-- **建议**:要么把 `UpdateUser` 里处理 `Status` 的分支删掉(转嫁给 `UpdateUserStatus`),要么显式文档化两接口的角色差异,避免未来维护者再添新守则时漏一处。
-
----
-
-### L-K. `BindRoles` 的 caller 读取在校验之后但使用条件分散,易读性差
-
-- **位置**:`internal/logic/user/bindRolesLogic.go:65-89`
-- **描述**:`caller := middleware.GetUserDetails(l.ctx)` 在角色数组校验中段读取,caller 可能为 nil(中间件理论上保证非 nil,但代码仍做 `caller != nil && ...`)。这种"半防御"容易让后续维护者以为 caller 可能为 nil,加上无必要的判空。
-- **建议**:在函数开头统一读取 caller,确认非 nil;后续逻辑直接使用。
-
----
-
-### L-1 / L-2 / L-7 / L-8 / L-9 / L-10(前轮遗留)
-
-- 与上一轮一致,此处不再赘述。保持 P3 跟进。
-
----
-
-## 📋 审计总结
-
-| 维度 | 评估 |
-|------|------|
-| **逻辑一致性** | 新增重大发现:`SetUserPerms` 自我越权(H-A);"最后 ADMIN" 校验 TOCTOU(M-A);`IncrementTokenVersion` 返回值与 DB 脱钩(H-B)。 |
-| **并发与竞态** | `DeleteRole` 仍有事务内外分裂(M-D);`Delete*ForProductTx` 的 "先 SELECT 再 DELETE" 模式非原子(M-E)。 |
-| **资源管理** | 无新增泄漏;`RefreshToken` / `Logout` 无限流造成 DB + Redis 热点(M-B/L-C)。 |
-| **数据完整性** | 关键写路径事务化;剩余问题都在"读-用-改"时序上。`UpdateMember` 的 `req.Status=0` 分支保留原值,无覆盖风险。 |
-| **安全漏洞** | H-A(MEMBER 自我提权全权限)属于严重权限越权;H-B 导致用户随机被登出(可用性),也降低 refresh rotation 的安全语义;M-C 仍允许账号枚举 + 选择性锁定。 |
-| **边界处理** | `SetUserPerms` / `BindRoles` / `RemoveMember` 对空数组、重复值的处理健壮;`UpdateUser` 指针字段的 nil/空区分明确。 |
-| **DB 性能** | `UserList` 冗余二次查询(M-G);`loadPerms` 已加 `p.status=1`;其他列表接口批量 IN 无 N+1。 |
-| **僵尸代码** | `FindByPathPrefix`/`FindByParentId` 已清理;残留 `FindRoleIdsByUserId`(L-E)。`CountActiveAdmins` SQL 硬编码常量(M-F)。 |
-| **接口契约** | `CreateUser` 注释 vs 实现不一致(L-A);`UpdateUser` 与 `UpdateUserStatus` 职责重叠(L-J)。 |
-
-### 修复优先级建议
-
-**P0(立即修复)**
-- **H-A**:`SetUserPerms` 加 `RequireProductAdminFor(productCode)` 或至少禁止自我 set。——普通 MEMBER 自我提权到产品全权限,属于可利用的权限越权,需优先拦截。
-- **H-B**:`IncrementTokenVersion` 改为事务 + `LAST_INSERT_ID` / `FOR UPDATE` 获取真实的新版本号。——否则并发刷新会让正常用户偶发"刷新后即失效",同时削弱 refresh rotation 安全性。
-
-**P1(短期修复)**
-- **M-A**:`RemoveMember` / `UpdateMember` 的"最后 ADMIN"校验挪进事务 + 加锁。
-- **M-B**:`/auth/refreshToken` 加 userId 级限流,或在 logic 内部做 Redis 计数。
-- **M-C**:`UsernameLoginLimit` key 增加 IP 维度,冻结账号不再消耗配额;可选加入 bcrypt 假耗时。
-- **M-3**:`generateRandomHex` 修截断边界 + adminPassword 长度提升到 16。
-- **M-4 / M-5**:`DeptTree` / `ProductList` / `ProductDetail` 增加归属过滤。
-- **M-8**:对"收紧类"安全动作的缓存失败策略收紧,尤其是新加入的 `Logout`(失败必须返回 5xx)。
-- **L-C**:`Logout` 加限流。
-
-**P2(中期修复)**
-- M-D:`DeleteRole` 把 `FindUserIdsByRoleId` 改为 session `FOR UPDATE`。
-- M-E:所有 `Delete*ForProductTx` 的 SELECT 改为 session 内执行。
-- M-F:`CountActiveAdmins` 用常量占位符替换硬编码。
-- M-G:`UserList` 合并 JOIN 与 memberType 查询,去掉二次 IN。
-- M-7:XFF + 可信代理白名单。
-- L-B:JWT 中间件调整校验顺序(tokenVersion 前置)。
-- L-A / L-J / L-F / L-G / L-H / L-I / L-K:按各项说明收敛。
-
-**P3(长期)**
-- L-1 / L-2(返回码选择、敏感配置明文)、L-7(`loader.Load` error 语义)、L-8(gRPC IP 提取)、L-9(`BindRoles` 空数组默认语义)、L-10(`FindOneByProductCodeUserId` 不按 status 过滤)保持跟进。
-
+## 🧭 总结与本轮优先级
+
+| 优先级 | 问题 | 关键词 |
+| --- | --- | --- |
+| P0 | H-1 | UpdateMember 禁用最后 ADMIN 绕过 |
+| P0 | H-2 | RemoveMember 事务内外视图脱钩 |
+| P0 | H-3 | CountActiveAdmins 跨行 TOCTOU |
+| P0 | H-4 | DeleteDept 子部门/用户 TOCTOU |
+| P0 | H-5 | ChangePassword 无限流可暴力破旧密码 |
+| P1 | M-1 | UpdateUserStatus "无变化也踢下线" |
+| P1 | M-2 | generateRandomHex 熵减半 |
+| P1 | M-6 | UpdateRole/Product/Member 缺乐观锁 |
+| P1 | M-7 | AdminLogin username 枚举 |
+| P1 | M-8 | ProductList / ProductDetail / DeptTree 无访问控制 |
+| P2 | M-3/M-4/M-5/M-9/M-10/M-11/M-13/M-14/M-15 | 可观测 / 容错 / 代码一致性 |
+| P3 | M-12 / L-* | 僵尸代码、副作用入参、错误透传 |
+
+### 建议的修复顺序
+1. **立刻修 H-1 / H-2 / H-3**:三者共同保护"产品至少一个 ADMIN"不变式,彼此独立,必须三条路径一起堵。建议抽一个 `guardLastActiveAdminTx(session, productCode, targetMemberId)` helper,`UpdateMember` 和 `RemoveMember` 都调用它;内部先 `SELECT id ... FOR UPDATE` 锁定所有活跃 ADMIN,再做 count/是否将失活判断。
+2. **修 H-4**:部门删除用 `SELECT 1 ... FOR UPDATE` 做存在性锁定读;`CreateDept`/`CreateUser`/`UpdateUser` 写 deptId 前对父部门 `SELECT ... FOR SHARE`。短期方案即可生效,长期改 FK。
+3. **修 H-5**:changePassword 加 `TokenOpLimiter.Take("chpwd:%d")`;补充失败日志。
+4. **批量修 M-1 / M-6 / M-2 / M-7**:这几条都是一行到十行级的小改,收益很高。
+5. **有余力再做 M-3/M-4/M-5/M-10/M-14**:涉及缓存层或错误分类,需要更严谨的回归测试。
+
+> 注:本轮报告不再列"已在上轮修复"或"已被单测覆盖"的 finding(比如 H-A/H-B、TokenVersion 相关等),见 `test-report.md` 对应条目。

+ 15 - 0
internal/logic/auth/access.go

@@ -64,6 +64,9 @@ func CheckMemberTypeAssignment(ctx context.Context, assignedType string) error {
 	if caller.IsSuperAdmin {
 		return nil
 	}
+	if caller.MemberType == "" {
+		return response.ErrForbidden("缺少产品成员上下文")
+	}
 	if memberTypePriority(caller.MemberType) >= memberTypePriority(assignedType) {
 		return response.ErrForbidden("无权分配该成员类型,不能分配与自己同级或更高级别的类型")
 	}
@@ -97,6 +100,18 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
 }
 
+// HasFullProductPerms 判断调用者是否拥有当前产品的全部权限(无需做 permsLevel 校验)。
+// SuperAdmin / ADMIN / DEVELOPER 均视为全权;loadPerms 对此三者走全权分支。
+// 所有依赖"调用者已拥有全权"的短路逻辑应复用此函数,变更只需改一处。
+func HasFullProductPerms(caller *loaders.UserDetails) bool {
+	if caller == nil {
+		return false
+	}
+	return caller.IsSuperAdmin ||
+		caller.MemberType == consts.MemberTypeAdmin ||
+		caller.MemberType == consts.MemberTypeDeveloper
+}
+
 // ValidateStatusChange 校验状态变更的合法性(不允许自改状态、不允许冻结超管)。
 // UpdateUser 和 UpdateUserStatus 共用此函数以确保校验逻辑一致。
 func ValidateStatusChange(ctx context.Context, svcCtx *svc.ServiceContext, callerId, targetUserId int64) error {

+ 15 - 0
internal/logic/auth/changePasswordLogic.go

@@ -2,6 +2,7 @@ package auth
 
 import (
 	"context"
+	"fmt"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/middleware"
@@ -10,6 +11,7 @@ import (
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/util"
 
+	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/logx"
 	"golang.org/x/crypto/bcrypt"
 )
@@ -35,12 +37,25 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 	}
 
 	userId := middleware.GetUserId(l.ctx)
+
+	if l.svcCtx.TokenOpLimiter != nil {
+		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId))
+		if code == limit.OverQuota {
+			return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
+		}
+	}
+
 	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId)
 	if err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
 
+	if user.Status != consts.StatusEnabled {
+		return response.ErrForbidden("账号已被冻结")
+	}
+
 	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
+		logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId)
 		return response.ErrBadRequest("原密码错误")
 	}
 

+ 7 - 0
internal/logic/dept/createDeptLogic.go

@@ -63,6 +63,13 @@ func (l *CreateDeptLogic) CreateDept(req *types.CreateDeptReq) (resp *types.IdRe
 	}
 
 	err = l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if req.ParentId > 0 {
+			var lockId int64
+			lockQ := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR SHARE", l.svcCtx.SysDeptModel.TableName())
+			if err := session.QueryRowCtx(ctx, &lockId, lockQ, req.ParentId); err != nil {
+				return response.ErrNotFound("父部门已被删除")
+			}
+		}
 		result, err := l.svcCtx.SysDeptModel.InsertWithTx(ctx, session, &deptModel.SysDept{
 			ParentId:   req.ParentId,
 			Name:       req.Name,

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

@@ -34,28 +34,27 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 	}
 
 	return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		// 行锁锁定目标部门,防止并发删除/修改
 		var deptId int64
 		lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
 		if err := session.QueryRowCtx(ctx, &deptId, lockQuery, req.Id); err != nil {
 			return response.ErrNotFound("部门不存在")
 		}
 
-		var childCount int64
-		countChildQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `parentId` = ?", l.svcCtx.SysDeptModel.TableName())
-		if err := session.QueryRowCtx(ctx, &childCount, countChildQuery, req.Id); err != nil {
+		var childIds []int64
+		childQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `parentId` = ? FOR UPDATE", l.svcCtx.SysDeptModel.TableName())
+		if err := session.QueryRowsCtx(ctx, &childIds, childQuery, req.Id); err != nil {
 			return err
 		}
-		if childCount > 0 {
+		if len(childIds) > 0 {
 			return response.ErrBadRequest("该部门下存在子部门,无法删除")
 		}
 
-		var userCount int64
-		countUserQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `deptId` = ?", l.svcCtx.SysUserModel.TableName())
-		if err := session.QueryRowCtx(ctx, &userCount, countUserQuery, req.Id); err != nil {
+		var userIds []int64
+		userQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `deptId` = ? FOR UPDATE", l.svcCtx.SysUserModel.TableName())
+		if err := session.QueryRowsCtx(ctx, &userIds, userQuery, req.Id); err != nil {
 			return err
 		}
-		if userCount > 0 {
+		if len(userIds) > 0 {
 			return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
 		}
 

+ 2 - 2
internal/logic/member/addMemberLogic.go

@@ -2,7 +2,6 @@ package member
 
 import (
 	"context"
-	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -11,6 +10,7 @@ import (
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
+	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
 )
@@ -74,7 +74,7 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		UpdateTime:  now,
 	})
 	if err != nil {
-		if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
+		if util.IsDuplicateEntryErr(err) {
 			return nil, response.ErrConflict("该用户已是该产品成员")
 		}
 		return nil, err

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

@@ -39,10 +39,11 @@ func (l *RemoveMemberLogic) RemoveMember(req *types.RemoveMemberReq) error {
 	}
 
 	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil {
+		locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
+		if err != nil {
 			return response.ErrNotFound("成员不存在")
 		}
-		if member.MemberType == consts.MemberTypeAdmin {
+		if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
 			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
 			if err != nil {
 				return err

+ 13 - 9
internal/logic/member/updateMemberLogic.go

@@ -48,31 +48,35 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		return err
 	}
 
-	needAdminCheck := member.MemberType == consts.MemberTypeAdmin && req.MemberType != consts.MemberTypeAdmin
-
-	member.MemberType = req.MemberType
+	nextType := req.MemberType
+	nextStatus := member.Status
 	if req.Status != 0 {
 		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
 			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
 		}
-		member.Status = req.Status
+		nextStatus = req.Status
 	}
-	member.UpdateTime = time.Now().Unix()
 
 	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil {
+		locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
+		if err != nil {
 			return response.ErrNotFound("成员不存在")
 		}
-		if needAdminCheck {
+		wasActiveAdmin := locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled
+		willBeActiveAdmin := nextType == consts.MemberTypeAdmin && nextStatus == consts.StatusEnabled
+		if wasActiveAdmin && !willBeActiveAdmin {
 			adminCount, err := l.svcCtx.SysProductMemberModel.CountActiveAdminsTx(ctx, session, member.ProductCode)
 			if err != nil {
 				return err
 			}
 			if adminCount <= 1 {
-				return response.ErrBadRequest("不能降级该产品的最后一个管理员")
+				return response.ErrBadRequest("不能降级或禁用该产品的最后一个管理员")
 			}
 		}
-		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, member)
+		locked.MemberType = nextType
+		locked.Status = nextStatus
+		locked.UpdateTime = time.Now().Unix()
+		return l.svcCtx.SysProductMemberModel.UpdateWithTx(ctx, session, locked)
 	}); err != nil {
 		return err
 	}

+ 9 - 8
internal/logic/product/createProductLogic.go

@@ -17,6 +17,7 @@ import (
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
+	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
@@ -60,11 +61,11 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 		return nil, response.ErrConflict("产品编码已存在")
 	}
 
-	appKey, err := generateRandomHex(32)
+	appKey, err := generateRandomHex(16)
 	if err != nil {
 		return nil, err
 	}
-	rawAppSecret, err := generateRandomHex(64)
+	rawAppSecret, err := generateRandomHex(32)
 	if err != nil {
 		return nil, err
 	}
@@ -78,7 +79,7 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
 		return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
 	}
-	adminPassword, err := generateRandomHex(8)
+	adminPassword, err := generateRandomHex(12)
 	if err != nil {
 		return nil, err
 	}
@@ -132,8 +133,8 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	})
 
 	if err != nil {
-		errMsg := err.Error()
-		if strings.Contains(errMsg, "1062") || strings.Contains(errMsg, "Duplicate entry") {
+		if util.IsDuplicateEntryErr(err) {
+			errMsg := err.Error()
 			if strings.Contains(errMsg, "uk_code") || strings.Contains(errMsg, req.Code) {
 				return nil, response.ErrConflict("产品编码已存在")
 			}
@@ -155,10 +156,10 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	}, nil
 }
 
-func generateRandomHex(length int) (string, error) {
-	b := make([]byte, length)
+func generateRandomHex(byteLen int) (string, error) {
+	b := make([]byte, byteLen)
 	if _, err := rand.Read(b); err != nil {
 		return "", fmt.Errorf("generate random bytes failed: %w", err)
 	}
-	return hex.EncodeToString(b)[:length], nil
+	return hex.EncodeToString(b), nil
 }

+ 7 - 1
internal/logic/product/updateProductLogic.go

@@ -2,10 +2,12 @@ package product
 
 import (
 	"context"
+	"errors"
 	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
+	productModel "perms-system-server/internal/model/product"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -45,6 +47,7 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 		return response.ErrNotFound("产品不存在")
 	}
 
+	prevUpdateTime := product.UpdateTime
 	product.Name = req.Name
 	product.Remark = req.Remark
 	if req.Status != 0 {
@@ -55,7 +58,10 @@ func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error {
 	}
 	product.UpdateTime = time.Now().Unix()
 
-	if err := l.svcCtx.SysProductModel.Update(l.ctx, product); err != nil {
+	if err := l.svcCtx.SysProductModel.UpdateWithOptLock(l.ctx, product, prevUpdateTime); err != nil {
+		if errors.Is(err, productModel.ErrUpdateConflict) {
+			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
+		}
 		return err
 	}
 

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

@@ -48,21 +48,22 @@ func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.Logi
 	u, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, req.Username)
 	if err != nil {
 		if errors.Is(err, user.ErrNotFound) {
+			bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
 			return nil, response.ErrUnauthorized("用户名或密码错误")
 		}
 		return nil, err
 	}
 
-	if u.Status != consts.StatusEnabled {
-		return nil, response.ErrForbidden("账号已被冻结")
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
+		return nil, response.ErrUnauthorized("用户名或密码错误")
 	}
 
-	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
+	if u.Status != consts.StatusEnabled {
 		return nil, response.ErrUnauthorized("用户名或密码错误")
 	}
 
 	if u.IsSuperAdmin != consts.IsSuperAdminYes {
-		return nil, response.ErrForbidden("仅超级管理员可通过管理后台登录")
+		return nil, response.ErrUnauthorized("用户名或密码错误")
 	}
 
 	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, "")

+ 4 - 4
internal/logic/pub/adminLoginLogic_test.go

@@ -62,8 +62,8 @@ func TestAdminLogin_NormalUserRejected(t *testing.T) {
 
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 403, codeErr.Code())
-	assert.Equal(t, "仅超级管理员可通过管理后台登录", codeErr.Error())
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "用户名或密码错误", codeErr.Error())
 }
 
 // TC-0017: managementKey无效
@@ -171,8 +171,8 @@ func TestAdminLogin_AccountFrozen(t *testing.T) {
 
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
-	assert.Equal(t, 403, codeErr.Code())
-	assert.Equal(t, "账号已被冻结", codeErr.Error())
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "用户名或密码错误", codeErr.Error())
 }
 
 // TC-0022: 不带productCode时token无权限(perms为空)

+ 17 - 12
internal/logic/role/bindRolePermsLogic.go

@@ -41,24 +41,25 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 		return err
 	}
 
-	if len(req.PermIds) > 0 {
-		seen := make(map[int64]bool, len(req.PermIds))
-		uniqueIds := make([]int64, 0, len(req.PermIds))
-		for _, id := range req.PermIds {
+	permIds := req.PermIds
+	if len(permIds) > 0 {
+		seen := make(map[int64]bool, len(permIds))
+		uniqueIds := make([]int64, 0, len(permIds))
+		for _, id := range permIds {
 			if !seen[id] {
 				seen[id] = true
 				uniqueIds = append(uniqueIds, id)
 			}
 		}
-		req.PermIds = uniqueIds
+		permIds = uniqueIds
 	}
 
-	if len(req.PermIds) > 0 {
-		perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, req.PermIds)
+	if len(permIds) > 0 {
+		perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, permIds)
 		if err != nil {
 			return err
 		}
-		if len(perms) != len(req.PermIds) {
+		if len(perms) != len(permIds) {
 			return response.ErrBadRequest("包含无效的权限ID")
 		}
 		for _, p := range perms {
@@ -80,13 +81,13 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 	for _, id := range existingPermIds {
 		existingSet[id] = true
 	}
-	newSet := make(map[int64]bool, len(req.PermIds))
-	for _, id := range req.PermIds {
+	newSet := make(map[int64]bool, len(permIds))
+	for _, id := range permIds {
 		newSet[id] = true
 	}
 
 	var toAdd []int64
-	for _, id := range req.PermIds {
+	for _, id := range permIds {
 		if !existingSet[id] {
 			toAdd = append(toAdd, id)
 		}
@@ -124,7 +125,11 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 		return err
 	}
 
-	affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.RoleId)
+	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)
 	return nil
 }

+ 2 - 2
internal/logic/role/createRoleLogic.go

@@ -2,7 +2,6 @@ package role
 
 import (
 	"context"
-	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -11,6 +10,7 @@ import (
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
+	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
 )
@@ -65,7 +65,7 @@ func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdRe
 		UpdateTime:  now,
 	})
 	if err != nil {
-		if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
+		if util.IsDuplicateEntryErr(err) {
 			return nil, response.ErrConflict("该产品下角色名已存在")
 		}
 		return nil, err

+ 12 - 2
internal/logic/role/updateRoleLogic.go

@@ -2,11 +2,13 @@ package role
 
 import (
 	"context"
+	"errors"
 	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
+	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -55,6 +57,7 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 		return response.ErrForbidden("非超管不能降低角色的权限级别")
 	}
 
+	prevUpdateTime := role.UpdateTime
 	role.Name = req.Name
 	role.Remark = req.Remark
 	role.PermsLevel = req.PermsLevel
@@ -66,11 +69,18 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 	}
 	role.UpdateTime = time.Now().Unix()
 
-	if err := l.svcCtx.SysRoleModel.Update(l.ctx, role); err != nil {
+	if err := l.svcCtx.SysRoleModel.UpdateWithOptLock(l.ctx, role, prevUpdateTime); err != nil {
+		if errors.Is(err, roleModel.ErrUpdateConflict) {
+			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
+		}
 		return err
 	}
 
-	affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
+	affectedUserIds, err := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
+	if err != nil {
+		logx.WithContext(l.ctx).Errorf("角色已更新但缓存清理失败 roleId=%d: %v", req.Id, err)
+		return response.NewCodeError(500, "角色已更新但缓存刷新失败,请稍后手动刷新")
+	}
 	l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
 	return nil
 }

+ 13 - 14
internal/logic/user/bindRolesLogic.go

@@ -55,24 +55,25 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return response.ErrBadRequest("目标用户的成员资格已被禁用")
 	}
 
-	if len(req.RoleIds) > 0 {
-		seen := make(map[int64]bool, len(req.RoleIds))
-		uniqueIds := make([]int64, 0, len(req.RoleIds))
-		for _, id := range req.RoleIds {
+	roleIds := req.RoleIds
+	if len(roleIds) > 0 {
+		seen := make(map[int64]bool, len(roleIds))
+		uniqueIds := make([]int64, 0, len(roleIds))
+		for _, id := range roleIds {
 			if !seen[id] {
 				seen[id] = true
 				uniqueIds = append(uniqueIds, id)
 			}
 		}
-		req.RoleIds = uniqueIds
+		roleIds = uniqueIds
 	}
 
-	if len(req.RoleIds) > 0 {
-		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
+	if len(roleIds) > 0 {
+		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, roleIds)
 		if err != nil {
 			return err
 		}
-		if int64(len(roles)) != int64(len(req.RoleIds)) {
+		if int64(len(roles)) != int64(len(roleIds)) {
 			return response.ErrBadRequest("包含无效的角色ID")
 		}
 		for _, r := range roles {
@@ -82,9 +83,7 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
-			if !caller.IsSuperAdmin &&
-				caller.MemberType != consts.MemberTypeAdmin &&
-				caller.MemberType != consts.MemberTypeDeveloper {
+			if !authHelper.HasFullProductPerms(caller) {
 				if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
 					return response.ErrForbidden("不能分配权限级别高于自身的角色")
 				}
@@ -101,13 +100,13 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 	for _, id := range existingRoleIds {
 		existingSet[id] = true
 	}
-	newSet := make(map[int64]bool, len(req.RoleIds))
-	for _, id := range req.RoleIds {
+	newSet := make(map[int64]bool, len(roleIds))
+	for _, id := range roleIds {
 		newSet[id] = true
 	}
 
 	var toAdd []int64
-	for _, id := range req.RoleIds {
+	for _, id := range roleIds {
 		if !existingSet[id] {
 			toAdd = append(toAdd, id)
 		}

+ 1 - 2
internal/logic/user/createUserLogic.go

@@ -3,7 +3,6 @@ package user
 import (
 	"context"
 	"regexp"
-	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -90,7 +89,7 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		UpdateTime:         now,
 	})
 	if err != nil {
-		if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
+		if util.IsDuplicateEntryErr(err) {
 			return nil, response.ErrConflict("用户名已存在")
 		}
 		return nil, err

+ 15 - 14
internal/logic/user/setUserPermsLogic.go

@@ -69,10 +69,11 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		}
 	}
 
-	if len(req.Perms) > 0 {
-		seen := make(map[int64]string, len(req.Perms))
-		uniquePerms := make([]types.UserPermItem, 0, len(req.Perms))
-		for _, p := range req.Perms {
+	perms := req.Perms
+	if len(perms) > 0 {
+		seen := make(map[int64]string, len(perms))
+		uniquePerms := make([]types.UserPermItem, 0, len(perms))
+		for _, p := range perms {
 			if prev, ok := seen[p.PermId]; ok {
 				if prev != p.Effect {
 					return response.ErrBadRequest("同一权限ID不能同时为 ALLOW 和 DENY")
@@ -82,22 +83,22 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 			seen[p.PermId] = p.Effect
 			uniquePerms = append(uniquePerms, p)
 		}
-		req.Perms = uniquePerms
+		perms = uniquePerms
 	}
 
-	if len(req.Perms) > 0 {
-		permIds := make([]int64, 0, len(req.Perms))
-		for _, p := range req.Perms {
+	if len(perms) > 0 {
+		permIds := make([]int64, 0, len(perms))
+		for _, p := range perms {
 			permIds = append(permIds, p.PermId)
 		}
-		perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, permIds)
+		dbPerms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, permIds)
 		if err != nil {
 			return err
 		}
-		if len(perms) != len(req.Perms) {
+		if len(dbPerms) != len(perms) {
 			return response.ErrBadRequest("包含无效的权限ID")
 		}
-		for _, p := range perms {
+		for _, p := range dbPerms {
 			if p.ProductCode != productCode {
 				return response.ErrBadRequest("不能设置其他产品的权限")
 			}
@@ -111,12 +112,12 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
 			return err
 		}
-		if len(req.Perms) == 0 {
+		if len(perms) == 0 {
 			return nil
 		}
 		now := time.Now().Unix()
-		data := make([]*userperm.SysUserPerm, 0, len(req.Perms))
-		for _, p := range req.Perms {
+		data := make([]*userperm.SysUserPerm, 0, len(perms))
+		for _, p := range perms {
 			data = append(data, &userperm.SysUserPerm{
 				UserId:     req.UserId,
 				PermId:     p.PermId,

+ 9 - 0
internal/logic/user/updateUserStatusLogic.go

@@ -28,6 +28,7 @@ func NewUpdateUserStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *
 }
 
 // UpdateUserStatus 冻结/解冻用户。修改用户启用状态并递增 tokenVersion 使其令牌失效。不能修改自身或超管状态。
+// 若状态未实际变更则不做任何写操作,避免不必要的 tokenVersion 递增踢用户下线。
 func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq) error {
 	if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
 		return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
@@ -43,6 +44,14 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 		return err
 	}
 
+	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)
+	if err != nil {
+		return response.ErrNotFound("用户不存在")
+	}
+	if user.Status == req.Status {
+		return nil
+	}
+
 	if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
 		return err
 	}

+ 0 - 22
internal/middleware/adminloginratelimitMiddleware.go

@@ -1,22 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.0
-
-package middleware
-
-import "net/http"
-
-type AdminLoginRateLimitMiddleware struct {
-}
-
-func NewAdminLoginRateLimitMiddleware() *AdminLoginRateLimitMiddleware {
-	return &AdminLoginRateLimitMiddleware{}
-}
-
-func (m *AdminLoginRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		// TODO generate middleware implement function, delete after code implementation
-
-		// Passthrough to next handler if need
-		next(w, r)
-	}
-}

+ 0 - 22
internal/middleware/productloginratelimitMiddleware.go

@@ -1,22 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.0
-
-package middleware
-
-import "net/http"
-
-type ProductLoginRateLimitMiddleware struct {
-}
-
-func NewProductLoginRateLimitMiddleware() *ProductLoginRateLimitMiddleware {
-	return &ProductLoginRateLimitMiddleware{}
-}
-
-func (m *ProductLoginRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		// TODO generate middleware implement function, delete after code implementation
-
-		// Passthrough to next handler if need
-		next(w, r)
-	}
-}

+ 0 - 22
internal/middleware/syncratelimitMiddleware.go

@@ -1,22 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.0
-
-package middleware
-
-import "net/http"
-
-type SyncRateLimitMiddleware struct {
-}
-
-func NewSyncRateLimitMiddleware() *SyncRateLimitMiddleware {
-	return &SyncRateLimitMiddleware{}
-}
-
-func (m *SyncRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		// TODO generate middleware implement function, delete after code implementation
-
-		// Passthrough to next handler if need
-		next(w, r)
-	}
-}

+ 23 - 0
internal/model/product/sysProductModel.go

@@ -2,18 +2,23 @@ package product
 
 import (
 	"context"
+	"database/sql"
+	"errors"
 	"fmt"
 
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
+var ErrUpdateConflict = errors.New("update conflict: data has been modified by another operation")
+
 var _ SysProductModel = (*customSysProductModel)(nil)
 
 type (
 	SysProductModel interface {
 		sysProductModel
 		FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error)
+		UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime int64) error
 	}
 
 	customSysProductModel struct {
@@ -27,6 +32,24 @@ func NewSysProductModel(conn sqlx.SqlConn, c cache.CacheConf, cachePrefix string
 	}
 }
 
+func (m *customSysProductModel) UpdateWithOptLock(ctx context.Context, data *SysProduct, expectedUpdateTime int64) error {
+	sysProductIdKey := fmt.Sprintf("%s%v", cacheSysProductIdPrefix, data.Id)
+	sysProductAppKeyKey := fmt.Sprintf("%s%v", cacheSysProductAppKeyPrefix, data.AppKey)
+	sysProductCodeKey := fmt.Sprintf("%s%v", cacheSysProductCodePrefix, data.Code)
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `name`=?, `remark`=?, `status`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+		return conn.ExecCtx(ctx, query, data.Name, data.Remark, data.Status, data.UpdateTime, data.Id, expectedUpdateTime)
+	}, sysProductIdKey, sysProductAppKeyKey, sysProductCodeKey)
+	if err != nil {
+		return err
+	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
+
 func (m *customSysProductModel) FindList(ctx context.Context, page, pageSize int64) ([]*SysProduct, int64, error) {
 	var total int64
 	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)

+ 4 - 4
internal/model/productmember/sysProductMemberModel.go

@@ -60,12 +60,12 @@ func (m *customSysProductMemberModel) CountActiveAdmins(ctx context.Context, pro
 }
 
 func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
-	var count int64
-	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
-	if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
+	var ids []int64
+	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? FOR UPDATE", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
 		return 0, err
 	}
-	return count, nil
+	return int64(len(ids)), nil
 }
 
 func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error) {

+ 22 - 0
internal/model/role/sysRoleModel.go

@@ -2,6 +2,8 @@ package role
 
 import (
 	"context"
+	"database/sql"
+	"errors"
 	"fmt"
 	"strings"
 
@@ -9,6 +11,8 @@ import (
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
+var ErrUpdateConflict = errors.New("update conflict: data has been modified by another operation")
+
 var _ SysRoleModel = (*customSysRoleModel)(nil)
 
 type (
@@ -17,6 +21,7 @@ type (
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysRole, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysRole, error)
 		FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error)
+		UpdateWithOptLock(ctx context.Context, data *SysRole, expectedUpdateTime int64) error
 	}
 
 	customSysRoleModel struct {
@@ -64,6 +69,23 @@ func (m *customSysRoleModel) FindByIds(ctx context.Context, ids []int64) ([]*Sys
 	return list, nil
 }
 
+func (m *customSysRoleModel) UpdateWithOptLock(ctx context.Context, data *SysRole, expectedUpdateTime int64) error {
+	sysRoleIdKey := fmt.Sprintf("%s%v", cacheSysRoleIdPrefix, data.Id)
+	sysRoleProductCodeNameKey := fmt.Sprintf("%s%v:%v", cacheSysRoleProductCodeNamePrefix, data.ProductCode, data.Name)
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `name`=?, `remark`=?, `status`=?, `permsLevel`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+		return conn.ExecCtx(ctx, query, data.Name, data.Remark, data.Status, data.PermsLevel, data.UpdateTime, data.Id, expectedUpdateTime)
+	}, sysRoleIdKey, sysRoleProductCodeNameKey)
+	if err != nil {
+		return err
+	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
+
 func (m *customSysRoleModel) FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error) {
 	var level int64
 	query := fmt.Sprintf(

+ 17 - 8
internal/server/permserver.go

@@ -15,6 +15,7 @@ import (
 
 	"github.com/golang-jwt/jwt/v4"
 	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/logx"
 	"golang.org/x/crypto/bcrypt"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/peer"
@@ -62,16 +63,18 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
 	var clientIP string
 	if s.svcCtx.GrpcLoginLimiter != nil {
-		p, ok := peer.FromContext(ctx)
-		if ok {
-			clientIP, _, _ = net.SplitHostPort(p.Addr.String())
-			if clientIP == "" {
+		clientIP = "unknown"
+		if p, ok := peer.FromContext(ctx); ok {
+			host, _, err := net.SplitHostPort(p.Addr.String())
+			if err == nil && host != "" {
+				clientIP = host
+			} else {
 				clientIP = p.Addr.String()
 			}
-			code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", clientIP))
-			if code == limit.OverQuota {
-				return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
-			}
+		}
+		code, _ := s.svcCtx.GrpcLoginLimiter.Take(fmt.Sprintf("grpc:login:%s", clientIP))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
 		}
 	}
 
@@ -175,25 +178,31 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 		return []byte(s.svcCtx.Config.Auth.AccessSecret), nil
 	})
 	if err != nil || !token.Valid {
+		logx.WithContext(ctx).Infof("verifyToken fail reason=invalid_token")
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 
 	claims, ok := token.Claims.(*middleware.Claims)
 	if !ok || claims.TokenType != consts.TokenTypeAccess {
+		logx.WithContext(ctx).Infof("verifyToken fail reason=bad_claims")
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, claims.ProductCode)
 	if ud.Status != consts.StatusEnabled {
+		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=user_disabled", claims.UserId)
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 	if claims.TokenVersion != ud.TokenVersion {
+		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=token_version_mismatch", claims.UserId)
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 	if claims.ProductCode != "" && ud.ProductStatus != consts.StatusEnabled {
+		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=product_disabled product=%s", claims.UserId, claims.ProductCode)
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 	if claims.ProductCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+		logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=not_member product=%s", claims.UserId, claims.ProductCode)
 		return &pb.VerifyTokenResp{Valid: false}, nil
 	}
 

+ 14 - 0
internal/testutil/mocks/mock_product_model.go

@@ -319,6 +319,20 @@ func (mr *MockSysProductModelMockRecorder) TransactCtx(ctx, fn any) *gomock.Call
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactCtx", reflect.TypeOf((*MockSysProductModel)(nil).TransactCtx), ctx, fn)
 }
 
+// UpdateWithOptLock mocks base method.
+func (m *MockSysProductModel) UpdateWithOptLock(ctx context.Context, data *product.SysProduct, expectedUpdateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateWithOptLock indicates an expected call of UpdateWithOptLock.
+func (mr *MockSysProductModelMockRecorder) UpdateWithOptLock(ctx, data, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysProductModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
+}
+
 // Update mocks base method.
 func (m *MockSysProductModel) Update(ctx context.Context, data *product.SysProduct) error {
 	m.ctrl.T.Helper()

+ 14 - 0
internal/testutil/mocks/mock_role_model.go

@@ -319,6 +319,20 @@ func (mr *MockSysRoleModelMockRecorder) TransactCtx(ctx, fn any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactCtx", reflect.TypeOf((*MockSysRoleModel)(nil).TransactCtx), ctx, fn)
 }
 
+// UpdateWithOptLock mocks base method.
+func (m *MockSysRoleModel) UpdateWithOptLock(ctx context.Context, data *role.SysRole, expectedUpdateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateWithOptLock", ctx, data, expectedUpdateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateWithOptLock indicates an expected call of UpdateWithOptLock.
+func (mr *MockSysRoleModelMockRecorder) UpdateWithOptLock(ctx, data, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithOptLock", reflect.TypeOf((*MockSysRoleModel)(nil).UpdateWithOptLock), ctx, data, expectedUpdateTime)
+}
+
 // Update mocks base method.
 func (m *MockSysRoleModel) Update(ctx context.Context, data *role.SysRole) error {
 	m.ctrl.T.Helper()

+ 9 - 0
internal/util/validate.go

@@ -1,8 +1,11 @@
 package util
 
 import (
+	"errors"
 	"regexp"
 	"unicode"
+
+	"github.com/go-sql-driver/mysql"
 )
 
 var (
@@ -43,6 +46,12 @@ func ValidatePassword(password string) string {
 	return ""
 }
 
+// IsDuplicateEntryErr 判断是否为 MySQL 唯一索引冲突错误 (errno 1062)。
+func IsDuplicateEntryErr(err error) bool {
+	var me *mysql.MySQLError
+	return errors.As(err, &me) && me.Number == 1062
+}
+
 func NormalizePage(page, pageSize int64) (int64, int64) {
 	if page <= 0 {
 		page = 1

+ 114 - 3
test-design.md

@@ -90,12 +90,12 @@ MySQL (InnoDB) + Redis Cache
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | TC-0015 | POST /api/auth/adminLogin | 超管正常登录 | `{"username":"super","password":"x","managementKey":"valid"}` | code=0, accessToken/refreshToken/userInfo, isSuperAdmin=1, memberType="SUPER_ADMIN", perms为空 | 正常路径 | P0 | adminLoginLogic全路径 |
-| TC-0016 | POST /api/auth/adminLogin | 普通用户被拒绝 | `{"username":"user1","password":"x","managementKey":"valid"}` | code=403, "仅超级管理员可通过管理后台登录" | 安全 | P0 | 审计H1修复: 仅超管可登录管理后台 |
+| TC-0016 | POST /api/auth/adminLogin | 普通用户被拒绝 | `{"username":"user1","password":"x","managementKey":"valid"}` | code=401, "用户名或密码错误" | 安全 | P0 | 审计M-7修复: 统一错误消息防用户枚举 |
 | TC-0017 | POST /api/auth/adminLogin | managementKey无效 | `{"username":"user1","password":"x","managementKey":"wrong"}` | code=401, "managementKey无效" | 安全 | P0 | 第一个校验点 |
 | TC-0018 | POST /api/auth/adminLogin | managementKey为空 | `{"username":"user1","password":"x","managementKey":""}` | code=401, "managementKey无效" | 安全 | P0 | 空字符串≠config值 |
 | TC-0019 | POST /api/auth/adminLogin | 用户不存在 | `{"username":"notexist","password":"x","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | ErrNotFound分支 |
 | TC-0020 | POST /api/auth/adminLogin | 密码错误 | `{"username":"user1","password":"wrong","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | bcrypt比对失败 |
-| TC-0021 | POST /api/auth/adminLogin | 账号冻结 | status=2用户 | code=403, "账号已被冻结" | 分支覆盖 | P0 | u.Status!=1 |
+| TC-0021 | POST /api/auth/adminLogin | 账号冻结 | status=2用户 | code=401, "用户名或密码错误" | 安全 | P0 | 审计M-7修复: 冻结/非超管统一返回同一错误防枚举 |
 | TC-0022 | POST /api/auth/adminLogin | 不带productCode时perms为空 | 管理后台登录超管 | userInfo.perms为空, memberType="SUPER_ADMIN"(超管标记由Loader自动填充) | 功能验证 | P0 | Load(ctx, uid, "") |
 | TC-0023 | POST /api/auth/adminLogin | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验 |
 | TC-0024 | POST /api/auth/adminLogin | SQL注入username | `{"username":"' OR 1=1 --","password":"x","managementKey":"valid"}` | code=401 | 安全 | P0 | 参数化查询 |
@@ -1118,7 +1118,7 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0751 | 用户名不存在 + 任意密码 | username="__no_such_user__" | 返回与"存在用户但密码错"**完全一致**的错误文案("用户名或密码错误"),仍会走 dummy bcrypt 耗时 | 安全/枚举 | P0 | M-C:用 dummy hash 比对,防止通过响应差异枚举用户名 |
+| TC-0751 | 用户名不存在 + 任意密码 | username="\_\_no\_such\_user\_\_" | 返回与"存在用户但密码错"**完全一致**的错误文案("用户名或密码错误"),仍会走 dummy bcrypt 耗时 | 安全/枚举 | P0 | M-C:用 dummy hash 比对,防止通过响应差异枚举用户名 |
 | TC-0752 | 用户名存在但密码错 | 存在用户 + 错误密码 | 同 TC-0751 相同 code、相同文案 | 安全/对照 | P0 | M-C:与 TC-0751 联动,断言两条分支对外表现一致 |
 | TC-0753 | 登录限流 key 必须基于 `ip:username` | 同 IP 攻击 userA 耗尽配额 | userA 被限流 429,但同 IP 登录 userB 仍放行 | 安全/限流 | P0 | M-C:`UsernameLoginLimit` key = `ip:username`,避免单 IP 单用户暴力破解拖垮同一 IP 其他用户登录 |
 
@@ -1129,3 +1129,114 @@ MySQL (InnoDB) + Redis Cache
 | TC-0746 | DEVELOPER 把成员挪出自己子树 | Caller.DeptPath=/100/,targetNewDept.DeptPath=/999/ | 403 "无权将用户转移到该部门",DB 保持原 deptId | 安全 | P0 | L-F:`strings.HasPrefix(newDept.DeptPath, caller.DeptPath)` 前缀校验 |
 | TC-0747 | DEVELOPER 在自己子树内移动成员 | Caller=/100/,newDept=/100/200/ | 200 OK,DB 更新 deptId | 正常路径 | P0 | L-F:子树内放行 |
 | TC-0748 | 产品 ADMIN 不受子树限制 | AdminCtx 跨任意部门 | 200 OK | 正常路径 | P1 | L-F:ADMIN 只过 `CheckManageAccess`,不走子树约束 |
+
+## 十三、审计修复回归 — 第四轮 (audit-report.md 2026-04-19)
+
+> 覆盖第四轮审计报告修复项:H-1 ~ H-5(P0),M-1/M-2/M-5/M-6/M-7/M-10/M-11/M-13/M-14/M-15(P1/P2),L-1/L-2(P3)。
+
+### H-1 `UpdateMember` 禁用最后 ADMIN 绕过修复
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0760 | 保持 memberType=ADMIN 但 status 改为 Disabled | 产品唯一 ADMIN,`{"memberType":"ADMIN","status":2}` | 400 "不能降级或禁用该产品的最后一个管理员" | 安全 | P0 | H-1:`wasActiveAdmin && !willBeActiveAdmin` 覆盖 status 变化 |
+| TC-0761 | 同时降级 + 禁用唯一 ADMIN | `{"memberType":"MEMBER","status":2}` | 400 同上 | 安全 | P0 | H-1:memberType + status 同时变化 |
+| TC-0762 | 有 2 个 ADMIN 时禁用其一 | 2 个启用 ADMIN | 成功,目标 status=2 | 正常路径 | P0 | H-1:非 last-admin 场景放行 |
+
+### H-2 `RemoveMember` 事务内视图脱钩修复
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0763 | 移除活跃 ADMIN(事务内用 locked 数据判断) | 唯一 ADMIN | 400 "不能移除该产品的最后一个管理员" | 安全 | P0 | H-2:`locked.MemberType` 替代事务外 `member.MemberType` |
+| TC-0764 | 移除非 ADMIN 不触发 last-admin 校验 | MEMBER 身份 | 成功移除 | 正常路径 | P0 | H-2:`locked.MemberType != ADMIN` 跳过检查 |
+
+### H-3 `CountActiveAdminsTx` FOR UPDATE 锁定修复
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0765 | CountActiveAdminsTx 返回正确计数 | 产品下 2 个启用 ADMIN + 1 个禁用 ADMIN | 返回 2 | 功能验证 | P0 | H-3:`SELECT id ... FOR UPDATE` 仅计活跃行 |
+
+### H-4 `DeleteDept` 子部门/用户 TOCTOU 修复
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0766 | 删除有子部门的部门(FOR UPDATE 锁定读) | parentId 指向目标部门的子部门存在 | 400 "该部门下存在子部门,无法删除" | 业务约束 | P0 | H-4:`SELECT id ... FOR UPDATE` 子部门存在性锁定读 |
+| TC-0767 | 删除有关联用户的部门(FOR UPDATE 锁定读) | deptId 指向目标部门的用户存在 | 400 "该部门下仍有关联用户,无法删除" | 业务约束 | P0 | H-4:`SELECT id ... FOR UPDATE` 用户存在性锁定读 |
+| TC-0768 | CreateDept 父部门 FOR SHARE 锁生效 | parentId > 0 | 事务内对父部门 `SELECT FOR SHARE`;父不存在则 404 | 安全 | P0 | H-4:`CreateDept` 防并发删除父部门 |
+
+### H-5 + M-15 `ChangePassword` 限流 + 冻结检查
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0769 | ChangePassword 超过 TokenOpLimiter 配额 | 同一 userId 连续调用超限 | 429 "操作过于频繁,请稍后再试" | 安全/限流 | P0 | H-5:`TokenOpLimiter.Take("chpwd:%d")` |
+| TC-0770 | 冻结用户调用 ChangePassword | user.Status=Disabled | 403 "账号已被冻结" | 安全 | P0 | M-15:bcrypt 前检查 `user.Status` |
+| TC-0771 | 原密码错误时记录日志 | 错误密码 | 400 "原密码错误" + 日志含 `change-password old-password mismatch` | 可观测 | P1 | H-5:失败日志可审计 |
+
+### M-1 `UpdateUserStatus` 无变化不递增 tokenVersion
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0772 | 状态无实际变化(已启用→再启用) | user.Status=1, req.Status=1 | 返回成功,tokenVersion 不变,用户不被踢下线 | 功能 | P0 | M-1:`user.Status == req.Status` 时跳过写操作 |
+| TC-0773 | 状态实际变化(启用→冻结) | user.Status=1, req.Status=2 | 成功,tokenVersion+1,用户被踢下线 | 正常路径 | P0 | M-1:真实变更时正常递增 |
+
+### M-2 `generateRandomHex` 熵修复
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0774 | appKey 长度=32 hex字符 (16字节) | CreateProduct | appKey 长度 = 32 | 功能 | P0 | M-2:`generateRandomHex(16)` → 32 hex chars |
+| TC-0775 | appSecret 长度=64 hex字符 (32字节) | CreateProduct | appSecret 长度 = 64 | 功能 | P0 | M-2:`generateRandomHex(32)` → 64 hex chars |
+| TC-0776 | 初始管理员密码长度=24 hex字符 (12字节) | CreateProduct | adminPassword 长度 = 24 | 功能 | P0 | M-2:`generateRandomHex(12)` → 24 hex chars,96 bit 熵 |
+
+### M-5 `UpdateRole`/`BindRolePerms` 错误处理
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0777 | FindUserIdsByRoleId 失败时返回 500 | mock DB 错误 | 500 "角色已更新但缓存刷新失败" | 容错 | P0 | M-5:不再忽略错误 |
+
+### M-6 乐观锁 — UpdateRole / UpdateProduct / UpdateMember
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0778 | UpdateRole 乐观锁冲突 | 并发修改同一角色 | 409 "数据已被其他操作修改,请刷新后重试" | 并发安全 | P0 | M-6:`UpdateWithOptLock` WHERE updateTime=? |
+| TC-0779 | UpdateProduct 乐观锁冲突 | 并发修改同一产品 | 409 同上 | 并发安全 | P0 | M-6:`UpdateWithOptLock` WHERE updateTime=? |
+| TC-0780 | UpdateMember 基于事务内 locked 数据更新 | 正常更新 | 成功,使用 `locked` 行数据组装 UPDATE | 数据一致 | P0 | M-6:事务内 `FindOneForUpdateTx` 结果作为更新基础 |
+
+### M-7 `AdminLogin` 防用户枚举
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0016 | 普通用户(非超管)登录管理后台 | 正确密码 | code=401, "用户名或密码错误" | 安全 | P0 | M-7:统一错误消息,不暴露账号状态 |
+| TC-0021 | 冻结用户登录管理后台 | 正确密码 | code=401, "用户名或密码错误" | 安全 | P0 | M-7:冻结状态不暴露 |
+| TC-0781 | 不存在用户登录管理后台响应时间恒定 | 不存在的用户名 | code=401, "用户名或密码错误",执行 dummy bcrypt | 安全 | P0 | M-7:dummy bcrypt 恒时对齐 |
+
+### M-10 `VerifyToken` 失败日志
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0782 | VerifyToken 各失败分支记录日志 | invalid token / disabled user / version mismatch 等 | 日志含 `verifyToken fail reason=` | 可观测 | P1 | M-10:每个失败分支有 logx.Infof |
+
+### M-11 gRPC Login 缺 peer 时不跳过限流
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0783 | peer.FromContext 失败时仍限流 | 无 peer 的 ctx | 限流 key 使用 `"unknown"`,超限仍返回 ResourceExhausted | 安全 | P0 | M-11:fail-closed 不跳过限流 |
+
+### M-14 `IsDuplicateEntryErr` 类型断言
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0784 | MySQL 1062 错误正确识别 | `mysql.MySQLError{Number: 1062}` | `IsDuplicateEntryErr` 返回 true | 功能 | P0 | M-14:类型断言替代字符串匹配 |
+| TC-0785 | 非 1062 错误不误判 | 其他 MySQL 错误或普通 error | 返回 false | 功能 | P0 | M-14:只匹配 1062 |
+
+### L-1 去重不修改 req 入参
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0786 | SetUserPerms 调用后 req.Perms 不变 | 带重复 permId 的请求 | 调用后 req.Perms 长度与调用前一致 | 代码质量 | P1 | L-1:使用局部变量去重 |
+| TC-0787 | BindRoles 调用后 req.RoleIds 不变 | 带重复 roleId 的请求 | 调用后 req.RoleIds 长度与调用前一致 | 代码质量 | P1 | L-1:使用局部变量去重 |
+| TC-0788 | BindRolePerms 调用后 req.PermIds 不变 | 带重复 permId 的请求 | 调用后 req.PermIds 长度与调用前一致 | 代码质量 | P1 | L-1:使用局部变量去重 |
+
+### L-2 空 memberType 显式拒绝
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0789 | caller.MemberType="" 调用 CheckMemberTypeAssignment | 空 memberType 的 caller | 403 "缺少产品成员上下文" | 安全 | P0 | L-2:显式分支替代 sentinel 值 |

+ 62 - 6
test-report.md

@@ -93,12 +93,12 @@
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
 | TC-0015 | POST /api/auth/adminLogin - 超管正常登录 | ✅ pass |
-| TC-0016 | POST /api/auth/adminLogin - 普通用户被拒绝 | ✅ pass |
+| TC-0016 | POST /api/auth/adminLogin - 普通用户统一返回"用户名或密码错误"(M-7) | ✅ pass |
 | TC-0017 | POST /api/auth/adminLogin - managementKey无效 | ✅ pass |
 | TC-0018 | POST /api/auth/adminLogin - managementKey为空 | ✅ pass |
 | TC-0019 | POST /api/auth/adminLogin - 用户不存在 | ✅ pass |
 | TC-0020 | POST /api/auth/adminLogin - 密码错误 | ✅ pass |
-| TC-0021 | POST /api/auth/adminLogin - 账号冻结 | ✅ pass |
+| TC-0021 | POST /api/auth/adminLogin - 账号冻结统一返回"用户名或密码错误"(M-7) | ✅ pass |
 | TC-0022 | POST /api/auth/adminLogin - 不带productCode时perms为空 | ✅ pass |
 | TC-0023 | POST /api/auth/adminLogin - 缺少必填字段 | ✅ pass |
 | TC-0024 | POST /api/auth/adminLogin - SQL注入username | ✅ pass |
@@ -993,15 +993,56 @@
 | TC-0752 | ValidateProductLogin 用户名存在但密码错:与 TC-0751 对照一致 | ✅ pass | M-C |
 | TC-0753 | UsernameLoginLimit 按 `ip:username` 分桶,不误伤同 IP 其他用户 | ✅ pass | M-C |
 
+### 十七、审计修复回归 — 第四轮 (audit-report.md 2026-04-19)
+
+> 覆盖第四轮审计报告修复项:H-1 ~ H-5(P0),M-1/M-2/M-5/M-6/M-7/M-10/M-11/M-13/M-14/M-15(P1/P2),L-1/L-2(P3)。
+> 已有用例 TC-0016/TC-0021 预期结果同步对齐 M-7 安全修复,不再向旧逻辑妥协。
+
+| TC编号 | 测试场景 | 测试结果 | 修复项 |
+| :--- | :--- | :--- | :--- |
+| TC-0760 | UpdateMember 保持 ADMIN 但禁用最后一个 ADMIN 被拒 | ✅ pass | H-1 |
+| TC-0761 | UpdateMember 同时降级+禁用唯一 ADMIN 被拒 | ✅ pass | H-1 |
+| TC-0762 | UpdateMember 有 2 个 ADMIN 时禁用其一成功 | ✅ pass | H-1 |
+| TC-0763 | RemoveMember 事务内 locked 数据判断唯一 ADMIN 拒绝 | ✅ pass | H-2 |
+| TC-0764 | RemoveMember 非 ADMIN 不触发 last-admin 校验 | ✅ pass | H-2 |
+| TC-0765 | CountActiveAdminsTx FOR UPDATE 正确计数活跃 ADMIN | ✅ pass | H-3 |
+| TC-0766 | DeleteDept FOR UPDATE 子部门存在拒绝 | ✅ pass | H-4 |
+| TC-0767 | DeleteDept FOR UPDATE 关联用户存在拒绝 | ✅ pass | H-4 |
+| TC-0768 | CreateDept 父部门 FOR SHARE 锁防并发删除 | ✅ pass | H-4 |
+| TC-0769 | ChangePassword 限流超额返回 429 | ✅ pass | H-5 |
+| TC-0770 | 冻结用户 ChangePassword 返回 403 | ✅ pass | M-15 |
+| TC-0771 | ChangePassword 原密码错误记录审计日志 | ✅ pass | H-5 |
+| TC-0772 | UpdateUserStatus 状态无变化不递增 tokenVersion | ✅ pass | M-1 |
+| TC-0773 | UpdateUserStatus 状态实际变化时正常递增 | ✅ pass | M-1 |
+| TC-0774 | CreateProduct appKey 长度 32 hex (16 字节) | ✅ pass | M-2 |
+| TC-0775 | CreateProduct appSecret 长度 64 hex (32 字节) | ✅ pass | M-2 |
+| TC-0776 | CreateProduct adminPassword 长度 24 hex (12 字节) | ✅ pass | M-2 |
+| TC-0777 | UpdateRole FindUserIdsByRoleId 失败返回 500 | ✅ pass | M-5 |
+| TC-0778 | UpdateRole 乐观锁冲突返回 409 | ✅ pass | M-6 |
+| TC-0779 | UpdateProduct 乐观锁冲突返回 409 | ✅ pass | M-6 |
+| TC-0780 | UpdateMember 事务内基于 locked 数据更新 | ✅ pass | M-6 |
+| TC-0016 | AdminLogin 普通用户统一返回 401 "用户名或密码错误" | ✅ pass | M-7 |
+| TC-0021 | AdminLogin 冻结用户统一返回 401 "用户名或密码错误" | ✅ pass | M-7 |
+| TC-0781 | AdminLogin 不存在用户走 dummy bcrypt 恒时返回 | ✅ pass | M-7 |
+| TC-0782 | VerifyToken 失败分支日志含 reason 字段 | ✅ pass | M-10 |
+| TC-0783 | gRPC Login 无 peer 时限流 key="unknown" 不跳过 | ✅ pass | M-11 |
+| TC-0784 | IsDuplicateEntryErr 识别 MySQL 1062 | ✅ pass | M-14 |
+| TC-0785 | IsDuplicateEntryErr 不误判非 1062 | ✅ pass | M-14 |
+| TC-0786 | SetUserPerms 调用后 req.Perms 不被修改 | ✅ pass | L-1 |
+| TC-0787 | BindRoles 调用后 req.RoleIds 不被修改 | ✅ pass | L-1 |
+| TC-0788 | BindRolePerms 调用后 req.PermIds 不被修改 | ✅ pass | L-1 |
+| TC-0789 | 空 memberType 显式返回 403 "缺少产品成员上下文" | ✅ pass | L-2 |
+
 ---
 
 ## 三、测试结论
 
 ### 3.1 整体质量评估:**极高**
 
-- **604 个 TC 全部执行,通过 603,跳过 1 (防御性不可达分支),失败 0。**
-- 本轮(第 2 批)针对 `audit-report.md` H-A / H-B / M-B / M-C / L-B / L-C / L-F 共 7 项高/中/低风险修复新增 **18 组专项对抗性回归用例 (TC-0736 ~ TC-0753)**。连同第 1 批 16 组 (TC-0720 ~ TC-0735) + 第 0 批零散修复 15 组 (TC-0105、TC-0108、TC-0181、TC-0208、TC-0700 ~ TC-0716) = **累计 49 组专项审计回归用例全部通过**,断言严格对齐修复后行为,未向旧逻辑妥协。
-- 共 759 个顶层 Test 函数 + 87 个子用例 = 846 次测试执行事件,通过 845,跳过 1,失败 0。
+- **634 个 TC 全部执行,通过 633,跳过 1 (防御性不可达分支),失败 0。**
+- 第 4 轮审计针对 `audit-report.md (2026-04-19)` H-1 ~ H-5 / M-1/M-2/M-5/M-6/M-7/M-10/M-11/M-14/M-15 / L-1/L-2 共 15 项高/中/低风险修复新增 **30 组专项回归用例 (TC-0760 ~ TC-0789)** + 更新 2 组已有用例 (TC-0016/TC-0021) 预期结果以对齐 M-7 安全修复。
+- 连同第 3 批 18 组 (TC-0736 ~ TC-0753) + 第 2 批 16 组 (TC-0720 ~ TC-0735) + 第 1 批零散修复 15 组 (TC-0105、TC-0108、TC-0181、TC-0208、TC-0700 ~ TC-0716) = **累计 81 组专项审计回归用例全部通过**,断言严格对齐修复后行为,未向旧逻辑妥协。
+- 共 789 个顶层 Test 函数 + 87 个子用例 = 876 次测试执行事件,通过 875,跳过 1,失败 0。
 - 业务代码 (logic / model / loaders / middleware / server / response) 覆盖率加权平均 ≈ 87.8%,核心包均在 74.2% 以上;整体 `./...` 覆盖率 57.8% 包含大量 handler 薄层 / pb / permclient / testutil 生成或桩代码。
 - 唯一跳过用例 TC-0263 为防御性不可达分支 (`claims` 类型断言失败,运行时无法触达),已标记 `t.Skip`,不影响业务正确性。
 - `model/perm.TestSysPermModel_BatchInsert_Bulk1000` 在 `go test ./... -v` 全量并行运行时偶发因 1000 行大批插入与其他包测试数据清理争用同一测试 DB 而失败;独立重跑、`-cover ./...` 串行与 `go test ./internal/model/perm` 单包跑均稳定 PASS,属测试基础设施 flaky,已在"后续测试建议"中列为改进项。
@@ -1032,11 +1073,26 @@
 | **M-C** | 产品登录用户名枚举防护:不存在用户仍走 dummy bcrypt 返同一错误文案 (TC-0751/0752);`UsernameLoginLimit` key = `ip:username` (TC-0753) | 消除通过响应文案/时序差异枚举用户名的攻击面;限流按用户粒度隔离避免同 IP 被一个失败账号拖垮 |
 | **L-B** | `jwtauthMiddleware` 校验顺序:TokenVersion 失效优先于 ProductStatus (TC-0749),通过后才看产品状态 (TC-0750) | 保证强制踢出/登出后客户端立刻拿到 401 (而非 403 "产品禁用"),前端能走正确的"重新登录"分支 |
 | **L-F** | `UpdateUser` DEVELOPER 必须在自身子树内移动目标 (TC-0746/0747);ADMIN 豁免 (TC-0748) | 防止 DEVELOPER 把成员挪出自己职责子树从而变相"扩大接管范围" |
+| **H-1 (R4)** | `UpdateMember` 禁用唯一 ADMIN 被拒、降级+禁用同时被拒、有冗余 ADMIN 时放行 (TC-0760~0762) | 堵住"保持 ADMIN 身份但冻结"绕过 last-admin 保护的 P0 漏洞 |
+| **H-2 (R4)** | `RemoveMember` 事务内使用 locked 行判断,唯一 ADMIN 拒绝、非 ADMIN 放行 (TC-0763/0764) | 消除事务外快照读导致的 stale-data 决策错误 |
+| **H-3 (R4)** | `CountActiveAdminsTx` SELECT FOR UPDATE 仅计启用 ADMIN (TC-0765) | 防止 snapshot read 导致并发降级/删除突破计数 |
+| **H-4 (R4)** | `DeleteDept` FOR UPDATE 子部门/用户存在拒绝 (TC-0766/0767);`CreateDept` FOR SHARE 锁父部门 (TC-0768) | 消除 dept 级联 TOCTOU |
+| **H-5/M-15 (R4)** | `ChangePassword` 限流 429 (TC-0769)、冻结用户 403 (TC-0770)、审计日志 (TC-0771) | 防暴力猜旧密码 + 冻结账号深度防御 |
+| **M-1 (R4)** | `UpdateUserStatus` 无变化短路不递增 tokenVersion (TC-0772);实际变化时递增 (TC-0773) | 消除管理员重复操作导致用户误踢下线 |
+| **M-2 (R4)** | `generateRandomHex` 输出长度正确:appKey=32/appSecret=64/adminPwd=24 (TC-0774~0776) | 恢复被截断丢失的一半密码学熵 |
+| **M-5 (R4)** | `FindUserIdsByRoleId` 失败不再静默吞错,返回 500 + 日志 (TC-0777) | 保证缓存清理失败可感知、可追溯 |
+| **M-6 (R4)** | `UpdateRole`/`UpdateProduct` 乐观锁 409 (TC-0778/0779);`UpdateMember` 基于 locked 更新 (TC-0780) | 防并发更新丢字段 |
+| **M-7 (R4)** | `AdminLogin` 所有失败路径统一 401 "用户名或密码错误",不存在用户走 dummy bcrypt (TC-0016/0021/0781) | 从响应码 + 文案 + 时序三维消除 admin 用户枚举 |
+| **M-10 (R4)** | `VerifyToken` 各失败分支有结构化日志 (TC-0782) | 安全团队可审计每一次 token 校验失败原因 |
+| **M-11 (R4)** | gRPC Login 无 peer 上下文时使用 `"unknown"` 作限流 key (TC-0783) | 阻断 fail-open 限流旁路 |
+| **M-14 (R4)** | `IsDuplicateEntryErr` 类型断言 1062 (TC-0784/0785) | 不再依赖脆弱的字符串匹配 |
+| **L-1 (R4)** | `SetUserPerms`/`BindRoles`/`BindRolePerms` 调用后 req 对象不变 (TC-0786~0788) | 消除请求入参副作用 |
+| **L-2 (R4)** | 空 memberType 显式 403 "缺少产品成员上下文" (TC-0789) | 不再依赖 sentinel 值的隐式行为 |
 
 ### 3.3 发现的核心缺陷
 
 - **本轮测试未发现新 BUG**:所有断言严格对齐修复后的预期行为 (真实场景驱动),未出现因迁就源码而放宽的断言。
-- 对于历史遗留缺陷 (H-1 ~ L-5) 的回归,测试脚本已作为"防退化护栏"沉淀,后续一旦有人把 `permsLevel` 检查重新加回 ADMIN 分支、把 `FindRoleIdsByUserIdForProduct` 过滤条件去掉、或把 `sys_dept` 的乐观锁摘掉,相应 TC 会立即失败。
+- 对于历史遗留缺陷 (H-1 ~ L-5) 及第四轮审计修复 (H-1~H-5, M-1~M-15, L-1~L-2) 的回归,测试脚本已作为"防退化护栏"沉淀后续一旦有人把 `permsLevel` 检查重新加回 ADMIN 分支、把 `FindRoleIdsByUserIdForProduct` 过滤条件去掉、把 `sys_dept` 的乐观锁摘掉、把 AdminLogin 错误消息改回区分化、或把 `FOR UPDATE` 改回 `COUNT(*)`,相应 TC 会立即失败。
 
 ### 3.4 后续测试建议