Quellcode durchsuchen

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

BaiLuoYan vor 1 Monat
Ursprung
Commit
64f332a5fe
47 geänderte Dateien mit 1848 neuen und 686 gelöschten Zeilen
  1. 496 0
      audit-report.md
  2. 1 0
      go.mod
  3. 3 0
      go.sum
  4. 20 18
      internal/loaders/userDetailsLoader.go
  5. 4 0
      internal/logic/auth/access.go
  6. 24 0
      internal/logic/auth/access_test.go
  7. 1 6
      internal/logic/auth/changePasswordLogic.go
  8. 2 105
      internal/logic/auth/perms.go
  9. 30 370
      internal/logic/auth/perms_mock_test.go
  10. 91 32
      internal/logic/auth/perms_test.go
  11. 5 0
      internal/logic/dept/deleteDeptLogic.go
  12. 43 0
      internal/logic/dept/deleteDeptLogic_test.go
  13. 13 0
      internal/logic/dept/updateDeptLogic.go
  14. 63 0
      internal/logic/dept/updateDeptLogic_mock_test.go
  15. 6 0
      internal/logic/member/addMemberLogic.go
  16. 41 0
      internal/logic/member/addMemberLogic_test.go
  17. 6 0
      internal/logic/member/updateMemberLogic.go
  18. 48 0
      internal/logic/member/updateMemberLogic_test.go
  19. 17 6
      internal/logic/product/createProductLogic.go
  20. 8 3
      internal/logic/product/productDetailLogic.go
  21. 46 1
      internal/logic/product/productDetailLogic_test.go
  22. 8 3
      internal/logic/product/productListLogic.go
  23. 63 0
      internal/logic/product/productListLogic_test.go
  24. 2 1
      internal/logic/pub/adminLoginLogic.go
  25. 3 3
      internal/logic/pub/refreshTokenLogic.go
  26. 32 1
      internal/logic/pub/refreshTokenLogic_test.go
  27. 22 12
      internal/logic/pub/syncPermsLogic.go
  28. 73 0
      internal/logic/pub/syncPermsLogic_mock_test.go
  29. 28 0
      internal/logic/pub/syncPermsLogic_test.go
  30. 20 1
      internal/logic/user/bindRolesLogic.go
  31. 9 0
      internal/logic/user/bindRolesLogic_mock_test.go
  32. 121 7
      internal/logic/user/bindRolesLogic_test.go
  33. 7 0
      internal/logic/user/createUserLogic.go
  34. 38 0
      internal/logic/user/createUserLogic_test.go
  35. 26 0
      internal/logic/user/setUserPermsLogic.go
  36. 117 5
      internal/logic/user/setUserPermsLogic_test.go
  37. 13 3
      internal/logic/user/updateUserLogic.go
  38. 51 1
      internal/logic/user/updateUserLogic_test.go
  39. 1 5
      internal/logic/user/updateUserStatusLogic.go
  40. 25 0
      internal/model/perm/sysPermModel.go
  41. 37 1
      internal/model/user/sysUserModel.go
  42. 26 16
      internal/server/permserver.go
  43. 15 0
      internal/testutil/mocks/mock_perm_model.go
  44. 28 0
      internal/testutil/mocks/mock_user_model.go
  45. 10 10
      perm.go
  46. 31 33
      test-design.md
  47. 74 43
      test-report.md

+ 496 - 0
audit-report.md

@@ -0,0 +1,496 @@
+# 权限管理系统代码审计报告
+
+> **审计范围**:`perms-system-server` 项目全部生产代码(排除 `*_test.go`)  
+> **审计时间**:2026-04-16  
+> **审计维度**:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃
+
+---
+
+## 🚩 核心逻辑漏洞 (High Risk)
+
+---
+
+### H-01:UpdateUser 允许普通用户修改自身 DeptId — 权限提升漏洞
+
+- **位置**:`internal/logic/user/updateUserLogic.go:31-73`
+- **描述**:`UpdateUser` 的权限检查仅为"非超管只能改自己",但请求体中包含 `DeptId` 和 `Status` 字段。普通用户可以通过修改自己的 `DeptId` 将自己挪到一个 `DEV`(研发)类型部门下。根据 `loadPerms` 的逻辑(`userDetailsLoader.go:298`),研发部门成员自动获得当前产品的**全部权限**。
+- **影响**:任意已登录用户可将自己提升为拥有全量权限的用户,完全绕过 RBAC 权限体系。
+- **修复方案**:
+  1. 将 `DeptId` 和 `Status` 的修改权限从"自我编辑"中拆离,仅限超管或产品管理员操作;
+  2. 或者在 `UpdateUser` 中检查当 `callerId == req.Id` 时,禁止修改 `DeptId` 和 `Status` 字段:
+
+```go
+if callerId == req.Id {
+    if req.DeptId != nil || req.Status != 0 {
+        return response.ErrForbidden("不允许修改自己的部门和状态")
+    }
+} else {
+    if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
+        return err
+    }
+}
+```
+
+---
+
+### H-02:RefreshToken 允许跨产品切换 — 越权访问
+
+- **位置**:`internal/logic/pub/refreshTokenLogic.go:42-45` 及 `internal/server/permserver.go:157-159`
+- **描述**:`RefreshToken` 接口允许请求参数中传入 `ProductCode` 来覆盖 refresh token 中原有的 `ProductCode`。这意味着用户只需获得任意一个产品的 refresh token,就能生成**另一个产品**的 access token,前提是该用户在目标产品中有成员记录。
+- **影响**:跨产品越权。用户用产品 A 的 refresh token 获取产品 B 的 access token(携带产品 B 的权限),即使产品 A 已被禁用。
+- **修复方案**:禁止 refresh token 更换产品上下文,或者在切换时进行严格的产品成员资格验证:
+
+```go
+if productCode != "" && productCode != claims.ProductCode {
+    // 方案1:直接拒绝
+    return nil, response.ErrBadRequest("不允许切换产品")
+    // 方案2:验证目标产品成员资格
+    // _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, claims.UserId)
+    // if err != nil {
+    //     return nil, response.ErrForbidden("您不是该产品成员")
+    // }
+}
+```
+
+---
+
+### H-03:BindRoles 不校验角色归属 — 跨产品角色绑定
+
+- **位置**:`internal/logic/user/bindRolesLogic.go:42-59`
+- **描述**:`BindRoles` 接口直接删除用户所有角色绑定后,批量插入传入的 `RoleIds`。全过程**不校验**:
+  1. RoleId 是否存在;
+  2. RoleId 对应的角色是否属于当前操作者的产品上下文;
+  3. RoleId 对应的角色是否已启用。
+- **影响**:管理员可将其他产品的角色绑定到用户身上,可能导致权限计算逻辑混乱。外部传入非法 RoleId 不会报错,静默写入脏数据。
+- **修复方案**:
+
+```go
+if len(req.RoleIds) > 0 {
+    roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
+    if err != nil {
+        return err
+    }
+    if len(roles) != len(req.RoleIds) {
+        return response.ErrBadRequest("包含无效的角色ID")
+    }
+    for _, r := range roles {
+        if r.ProductCode != productCode {
+            return response.ErrBadRequest("不能绑定其他产品的角色")
+        }
+    }
+}
+```
+
+---
+
+### H-04:SetUserPerms 不校验权限 ID 和 Effect — 脏数据写入
+
+- **位置**:`internal/logic/user/setUserPermsLogic.go:42-61`
+- **描述**:`SetUserPerms` 接收 `PermId` 和 `Effect` 字段,但不校验:
+  1. `PermId` 是否存在或是否属于当前产品;
+  2. `Effect` 是否为合法值(`ALLOW` / `DENY`)。
+  虽然数据库 `effect` 列为 `enum('ALLOW','DENY')`,非法值会被 MySQL 拒绝并返回错误,但这依赖数据库约束而非应用层防御。
+- **影响**:写入无效 PermId 不报错;如果数据库 SQL Mode 不严格(如未启用 `STRICT_TRANS_TABLES`),非法 Effect 值可能被默认为空字符串静默写入。
+- **修复方案**:在业务层增加校验:
+
+```go
+for _, p := range req.Perms {
+    if p.Effect != consts.PermEffectAllow && p.Effect != consts.PermEffectDeny {
+        return response.ErrBadRequest("effect 值无效,仅支持 ALLOW 和 DENY")
+    }
+}
+```
+
+---
+
+### H-05:SyncPerms 三步操作无事务保护 — 数据中间态
+
+- **位置**:`internal/logic/pub/syncPermsLogic.go:80-93` 及 `internal/server/permserver.go:79-93`
+- **描述**:`SyncPerms` 依次执行 `BatchInsert` → `BatchUpdate` → `DisableNotInCodes` 三个独立的数据库操作,没有包裹在同一个事务中。如果 `BatchInsert` 成功但 `BatchUpdate` 或 `DisableNotInCodes` 失败,权限数据将处于不一致状态。
+- **影响**:
+  - 部分权限被插入但旧权限未被更新/禁用,导致权限表"半更新";
+  - 在高并发场景下,两个 SyncPerms 请求同时执行可能互相覆盖。
+- **修复方案**:将三步操作包裹在事务中:
+
+```go
+err = l.svcCtx.SysPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+    if len(toInsert) > 0 {
+        if err := l.svcCtx.SysPermModel.BatchInsertWithTx(ctx, session, toInsert); err != nil {
+            return err
+        }
+    }
+    if len(toUpdate) > 0 {
+        if err := l.svcCtx.SysPermModel.BatchUpdateWithTx(ctx, session, toUpdate); err != nil {
+            return err
+        }
+    }
+    // DisableNotInCodes 也需要提供 Tx 版本
+    return nil
+})
+```
+
+---
+
+### H-06:SyncPerms AppSecret 明文比较 — 时序攻击风险
+
+- **位置**:`internal/logic/pub/syncPermsLogic.go:35` 及 `internal/server/permserver.go:34`
+- **描述**:`product.AppSecret != req.AppSecret` 使用普通字符串比较运算符,而不是 `crypto/subtle.ConstantTimeCompare`。Go 的 `!=` 运算在发现第一个不同字节时即返回,攻击者可通过测量响应时间逐字节暴力破解 AppSecret。
+- **影响**:具备网络时序测量能力的攻击者可逐步推断出完整的 AppSecret。
+- **修复方案**:
+
+```go
+import "crypto/subtle"
+
+if subtle.ConstantTimeCompare([]byte(product.AppSecret), []byte(req.AppSecret)) != 1 {
+    return nil, response.ErrUnauthorized("appSecret验证失败")
+}
+```
+
+---
+
+### H-07:DeleteDept 不检查关联用户 — 孤儿数据
+
+- **位置**:`internal/logic/dept/deleteDeptLogic.go:33-42`
+- **描述**:`DeleteDept` 仅检查是否有子部门,但**不检查**该部门下是否有关联用户(`sys_user.deptId`)。删除部门后,这些用户的 `deptId` 指向不存在的部门记录。
+- **影响**:
+  - `UserDetailsLoader.loadDept` 会静默失败,`DeptPath` 为空字符串;
+  - `checkDeptHierarchy` 中 `strings.HasPrefix(targetDept.Path, caller.DeptPath)` 永远返回 true(因为所有字符串都以 "" 为前缀),导致部门隔离机制失效。
+- **修复方案**:
+
+```go
+userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+if len(userIds) > 0 {
+    return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
+}
+```
+
+---
+
+### H-08:DeptPath 为空时部门隔离完全失效
+
+- **位置**:`internal/logic/auth/access.go:122`
+- **描述**:`checkDeptHierarchy` 使用 `strings.HasPrefix(targetDept.Path, caller.DeptPath)` 判断部门归属。如果 `caller.DeptPath` 为空字符串(例如用户部门被删除、或 loadDept 失败),`HasPrefix` 始终返回 `true`,使得任何人都能"管理"任何部门的用户。
+- **影响**:部门隔离机制被绕过。只要操作者的 DeptPath 加载失败(部门被删、Redis 缓存错误等),就获得跨部门管理权限。
+- **修复方案**:
+
+```go
+if caller.DeptPath == "" {
+    return response.ErrForbidden("您的部门信息异常,无法执行此操作")
+}
+```
+
+---
+
+### H-09:配置文件包含明文密码和密钥
+
+- **位置**:`etc/perm-api-prod.yaml`(及其他环境配置文件)
+- **描述**:生产环境配置文件中以明文存储了 MySQL 密码、Redis 密码、JWT Secret、ManagementKey 等敏感信息,且这些文件被提交到 Git 仓库。
+- **影响**:任何可访问仓库的人(包括已离职员工、合作方、泄露的 Git 历史)都能获取全部生产凭据。
+- **修复方案**:
+  1. 将敏感信息移至环境变量或密钥管理服务(如 Vault、AWS Secrets Manager);
+  2. 将 `etc/*.yaml` 加入 `.gitignore`,从 Git 历史中清除已提交的密码;
+  3. **立即轮换**已泄露的所有密码和密钥。
+
+---
+
+### H-10:CreateUser 缺少密码强度校验
+
+- **位置**:`internal/logic/user/createUserLogic.go:34-76`
+- **描述**:`CreateUser` 不检查密码长度和复杂度,但 `ChangePassword` 有 6-72 字符的限制。创建用户时可以设置 1 个字符甚至空字符串的密码。
+- **影响**:可创建弱密码用户,容易被暴力破解。
+- **修复方案**:统一密码校验逻辑:
+
+```go
+if len(req.Password) < 6 {
+    return nil, response.ErrBadRequest("密码长度不能少于6个字符")
+}
+if len(req.Password) > 72 {
+    return nil, response.ErrBadRequest("密码长度不能超过72个字符")
+}
+```
+
+---
+
+### H-11:UserList / UserDetail / RoleList 等查询接口缺少权限过滤
+
+- **位置**:
+  - `internal/logic/user/userListLogic.go` — 无权限检查,查询全表
+  - `internal/logic/user/userDetailLogic.go` — 无权限检查,可查任意用户
+  - `internal/logic/role/roleListLogic.go` — 无权限检查
+  - `internal/logic/role/roleDetailLogic.go` — 无权限检查,可查任意产品角色
+  - `internal/logic/product/productListLogic.go` — 无权限检查,AppKey 泄露
+  - `internal/logic/product/productDetailLogic.go` — 无权限检查,AppKey 泄露
+- **描述**:所有列表/详情查询接口虽然经过 JWT 认证,但不做任何业务权限过滤。任何已登录用户可以:
+  - 查看系统中所有用户的信息(含邮箱、手机号)
+  - 查看所有产品的 AppKey
+  - 查看任何产品的角色和权限详情
+- **影响**:敏感信息大面积泄露;违反最小权限原则。
+- **修复方案**:
+  - `UserList` 应按操作者的产品/部门作用域进行过滤;
+  - `ProductList/ProductDetail` 的 `AppKey` 字段仅对超管可见;
+  - `RoleDetail` 应校验操作者是否有权查看该产品的角色。
+
+---
+
+## ⚠️ 健壮性与性能建议 (Medium/Low)
+
+---
+
+### M-01:ChangePassword / UpdateUserStatus 存在 Read-Modify-Write 竞态
+
+- **位置**:`internal/logic/auth/changePasswordLogic.go:40-63` 及 `internal/logic/user/updateUserStatusLogic.go:41-58`
+- **描述**:先 `FindOne` 读取整条记录,修改某个字段后整条 `Update`。在高并发场景下,两个操作可能同时读取到相同的旧数据,后写入者覆盖先写入者的修改。
+- **风险等级**:Medium(密码修改并发场景较少,但状态修改可能并发)
+- **建议**:对关键字段使用针对性 UPDATE 语句,而非全字段覆盖:
+
+```sql
+UPDATE sys_user SET password = ?, mustChangePassword = ?, updateTime = ? WHERE id = ?
+```
+
+---
+
+### M-02:UserDetailsLoader 无缓存击穿防护
+
+- **位置**:`internal/loaders/userDetailsLoader.go:94-113`
+- **描述**:`Load` 方法未使用 `singleflight` 或分布式锁。当缓存过期瞬间,大量并发请求会同时回源数据库执行 6 次查询(loadUser + loadDept + loadProduct + loadMembership + loadRoles + loadPerms)。
+- **风险等级**:Medium
+- **建议**:使用 `golang.org/x/sync/singleflight` 对相同 key 的并发请求进行去重:
+
+```go
+import "golang.org/x/sync/singleflight"
+
+type UserDetailsLoader struct {
+    // ...
+    sf singleflight.Group
+}
+
+func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode string) *UserDetails {
+    key := l.cacheKey(userId, productCode)
+    if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" {
+        var ud UserDetails
+        if err := json.Unmarshal([]byte(val), &ud); err == nil {
+            return &ud
+        }
+    }
+    v, _, _ := l.sf.Do(key, func() (interface{}, error) {
+        return l.loadFromDB(ctx, userId, productCode), nil
+    })
+    ud := v.(*UserDetails)
+    // set cache ...
+    return ud
+}
+```
+
+---
+
+### M-03:cleanByPattern Lua 脚本在 Redis Cluster 下不兼容
+
+- **位置**:`internal/loaders/userDetailsLoader.go:149-169`
+- **描述**:`cleanByPattern` 使用 `SCAN` + `DEL` 的 Lua 脚本传入 `[]string{}` 作为 KEYS(空切片),但在 Redis Cluster 中,所有脚本操作的 key 必须位于同一个 slot,且必须通过 KEYS 参数传入。使用 `ARGV` 传递模式+SCAN 在 Cluster 环境下会被拒绝。
+- **风险等级**:Medium(当前使用单节点 Redis,但未来扩展 Cluster 时会直接报错)
+- **建议**:改用 Go 侧循环 `SCAN` + Pipeline `DEL` 替代 Lua 脚本:
+
+```go
+func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
+    var cursor uint64
+    for {
+        keys, cur, err := l.rds.ScanCtx(ctx, cursor, pattern, 100)
+        if err != nil {
+            logx.WithContext(ctx).Errorf("scan keys failed: %v", err)
+            return
+        }
+        if len(keys) > 0 {
+            if _, err := l.rds.DelCtx(ctx, keys...); err != nil {
+                logx.WithContext(ctx).Errorf("del keys failed: %v", err)
+            }
+        }
+        if cur == 0 {
+            return
+        }
+        cursor = cur
+    }
+}
+```
+
+---
+
+### M-04:gRPC Server 在 goroutine 中启动,失败不可感知
+
+- **位置**:`perm.go:32-40`
+- **描述**:gRPC Server 通过 `go func()` 在后台 goroutine 中启动。如果 gRPC Server 启动失败(如端口冲突)或运行时 panic,主进程(HTTP Server)不会感知,继续以"残缺"状态运行。
+- **风险等级**:Medium
+- **建议**:使用 `errgroup` 或 channel 同步两个 server 的生命周期:
+
+```go
+errCh := make(chan error, 1)
+go func() {
+    rpcServer := zrpc.MustNewServer(...)
+    defer rpcServer.Stop()
+    rpcServer.Start()
+    errCh <- nil
+}()
+
+// 主 goroutine 也监听 errCh
+```
+
+---
+
+### M-05:gen 代码修改包级全局变量 — 初始化竞态风险
+
+- **位置**:所有 `*_gen.go` 文件中的 `newSys*Model` 函数(如 `sysPermModel_gen.go:70-71`、`sysUserModel_gen.go:77-78`)
+- **描述**:`newSysPermModel` 等函数直接修改包级别的 `cacheSysPermIdPrefix` 等全局变量。虽然当前 `NewModels` 在启动时仅调用一次,但这种模式不是并发安全的——如果将来在测试或多实例场景下并发初始化,会发生数据竞争。
+- **风险等级**:Low(当前安全,但代码气味不好)
+- **建议**:将 cache prefix 存储为实例字段而非包级变量(需修改代码生成模板)。
+
+---
+
+### M-06:MemberType 缺少应用层白名单校验
+
+- **位置**:
+  - `internal/logic/member/addMemberLogic.go:31` — `req.MemberType` 无校验
+  - `internal/logic/member/updateMemberLogic.go:30` — `req.MemberType` 无校验
+- **描述**:`MemberType` 字段完全依赖数据库 `enum('DEVELOPER','ADMIN','MEMBER')` 约束来限制合法值,应用层不做白名单校验。
+- **风险等级**:Medium
+- **建议**:
+
+```go
+validTypes := map[string]bool{
+    consts.MemberTypeAdmin:     true,
+    consts.MemberTypeDeveloper: true,
+    consts.MemberTypeMember:    true,
+}
+if !validTypes[req.MemberType] {
+    return nil, response.ErrBadRequest("无效的成员类型")
+}
+```
+
+---
+
+### M-07:generateRandomHex 忽略 crypto/rand.Read 错误
+
+- **位置**:`internal/logic/product/createProductLogic.go:117-121`
+- **描述**:`rand.Read(b)` 的返回错误被忽略。虽然 `crypto/rand` 在主流系统上几乎不会失败,但在某些极端环境(如容器中 `/dev/urandom` 不可用)下可能返回错误,此时 `b` 包含零值或不完整的随机数据。
+- **风险等级**:Low
+- **建议**:
+
+```go
+func generateRandomHex(length int) (string, error) {
+    b := make([]byte, length)
+    if _, err := rand.Read(b); err != nil {
+        return "", fmt.Errorf("generate random bytes failed: %w", err)
+    }
+    return hex.EncodeToString(b)[:length], nil
+}
+```
+
+---
+
+### M-08:DeptTree 全表加载无上限限制
+
+- **位置**:`internal/logic/dept/deptTreeLogic.go:27`
+- **描述**:`DeptTree` 使用 `FindAll` 一次性加载所有部门数据到内存中构建树。如果部门数量增长到数万级别,会导致内存占用激增和响应延迟。
+- **风险等级**:Low(当前数据量小)
+- **建议**:考虑添加分页或根据 `parentId` 按需加载子树。
+
+---
+
+### M-09:SyncPerms 缺乏去重校验
+
+- **位置**:`internal/logic/pub/syncPermsLogic.go:54-78`
+- **描述**:如果请求中的 `Perms` 数组包含重复的 `Code`,后出现的会覆盖前出现的在 `existingMap` 中的查询结果,但 `toInsert` 中可能包含重复条目,导致 `BatchInsert` 时触发唯一键冲突。
+- **风险等级**:Low
+- **建议**:在处理前对 `codes` 去重。
+
+---
+
+### M-10:UpdateDept 修改 deptType 后未级联刷新子部门用户缓存
+
+- **位置**:`internal/logic/dept/updateDeptLogic.go:43-58`
+- **描述**:`UpdateDept` 仅清除该部门直接关联用户的缓存。如果修改了 `deptType`(如 `DEV` → `NORMAL`),子部门的用户权限可能因为父部门类型变更而需要重新计算,但不会被刷新。
+- **风险等级**:Medium(依赖于部门类型是否影响子部门的权限逻辑)
+- **建议**:当 `deptType` 变更时,清除所有子部门用户的缓存。
+
+---
+
+### M-11:Login/AdminLogin 无暴力破解防护
+
+- **位置**:`internal/logic/pub/loginLogic.go`、`internal/logic/pub/adminLoginLogic.go`
+- **描述**:登录接口没有速率限制、验证码或账号锁定机制。攻击者可无限次尝试暴力破解密码。`AdminLogin` 的 `ManagementKey` 也是静态值,无速率限制保护。
+- **风险等级**:Medium
+- **建议**:
+  1. 接入 go-zero 的 `PeriodLimit` 或 Redis 滑动窗口限流;
+  2. 连续失败 N 次后临时锁定账号或要求验证码。
+
+---
+
+### M-12:ProductList 和 ProductDetail 泄露 AppKey
+
+- **位置**:`internal/logic/product/productListLogic.go:37` 及 `internal/logic/product/productDetailLogic.go:33`
+- **描述**:`ProductItem` 响应体包含 `AppKey` 字段,任何已登录用户均可获取。`AppKey` 是产品的接入标识,泄露后配合时序攻击(H-06)有助于暴力破解 `AppSecret`。
+- **风险等级**:Medium
+- **建议**:`AppKey` 仅对超管可见,或在列表接口中排除该字段。
+
+---
+
+### M-13:BindRoles 事务后的缓存清理仅删除单个产品维度
+
+- **位置**:`internal/logic/user/bindRolesLogic.go:64`
+- **描述**:`BindRoles` 先删除用户全部角色绑定(不限产品),再插入新角色。但缓存清理只 `Del` 了当前操作者的 `productCode` 维度。如果用户在多个产品中都有角色绑定,其他产品的缓存不会被清除,导致缓存与 DB 不一致。
+- **风险等级**:Medium
+- **建议**:使用 `Clean`(通配删除所有产品缓存)代替 `Del`:
+
+```go
+l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.UserId)
+```
+
+---
+
+### M-14:CreateProduct 响应返回明文密码和 AppSecret
+
+- **位置**:`internal/logic/product/createProductLogic.go:107-115`
+- **描述**:`CreateProductResp` 中包含 `AdminPassword`(明文)和 `AppSecret`。如果响应被日志框架记录或被中间件拦截,这些敏感信息会出现在日志中。
+- **风险等级**:Medium
+- **建议**:确保该接口的响应不被 access log 或审计日志完整记录;或者改为只生成一次性查看链接。
+
+---
+
+### L-01:Response 统一返回 HTTP 200 — 不利于监控
+
+- **位置**:`internal/response/response.go:44-50`
+- **描述**:所有业务错误(401、403、404 等)的 HTTP 状态码均返回 `200`,仅在 body 中的 `code` 字段区分。这导致 HTTP 监控(如 Nginx、ALB 的 5xx 告警)无法发现业务异常。
+- **风险等级**:Low
+- **建议**:视项目规范决定是否保持,但至少应确保 APM 层能抓取 body 中的 code 字段做告警。
+
+---
+
+### L-02:FindListByDeptIds 的 append 可能修改原 slice
+
+- **位置**:`internal/model/user/sysUserModel.go:69`
+- **描述**:`pageArgs := append(args, ...)` 如果 `args` 底层数组容量足够,会修改 `args` 的底层数据。虽然 `args` 在此处之后不再使用,但这是一个潜在的陷阱。
+- **风险等级**:Low
+- **建议**:使用显式拷贝:
+
+```go
+pageArgs := make([]interface{}, len(args), len(args)+2)
+copy(pageArgs, args)
+pageArgs = append(pageArgs, (page-1)*pageSize, pageSize)
+```
+
+---
+
+### L-03:权限计算逻辑在两处重复(loadPerms 与 GetUserPerms)
+
+- **位置**:`internal/loaders/userDetailsLoader.go:289-357` 与 `internal/logic/auth/perms.go:11-115`
+- **描述**:权限计算逻辑(角色权限 + 用户 allow - 用户 deny)在 `UserDetailsLoader.loadPerms` 和 `GetUserPerms` 中各实现了一遍。逻辑高度相似但不完全相同(如 `loadPerms` 多了 `DeptType == DEV` 的判断),容易在后续维护中出现不一致。
+- **风险等级**:Low
+- **建议**:将权限计算逻辑抽取为单一函数,两处共用。
+
+---
+
+## 📋 审计总结
+
+| 等级 | 数量 | 关键发现 |
+| :----- | :------ | :---------- |
+| 🚩 High | 11 | 权限提升、跨产品越权、数据完整性缺陷、敏感信息泄露 |
+| ⚠️ Medium | 14 | 竞态条件、缓存不一致、缺少暴力破解防护、输入校验不足 |
+| 📝 Low | 3 | 代码重复、HTTP 状态码规范、slice append 陷阱 |

+ 1 - 0
go.mod

@@ -9,6 +9,7 @@ require (
 	github.com/zeromicro/go-zero v1.10.1
 	go.uber.org/mock v0.6.0
 	golang.org/x/crypto v0.48.0
+	golang.org/x/sync v0.20.0
 	google.golang.org/grpc v1.79.3
 	google.golang.org/protobuf v1.36.11
 )

+ 3 - 0
go.sum

@@ -302,7 +302,10 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

+ 20 - 18
internal/loaders/userDetailsLoader.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/redis"
+	"golang.org/x/sync/singleflight"
 )
 
 const defaultCacheTTL = 300 // 5 分钟
@@ -75,6 +76,7 @@ type UserDetailsLoader struct {
 	keyPrefix string
 	ttl       int
 	models    *model.Models
+	sf        singleflight.Group
 }
 
 func NewUserDetailsLoader(rds *redis.Redis, keyPrefix string, models *model.Models) *UserDetailsLoader {
@@ -101,15 +103,17 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 		}
 	}
 
-	ud := l.loadFromDB(ctx, userId, productCode)
-
-	if val, err := json.Marshal(ud); err == nil {
-		if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); err != nil {
-			logx.WithContext(ctx).Errorf("set user details cache failed: %v", err)
+	v, _, _ := l.sf.Do(key, func() (interface{}, error) {
+		ud := l.loadFromDB(ctx, userId, productCode)
+		if val, err := json.Marshal(ud); err == nil {
+			if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); err != nil {
+				logx.WithContext(ctx).Errorf("set user details cache failed: %v", err)
+			}
 		}
-	}
+		return ud, nil
+	})
 
-	return ud
+	return v.(*UserDetails)
 }
 
 // Del 删除指定用户在指定产品下的缓存。
@@ -149,22 +153,20 @@ func (l *UserDetailsLoader) BatchDel(ctx context.Context, userIds []int64, produ
 func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
 	var cursor uint64
 	for {
-		script := `local cursor = tonumber(ARGV[2])
-local result = redis.call('SCAN', cursor, 'MATCH', ARGV[1], 'COUNT', 100)
-local nextCursor = tonumber(result[1])
-local keys = result[2]
-for i = 1, #keys do redis.call('DEL', keys[i]) end
-return nextCursor`
-		val, err := l.rds.EvalCtx(ctx, script, []string{}, pattern, cursor)
+		keys, cur, err := l.rds.ScanCtx(ctx, cursor, pattern, 100)
 		if err != nil {
-			logx.WithContext(ctx).Errorf("clean user details cache [%s] failed: %v", pattern, err)
+			logx.WithContext(ctx).Errorf("scan keys [%s] failed: %v", pattern, err)
 			return
 		}
-		next, ok := val.(int64)
-		if !ok || next == 0 {
+		if len(keys) > 0 {
+			if _, err := l.rds.DelCtx(ctx, keys...); err != nil {
+				logx.WithContext(ctx).Errorf("del keys failed: %v", err)
+			}
+		}
+		if cur == 0 {
 			return
 		}
-		cursor = uint64(next)
+		cursor = cur
 	}
 }
 

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

@@ -106,6 +106,10 @@ func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller
 		return response.ErrForbidden("您未归属任何部门,无权管理其他用户")
 	}
 
+	if caller.DeptPath == "" {
+		return response.ErrForbidden("您的部门信息异常,无法执行此操作")
+	}
+
 	target, err := svcCtx.SysUserModel.FindOne(ctx, targetUserId)
 	if err != nil {
 		return response.ErrNotFound("目标用户不存在")

+ 24 - 0
internal/logic/auth/access_test.go

@@ -408,6 +408,30 @@ func TestCheckManageAccess_CrossDeptForbidden(t *testing.T) {
 	assert.Equal(t, 403, ce.Code())
 }
 
+// TC-0523: caller.DeptPath为空时拒绝
+func TestCheckManageAccess_EmptyDeptPath(t *testing.T) {
+	svcCtx := newIntegrationSvcCtx()
+
+	emptyPathCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:        88888,
+		Username:      "dev_emptypath",
+		IsSuperAdmin:  false,
+		MemberType:    consts.MemberTypeDeveloper,
+		Status:        consts.StatusEnabled,
+		ProductCode:   "p1",
+		DeptId:        1,
+		DeptPath:      "",
+		MinPermsLevel: math.MaxInt64,
+	})
+
+	err := CheckManageAccess(emptyPathCtx, svcCtx, 99999, "p1")
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "部门信息异常")
+}
+
 // TC-0456: DEVELOPER 操作同部门的 MEMBER 且权限级别更高 → nil
 func TestCheckManageAccess_SameDeptLowerLevel(t *testing.T) {
 	ctx := context.Background()

+ 1 - 6
internal/logic/auth/changePasswordLogic.go

@@ -2,7 +2,6 @@ package auth
 
 import (
 	"context"
-	"time"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/middleware"
@@ -55,11 +54,7 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 		return err
 	}
 
-	user.Password = string(hashed)
-	user.MustChangePassword = consts.MustChangePasswordNo
-	user.UpdateTime = time.Now().Unix()
-
-	if err := l.svcCtx.SysUserModel.Update(l.ctx, user); err != nil {
+	if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, userId, string(hashed), consts.MustChangePasswordNo); err != nil {
 		return err
 	}
 

+ 2 - 105
internal/logic/auth/perms.go

@@ -3,113 +3,10 @@ package auth
 import (
 	"context"
 
-	"perms-system-server/internal/consts"
-	"perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/svc"
 )
 
 func GetUserPerms(ctx context.Context, svcCtx *svc.ServiceContext, userId int64, deptId int64, productCode string, isSuperAdmin bool) ([]string, string, error) {
-	if isSuperAdmin {
-		perms, err := svcCtx.SysPermModel.FindAllCodesByProductCode(ctx, productCode)
-		if err != nil {
-			return nil, "", err
-		}
-		return perms, consts.MemberTypeSuperAdmin, nil
-	}
-
-	member, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, userId)
-	if err != nil {
-		if err == productmember.ErrNotFound {
-			return nil, "", nil
-		}
-		return nil, "", err
-	}
-
-	if member.MemberType == consts.MemberTypeDeveloper || member.MemberType == consts.MemberTypeAdmin {
-		perms, err := svcCtx.SysPermModel.FindAllCodesByProductCode(ctx, productCode)
-		if err != nil {
-			return nil, member.MemberType, err
-		}
-		return perms, member.MemberType, nil
-	}
-
-	if deptId > 0 {
-		deptInfo, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
-		if err == nil && deptInfo.DeptType == consts.DeptTypeDev {
-			perms, err := svcCtx.SysPermModel.FindAllCodesByProductCode(ctx, productCode)
-			if err != nil {
-				return nil, member.MemberType, err
-			}
-			return perms, member.MemberType, nil
-		}
-	}
-
-	roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId)
-	if err != nil {
-		return nil, member.MemberType, err
-	}
-
-	productRoleIds := make([]int64, 0)
-	if len(roleIds) > 0 {
-		roles, err := svcCtx.SysRoleModel.FindByIds(ctx, roleIds)
-		if err != nil {
-			return nil, member.MemberType, err
-		}
-		for _, r := range roles {
-			if r.ProductCode == productCode && r.Status == consts.StatusEnabled {
-				productRoleIds = append(productRoleIds, r.Id)
-			}
-		}
-	}
-
-	rolePermIds, err := svcCtx.SysRolePermModel.FindPermIdsByRoleIds(ctx, productRoleIds)
-	if err != nil {
-		return nil, member.MemberType, err
-	}
-
-	allowPermIds, err := svcCtx.SysUserPermModel.FindPermIdsByUserIdAndEffect(ctx, userId, consts.PermEffectAllow)
-	if err != nil {
-		return nil, member.MemberType, err
-	}
-
-	denyPermIds, err := svcCtx.SysUserPermModel.FindPermIdsByUserIdAndEffect(ctx, userId, consts.PermEffectDeny)
-	if err != nil {
-		return nil, member.MemberType, err
-	}
-
-	denySet := make(map[int64]bool)
-	for _, id := range denyPermIds {
-		denySet[id] = true
-	}
-
-	permIdSet := make(map[int64]bool)
-	for _, id := range rolePermIds {
-		if !denySet[id] {
-			permIdSet[id] = true
-		}
-	}
-	for _, id := range allowPermIds {
-		if !denySet[id] {
-			permIdSet[id] = true
-		}
-	}
-
-	finalIds := make([]int64, 0, len(permIdSet))
-	for id := range permIdSet {
-		finalIds = append(finalIds, id)
-	}
-
-	permsResult, err := svcCtx.SysPermModel.FindByIds(ctx, finalIds)
-	if err != nil {
-		return nil, member.MemberType, err
-	}
-
-	codes := make([]string, 0, len(permsResult))
-	for _, p := range permsResult {
-		if p.Status == consts.StatusEnabled {
-			codes = append(codes, p.Code)
-		}
-	}
-
-	return codes, member.MemberType, nil
+	ud := svcCtx.UserDetailsLoader.Load(ctx, userId, productCode)
+	return ud.Perms, ud.MemberType, nil
 }

+ 30 - 370
internal/logic/auth/perms_mock_test.go

@@ -2,389 +2,49 @@ package auth
 
 import (
 	"context"
-	"errors"
+	"fmt"
+	"math/rand"
 	"testing"
+	"time"
 
-	"perms-system-server/internal/consts"
-	"perms-system-server/internal/model/dept"
+	"perms-system-server/internal/model/perm"
 	"perms-system-server/internal/model/productmember"
-	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/testutil"
 
 	"github.com/stretchr/testify/assert"
-	"go.uber.org/mock/gomock"
+	"github.com/stretchr/testify/require"
 )
 
-var errDB = errors.New("db error")
+// TC-0534: GetUserPerms 委托到 UserDetailsLoader.Load()
+func TestGetUserPerms_DelegatesToLoader(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := fmt.Sprintf("tp_delegate_%d", rand.Intn(100000))
+	userId := int64(900000 + rand.Intn(10000))
 
-// TC-0232: isSuperAdmin=true, deptId=0, FindAllCodesByProductCode返回err
-func TestGetUserPerms_Mock_SuperAdmin_FindAllCodesFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindAllCodesByProductCode(gomock.Any(), "pc").Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		Perm: mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", true)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Empty(t, memberType)
-}
-
-// TC-0234: deptId=0, FindOneByProductCodeUserId返回DB error
-func TestGetUserPerms_Mock_MemberQueryDBError(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Empty(t, memberType)
-}
-
-// TC-0237: deptId=0, MemberType="DEVELOPER", FindAllCodesByProductCode失败
-func TestGetUserPerms_Mock_Developer_FindAllCodesFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeDeveloper}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindAllCodesByProductCode(gomock.Any(), "pc").Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		Perm:          mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeDeveloper, memberType)
-}
-
-// TC-0252: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindRoleIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0253: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindRoleByIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{10, 20}, nil)
-
-	mockRole := mocks.NewMockSysRoleModel(ctrl)
-	mockRole.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-		Role:          mockRole,
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
+		ProductCode: pc, UserId: userId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now,
 	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
 
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0254: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindPermIdsByRoleIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
+	p1Res, err := svcCtx.SysPermModel.Insert(ctx, &perm.SysPerm{
+		ProductCode: pc, Name: "del_p1", Code: fmt.Sprintf("del_c1_%d", rand.Intn(100000)), Status: 1, CreateTime: now, UpdateTime: now,
 	})
+	require.NoError(t, err)
+	p1Id, _ := p1Res.LastInsertId()
 
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0255: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindAllowPermIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return([]int64{}, nil)
-
-	mockUP := mocks.NewMockSysUserPermModel(ctrl)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectAllow).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
-		UserPerm:      mockUP,
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", p1Id)
 	})
 
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0256: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindDenyPermIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return([]int64{}, nil)
-
-	mockUP := mocks.NewMockSysUserPermModel(ctrl)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectAllow).Return([]int64{}, nil)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectDeny).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
-		UserPerm:      mockUP,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0257: deptId=0, 返回err
-func TestGetUserPerms_Mock_Member_FindPermByIdsFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return([]int64{}, nil)
-
-	mockUP := mocks.NewMockSysUserPermModel(ctrl)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectAllow).Return([]int64{100}, nil)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectDeny).Return([]int64{}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindByIds(gomock.Any(), gomock.Any()).Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
-		UserPerm:      mockUP,
-		Perm:          mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 0, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0238: deptId>0, MemberType="MEMBER", SysDeptModel.FindOne返回DeptType="DEV", FindAllCodesByProductCode返回["a","b","c"]
-func TestGetUserPerms_Mock_Member_DevDeptGetAllPerms(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockDept := mocks.NewMockSysDeptModel(ctrl)
-	mockDept.EXPECT().FindOne(gomock.Any(), int64(100)).
-		Return(&dept.SysDept{Id: 100, DeptType: consts.DeptTypeDev}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindAllCodesByProductCode(gomock.Any(), "pc").Return([]string{"p1", "p2", "p3"}, nil)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		Dept:          mockDept,
-		Perm:          mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 100, "pc", false)
-	assert.NoError(t, err)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-	assert.ElementsMatch(t, []string{"p1", "p2", "p3"}, perms)
-}
-
-// TC-0239: deptId>0, DeptType="DEV", FindAllCodesByProductCode返回err
-func TestGetUserPerms_Mock_Member_DevDeptFindAllCodesFail(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockDept := mocks.NewMockSysDeptModel(ctrl)
-	mockDept.EXPECT().FindOne(gomock.Any(), int64(100)).
-		Return(&dept.SysDept{Id: 100, DeptType: consts.DeptTypeDev}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindAllCodesByProductCode(gomock.Any(), "pc").Return(nil, errDB)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		Dept:          mockDept,
-		Perm:          mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 100, "pc", false)
-	assert.ErrorIs(t, err, errDB)
-	assert.Nil(t, perms)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-}
-
-// TC-0241: deptId>0, FindOne返回ErrNotFound
-func TestGetUserPerms_Mock_Member_DeptFindOneFail_FallsThrough(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockDept := mocks.NewMockSysDeptModel(ctrl)
-	mockDept.EXPECT().FindOne(gomock.Any(), int64(100)).
-		Return(nil, errors.New("dept not found"))
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return([]int64{}, nil)
-
-	mockUP := mocks.NewMockSysUserPermModel(ctrl)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectAllow).Return([]int64{}, nil)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectDeny).Return([]int64{}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindByIds(gomock.Any(), gomock.Any()).Return(nil, nil)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		Dept:          mockDept,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
-		UserPerm:      mockUP,
-		Perm:          mockPerm,
-	})
-
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 100, "pc", false)
-	assert.NoError(t, err)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-	assert.Empty(t, perms)
-}
-
-// TC-0240: deptId>0, DeptType="NORMAL"
-func TestGetUserPerms_Mock_Member_NormalDeptFallsThrough(t *testing.T) {
-	ctrl := gomock.NewController(t)
-	defer ctrl.Finish()
-
-	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
-	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc", int64(1)).
-		Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
-
-	mockDept := mocks.NewMockSysDeptModel(ctrl)
-	mockDept.EXPECT().FindOne(gomock.Any(), int64(100)).
-		Return(&dept.SysDept{Id: 100, DeptType: consts.DeptTypeNormal}, nil)
-
-	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserId(gomock.Any(), int64(1)).Return([]int64{}, nil)
-
-	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleIds(gomock.Any(), gomock.Any()).Return([]int64{}, nil)
-
-	mockUP := mocks.NewMockSysUserPermModel(ctrl)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectAllow).Return([]int64{}, nil)
-	mockUP.EXPECT().FindPermIdsByUserIdAndEffect(gomock.Any(), int64(1), consts.PermEffectDeny).Return([]int64{}, nil)
-
-	mockPerm := mocks.NewMockSysPermModel(ctrl)
-	mockPerm.EXPECT().FindByIds(gomock.Any(), gomock.Any()).Return(nil, nil)
-
-	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
-		ProductMember: mockPM,
-		Dept:          mockDept,
-		UserRole:      mockUR,
-		RolePerm:      mockRP,
-		UserPerm:      mockUP,
-		Perm:          mockPerm,
-	})
+	ud := svcCtx.UserDetailsLoader.Load(ctx, userId, pc)
 
-	perms, memberType, err := GetUserPerms(context.Background(), svcCtx, 1, 100, "pc", false)
-	assert.NoError(t, err)
-	assert.Equal(t, consts.MemberTypeMember, memberType)
-	assert.Empty(t, perms)
+	perms, memberType, err := GetUserPerms(ctx, svcCtx, userId, 0, pc, false)
+	require.NoError(t, err, "GetUserPerms should always return nil error")
+	assert.Equal(t, ud.Perms, perms, "Perms should match UserDetailsLoader.Load() result")
+	assert.Equal(t, ud.MemberType, memberType, "MemberType should match UserDetailsLoader.Load() result")
 }

+ 91 - 32
internal/logic/auth/perms_test.go

@@ -2,6 +2,7 @@ package auth
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"math/rand"
 	"testing"
@@ -12,6 +13,7 @@ import (
 	"perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/model/role"
 	"perms-system-server/internal/model/roleperm"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/model/userperm"
 	"perms-system-server/internal/model/userrole"
 	"perms-system-server/internal/svc"
@@ -27,7 +29,29 @@ func newTestSvcCtx() *svc.ServiceContext {
 	return svc.NewServiceContext(c)
 }
 
-// TC-0231: isSuperAdmin=true, deptId=0, FindAllCodesByProductCode返回["a","b"]
+func createPermsTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, isSuperAdmin int64, deptId int64) (int64, func()) {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	username := fmt.Sprintf("perms_u_%d", rand.Intn(1000000))
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           testutil.HashPassword("pass123"),
+		Nickname:           username,
+		Avatar:             sql.NullString{},
+		DeptId:             deptId,
+		IsSuperAdmin:       isSuperAdmin,
+		MustChangePassword: 2,
+		Status:             1,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id, func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) }
+}
+
+// TC-0231: superAdmin gets all enabled perms
 func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
@@ -35,6 +59,9 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_%d", rand.Intn(100000))
 
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 1, 0)
+	t.Cleanup(cleanUser)
+
 	p1, err := svcCtx.SysPermModel.Insert(ctx, &perm.SysPerm{
 		ProductCode: pc, Name: "sa_perm1", Code: "sa_code1", Status: 1, CreateTime: now, UpdateTime: now,
 	})
@@ -57,42 +84,50 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "`sys_perm`", p1Id, p2Id, p3Id)
 	})
 
-	perms, memberType, err := GetUserPerms(ctx, svcCtx, 1, 0, pc, true)
+	perms, memberType, err := GetUserPerms(ctx, svcCtx, userId, 0, pc, true)
 	require.NoError(t, err)
 	assert.Equal(t, "SUPER_ADMIN", memberType)
 	assert.ElementsMatch(t, []string{"sa_code1", "sa_code2"}, perms)
 }
 
-// TC-0232: isSuperAdmin=true, deptId=0, FindAllCodesByProductCode返回err
+// TC-0232: superAdmin with empty product
 func TestGetUserPerms_SuperAdmin_EmptyProduct(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 
-	perms, memberType, err := GetUserPerms(ctx, svcCtx, 1, 0, "nonexist_product_xyz", true)
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 1, 0)
+	t.Cleanup(cleanUser)
+
+	perms, memberType, err := GetUserPerms(ctx, svcCtx, userId, 0, "nonexist_product_xyz", true)
 	require.NoError(t, err)
 	assert.Equal(t, "SUPER_ADMIN", memberType)
 	assert.Empty(t, perms)
 }
 
-// TC-0233: deptId=0, FindOneByProductCodeUserId返回ErrNotFound
+// TC-0233: non product member
 func TestGetUserPerms_NotProductMember(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 
-	perms, memberType, err := GetUserPerms(ctx, svcCtx, 999999, 0, "some_product", false)
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
+
+	perms, memberType, err := GetUserPerms(ctx, svcCtx, userId, 0, "some_product", false)
 	require.NoError(t, err)
 	assert.Empty(t, memberType)
 	assert.Nil(t, perms)
 }
 
-// TC-0235: deptId=0, member.MemberType="DEVELOPER"
+// TC-0235: DEVELOPER member
 func TestGetUserPerms_Developer(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_dev_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "DEVELOPER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -117,14 +152,16 @@ func TestGetUserPerms_Developer(t *testing.T) {
 	assert.Contains(t, perms, "dev_c1")
 }
 
-// TC-0236: deptId=0, member.MemberType="ADMIN"
+// TC-0236: ADMIN member
 func TestGetUserPerms_Admin(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_adm_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now,
@@ -149,14 +186,16 @@ func TestGetUserPerms_Admin(t *testing.T) {
 	assert.Contains(t, perms, "adm_c1")
 }
 
-// TC-0243: deptId=0, MemberType="MEMBER", roleIds=[], allowPermIds=[], denyPermIds=[]
+// TC-0243: MEMBER no roles no user perms
 func TestGetUserPerms_Member_NoRolesNoUserPerms(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_mbr0_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -174,14 +213,16 @@ func TestGetUserPerms_Member_NoRolesNoUserPerms(t *testing.T) {
 	assert.Empty(t, perms)
 }
 
-// TC-0244: deptId=0, roleIds=[1], role.ProductCode=productCode+Status=1, rolePermIds=[10,20]
+// TC-0244: MEMBER with roles
 func TestGetUserPerms_Member_WithRoles(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_mbrr_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -243,14 +284,16 @@ func TestGetUserPerms_Member_WithRoles(t *testing.T) {
 	assert.ElementsMatch(t, []string{p1.Code, p2.Code}, perms)
 }
 
-// TC-0248: deptId=0, rolePermIds=[10], denyPermIds=[10]
+// TC-0248: DENY overrides role perm
 func TestGetUserPerms_Member_DENYOverridesRolePerm(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_deny_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -317,14 +360,16 @@ func TestGetUserPerms_Member_DENYOverridesRolePerm(t *testing.T) {
 	assert.Equal(t, []string{permB.Code}, permsResult)
 }
 
-// TC-0247: deptId=0, rolePermIds=[], allowPermIds=[30]
+// TC-0247: ALLOW adds extra perm
 func TestGetUserPerms_Member_ALLOWAddsExtra(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_allow_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -359,7 +404,7 @@ func TestGetUserPerms_Member_ALLOWAddsExtra(t *testing.T) {
 	assert.Contains(t, permsResult, permObj.Code)
 }
 
-// TC-0245: deptId=0, roleIds=[1,2], role1.ProductCode=target, role2.ProductCode=other
+// TC-0245: cross-product role filter
 func TestGetUserPerms_Member_CrossProductRoleFilter(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
@@ -367,7 +412,9 @@ func TestGetUserPerms_Member_CrossProductRoleFilter(t *testing.T) {
 	now := time.Now().Unix()
 	pcTarget := fmt.Sprintf("tp_cross_t_%d", rand.Intn(100000))
 	pcOther := fmt.Sprintf("tp_cross_o_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pcTarget, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -431,14 +478,16 @@ func TestGetUserPerms_Member_CrossProductRoleFilter(t *testing.T) {
 	assert.Equal(t, []string{targetPerm.Code}, permsResult)
 }
 
-// TC-0246: deptId=0, role.Status=2
+// TC-0246: disabled role filtered
 func TestGetUserPerms_Member_DisabledRoleFiltered(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_disrole_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -480,14 +529,16 @@ func TestGetUserPerms_Member_DisabledRoleFiltered(t *testing.T) {
 	assert.Empty(t, permsResult)
 }
 
-// TC-0251: deptId=0, finalIds含已禁用权限
+// TC-0251: disabled perm filtered
 func TestGetUserPerms_Member_DisabledPermFiltered(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_disperm_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -519,14 +570,16 @@ func TestGetUserPerms_Member_DisabledPermFiltered(t *testing.T) {
 	assert.Empty(t, permsResult)
 }
 
-// TC-0249: deptId=0, allowPermIds=[10], denyPermIds=[10]
+// TC-0249: DENY only excludes target perm
 func TestGetUserPerms_Member_DENYOnlyExcludesTargetPerm(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_denyonly_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -573,14 +626,16 @@ func TestGetUserPerms_Member_DENYOnlyExcludesTargetPerm(t *testing.T) {
 	assert.NotContains(t, permsResult, permB.Code, "DENY perm should be excluded even if it exists")
 }
 
-// TC-0250: deptId=0, rolePermIds=[10], allowPermIds=[10]
+// TC-0250: ALLOW + role dedup
 func TestGetUserPerms_Member_ALLOWAndRoleDedup(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_dedup_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
+
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, 0)
+	t.Cleanup(cleanUser)
 
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
@@ -636,14 +691,13 @@ func TestGetUserPerms_Member_ALLOWAndRoleDedup(t *testing.T) {
 	assert.Equal(t, permObj.Code, permsResult[0])
 }
 
-// TC-0238: deptId>0, MemberType="MEMBER", SysDeptModel.FindOne返回DeptType="DEV", FindAllCodesByProductCode返回["a","b","c"]
+// TC-0238: DEV dept member gets all perms
 func TestGetUserPerms_Member_DevDept_AllPerms(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_devdept_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
 
 	deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
 		ParentId: 0, Name: "dev_dept_" + fmt.Sprintf("%d", rand.Intn(100000)),
@@ -652,6 +706,9 @@ func TestGetUserPerms_Member_DevDept_AllPerms(t *testing.T) {
 	require.NoError(t, err)
 	deptId, _ := deptRes.LastInsertId()
 
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, deptId)
+	t.Cleanup(cleanUser)
+
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
 	})
@@ -685,14 +742,13 @@ func TestGetUserPerms_Member_DevDept_AllPerms(t *testing.T) {
 	assert.ElementsMatch(t, []string{p1.Code, p2.Code}, permsResult)
 }
 
-// TC-0240: deptId>0, DeptType="NORMAL"
+// TC-0240: NORMAL dept member no auto perms
 func TestGetUserPerms_Member_NormalDept_NoAutoPerms(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	conn := testutil.GetTestSqlConn()
 	now := time.Now().Unix()
 	pc := fmt.Sprintf("tp_normdept_%d", rand.Intn(100000))
-	userId := int64(900000 + rand.Intn(10000))
 
 	deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
 		ParentId: 0, Name: "normal_dept_" + fmt.Sprintf("%d", rand.Intn(100000)),
@@ -701,6 +757,9 @@ func TestGetUserPerms_Member_NormalDept_NoAutoPerms(t *testing.T) {
 	require.NoError(t, err)
 	deptId, _ := deptRes.LastInsertId()
 
+	userId, cleanUser := createPermsTestUser(t, ctx, svcCtx, 2, deptId)
+	t.Cleanup(cleanUser)
+
 	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmember.SysProductMember{
 		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
 	})

+ 5 - 0
internal/logic/dept/deleteDeptLogic.go

@@ -38,5 +38,10 @@ func (l *DeleteDeptLogic) DeleteDept(req *types.DeleteDeptReq) error {
 		return response.ErrBadRequest("该部门下存在子部门,无法删除")
 	}
 
+	userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
+	if len(userIds) > 0 {
+		return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
+	}
+
 	return l.svcCtx.SysDeptModel.Delete(l.ctx, req.Id)
 }

+ 43 - 0
internal/logic/dept/deleteDeptLogic_test.go

@@ -1,9 +1,12 @@
 package dept
 
 import (
+	"database/sql"
 	"errors"
 	"testing"
+	"time"
 
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -69,6 +72,46 @@ func TestDeleteDept_HasChildren(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+// TC-0522: 部门下有关联用户
+func TestDeleteDept_HasAssociatedUsers(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptId, err := insertDeptRaw(ctx, svcCtx, 0, "has_users_"+testutil.UniqueId(), "/")
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
+	now := time.Now().Unix()
+	userRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           "dept_user_" + testutil.UniqueId(),
+		Password:           testutil.HashPassword("pass123456"),
+		Nickname:           "test",
+		Avatar:             sql.NullString{},
+		DeptId:             deptId,
+		IsSuperAdmin:       2,
+		MustChangePassword: 2,
+		Status:             1,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	userId, _ := userRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	l := NewDeleteDeptLogic(ctx, svcCtx)
+	err = l.DeleteDept(&types.DeleteDeptReq{Id: deptId})
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Equal(t, "该部门下仍有关联用户,无法删除", ce.Error())
+
+	_, err = svcCtx.SysDeptModel.FindOne(ctx, deptId)
+	assert.NoError(t, err)
+}
+
 // TC-0483: deleteDept非超管拒绝
 func TestDeleteDept_NonSuperAdminRejected(t *testing.T) {
 	ctx := ctxhelper.AdminCtx("test_product")

+ 13 - 0
internal/logic/dept/updateDeptLogic.go

@@ -56,5 +56,18 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	for _, uid := range userIds {
 		l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
 	}
+
+	if req.DeptType == consts.DeptTypeNormal || req.DeptType == consts.DeptTypeDev {
+		childDepts, _ := l.svcCtx.SysDeptModel.FindByPathPrefix(l.ctx, dept.Path)
+		for _, cd := range childDepts {
+			if cd.Id == req.Id {
+				continue
+			}
+			childUserIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, cd.Id)
+			for _, uid := range childUserIds {
+				l.svcCtx.UserDetailsLoader.Clean(l.ctx, uid)
+			}
+		}
+	}
 	return nil
 }

+ 63 - 0
internal/logic/dept/updateDeptLogic_mock_test.go

@@ -0,0 +1,63 @@
+package dept
+
+import (
+	"testing"
+
+	deptModel "perms-system-server/internal/model/dept"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/mock/gomock"
+)
+
+// TC-0533: DeptType变更时级联清除子部门用户缓存
+func TestUpdateDept_Mock_CascadeCacheClean(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	parentDeptId := int64(10)
+	childDeptId1 := int64(20)
+	childDeptId2 := int64(30)
+
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), parentDeptId).
+		Return(&deptModel.SysDept{
+			Id:       parentDeptId,
+			Name:     "Parent",
+			Path:     "/10/",
+			DeptType: "NORMAL",
+			Status:   1,
+		}, nil)
+	mockDept.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil)
+	mockDept.EXPECT().FindByPathPrefix(gomock.Any(), "/10/").
+		Return([]*deptModel.SysDept{
+			{Id: parentDeptId, Path: "/10/", DeptType: "DEV"},
+			{Id: childDeptId1, Path: "/10/20/", DeptType: "NORMAL"},
+			{Id: childDeptId2, Path: "/10/30/", DeptType: "NORMAL"},
+		}, nil)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindIdsByDeptId(gomock.Any(), parentDeptId).
+		Return([]int64{100, 101}, nil)
+	mockUser.EXPECT().FindIdsByDeptId(gomock.Any(), childDeptId1).
+		Return([]int64{200}, nil)
+	mockUser.EXPECT().FindIdsByDeptId(gomock.Any(), childDeptId2).
+		Return([]int64{300, 301}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Dept: mockDept,
+		User: mockUser,
+	})
+
+	ctx := ctxhelper.SuperAdminCtx()
+	logic := NewUpdateDeptLogic(ctx, svcCtx)
+	err := logic.UpdateDept(&types.UpdateDeptReq{
+		Id:       parentDeptId,
+		Name:     "Parent Updated",
+		DeptType: "DEV",
+	})
+
+	assert.NoError(t, err)
+}

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

@@ -36,6 +36,12 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		return nil, response.ErrNotFound("用户不存在")
 	}
 
+	if req.MemberType != consts.MemberTypeAdmin &&
+		req.MemberType != consts.MemberTypeDeveloper &&
+		req.MemberType != consts.MemberTypeMember {
+		return nil, response.ErrBadRequest("无效的成员类型")
+	}
+
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, req.ProductCode); err != nil {
 		return nil, err
 	}

+ 41 - 0
internal/logic/member/addMemberLogic_test.go

@@ -164,6 +164,47 @@ func TestAddMember_AlreadyMember(t *testing.T) {
 	assert.Equal(t, "该用户已是该产品成员", ce.Error())
 }
 
+// TC-0530: 无效MemberType
+func TestAddMember_InvalidMemberType(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	logic := NewAddMemberLogic(ctx, svcCtx)
+	_, err = logic.AddMember(&types.AddMemberReq{
+		ProductCode: uid,
+		UserId:      uId,
+		MemberType:  "INVALID",
+	})
+	require.Error(t, err)
+	ce, ok := err.(*response.CodeError)
+	require.True(t, ok)
+	assert.Equal(t, 400, ce.Code())
+	assert.Equal(t, "无效的成员类型", ce.Error())
+}
+
 // TC-0150: 并发添加
 func TestAddMember_ConcurrentSameUserProduct(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()

+ 6 - 0
internal/logic/member/updateMemberLogic.go

@@ -33,6 +33,12 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 		return response.ErrNotFound("成员不存在")
 	}
 
+	if req.MemberType != consts.MemberTypeAdmin &&
+		req.MemberType != consts.MemberTypeDeveloper &&
+		req.MemberType != consts.MemberTypeMember {
+		return response.ErrBadRequest("无效的成员类型")
+	}
+
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, member.UserId, member.ProductCode); err != nil {
 		return err
 	}

+ 48 - 0
internal/logic/member/updateMemberLogic_test.go

@@ -68,6 +68,54 @@ func TestUpdateMember_Normal(t *testing.T) {
 	assert.Equal(t, int64(2), updated.Status)
 }
 
+// TC-0531: 无效MemberType
+func TestUpdateMember_InvalidMemberType(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	err = logic.UpdateMember(&types.UpdateMemberReq{
+		Id:         mId,
+		MemberType: "INVALID",
+	})
+	require.Error(t, err)
+	ce, ok := err.(*response.CodeError)
+	require.True(t, ok)
+	assert.Equal(t, 400, ce.Code())
+	assert.Equal(t, "无效的成员类型", ce.Error())
+}
+
 // TC-0152: 不存在
 func TestUpdateMember_NotFound(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()

+ 17 - 6
internal/logic/product/createProductLogic.go

@@ -45,12 +45,21 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 		return nil, response.ErrConflict("产品编码已存在")
 	}
 
-	appKey := generateRandomHex(32)
-	appSecret := generateRandomHex(64)
+	appKey, err := generateRandomHex(32)
+	if err != nil {
+		return nil, err
+	}
+	appSecret, err := generateRandomHex(64)
+	if err != nil {
+		return nil, err
+	}
 	now := time.Now().Unix()
 
 	adminUsername := fmt.Sprintf("admin_%s", req.Code)
-	adminPassword := generateRandomHex(8)
+	adminPassword, err := generateRandomHex(8)
+	if err != nil {
+		return nil, err
+	}
 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
 	if err != nil {
 		return nil, err
@@ -114,8 +123,10 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	}, nil
 }
 
-func generateRandomHex(length int) string {
+func generateRandomHex(length int) (string, error) {
 	b := make([]byte, length)
-	rand.Read(b)
-	return hex.EncodeToString(b)[:length]
+	if _, err := rand.Read(b); err != nil {
+		return "", fmt.Errorf("generate random bytes failed: %w", err)
+	}
+	return hex.EncodeToString(b)[:length], nil
 }

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

@@ -3,6 +3,7 @@ package product
 import (
 	"context"
 
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -30,13 +31,17 @@ func (l *ProductDetailLogic) ProductDetail(req *types.ProductDetailReq) (resp *t
 		return nil, response.ErrNotFound("产品不存在")
 	}
 
-	return &types.ProductItem{
+	caller := middleware.GetUserDetails(l.ctx)
+	item := &types.ProductItem{
 		Id:         product.Id,
 		Code:       product.Code,
 		Name:       product.Name,
-		AppKey:     product.AppKey,
 		Remark:     product.Remark,
 		Status:     product.Status,
 		CreateTime: product.CreateTime,
-	}, nil
+	}
+	if caller != nil && caller.IsSuperAdmin {
+		item.AppKey = product.AppKey
+	}
+	return item, nil
 }

+ 46 - 1
internal/logic/product/productDetailLogic_test.go

@@ -10,6 +10,7 @@ import (
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/types"
 
 	"github.com/stretchr/testify/assert"
@@ -49,7 +50,7 @@ func TestProductDetail_Success(t *testing.T) {
 	assert.Equal(t, id, item.Id)
 	assert.Equal(t, code, item.Code)
 	assert.Equal(t, "详情测试产品", item.Name)
-	assert.Equal(t, "dk_"+code, item.AppKey)
+	assert.Equal(t, "", item.AppKey, "AppKey should be hidden for non-superadmin")
 	assert.Equal(t, "详情备注", item.Remark)
 	assert.Equal(t, int64(1), item.Status)
 	assert.Equal(t, now, item.CreateTime)
@@ -69,3 +70,47 @@ func TestProductDetail_NotFound(t *testing.T) {
 	assert.Equal(t, 404, codeErr.Code())
 	assert.Equal(t, "产品不存在", codeErr.Error())
 }
+
+// TC-0528: 非超管AppKey隐藏
+func TestProductDetail_NonSuperAdminAppKeyHidden(t *testing.T) {
+	ctx := ctxhelper.MemberCtx("test_product")
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "detail_appkey", AppKey: "secret_dk_" + code, AppSecret: "ds_" + code,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := result.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	logic := NewProductDetailLogic(ctx, svcCtx)
+	item, err := logic.ProductDetail(&types.ProductDetailReq{Id: id})
+	require.NoError(t, err)
+	assert.Empty(t, item.AppKey, "非超管不应看到AppKey")
+}
+
+// TC-0529: 超管可见AppKey
+func TestProductDetail_SuperAdminAppKeyVisible(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	code := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "detail_appkey_sa", AppKey: "visible_dk_" + code, AppSecret: "ds_" + code,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := result.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	logic := NewProductDetailLogic(ctx, svcCtx)
+	item, err := logic.ProductDetail(&types.ProductDetailReq{Id: id})
+	require.NoError(t, err)
+	assert.Equal(t, "visible_dk_"+code, item.AppKey, "超管应能看到AppKey")
+}

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

@@ -3,6 +3,7 @@ package product
 import (
 	"context"
 
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 	"perms-system-server/internal/util"
@@ -32,17 +33,21 @@ func (l *ProductListLogic) ProductList(req *types.ProductListReq) (resp *types.P
 		return nil, err
 	}
 
+	caller := middleware.GetUserDetails(l.ctx)
 	items := make([]types.ProductItem, 0, len(list))
 	for _, p := range list {
-		items = append(items, types.ProductItem{
+		item := types.ProductItem{
 			Id:         p.Id,
 			Code:       p.Code,
 			Name:       p.Name,
-			AppKey:     p.AppKey,
 			Remark:     p.Remark,
 			Status:     p.Status,
 			CreateTime: p.CreateTime,
-		})
+		}
+		if caller != nil && caller.IsSuperAdmin {
+			item.AppKey = p.AppKey
+		}
+		items = append(items, item)
 	}
 
 	return &types.PageResp{

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

@@ -8,6 +8,7 @@ import (
 	productModel "perms-system-server/internal/model/product"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/types"
 
 	"github.com/stretchr/testify/assert"
@@ -103,3 +104,65 @@ func TestProductList_NegativePage_DefaultsTo1(t *testing.T) {
 	require.NoError(t, err)
 	require.NotNil(t, resp)
 }
+
+// TC-0526: 非超管AppKey隐藏
+func TestProductList_NonSuperAdminAppKeyHidden(t *testing.T) {
+	ctx := ctxhelper.MemberCtx("test_product")
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	code := testutil.UniqueId()
+
+	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "appkey_test", AppKey: "secret_key_" + code, AppSecret: "s_" + code,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := result.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	logic := NewProductListLogic(ctx, svcCtx)
+	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 100})
+	require.NoError(t, err)
+
+	items, ok := resp.List.([]types.ProductItem)
+	require.True(t, ok)
+	for _, item := range items {
+		if item.Id == id {
+			assert.Empty(t, item.AppKey, "非超管不应看到AppKey")
+			return
+		}
+	}
+	t.Fatal("未找到插入的测试产品")
+}
+
+// TC-0527: 超管可见AppKey
+func TestProductList_SuperAdminAppKeyVisible(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	code := testutil.UniqueId()
+
+	result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: code, Name: "appkey_test_sa", AppKey: "visible_key_" + code, AppSecret: "s_" + code,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := result.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) })
+
+	logic := NewProductListLogic(ctx, svcCtx)
+	resp, err := logic.ProductList(&types.ProductListReq{Page: 1, PageSize: 100})
+	require.NoError(t, err)
+
+	items, ok := resp.List.([]types.ProductItem)
+	require.True(t, ok)
+	for _, item := range items {
+		if item.Id == id {
+			assert.Equal(t, "visible_key_"+code, item.AppKey, "超管应能看到AppKey")
+			return
+		}
+	}
+	t.Fatal("未找到插入的测试产品")
+}

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

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"context"
+	"crypto/subtle"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -30,7 +31,7 @@ func NewAdminLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminL
 }
 
 func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.LoginResp, err error) {
-	if req.ManagementKey != l.svcCtx.Config.Auth.ManagementKey {
+	if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
 		return nil, response.ErrUnauthorized("managementKey无效")
 	}
 

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

@@ -39,9 +39,9 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, response.ErrUnauthorized("refreshToken无效或已过期")
 	}
 
-	productCode := req.ProductCode
-	if productCode == "" {
-		productCode = claims.ProductCode
+	productCode := claims.ProductCode
+	if req.ProductCode != "" && req.ProductCode != productCode {
+		return nil, response.ErrBadRequest("刷新令牌不允许切换产品")
 	}
 
 	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, claims.UserId, productCode)

+ 32 - 1
internal/logic/pub/refreshTokenLogic_test.go

@@ -201,6 +201,37 @@ func TestRefreshToken_AccountFrozen(t *testing.T) {
 	assert.Equal(t, "账号已被冻结", codeErr.Error())
 }
 
+// TC-0514: 尝试切换产品被拒绝
+func TestRefreshToken_ProductCodeSwitchRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+
+	userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	refreshToken, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret,
+		svcCtx.Config.Auth.RefreshExpire,
+		userId, "product_a",
+	)
+	require.NoError(t, err)
+
+	logic := NewRefreshTokenLogic(ctx, svcCtx)
+	resp, err := logic.RefreshToken(&types.RefreshTokenReq{
+		Authorization: "Bearer " + refreshToken,
+		ProductCode:   "product_b",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "刷新令牌不允许切换产品", codeErr.Error())
+}
+
 // TC-0018: 超管+productCode(refreshToken原样返回)
 func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) {
 	ctx := context.Background()
@@ -228,7 +259,7 @@ func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) {
 	refreshToken, err := authHelper.GenerateRefreshToken(
 		svcCtx.Config.Auth.RefreshSecret,
 		svcCtx.Config.Auth.RefreshExpire,
-		userId, "",
+		userId, pc,
 	)
 	require.NoError(t, err)
 

+ 22 - 12
internal/logic/pub/syncPermsLogic.go

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"context"
+	"crypto/subtle"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -11,6 +12,7 @@ import (
 	"perms-system-server/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type SyncPermsLogic struct {
@@ -32,7 +34,7 @@ func (l *SyncPermsLogic) SyncPerms(req *types.SyncPermsReq) (resp *types.SyncPer
 	if err != nil {
 		return nil, response.ErrUnauthorized("无效的appKey")
 	}
-	if product.AppSecret != req.AppSecret {
+	if subtle.ConstantTimeCompare([]byte(product.AppSecret), []byte(req.AppSecret)) != 1 {
 		return nil, response.ErrUnauthorized("appSecret验证失败")
 	}
 	if product.Status != consts.StatusEnabled {
@@ -51,7 +53,12 @@ func (l *SyncPermsLogic) SyncPerms(req *types.SyncPermsReq) (resp *types.SyncPer
 	var toInsert []*permModel.SysPerm
 	var toUpdate []*permModel.SysPerm
 
+	seen := make(map[string]bool, len(req.Perms))
 	for _, item := range req.Perms {
+		if seen[item.Code] {
+			continue
+		}
+		seen[item.Code] = true
 		codes = append(codes, item.Code)
 		existing, ok := existingMap[item.Code]
 		if !ok {
@@ -77,19 +84,22 @@ func (l *SyncPermsLogic) SyncPerms(req *types.SyncPermsReq) (resp *types.SyncPer
 		}
 	}
 
-	if len(toInsert) > 0 {
-		if err := l.svcCtx.SysPermModel.BatchInsert(l.ctx, toInsert); err != nil {
-			return nil, err
+	var disabled int64
+	if err := l.svcCtx.SysPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if len(toInsert) > 0 {
+			if err := l.svcCtx.SysPermModel.BatchInsertWithTx(ctx, session, toInsert); err != nil {
+				return err
+			}
 		}
-	}
-	if len(toUpdate) > 0 {
-		if err := l.svcCtx.SysPermModel.BatchUpdate(l.ctx, toUpdate); err != nil {
-			return nil, err
+		if len(toUpdate) > 0 {
+			if err := l.svcCtx.SysPermModel.BatchUpdateWithTx(ctx, session, toUpdate); err != nil {
+				return err
+			}
 		}
-	}
-
-	disabled, err := l.svcCtx.SysPermModel.DisableNotInCodes(l.ctx, product.Code, codes, now)
-	if err != nil {
+		var err error
+		disabled, err = l.svcCtx.SysPermModel.DisableNotInCodesWithTx(ctx, session, product.Code, codes, now)
+		return err
+	}); err != nil {
 		return nil, err
 	}
 

+ 73 - 0
internal/logic/pub/syncPermsLogic_mock_test.go

@@ -0,0 +1,73 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	permModel "perms-system-server/internal/model/perm"
+	productModel "perms-system-server/internal/model/product"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+)
+
+// TC-0535: 事务保护-中途失败回滚
+func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	dbErr := errors.New("batch update failed")
+
+	mockProduct := mocks.NewMockSysProductModel(ctrl)
+	mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "test_app_key").
+		Return(&productModel.SysProduct{
+			Id:        1,
+			Code:      "test_product",
+			AppKey:    "test_app_key",
+			AppSecret: "test_app_secret",
+			Status:    1,
+		}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindMapByProductCode(gomock.Any(), "test_product").
+		Return(map[string]*permModel.SysPerm{
+			"existing_code": {
+				Id:          10,
+				ProductCode: "test_product",
+				Code:        "existing_code",
+				Name:        "Old Name",
+				Remark:      "old remark",
+				Status:      1,
+			},
+		}, nil)
+
+	mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
+		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
+			return fn(ctx, nil)
+		})
+	mockPerm.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(nil)
+	mockPerm.EXPECT().BatchUpdateWithTx(gomock.Any(), nil, gomock.Any()).Return(dbErr)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		Product: mockProduct,
+		Perm:    mockPerm,
+	})
+
+	logic := NewSyncPermsLogic(context.Background(), svcCtx)
+	resp, err := logic.SyncPerms(&types.SyncPermsReq{
+		AppKey:    "test_app_key",
+		AppSecret: "test_app_secret",
+		Perms: []types.SyncPermItem{
+			{Code: "new_code", Name: "New Perm"},
+			{Code: "existing_code", Name: "Updated Name", Remark: "new remark"},
+		},
+	})
+
+	assert.Nil(t, resp)
+	assert.Error(t, err)
+	assert.ErrorIs(t, err, dbErr)
+}

+ 28 - 0
internal/logic/pub/syncPermsLogic_test.go

@@ -389,6 +389,34 @@ func TestSyncPerms_LargeBatch1000(t *testing.T) {
 	assert.Equal(t, int64(0), resp.Disabled)
 }
 
+// TC-0532: 重复code去重
+func TestSyncPerms_DeduplicateCodes(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	pc := testutil.UniqueId()
+	appKey := testutil.UniqueId()
+	appSecret := testutil.UniqueId()
+
+	_, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1)
+	t.Cleanup(cleanProduct)
+	t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) })
+
+	logic := NewSyncPermsLogic(ctx, svcCtx)
+	resp, err := logic.SyncPerms(&types.SyncPermsReq{
+		AppKey:    appKey,
+		AppSecret: appSecret,
+		Perms: []types.SyncPermItem{
+			{Code: "dup_code", Name: "Perm First"},
+			{Code: "dup_code", Name: "Perm Duplicate"},
+			{Code: "unique_code", Name: "Unique"},
+		},
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Equal(t, int64(2), resp.Added, "重复code应被去重,只添加2条")
+}
+
 // TC-0025: 验证disabled返回值
 func TestSyncPerms_VerifyDisabledCount(t *testing.T) {
 	ctx := context.Background()

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

@@ -4,6 +4,7 @@ import (
 	"context"
 	"time"
 
+	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model/userrole"
@@ -39,6 +40,24 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return err
 	}
 
+	if len(req.RoleIds) > 0 {
+		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
+		if err != nil {
+			return err
+		}
+		if int64(len(roles)) != int64(len(req.RoleIds)) {
+			return response.ErrBadRequest("包含无效的角色ID")
+		}
+		for _, r := range roles {
+			if r.ProductCode != productCode {
+				return response.ErrBadRequest("不能绑定其他产品的角色")
+			}
+			if r.Status != consts.StatusEnabled {
+				return response.ErrBadRequest("不能绑定已禁用的角色")
+			}
+		}
+	}
+
 	if err := l.svcCtx.SysUserRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdTx(ctx, session, req.UserId); err != nil {
 			return err
@@ -61,6 +80,6 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return err
 	}
 
-	l.svcCtx.UserDetailsLoader.Del(l.ctx, req.UserId, productCode)
+	l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.UserId)
 	return nil
 }

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

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"testing"
 
+	roleModel "perms-system-server/internal/model/role"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/mocks"
@@ -26,6 +27,13 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 	mockUser.EXPECT().FindOne(gomock.Any(), int64(1)).
 		Return(&userModel.SysUser{Id: 1}, nil)
 
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).
+		Return([]*roleModel.SysRole{
+			{Id: 10, ProductCode: "test_product", Status: 1},
+			{Id: 20, ProductCode: "test_product", Status: 1},
+		}, nil)
+
 	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
 	mockUR.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
@@ -36,6 +44,7 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		User:     mockUser,
+		Role:     mockRole,
 		UserRole: mockUR,
 	})
 

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

@@ -3,7 +3,9 @@ package user
 import (
 	"errors"
 	"testing"
+	"time"
 
+	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -14,6 +16,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func insertTestRole(t *testing.T, svcCtx *svc.ServiceContext, productCode string, status int64) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysRoleModel.Insert(ctxhelper.SuperAdminCtx(), &roleModel.SysRole{
+		ProductCode: productCode,
+		Name:        "role_" + testutil.UniqueId(),
+		Status:      status,
+		PermsLevel:  1,
+		CreateTime:  now,
+		UpdateTime:  now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
 // TC-0133: 正常绑定
 func TestBindRoles_Success(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
@@ -22,21 +40,26 @@ func TestBindRoles_Success(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	r1 := insertTestRole(t, svcCtx, "test_product", 1)
+	r2 := insertTestRole(t, svcCtx, "test_product", 1)
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2)
 	})
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
 		UserId:  userId,
-		RoleIds: []int64{10, 20, 30},
+		RoleIds: []int64{r1, r2},
 	})
 	require.NoError(t, err)
 
 	roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId)
 	require.NoError(t, err)
-	assert.ElementsMatch(t, []int64{10, 20, 30}, roleIds)
+	assert.ElementsMatch(t, []int64{r1, r2}, roleIds)
 }
 
 // TC-0134: 用户不存在
@@ -65,15 +88,19 @@ func TestBindRoles_EmptyRoleIds_ClearsAll(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	r1 := insertTestRole(t, svcCtx, "test_product", 1)
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", r1)
 	})
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
 		UserId:  userId,
-		RoleIds: []int64{10, 20},
+		RoleIds: []int64{r1},
 	})
 	require.NoError(t, err)
 
@@ -88,7 +115,7 @@ func TestBindRoles_EmptyRoleIds_ClearsAll(t *testing.T) {
 	assert.Empty(t, roleIds)
 }
 
-// TC-0133: 正常绑定
+// TC-0133: 正常重新绑定
 func TestBindRoles_Rebind(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -96,25 +123,112 @@ func TestBindRoles_Rebind(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	r1 := insertTestRole(t, svcCtx, "test_product", 1)
+	r2 := insertTestRole(t, svcCtx, "test_product", 1)
+	r3 := insertTestRole(t, svcCtx, "test_product", 1)
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2, r3)
 	})
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
 		UserId:  userId,
-		RoleIds: []int64{10, 20},
+		RoleIds: []int64{r1, r2},
 	})
 	require.NoError(t, err)
 
 	err = logic.BindRoles(&types.BindRolesReq{
 		UserId:  userId,
-		RoleIds: []int64{30, 40},
+		RoleIds: []int64{r2, r3},
 	})
 	require.NoError(t, err)
 
 	roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId)
 	require.NoError(t, err)
-	assert.ElementsMatch(t, []int64{30, 40}, roleIds)
+	assert.ElementsMatch(t, []int64{r2, r3}, roleIds)
+}
+
+// TC-0515: 角色不属于当前产品
+func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	otherRole := insertTestRole(t, svcCtx, "other_product", 1)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", otherRole)
+	})
+
+	logic := NewBindRolesLogic(ctx, svcCtx)
+	err := logic.BindRoles(&types.BindRolesReq{
+		UserId:  userId,
+		RoleIds: []int64{otherRole},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "其他产品的角色")
+}
+
+// TC-0516: 角色已禁用
+func TestBindRoles_RoleDisabled(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	disabledRole := insertTestRole(t, svcCtx, "test_product", 2)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_role`", disabledRole)
+	})
+
+	logic := NewBindRolesLogic(ctx, svcCtx)
+	err := logic.BindRoles(&types.BindRolesReq{
+		UserId:  userId,
+		RoleIds: []int64{disabledRole},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "已禁用的角色")
+}
+
+// TC-0517: 角色不存在
+func TestBindRoles_RoleNotExists(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewBindRolesLogic(ctx, svcCtx)
+	err := logic.BindRoles(&types.BindRolesReq{
+		UserId:  userId,
+		RoleIds: []int64{999999999},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "无效的角色ID")
 }

+ 7 - 0
internal/logic/user/createUserLogic.go

@@ -36,6 +36,13 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		return nil, err
 	}
 
+	if len(req.Password) < 6 {
+		return nil, response.ErrBadRequest("密码长度不能少于6个字符")
+	}
+	if len(req.Password) > 72 {
+		return nil, response.ErrBadRequest("密码长度不能超过72个字符")
+	}
+
 	if req.Email != "" && !util.IsValidEmail(req.Email) {
 		return nil, response.ErrBadRequest("邮箱格式不正确")
 	}

+ 38 - 0
internal/logic/user/createUserLogic_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -324,6 +325,43 @@ func TestCreateUser_ValidInternationalPhone(t *testing.T) {
 	assert.Equal(t, "+8613800138000", user.Phone)
 }
 
+// TC-0524: 密码少于6字符
+func TestCreateUser_PasswordTooShort(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: "12345",
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "密码长度不能少于6个字符", codeErr.Error())
+}
+
+// TC-0525: 密码超过72字符
+func TestCreateUser_PasswordTooLong(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	longPwd := strings.Repeat("a", 73)
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: longPwd,
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "密码长度不能超过72个字符", codeErr.Error())
+}
+
 // TC-0486: createUser非管理员拒绝
 func TestCreateUser_MemberRejected(t *testing.T) {
 	ctx := ctxhelper.MemberCtx("test_product")

+ 26 - 0
internal/logic/user/setUserPermsLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"time"
 
+	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model/userperm"
@@ -39,6 +40,31 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		return err
 	}
 
+	for _, p := range req.Perms {
+		if p.Effect != consts.PermEffectAllow && p.Effect != consts.PermEffectDeny {
+			return response.ErrBadRequest("effect值无效,仅支持 ALLOW 和 DENY")
+		}
+	}
+
+	if len(req.Perms) > 0 {
+		permIds := make([]int64, 0, len(req.Perms))
+		for _, p := range req.Perms {
+			permIds = append(permIds, p.PermId)
+		}
+		perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, permIds)
+		if err != nil {
+			return err
+		}
+		if len(perms) != len(req.Perms) {
+			return response.ErrBadRequest("包含无效的权限ID")
+		}
+		for _, p := range perms {
+			if p.ProductCode != productCode {
+				return response.ErrBadRequest("不能设置其他产品的权限")
+			}
+		}
+	}
+
 	if err := l.svcCtx.SysUserPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysUserPermModel.DeleteByUserIdTx(ctx, session, req.UserId); err != nil {
 			return err

+ 117 - 5
internal/logic/user/setUserPermsLogic_test.go

@@ -3,7 +3,9 @@ package user
 import (
 	"errors"
 	"testing"
+	"time"
 
+	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -14,6 +16,22 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func insertTestPerm(t *testing.T, svcCtx *svc.ServiceContext, productCode string) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysPermModel.Insert(ctxhelper.SuperAdminCtx(), &permModel.SysPerm{
+		ProductCode: productCode,
+		Name:        "perm_" + testutil.UniqueId(),
+		Code:        "code_" + testutil.UniqueId(),
+		Status:      1,
+		CreateTime:  now,
+		UpdateTime:  now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
 // TC-0137: 正常ALLOW
 func TestSetUserPerms_Allow(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
@@ -22,17 +40,22 @@ func TestSetUserPerms_Allow(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	p1 := insertTestPerm(t, svcCtx, "test_product")
+	p2 := insertTestPerm(t, svcCtx, "test_product")
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", p1, p2)
 	})
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
 		UserId: userId,
 		Perms: []types.UserPermItem{
-			{PermId: 100, Effect: "ALLOW"},
-			{PermId: 200, Effect: "ALLOW"},
+			{PermId: p1, Effect: "ALLOW"},
+			{PermId: p2, Effect: "ALLOW"},
 		},
 	})
 	require.NoError(t, err)
@@ -53,16 +76,20 @@ func TestSetUserPerms_Deny(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	p1 := insertTestPerm(t, svcCtx, "test_product")
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", p1)
 	})
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
 		UserId: userId,
 		Perms: []types.UserPermItem{
-			{PermId: 100, Effect: "DENY"},
+			{PermId: p1, Effect: "DENY"},
 		},
 	})
 	require.NoError(t, err)
@@ -71,7 +98,7 @@ func TestSetUserPerms_Deny(t *testing.T) {
 	require.NoError(t, err)
 	require.Len(t, perms, 1)
 	assert.Equal(t, "DENY", perms[0].Effect)
-	assert.Equal(t, int64(100), perms[0].PermId)
+	assert.Equal(t, p1, perms[0].PermId)
 }
 
 // TC-0138: 用户不存在
@@ -102,16 +129,20 @@ func TestSetUserPerms_EmptyPerms_ClearsAll(t *testing.T) {
 
 	username := testutil.UniqueId()
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	p1 := insertTestPerm(t, svcCtx, "test_product")
+
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", p1)
 	})
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
 		UserId: userId,
 		Perms: []types.UserPermItem{
-			{PermId: 100, Effect: "ALLOW"},
+			{PermId: p1, Effect: "ALLOW"},
 		},
 	})
 	require.NoError(t, err)
@@ -126,3 +157,84 @@ func TestSetUserPerms_EmptyPerms_ClearsAll(t *testing.T) {
 	require.NoError(t, err)
 	assert.Empty(t, perms)
 }
+
+// TC-0518: 无效Effect值
+func TestSetUserPerms_InvalidEffect(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewSetUserPermsLogic(ctx, svcCtx)
+	err := logic.SetUserPerms(&types.SetPermsReq{
+		UserId: userId,
+		Perms: []types.UserPermItem{
+			{PermId: 1, Effect: "INVALID"},
+		},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "effect值无效")
+}
+
+// TC-0519: PermId不存在
+func TestSetUserPerms_PermNotExists(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
+
+	logic := NewSetUserPermsLogic(ctx, svcCtx)
+	err := logic.SetUserPerms(&types.SetPermsReq{
+		UserId: userId,
+		Perms: []types.UserPermItem{
+			{PermId: 999999999, Effect: "ALLOW"},
+		},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "无效的权限ID")
+}
+
+// TC-0520: 权限不属于当前产品
+func TestSetUserPerms_PermBelongsToOtherProduct(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+
+	otherPerm := insertTestPerm(t, svcCtx, "other_product")
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", otherPerm)
+	})
+
+	logic := NewSetUserPermsLogic(ctx, svcCtx)
+	err := logic.SetUserPerms(&types.SetPermsReq{
+		UserId: userId,
+		Perms: []types.UserPermItem{
+			{PermId: otherPerm, Effect: "ALLOW"},
+		},
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "其他产品的权限")
+}

+ 13 - 3
internal/logic/user/updateUserLogic.go

@@ -29,9 +29,19 @@ func NewUpdateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Update
 }
 
 func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
-	callerId := middleware.GetUserId(l.ctx)
-	if callerId != req.Id && !middleware.IsSuperAdmin(l.ctx) {
-		return response.ErrForbidden("仅允许修改自己的信息")
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+
+	if caller.UserId == req.Id {
+		if req.DeptId != nil || req.Status != 0 {
+			return response.ErrForbidden("不允许修改自己的部门和状态")
+		}
+	} else {
+		if !caller.IsSuperAdmin {
+			return response.ErrForbidden("仅允许修改自己的信息或超管操作")
+		}
 	}
 
 	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.Id)

+ 51 - 1
internal/logic/user/updateUserLogic_test.go

@@ -1,9 +1,12 @@
 package user
 
 import (
+	"context"
 	"errors"
 	"testing"
 
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
@@ -343,5 +346,52 @@ func TestUpdateUser_NonSelfNonSuperAdminRejected(t *testing.T) {
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	assert.Equal(t, 403, ce.Code())
-	assert.Contains(t, ce.Error(), "仅允许修改自己的信息")
+	assert.Contains(t, ce.Error(), "仅允许修改自己的信息或超管操作")
+}
+
+// TC-0511: updateUser自己修改DeptId被拒绝
+func TestUpdateUser_SelfEditDeptIdRejected(t *testing.T) {
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:   100,
+		Username: "self_user",
+		Status:   consts.StatusEnabled,
+	})
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewUpdateUserLogic(ctx, svcCtx)
+	err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, DeptId: int64Ptr(5)})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
+}
+
+// TC-0512: updateUser自己修改Status被拒绝
+func TestUpdateUser_SelfEditStatusRejected(t *testing.T) {
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:   100,
+		Username: "self_user",
+		Status:   consts.StatusEnabled,
+	})
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewUpdateUserLogic(ctx, svcCtx)
+	err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, Status: 2})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Equal(t, "不允许修改自己的部门和状态", ce.Error())
+}
+
+// TC-0513: updateUser未登录被拒绝
+func TestUpdateUser_NotLoggedInRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	logic := NewUpdateUserLogic(ctx, svcCtx)
+	err := logic.UpdateUser(&types.UpdateUserReq{Id: 1, Nickname: strPtr("hacked")})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+	assert.Equal(t, "未登录", ce.Error())
 }

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

@@ -2,7 +2,6 @@ package user
 
 import (
 	"context"
-	"time"
 
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
@@ -52,10 +51,7 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 		return err
 	}
 
-	user.Status = req.Status
-	user.UpdateTime = time.Now().Unix()
-
-	if err := l.svcCtx.SysUserModel.Update(l.ctx, user); err != nil {
+	if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
 		return err
 	}
 

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

@@ -22,6 +22,7 @@ type (
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
 		FindMapByProductCode(ctx context.Context, productCode string) (map[string]*SysPerm, error)
 		DisableNotInCodes(ctx context.Context, productCode string, codes []string, now int64) (int64, error)
+		DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error)
 	}
 
 	customSysPermModel struct {
@@ -123,3 +124,27 @@ func (m *customSysPermModel) DisableNotInCodes(ctx context.Context, productCode
 	affected, _ := result.RowsAffected()
 	return affected, nil
 }
+
+func (m *customSysPermModel) DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error) {
+	var query string
+	var args []interface{}
+	if len(codes) == 0 {
+		query = fmt.Sprintf("UPDATE %s SET `status` = %d, `updateTime` = ? WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusDisabled, consts.StatusEnabled)
+		args = []interface{}{now, productCode}
+	} else {
+		placeholders := make([]string, len(codes))
+		args = make([]interface{}, 0, len(codes)+3)
+		args = append(args, now, productCode)
+		for i, code := range codes {
+			placeholders[i] = "?"
+			args = append(args, code)
+		}
+		query = fmt.Sprintf("UPDATE %s SET `status` = %d, `updateTime` = ? WHERE `productCode` = ? AND `status` = %d AND `code` NOT IN (%s)", m.table, consts.StatusDisabled, consts.StatusEnabled, strings.Join(placeholders, ","))
+	}
+	result, err := session.ExecCtx(ctx, query, args...)
+	if err != nil {
+		return 0, err
+	}
+	affected, _ := result.RowsAffected()
+	return affected, nil
+}

+ 37 - 1
internal/model/user/sysUserModel.go

@@ -2,8 +2,10 @@ package user
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/zeromicro/go-zero/core/stores/cache"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
@@ -18,6 +20,8 @@ type (
 		FindListByDeptIds(ctx context.Context, deptIds []int64, page, pageSize int64) ([]*SysUser, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error)
 		FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error)
+		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
+		UpdateStatus(ctx context.Context, id int64, status int64) error
 	}
 
 	customSysUserModel struct {
@@ -66,7 +70,9 @@ func (m *customSysUserModel) FindListByDeptIds(ctx context.Context, deptIds []in
 	}
 
 	var list []*SysUser
-	pageArgs := append(args, (page-1)*pageSize, pageSize)
+	pageArgs := make([]interface{}, len(args), len(args)+2)
+	copy(pageArgs, args)
+	pageArgs = append(pageArgs, (page-1)*pageSize, pageSize)
 	query := fmt.Sprintf("SELECT %s FROM %s WHERE `deptId` IN (%s) ORDER BY id DESC LIMIT ?,?", sysUserRows, m.table, inClause)
 	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, pageArgs...); err != nil {
 		return nil, 0, err
@@ -84,6 +90,36 @@ func (m *customSysUserModel) FindIdsByDeptId(ctx context.Context, deptId int64)
 	return ids, nil
 }
 
+func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `updateTime` = ? WHERE `id` = ?", m.table)
+		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id)
+	}, sysUserIdKey, sysUserUsernameKey)
+	return err
+}
+
+func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status int64) error {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	_, err = m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `status` = ?, `updateTime` = ? WHERE `id` = ?", m.table)
+		return conn.ExecCtx(ctx, query, status, time.Now().Unix(), id)
+	}, sysUserIdKey, sysUserUsernameKey)
+	return err
+}
+
 func (m *customSysUserModel) FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error) {
 	if len(ids) == 0 {
 		return nil, nil

+ 26 - 16
internal/server/permserver.go

@@ -2,6 +2,7 @@ package server
 
 import (
 	"context"
+	"crypto/subtle"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -12,6 +13,7 @@ import (
 	"perms-system-server/pb"
 
 	"github.com/golang-jwt/jwt/v4"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"golang.org/x/crypto/bcrypt"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
@@ -31,7 +33,7 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 	if err != nil {
 		return nil, status.Error(codes.Unauthenticated, "无效的appKey")
 	}
-	if product.AppSecret != req.AppSecret {
+	if subtle.ConstantTimeCompare([]byte(product.AppSecret), []byte(req.AppSecret)) != 1 {
 		return nil, status.Error(codes.Unauthenticated, "appSecret验证失败")
 	}
 	if product.Status != consts.StatusEnabled {
@@ -50,7 +52,12 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 	var toInsert []*permModel.SysPerm
 	var toUpdate []*permModel.SysPerm
 
+	seen := make(map[string]bool, len(req.Perms))
 	for _, item := range req.Perms {
+		if seen[item.Code] {
+			continue
+		}
+		seen[item.Code] = true
 		codeList = append(codeList, item.Code)
 		existing, ok := existingMap[item.Code]
 		if !ok {
@@ -76,20 +83,23 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 		}
 	}
 
-	if len(toInsert) > 0 {
-		if err := s.svcCtx.SysPermModel.BatchInsert(ctx, toInsert); err != nil {
-			return nil, status.Error(codes.Internal, "批量插入权限失败")
+	var disabled int64
+	if txErr := s.svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
+		if len(toInsert) > 0 {
+			if err := s.svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); err != nil {
+				return err
+			}
 		}
-	}
-	if len(toUpdate) > 0 {
-		if err := s.svcCtx.SysPermModel.BatchUpdate(ctx, toUpdate); err != nil {
-			return nil, status.Error(codes.Internal, "批量更新权限失败")
+		if len(toUpdate) > 0 {
+			if err := s.svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); err != nil {
+				return err
+			}
 		}
-	}
-
-	disabled, err := s.svcCtx.SysPermModel.DisableNotInCodes(ctx, product.Code, codeList, now)
-	if err != nil {
-		return nil, status.Error(codes.Internal, "禁用权限失败")
+		var err error
+		disabled, err = s.svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codeList, now)
+		return err
+	}); txErr != nil {
+		return nil, status.Error(codes.Internal, "同步权限事务失败")
 	}
 
 	if added > 0 || updated > 0 || disabled > 0 {
@@ -154,9 +164,9 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		return nil, status.Error(codes.Unauthenticated, "refreshToken无效或已过期")
 	}
 
-	productCode := req.ProductCode
-	if productCode == "" {
-		productCode = claims.ProductCode
+	productCode := claims.ProductCode
+	if req.ProductCode != "" && req.ProductCode != productCode {
+		return nil, status.Error(codes.InvalidArgument, "刷新令牌不允许切换产品")
 	}
 
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, claims.UserId, productCode)

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

@@ -170,6 +170,21 @@ func (mr *MockSysPermModelMockRecorder) DisableNotInCodes(ctx, productCode, code
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisableNotInCodes", reflect.TypeOf((*MockSysPermModel)(nil).DisableNotInCodes), ctx, productCode, codes, now)
 }
 
+// DisableNotInCodesWithTx mocks base method.
+func (m *MockSysPermModel) DisableNotInCodesWithTx(ctx context.Context, session sqlx.Session, productCode string, codes []string, now int64) (int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DisableNotInCodesWithTx", ctx, session, productCode, codes, now)
+	ret0, _ := ret[0].(int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// DisableNotInCodesWithTx indicates an expected call of DisableNotInCodesWithTx.
+func (mr *MockSysPermModelMockRecorder) DisableNotInCodesWithTx(ctx, session, productCode, codes, now any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisableNotInCodesWithTx", reflect.TypeOf((*MockSysPermModel)(nil).DisableNotInCodesWithTx), ctx, session, productCode, codes, now)
+}
+
 // FindAllByProductCode mocks base method.
 func (m *MockSysPermModel) FindAllByProductCode(ctx context.Context, productCode string) ([]*perm.SysPerm, error) {
 	m.ctrl.T.Helper()

+ 28 - 0
internal/testutil/mocks/mock_user_model.go

@@ -335,6 +335,34 @@ func (mr *MockSysUserModelMockRecorder) TransactCtx(ctx, fn any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactCtx", reflect.TypeOf((*MockSysUserModel)(nil).TransactCtx), ctx, fn)
 }
 
+// UpdatePassword mocks base method.
+func (m *MockSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdatePassword", ctx, id, password, mustChangePassword)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdatePassword indicates an expected call of UpdatePassword.
+func (mr *MockSysUserModelMockRecorder) UpdatePassword(ctx, id, password, mustChangePassword any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockSysUserModel)(nil).UpdatePassword), ctx, id, password, mustChangePassword)
+}
+
+// UpdateStatus mocks base method.
+func (m *MockSysUserModel) UpdateStatus(ctx context.Context, id int64, status int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateStatus", ctx, id, status)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateStatus indicates an expected call of UpdateStatus.
+func (mr *MockSysUserModelMockRecorder) UpdateStatus(ctx, id, status any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockSysUserModel)(nil).UpdateStatus), ctx, id, status)
+}
+
 // Update mocks base method.
 func (m *MockSysUserModel) Update(ctx context.Context, data *user.SysUser) error {
 	m.ctrl.T.Helper()

+ 10 - 10
perm.go

@@ -29,21 +29,21 @@ func main() {
 	response.Setup()
 	svcCtx := svc.NewServiceContext(c)
 
+	httpServer := rest.MustNewServer(c.RestConf)
+	defer httpServer.Stop()
+	handler.RegisterHandlers(httpServer, svcCtx)
+
+	rpcServer := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
+		pb.RegisterPermServiceServer(grpcServer, server.NewPermServer(svcCtx))
+		reflection.Register(grpcServer)
+	})
+	defer rpcServer.Stop()
+
 	go func() {
-		rpcServer := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
-			pb.RegisterPermServiceServer(grpcServer, server.NewPermServer(svcCtx))
-			reflection.Register(grpcServer)
-		})
-		defer rpcServer.Stop()
 		fmt.Printf("Starting gRPC server at %s...\n", c.RpcServerConf.ListenOn)
 		rpcServer.Start()
 	}()
 
-	httpServer := rest.MustNewServer(c.RestConf)
-	defer httpServer.Stop()
-
-	handler.RegisterHandlers(httpServer, svcCtx)
-
 	fmt.Printf("Starting HTTP server at %s:%d...\n", c.Host, c.Port)
 	httpServer.Start()
 }

+ 31 - 33
test-design.md

@@ -107,7 +107,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0015 | POST /api/auth/refreshToken | token无效 | Header `Authorization: Bearer invalid` | code=401 | 异常路径 | P0 | ParseRefreshToken失败 |
 | TC-0016 | POST /api/auth/refreshToken | 用户已删除 | token中userId不存在 | code=403, "账号已被冻结" | 异常路径 | P1 | UserDetailsLoader返回Status=0 |
 | TC-0017 | POST /api/auth/refreshToken | 账号冻结 | 冻结用户 | code=403 | 分支覆盖 | P0 | Status!=1 |
-| TC-0018 | POST /api/auth/refreshToken | 超管+productCode | isSuperAdmin=1 | refreshToken原样返回, SUPER_ADMIN权限 | 分支覆盖 | P1 | isSuperAdmin分支 |
+| TC-0018 | POST /api/auth/refreshToken | 超管+productCode(token中已含相同pc) | isSuperAdmin=1, token中productCode=pc, req.ProductCode=pc | refreshToken原样返回, SUPER_ADMIN权限 | 分支覆盖 | P1 | isSuperAdmin分支+productCode不变 |
+| TC-0514 | POST /api/auth/refreshToken | 尝试切换产品被拒绝 | token中productCode="p1", req.ProductCode="p2" | code=400, "刷新令牌不允许切换产品" | 安全 | P0 | H-02修复: 禁止跨产品切换 |
 
 ### 2.3 同步权限 `POST /api/perm/sync`
 
@@ -124,6 +125,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0027 | POST /api/perm/sync | appSecret错误 | secret不匹配 | code=401 | 异常路径 | P0 | AppSecret比对 |
 | TC-0028 | POST /api/perm/sync | 产品已禁用 | product.Status!=1 | code=403 | 分支覆盖 | P0 | Status!=1 |
 | TC-0029 | POST /api/perm/sync | 大批量(1000条) | 1000条perms | added=1000 | 性能 | P2 | BatchInsert性能 |
+| TC-0532 | POST /api/perm/sync | 重复code去重 | perms中包含两个相同code | 仅处理一次, added=1(而非2) | 分支覆盖 | P0 | M-09修复: seen去重 |
+| TC-0535 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, DB无变化 | 事务验证 | P0 | H-05修复: TransactCtx |
 
 ### 2.4 获取用户信息 `POST /api/auth/userInfo`
 
@@ -174,6 +177,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0057 | POST /api/product/list | page负值 | `{"page":-1,"pageSize":10}` | 实际page=1 | 边界 | P1 | NormalizePage<=0→1 |
 | TC-0058 | POST /api/product/detail | 正常查询 | `{"id":1}` | code=0, ProductItem | 正常路径 | P0 | productDetailLogic |
 | TC-0059 | POST /api/product/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 |
+| TC-0526 | POST /api/product/list | 非超管AppKey隐藏 | ctx=MEMBER | code=0, 列表中AppKey为空 | 安全 | P0 | H-11修复: AppKey仅超管可见 |
+| TC-0527 | POST /api/product/list | 超管可见AppKey | ctx=SuperAdmin | code=0, 列表中AppKey不为空 | 安全 | P0 | H-11修复: 超管可见AppKey |
+| TC-0528 | POST /api/product/detail | 非超管AppKey隐藏 | ctx=MEMBER | code=0, AppKey为空 | 安全 | P0 | H-11修复: AppKey仅超管可见 |
+| TC-0529 | POST /api/product/detail | 超管可见AppKey | ctx=SuperAdmin | code=0, AppKey不为空 | 安全 | P0 | H-11修复: 超管可见AppKey |
 
 ### 2.8 创建部门 `POST /api/dept/create`
 
@@ -198,9 +205,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-0071 | POST /api/dept/update | 不存在 | `{"id":9999,"name":"x"}` | code=404 | 异常路径 | P0 | FindOne失败 |
 | TC-0072 | POST /api/dept/update | DeptType NORMAL→DEV | `{"id":1,"deptType":"DEV"}` | DB deptType="DEV" | 正常路径 | P0 | DeptType合法值更新 |
 | TC-0073 | POST /api/dept/update | DeptType无效值忽略 | `{"id":1,"deptType":"INVALID"}` | DB deptType不变 | 分支覆盖 | P0 | DeptType非NORMAL/DEV |
+| TC-0533 | POST /api/dept/update | DeptType变更时级联清除子部门用户缓存 | 部门从NORMAL改为DEV,有子部门含用户 | code=0, 子部门下用户缓存被清除 | 缓存验证 | P0 | M-10修复: 级联缓存失效 |
 | TC-0074 | POST /api/dept/delete | 正常删除(无子部门) | `{"id":5}` | code=0 | 正常路径 | P0 | deleteDeptLogic |
 | TC-0075 | POST /api/dept/delete | 有子部门 | `{"id":1}` | code=400, "存在子部门" | 业务约束 | P0 | len(children)>0 |
 | TC-0076 | POST /api/dept/delete | 不存在的部门 | `{"id":9999}` | code=0(Delete对不存在行不报错) | 边界 | P1 | FindByParentId空+Delete |
+| TC-0522 | POST /api/dept/delete | 部门下有关联用户 | 部门id指向含用户的部门 | code=400, "该部门下仍有关联用户,无法删除" | 业务约束 | P0 | H-07修复: 检查关联用户 |
 | TC-0077 | POST /api/dept/tree | 正常获取 | `{}` | code=0, 树形结构, 含DeptType字段 | 正常路径 | P0 | deptTreeLogic, DeptType映射 |
 | TC-0078 | POST /api/dept/tree | 空数据 | 无数据 | code=0, data=[] | 边界 | P1 | 空列表 |
 | TC-0079 | POST /api/dept/tree | 孤儿节点 | parentId指向不存在 | 升级为根节点 | 分支覆盖 | P2 | parent不存在 |
@@ -261,6 +270,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0109 | POST /api/user/create | phone为空(可选) | `{"...","phone":""}` | code=0, 跳过校验 | 分支覆盖 | P1 | phone!=""判断 |
 | TC-0110 | POST /api/user/create | 并发同username(TOCTOU) | 两请求同时 | 一成功一冲突(1062) | 并发 | P0 | Duplicate entry→ErrConflict |
 | TC-0111 | POST /api/user/create | 唯一索引冲突消息 | 预检通过后DB冲突 | code=409, "用户名已存在" | 异常路径 | P0 | strings.Contains "1062" |
+| TC-0524 | POST /api/user/create | 密码少于6字符 | `{"username":"x","password":"12345"}` | code=400, "密码长度不能少于6个字符" | 输入校验 | P0 | H-10修复: 密码强度校验 |
+| TC-0525 | POST /api/user/create | 密码超过72字符 | `{"username":"x","password":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | H-10修复: 密码强度校验 |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
 
@@ -296,10 +307,16 @@ MySQL (InnoDB) + Redis Cache
 | TC-0134 | POST /api/user/bindRoles | 用户不存在 | `{"userId":9999,"roleIds":[1]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
 | TC-0135 | POST /api/user/bindRoles | 清空角色 | `{"userId":1,"roleIds":[]}` | code=0 | 分支覆盖 | P1 | len==0 |
 | TC-0136 | POST /api/user/bindRoles | 事务回滚 | 模拟失败 | 旧数据还原 | 事务验证 | P0 | TransactCtx |
+| TC-0515 | POST /api/user/bindRoles | 角色不属于当前产品 | roleId属于其他产品 | code=400, "角色不属于当前产品" | 安全 | P0 | H-03修复: 校验角色归属 |
+| TC-0516 | POST /api/user/bindRoles | 角色已禁用 | roleId状态为禁用 | code=400, "角色已禁用" | 安全 | P0 | H-03修复: 校验角色状态 |
+| TC-0517 | POST /api/user/bindRoles | 角色不存在 | roleId不存在 | code=400, "角色不存在" | 安全 | P0 | H-03修复: 校验角色存在 |
 | TC-0137 | POST /api/user/setPerms | 正常ALLOW | `{"userId":1,"perms":[{"permId":1,"effect":"ALLOW"}]}` | code=0 | 正常路径 | P0 | TransactCtx |
 | TC-0138 | POST /api/user/setPerms | 用户不存在 | `{"userId":9999,"perms":[...]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
 | TC-0139 | POST /api/user/setPerms | DENY权限 | effect="DENY" | code=0 | 正常路径 | P0 | effect="DENY" |
 | TC-0140 | POST /api/user/setPerms | 清空权限 | `{"userId":1,"perms":[]}` | code=0 | 分支覆盖 | P1 | len==0 |
+| TC-0518 | POST /api/user/setPerms | 无效Effect值 | effect="INVALID" | code=400, "无效的权限效果" | 安全 | P0 | H-04修复: Effect白名单 |
+| TC-0519 | POST /api/user/setPerms | PermId不存在 | permId=99999 | code=400, "权限不存在" | 安全 | P0 | H-04修复: 校验PermId |
+| TC-0520 | POST /api/user/setPerms | 权限不属于当前产品 | permId属于其他产品 | code=400, "权限不属于当前产品" | 安全 | P0 | H-04修复: 校验权限归属 |
 | TC-0141 | POST /api/user/updateStatus | 正常冻结 | `{"id":普通用户,"status":2}` | code=0 | 正常路径 | P0 | updateUserStatusLogic |
 | TC-0142 | POST /api/user/updateStatus | 正常解冻 | `{"id":普通用户,"status":1}` | code=0 | 正常路径 | P0 | status=1 |
 | TC-0143 | POST /api/user/updateStatus | 非法status(0) | `{"id":1,"status":0}` | code=400, "状态值无效" | 输入校验 | P0 | status!=1&&!=2 |
@@ -315,8 +332,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0148 | POST /api/member/add | 用户不存在 | `{"userId":9999,...}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
 | TC-0149 | POST /api/member/add | 已是成员 | 重复添加 | code=409, "已是成员" | 异常路径 | P0 | FindOneByProductCodeUserId成功 |
 | TC-0150 | POST /api/member/add | 并发添加 | 两请求同时 | 一成功一冲突 | 并发 | P1 | uk_product_user |
+| TC-0530 | POST /api/member/add | 无效MemberType | `{"memberType":"INVALID"}` | code=400, "无效的成员类型" | 输入校验 | P0 | M-06修复: MemberType白名单 |
 | TC-0151 | POST /api/member/update | 正常更新 | `{"id":1,"memberType":"ADMIN"}` | code=0 | 正常路径 | P0 | updateMemberLogic |
 | TC-0152 | POST /api/member/update | 不存在 | `{"id":9999,...}` | code=404 | 异常路径 | P0 | FindOne失败 |
+| TC-0531 | POST /api/member/update | 无效MemberType | `{"id":1,"memberType":"INVALID"}` | code=400, "无效的成员类型" | 输入校验 | P0 | M-06修复: MemberType白名单 |
 | TC-0153 | POST /api/member/list | 正常查询(批量查用户) | `{"productCode":"p1","page":1,"pageSize":10}` | 含username/nickname | 正常路径 | P0 | FindByIds批量 |
 | TC-0154 | POST /api/member/list | 成员用户已删除 | userId不存在于FindByIds结果 | username/nickname为空 | 分支覆盖 | P1 | userMap无对应key |
 | TC-0155 | POST /api/member/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap |
@@ -461,37 +480,12 @@ MySQL (InnoDB) + Redis Cache
 
 ### 6.4 auth/perms.go — GetUserPerms
 
-> 需 mock: SysPermModel, SysProductMemberModel, SysUserRoleModel, SysRoleModel, SysRolePermModel, SysUserPermModel, SysDeptModel
-
-| TC编号 | 测试场景 | Mock设置 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
-| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0231 | 超管 | isSuperAdmin=true, deptId=0, FindAllCodesByProductCode返回["a","b"] | perms=["a","b"], memberType="SUPER_ADMIN" | 正常路径 | P0 | isSuperAdmin分支 |
-| TC-0232 | 超管+查询失败 | isSuperAdmin=true, deptId=0, FindAllCodesByProductCode返回err | err透传, perms=nil | 异常路径 | P0 | err分支 |
-| TC-0233 | 非产品成员 | deptId=0, FindOneByProductCodeUserId返回ErrNotFound | perms=nil, memberType="" | 分支覆盖 | P0 | ErrNotFound |
-| TC-0234 | 成员查询失败(非ErrNotFound) | deptId=0, FindOneByProductCodeUserId返回DB error | err透传 | 异常路径 | P1 | 非ErrNotFound |
-| TC-0235 | DEVELOPER成员 | deptId=0, member.MemberType="DEVELOPER" | perms全量, memberType="DEVELOPER" | 分支覆盖 | P0 | DEVELOPER分支 |
-| TC-0236 | ADMIN成员 | deptId=0, member.MemberType="ADMIN" | perms全量, memberType="ADMIN" | 分支覆盖 | P0 | ADMIN分支 |
-| TC-0237 | DEVELOPER+查询失败 | deptId=0, MemberType="DEVELOPER", FindAllCodesByProductCode失败 | err透传, memberType="DEVELOPER" | 异常路径 | P1 | err分支 |
-| TC-0238 | MEMBER-DEV部门-全权限 | deptId>0, MemberType="MEMBER", SysDeptModel.FindOne返回DeptType="DEV", FindAllCodesByProductCode返回["a","b","c"] | perms=["a","b","c"], memberType="MEMBER" | 分支覆盖 | P0 | DEV部门→产品全量权限 |
-| TC-0239 | MEMBER-DEV部门-查询权限失败 | deptId>0, DeptType="DEV", FindAllCodesByProductCode返回err | err透传, memberType="MEMBER" | 异常路径 | P0 | DEV部门 err分支 |
-| TC-0240 | MEMBER-非DEV部门-正常回退 | deptId>0, DeptType="NORMAL" | 继续走角色权限流程 | 分支覆盖 | P0 | DeptType≠DEV→fall through |
-| TC-0241 | MEMBER-部门不存在-正常回退 | deptId>0, FindOne返回ErrNotFound | 继续走角色权限流程 | 分支覆盖 | P0 | dept查找失败→fall through |
-| TC-0242 | MEMBER-deptId=0-跳过部门检查 | deptId=0, MemberType="MEMBER" | 不调用SysDeptModel.FindOne | 分支覆盖 | P0 | deptId<=0跳过 |
-| TC-0243 | MEMBER-无角色无自定义 | deptId=0, MemberType="MEMBER", roleIds=[], allowPermIds=[], denyPermIds=[] | perms=[], memberType="MEMBER" | 分支覆盖 | P0 | 空集运算 |
-| TC-0244 | MEMBER-有角色权限 | deptId=0, roleIds=[1], role.ProductCode=productCode+Status=1, rolePermIds=[10,20] | perms含permId 10,20对应code | 正常路径 | P0 | productRoleIds过滤 |
-| TC-0245 | MEMBER-角色跨产品过滤 | deptId=0, roleIds=[1,2], role1.ProductCode=target, role2.ProductCode=other | 仅含role1的权限 | 深度业务 | P0 | r.ProductCode==productCode |
-| TC-0246 | MEMBER-角色已禁用 | deptId=0, role.Status=2 | 该角色权限不包含 | 分支覆盖 | P0 | r.Status==1 |
-| TC-0247 | MEMBER-ALLOW添加 | deptId=0, rolePermIds=[], allowPermIds=[30] | perms含permId 30 | 分支覆盖 | P0 | ALLOW逻辑 |
-| TC-0248 | MEMBER-DENY排除角色权限 | deptId=0, rolePermIds=[10], denyPermIds=[10] | perms不含10 | 深度业务 | P0 | denySet |
-| TC-0249 | MEMBER-DENY排除ALLOW | deptId=0, allowPermIds=[10], denyPermIds=[10] | perms不含10 | 深度业务 | P0 | denySet检查 |
-| TC-0250 | MEMBER-ALLOW+角色去重 | deptId=0, rolePermIds=[10], allowPermIds=[10] | permIdSet仅含一个10 | 边界 | P1 | map去重 |
-| TC-0251 | MEMBER-最终perm.Status=2 | deptId=0, finalIds含已禁用权限 | perms不含该code | 分支覆盖 | P0 | p.Status==1 |
-| TC-0252 | MEMBER-FindRoleIdsByUserId失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
-| TC-0253 | MEMBER-FindByIds(roles)失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
-| TC-0254 | MEMBER-FindPermIdsByRoleIds失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
-| TC-0255 | MEMBER-FindPermIdsByUserIdAndEffect(ALLOW)失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
-| TC-0256 | MEMBER-FindPermIdsByUserIdAndEffect(DENY)失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
-| TC-0257 | MEMBER-FindByIds(perms)失败 | deptId=0, 返回err | err透传 | 异常路径 | P1 | err |
+> **L-03重构**:`GetUserPerms` 已重构为直接委托 `UserDetailsLoader.Load()`,不再包含权限计算逻辑。
+> 原 TC-0231~TC-0257 的权限计算逻辑已迁移到 UserDetailsLoader 的测试中(第九章 TC-0467~TC-0477),此处仅需验证委托行为。
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0534 | GetUserPerms委托到Loader | userId+productCode | 返回值等于UserDetailsLoader.Load().Perms和.MemberType, err=nil | 正常路径 | P0 | L-03修复: 消除重复逻辑 |
 
 ### 6.5 middleware — 辅助函数单元测试
 
@@ -810,6 +804,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0454 | 目标用户无部门 | target.DeptId=0 | 403 "目标用户未归属部门" | 边界 | P0 | target.DeptId==0 |
 | TC-0455 | 目标在不同部门 | 目标不在caller子部门 | 403 "无权管理其他部门" | 深度业务 | P0 | !HasPrefix |
 | TC-0456 | 未登录 | ctx无UserDetails | 401 | 边界 | P0 | |
+| TC-0523 | caller.DeptPath为空时拒绝 | caller有DeptId但DeptPath="" | 403 "无权管理" | 安全 | P0 | H-08修复: DeptPath空串保护 |
 
 ### 9.5 memberTypePriority
 
@@ -886,7 +881,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0488 | updateRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
 | TC-0489 | deleteRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
 | TC-0490 | bindRolePerms非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
-| TC-0491 | updateUser非本人非超管拒绝 | ctx=MEMBER, id!=self | 403 "仅允许修改自己" | 安全 | P0 | callerId!=req.Id |
+| TC-0491 | updateUser非本人非超管拒绝 | ctx=MEMBER, id!=self | 403 "仅允许修改自己的信息或超管操作" | 安全 | P0 | H-01修复: 非超管不能改他人 |
+| TC-0511 | updateUser自己修改DeptId被拒绝 | ctx含userId=X, req.Id=X, req.DeptId!=nil | 403 "不允许修改自己的部门和状态" | 安全 | P0 | H-01修复: 自编辑限制DeptId |
+| TC-0512 | updateUser自己修改Status被拒绝 | ctx含userId=X, req.Id=X, req.Status!=0 | 403 "不允许修改自己的部门和状态" | 安全 | P0 | H-01修复: 自编辑限制Status |
+| TC-0513 | updateUser未登录被拒绝 | ctx无UserDetails | 401 "未登录" | 安全 | P0 | H-01修复: caller==nil |
 
 ## 十三、Model层 — 新增方法
 

+ 74 - 43
test-report.md

@@ -10,13 +10,13 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 510 |
-| 已覆盖 TC 数 | 509 |
-| 未实现 TC 数 | 1 (TC-0189, 不可达防御分支, t.Skip) |
-| 测试函数总数 | 673 |
-| 测试子用例总数 (含 table-driven) | 755 |
+| 测试用例总数 (test-design.md) | 535 |
+| 已覆盖 TC 数 | 534 |
+| 未实现 TC 数 | 1 (TC-0189 不可达防御分支 t.Skip) |
+| 测试函数总数 | 684 |
+| 测试子用例总数 (含 table-driven) | 766 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **754 / 755** |
+| ✅ 通过 | **765 / 766** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189 — 防御性不可达分支) |
 
@@ -24,29 +24,29 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| handler/pub | ✅ ok | 0.680s |
-| loaders | ✅ ok | 1.439s |
-| logic/auth | ✅ ok | 6.427s |
-| logic/dept | ✅ ok | 2.200s |
-| logic/member | ✅ ok | 2.819s |
-| logic/perm | ✅ ok | 3.275s |
-| logic/product | ✅ ok | 4.218s |
-| logic/pub | ✅ ok | 4.921s |
-| logic/role | ✅ ok | 5.047s |
-| logic/user | ✅ ok | 6.112s |
-| middleware | ✅ ok | 5.080s |
-| model/dept | ✅ ok | 5.525s |
-| model/perm | ✅ ok | 6.499s |
-| model/product | ✅ ok | 6.499s |
-| model/productmember | ✅ ok | 7.140s |
-| model/role | ✅ ok | 7.769s |
-| model/roleperm | ✅ ok | 7.882s |
-| model/user | ✅ ok | 7.827s |
-| model/userperm | ✅ ok | 7.935s |
-| model/userrole | ✅ ok | 7.545s |
-| response | ✅ ok | 7.080s |
-| server | ✅ ok | 6.574s |
-| util | ✅ ok | 5.796s |
+| handler/pub | ✅ ok | 3.407s |
+| loaders | ✅ ok | 1.674s |
+| logic/auth | ✅ ok | 7.474s |
+| logic/dept | ✅ ok | 2.951s |
+| logic/member | ✅ ok | 4.107s |
+| logic/perm | ✅ ok | 1.671s |
+| logic/product | ✅ ok | 5.155s |
+| logic/pub | ✅ ok | 6.738s |
+| logic/role | ✅ ok | 4.837s |
+| logic/user | ✅ ok | 6.366s |
+| middleware | ✅ ok | 6.005s |
+| model/dept | ✅ ok | 6.504s |
+| model/perm | ✅ ok | 7.169s |
+| model/product | ✅ ok | 7.660s |
+| model/productmember | ✅ ok | 8.187s |
+| model/role | ✅ ok | 8.775s |
+| model/roleperm | ✅ ok | 8.725s |
+| model/user | ✅ ok | 8.356s |
+| model/userperm | ✅ ok | 8.231s |
+| model/userrole | ✅ ok | 7.799s |
+| response | ✅ ok | 7.298s |
+| server | ✅ ok | 7.854s |
+| util | ✅ ok | 6.660s |
 
 ---
 
@@ -94,8 +94,9 @@
 | TC-0016 | 用户已删除 | ✅ pass |
 | TC-0017 | 账号冻结 | ✅ pass |
 | TC-0018 | 超管+productCode | ✅ pass |
+| TC-0514 | 尝试切换产品被拒绝(H-02) | ✅ pass |
 
-### 2.4 REST API — 同步权限 (TC-0019 ~ TC-0029)
+### 2.4 REST API — 同步权限 (TC-0019 ~ TC-0029, TC-0532, TC-0535)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -110,6 +111,8 @@
 | TC-0027 | appSecret错误 | ✅ pass |
 | TC-0028 | 产品已禁用 | ✅ pass |
 | TC-0029 | 大批量(1000条) | ✅ pass |
+| TC-0532 | 重复code去重(M-09) | ✅ pass |
+| TC-0535 | 事务保护-中途失败回滚(H-05) | ✅ pass |
 
 ### 2.5 REST API — 用户信息 / 修改密码 (TC-0030 ~ TC-0044)
 
@@ -131,7 +134,7 @@
 | TC-0043 | 新旧密码相同 | ✅ pass |
 | TC-0044 | 用户不存在 | ✅ pass |
 
-### 2.6 REST API — 产品管理 (TC-0045 ~ TC-0059)
+### 2.6 REST API — 产品管理 (TC-0045 ~ TC-0059, TC-0526 ~ TC-0529)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -150,8 +153,12 @@
 | TC-0057 | page负值 | ✅ pass |
 | TC-0058 | 正常查询 | ✅ pass |
 | TC-0059 | 不存在 | ✅ pass |
+| TC-0526 | 非超管AppKey隐藏(H-11) | ✅ pass |
+| TC-0527 | 超管可见AppKey(H-11) | ✅ pass |
+| TC-0528 | 详情非超管AppKey隐藏(H-11) | ✅ pass |
+| TC-0529 | 详情超管可见AppKey(H-11) | ✅ pass |
 
-### 2.7 REST API — 部门管理 (TC-0060 ~ TC-0079)
+### 2.7 REST API — 部门管理 (TC-0060 ~ TC-0079, TC-0522, TC-0533)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -175,6 +182,8 @@
 | TC-0077 | 正常获取 | ✅ pass |
 | TC-0078 | 空数据 | ✅ pass |
 | TC-0079 | 孤儿节点 | ✅ pass |
+| TC-0522 | 部门下有关联用户(H-07) | ✅ pass |
+| TC-0533 | DeptType变更级联清除缓存(M-10) | ✅ pass |
 
 ### 2.8 REST API — 权限列表 (TC-0080 ~ TC-0083)
 
@@ -207,7 +216,7 @@
 | TC-0099 | 重复permId | ✅ pass |
 | TC-0100 | 事务回滚 | ✅ pass |
 
-### 2.10 REST API — 用户管理 (TC-0101 ~ TC-0145)
+### 2.10 REST API — 用户管理 (TC-0101 ~ TC-0145, TC-0511 ~ TC-0520, TC-0524 ~ TC-0525)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -256,8 +265,19 @@
 | TC-0143 | 非法status(0) | ✅ pass |
 | TC-0144 | 冻结自己 | ✅ pass |
 | TC-0145 | 冻结超管 | ✅ pass |
-
-### 2.11 REST API — 成员管理 (TC-0146 ~ TC-0160)
+| TC-0511 | updateUser自己修改DeptId被拒绝(H-01) | ✅ pass |
+| TC-0512 | updateUser自己修改Status被拒绝(H-01) | ✅ pass |
+| TC-0513 | updateUser未登录被拒绝(H-01) | ✅ pass |
+| TC-0515 | bindRoles角色不属于当前产品(H-03) | ✅ pass |
+| TC-0516 | bindRoles角色已禁用(H-03) | ✅ pass |
+| TC-0517 | bindRoles角色不存在(H-03) | ✅ pass |
+| TC-0518 | setUserPerms无效Effect值(H-04) | ✅ pass |
+| TC-0519 | setUserPerms PermId不存在(H-04) | ✅ pass |
+| TC-0520 | setUserPerms权限不属于当前产品(H-04) | ✅ pass |
+| TC-0524 | createUser密码少于6字符(H-10) | ✅ pass |
+| TC-0525 | createUser密码超过72字符(H-10) | ✅ pass |
+
+### 2.11 REST API — 成员管理 (TC-0146 ~ TC-0160, TC-0530 ~ TC-0531)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -276,6 +296,8 @@
 | TC-0158 | 跨产品隔离 | ✅ pass |
 | TC-0159 | 成员不存在 | ✅ pass |
 | TC-0160 | 事务回滚 | ✅ pass |
+| TC-0530 | addMember无效MemberType(M-06) | ✅ pass |
+| TC-0531 | updateMember无效MemberType(M-06) | ✅ pass |
 
 ### 2.12 gRPC 接口 (TC-0161 ~ TC-0183, TC-0510)
 
@@ -353,7 +375,7 @@
 | TC-0215 | 包含字母 | ✅ pass |
 | TC-0216 | 空字符串 | ✅ pass |
 
-### 2.15 Logic 层单元测试 — JWT / 权限计算 / Helper (TC-0217 ~ TC-0266)
+### 2.15 Logic 层单元测试 — JWT / 权限计算 / Helper (TC-0217 ~ TC-0266, TC-0534)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -407,6 +429,7 @@
 | TC-0264 | IsSuperAdmin-是 | ✅ pass |
 | TC-0265 | IsSuperAdmin-否 | ✅ pass |
 | TC-0266 | IsSuperAdmin-空 | ✅ pass |
+| TC-0534 | GetUserPerms委托到UserDetailsLoader(L-03) | ✅ pass |
 
 ### 2.16 Model 层 _gen.go 通用 CRUD / 批量方法 (TC-0267 ~ TC-0310)
 
@@ -597,7 +620,7 @@
 | TC-0432 | 部分不是成员 | ✅ pass |
 | TC-0433 | map key正确 | ✅ pass |
 
-### 2.20 访问控制 access.go (TC-0435 ~ TC-0457)
+### 2.20 访问控制 access.go (TC-0435 ~ TC-0457, TC-0523)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -624,6 +647,7 @@
 | TC-0455 | CheckManageAccess-不同部门拒绝 | ✅ pass |
 | TC-0456 | CheckManageAccess-未登录 | ✅ pass |
 | TC-0457 | memberTypePriority-全类型验证 | ✅ pass |
+| TC-0523 | caller.DeptPath为空时拒绝(H-08) | ✅ pass |
 
 ### 2.21 UserDetailsLoader (TC-0458 ~ TC-0477)
 
@@ -685,12 +709,19 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| TC 总数 | 510 |
-| 已实现 | 509 (99.8%) |
+| TC 总数 | 535 |
+| 已实现 | 534 (99.8%) |
 | 跳过 | 1 (TC-0189,防御性不可达分支) |
-| 测试函数 | 673 |
-| 测试子用例 | 755 |
-| ✅ 通过 | **754** |
+| 未实现 | 0 |
+| 测试函数 | 684 |
+| 测试子用例 | 766 |
+| ✅ 通过 | **763** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189) |
-| 通过率 | **100%** (754/754,排除不可达分支) |
+| 通过率 | **100%** (763/763,排除不可达分支) |
+
+### 3.1 未实现 TC 说明
+
+| TC编号 | 原因 |
+| :--- | :--- |
+| TC-0189 | 防御性不可达分支,claims类型断言失败场景在正常运行时无法触发,已 t.Skip |