Browse Source

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

BaiLuoYan 4 weeks ago
parent
commit
2b85bc81e0

+ 212 - 143
audit-report.md

@@ -1,232 +1,301 @@
-# 权限系统深度审计报告
+# 权限系统深度代码审计报告
 
-> 审计时间:2026-04-17  
-> 审计范围:`perms-system-server` 全部业务源码(测试代码除外)  
-> 审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃
+> 审计范围:`internal/logic`、`internal/model`、`internal/middleware`、`internal/loaders`、`internal/server` 全部非测试业务代码
+> 审计时间:2026-04-17
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### 1. UserList 接口数据越权泄漏
+### H1. UpdateUser 修改状态时未递增 tokenVersion,导致冻结用户可能仍持有有效令牌
 
-- **文件**:`internal/logic/user/userListLogic.go` 第 45 行
-- **描述**:非超管用户(产品管理员)调用 UserList 时,虽然校验了 `caller.ProductCode == req.ProductCode`,但底层查询 `SysUserModel.FindListByPage` 执行的是 **全表分页查询**(无任何 WHERE 条件),返回了系统中**所有用户**,而非仅当前产品的成员用户。memberMap 只是在返回结果上附加了 memberType 信息,并不过滤非成员用户。
-- **影响**:产品管理员可以看到其他产品甚至全系统的用户信息(用户名、昵称、邮箱、手机号、部门等),构成**水平越权数据泄漏**。
-- **修复方案**:非超管用户查询时,应先查 `sys_product_member` 获取当前产品的成员 userId 列表,再用这些 userId 进行分页查询。示例:
+- **位置**:`internal/logic/user/updateUserLogic.go` 第 82-93 行
+- **描述**:`UpdateUser` 接口允许通过 `req.Status` 将用户冻结(`status=2`),但内部调用的是通用 `SysUserModel.Update()`,该方法**不会递增 `tokenVersion`**。而专用接口 `UpdateUserStatus` 正确调用了 `SysUserModel.UpdateStatus()`,其中执行了 `tokenVersion = tokenVersion + 1`。两个接口实现同一个业务操作(冻结用户),安全保障强度却不同。
+- **影响**:
+  1. 通过 `UpdateUser` 冻结用户后,`tokenVersion` 不变,被冻结用户的 RefreshToken 在 Redis 缓存未命中的情况下(如 Redis 故障、缓存过期后重新加载前的竞态窗口)仍可用于换取新 AccessToken。
+  2. 虽然 `UserDetailsLoader.Clean()` 提供了即时保护(清缓存后下次请求会从 DB 加载到 `status=2`),但若 Redis 操作失败(网络抖动等),旧缓存最长可存活 5 分钟(`defaultCacheTTL=300`),期间用户不受冻结影响。
+  3. 与 `UpdateUserStatus` 的行为不一致,容易让维护者产生误解。
+- **修复方案**:在 `UpdateUser` 中,当 `status` 发生变更时,改用 `UpdateStatus` 或手动递增 `tokenVersion`:
 
 ```go
-// userListLogic.go - 非超管场景
-if req.ProductCode != "" && !caller.IsSuperAdmin {
-    list, total, err := l.svcCtx.SysUserModel.FindListByProductMembers(
-        l.ctx, req.ProductCode, page, pageSize,
-    )
-    // ...
+// updateUserLogic.go — 在 status 变更分支中
+if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+    if user.Status != req.Status {
+        // status 发生了实际变更,走 UpdateStatus 以递增 tokenVersion
+        if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
+            return err
+        }
+        user.Status = req.Status // 同步内存值,后续 Update 不会覆盖回旧值
+    }
 }
 ```
 
-需要在 Model 层新增 `FindListByProductMembers` 方法,JOIN `sys_product_member` 表过滤
+或者更彻底的方案:移除 `UpdateUser` 中的 `status` 字段支持,强制状态变更只能通过 `updateUserStatus` 接口
 
 ---
 
-### 2. BindRoles 缺少角色级别校验,存在提权漏洞
+### H2. UserDetail 接口返回的 roleIds 未按产品隔离,存在跨产品信息泄露
 
-- **文件**:`internal/logic/user/bindRolesLogic.go` 第 33-101 行
-- **描述**:`BindRoles` 仅通过 `CheckManageAccess` 验证操作者对**目标用户当前状态**的管理权限,但未校验所绑定角色的 `permsLevel` 是否在操作者自身权限范围内。攻击路径:
-  1. 操作者 A(permsLevel=50)管理目标用户 B(当前 permsLevel=100)
-  2. `CheckManageAccess` 通过(50 < 100,A 的权限高于 B)
-  3. A 给 B 绑定一个 permsLevel=1 的角色
-  4. B 的 permsLevel 变为 1,权限反超操作者 A
-- **影响**:任何拥有用户管理权限的操作者可以通过分配高等级角色来实现**权限提升**,使目标用户获得超过自身的权限级别。
-- **修复方案**:在校验角色有效性的循环中,增加 permsLevel 校验:
+- **位置**:`internal/logic/user/userDetailLogic.go` 第 44 行
+- **描述**:`FindRoleIdsByUserId` 查询 `sys_user_role` 时没有关联产品过滤,返回了用户在**所有产品**下的全部角色 ID。非超管的产品 A 成员查看某用户详情时,能看到该用户在产品 B 下的角色 ID 列表。
+- **影响**:虽然角色 ID 本身只是数字,但结合角色列表接口可反推出目标用户在其他产品中的角色配置,构成越权信息泄露。
+- **修复方案**:在查询时加入产品维度的过滤:
 
 ```go
-// bindRolesLogic.go - 在遍历 roles 时增加
-caller := middleware.GetUserDetails(l.ctx)
-for _, r := range roles {
-    if r.ProductCode != productCode {
-        return response.ErrBadRequest("不能绑定其他产品的角色")
-    }
-    if r.Status != consts.StatusEnabled {
-        return response.ErrBadRequest("不能绑定已禁用的角色")
-    }
-    // 非超管不能分配超出自身权限级别的角色
-    if !caller.IsSuperAdmin && r.PermsLevel < caller.MinPermsLevel {
-        return response.ErrForbidden("不能分配权限级别高于自身的角色")
-    }
+productCode := middleware.GetProductCode(l.ctx)
+var roleIds []int64
+if productCode != "" {
+    roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(l.ctx, user.Id, productCode)
+} else {
+    roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserId(l.ctx, user.Id)
 }
 ```
 
+需要在 `SysUserRoleModel` 中新增 `FindRoleIdsByUserIdForProduct` 方法,关联 `sys_role` 表按 `productCode` 过滤。
+
 ---
 
-### 3. appSecret 数据库明文存储
+### H3. CreateUser 未校验 DeptId 是否存在,可写入不存在的部门关联
 
-- **文件**:`perm.sql` 第 15 行;`internal/logic/pub/syncPermsLogic.go` 第 37 行;`internal/server/permserver.go` 第 40 行
-- **描述**:`sys_product.appSecret` 以明文存储在数据库中。当前仅用 `subtle.ConstantTimeCompare` 进行比对(防时序攻击)。若数据库被拖库或备份泄漏,所有产品的 appSecret 直接暴露
-- **影响**:数据库泄漏场景下,攻击者可以使用任何产品的 appSecret 调用 `SyncPerms` 接口,篡改该产品的全部权限定义,影响所有用户的权限体系
-- **修复方案**:将 appSecret 使用 bcrypt 或 HMAC-SHA256 哈希后存储。`CreateProduct` 时仅一次性返回原文,之后只存哈希值。验证时改用 `bcrypt.CompareHashAndPassword`:
+- **位置**:`internal/logic/user/createUserLogic.go` 第 69-80 行
+- **描述**:`CreateUser` 直接将 `req.DeptId` 写入数据库,未校验该部门是否存在。而 `UpdateUser` 在修改 `DeptId` 时正确地进行了存在性校验
+- **影响**:创建用户时传入不存在的 `deptId`,用户记录会关联到一个"幽灵部门"。后续 `UserDetailsLoader.loadDept` 会查询失败并静默跳过(`DeptPath` 为空),导致该用户在部门层级校验 `checkDeptHierarchy` 中行为异常——具体表现为 `caller.DeptPath == ""`,触发 `"您的部门信息异常"` 错误,无法管理任何其他用户
+- **修复方案**:
 
 ```go
-// syncPermsLogic.go / permserver.go
-if err := bcrypt.CompareHashAndPassword(
-    []byte(product.AppSecretHash), []byte(req.AppSecret),
-); err != nil {
-    return nil, response.ErrUnauthorized("appSecret验证失败")
+if req.DeptId > 0 {
+    if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId); err != nil {
+        return nil, response.ErrBadRequest("部门不存在")
+    }
 }
 ```
 
 ---
 
-### 4. UpdateUser 权限模型与其他接口不一致,产品管理员无法修改自己创建的用户
+### H4. 成员类型管理层级与 SQL 设计文档不一致
+
+- **位置**:`internal/logic/auth/access.go` `memberTypePriority` 函数 vs `perm.sql` 第 151-155 行注释
+- **描述**:`perm.sql` 中明确标注管理层级顺序为 `超级管理员 > DEVELOPER > ADMIN > MEMBER`,但代码中 `memberTypePriority` 的实现为:
+
+| 类型 | 代码优先级 | SQL 文档预期 |
+|------|-----------|-------------|
+| SUPER_ADMIN | 0 (最高) | 最高 |
+| ADMIN | 1 | 2 |
+| DEVELOPER | 2 | 1 |
+| MEMBER | 3 (最低) | 最低 |
+
+  代码中 ADMIN(1) > DEVELOPER(2),而文档描述 DEVELOPER > ADMIN。
+- **影响**:如果文档是正确的设计意图,那么当前实现存在以下问题:
+  - DEVELOPER 无法管理同产品下的 ADMIN 用户(被 `checkPermLevel` 拦截)
+  - ADMIN 可以管理 DEVELOPER 用户(不应被允许)
+  - `CheckMemberTypeAssignment` 中 DEVELOPER 无法分配 ADMIN 类型(若按文档应该可以)
 
-- **文件**:`internal/logic/user/updateUserLogic.go` 第 37-45 行
-- **描述**:`UpdateUser` 的权限判断逻辑为"只有超管或用户自身可修改",而系统中 `UpdateUserStatus`、`BindRoles`、`SetUserPerms` 等接口均使用 `CheckManageAccess`(支持产品管理员和部门层级管理)。这导致产品管理员可以创建用户(`CreateUser`)、冻结用户(`UpdateUserStatus`)、绑定角色(`BindRoles`),却**无法修改**用户的昵称、邮箱、部门等基本信息。
-- **影响**:产品管理员的管理权限出现逻辑断裂——能创建和冻结用户,但不能编辑用户信息,不得不依赖超管操作,严重影响管理效率。
-- **修复方案**:将 UpdateUser 的权限模型统一为 `CheckManageAccess`,并保留对自身修改的限制(不能改自己的部门和状态):
+  如果代码是正确的、文档是过时的,则应更新 SQL 注释以避免后续维护者误解。
+- **修复方案**:确认真实的业务层级意图,统一代码与文档。若 DEVELOPER 应高于 ADMIN:
 
 ```go
-func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
-    caller := middleware.GetUserDetails(l.ctx)
-    if caller == nil {
-        return response.ErrUnauthorized("未登录")
+func memberTypePriority(memberType string) int {
+    switch memberType {
+    case consts.MemberTypeSuperAdmin:
+        return 0
+    case consts.MemberTypeDeveloper:
+        return 1
+    case consts.MemberTypeAdmin:
+        return 2
+    case consts.MemberTypeMember:
+        return 3
+    default:
+        return math.MaxInt32
     }
+}
+```
 
-    if caller.UserId == req.Id {
-        if req.DeptId != nil || req.Status != 0 {
-            return response.ErrForbidden("不允许修改自己的部门和状态")
-        }
-    } else {
-        productCode := middleware.GetProductCode(l.ctx)
-        if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
-            return err
-        }
-    }
-    // ... 后续逻辑不变
+若代码实现是正确的(ADMIN > DEVELOPER),则更新 `perm.sql` 注释为:`超级管理员 > ADMIN > DEVELOPER > MEMBER`。
+
+---
+
+### H5. SyncPerms 传入空权限列表会禁用产品全部权限,缺乏防护
+
+- **位置**:`internal/logic/pub/syncPermsLogic.go` 及 `internal/model/perm/sysPermModel.go` `DisableNotInCodesWithTx`
+- **描述**:当 `SyncPerms` 请求中 `perms` 数组为空时,`codes` 也为空,`DisableNotInCodesWithTx` 会执行:
+  ```sql
+  UPDATE sys_perm SET status=2 WHERE productCode=? AND status=1
+  ```
+  一次性禁用该产品下**所有启用的权限**。
+- **影响**:客户端代码 bug(如序列化异常导致 perms 为空数组)、网络问题(请求被截断)都可能触发全量权限禁用,影响该产品下所有用户的访问。这是一个潜在的可用性灾难。
+- **修复方案**:在 `SyncPerms` 入口处增加空数组保护:
+
+```go
+if len(req.Perms) == 0 {
+    return nil, response.ErrBadRequest("权限列表不能为空,如需禁用所有权限请使用专用接口")
 }
 ```
 
+gRPC 端的 `SyncPermissions` 也需要同步添加此校验。
+
 ---
 
 ## ⚠️ 健壮性与性能建议 (Medium/Low)
 
-### 5. [Medium] CreateUser 未将用户加入产品成员,工作流存在断裂
+### M1. RefreshToken 未实现轮转,被盗令牌在有效期内可无限复用
+
+- **位置**:`internal/logic/pub/refreshTokenLogic.go` 第 75 行
+- **级别**:Medium
+- **描述**:`RefreshToken` 接口刷新后直接原样返回旧的 `refreshToken`(`RefreshToken: tokenStr`)。这意味着 refresh token 在整个有效期内是静态不变的,一旦泄露,攻击者可以持续用它换取新的 access token,直到 refresh token 过期或用户主动修改密码(触发 tokenVersion 递增)。
+- **建议**:实现 Refresh Token Rotation:每次刷新时签发新的 refresh token 并使旧的失效(可通过在 Redis 中维护一个 token 黑名单或版本号实现)。
+
+---
+
+### M2. HTTP 与 gRPC 的 SyncPermissions 逻辑重复,存在不同步风险
 
-- **文件**:`internal/logic/user/createUserLogic.go`
-- **描述**:`CreateUser` 要求 `RequireProductAdminFor(productCode)` 校验产品管理员身份,但创建用户后并未将其加入当前产品的成员列表。新建用户需要管理员再单独调用 `AddMember` 才能登录和使用产品。
-- **影响**:管理员可能误以为创建用户即完成了入组操作,导致新用户无法登录。增加了操作步骤和出错概率。
-- **建议**:考虑在 CreateUser 中增加可选参数 `memberType`,当传入时在同一事务中自动创建产品成员记录;或在文档/前端层面明确引导管理员完成两步操作。
+- **位置**:`internal/logic/pub/syncPermsLogic.go` vs `internal/server/permserver.go` `SyncPermissions` 方法
+- **级别**:Medium
+- **描述**:HTTP 端走 `SyncPermsLogic`,gRPC 端在 `permserver.go` 中内联实现了几乎相同的逻辑。两处代码维护相同的业务语义(认证、去重、事务批量更新、缓存清理),但互相独立
+- **建议**:将核心逻辑抽取为共享的 service 函数(类似 `ValidateProductLogin` 的做法),让 HTTP Logic 和 gRPC Server 都调用同一份代码
 
 ---
 
-### 6. [Medium] 缓存前缀变量是包级可变全局状态
+### M3. RequireProductAdmin 函数未绑定具体产品,存在跨产品越权隐患
+
+- **位置**:`internal/logic/auth/access.go` `RequireProductAdmin` 函数
+- **级别**:Medium
+- **描述**:`RequireProductAdmin` 只检查 `caller.MemberType == ADMIN`,不验证操作者是否是**目标产品**的管理员。如果产品 A 的 ADMIN 调用了使用此函数鉴权的接口来操作产品 B 的数据,会被错误放行。
+- **现状**:当前所有业务代码使用的是带产品校验的 `RequireProductAdminFor`,`RequireProductAdmin` 实际未被引用。
+- **建议**:删除 `RequireProductAdmin` 函数或标记为 deprecated,避免后续开发者误用。
+
+---
 
-- **文件**:各 `*Model_gen.go`(如 `sysUserModel_gen.go` 第 26-27 行,`sysPermModel_gen.go` 第 26-27 行)
-- **描述**:`cacheSysUserIdPrefix`、`cacheSysPermIdPrefix` 等缓存前缀在 `newSys*Model` 函数中被直接修改(`cacheSysUserIdPrefix = cachePrefix + ":cache:sysUser:id:"`)。这些是 `var` 级别的全局变量。
-- **影响**:若代码中有多处以不同 `cachePrefix` 创建同类 Model 实例(当前不存在此情况),会产生竞态条件。虽然当前启动时仅初始化一次,但这种模式在代码演进中存在隐患。
-- **建议**:由于此代码由 goctl 生成,短期内可接受。长期建议修改 goctl 模板,将前缀存入 struct 字段而非修改包级变量。
+### M4. CreateProduct 事务中管理员用户名可能冲突导致整体回滚
+
+- **位置**:`internal/logic/product/createProductLogic.go` 第 87 行
+- **级别**:Medium
+- **描述**:创建产品时自动生成管理员用户名为 `admin_{productCode}`。如果系统中已存在同名用户(如手动创建或之前产品删除后遗留),`InsertWithTx` 会因唯一索引冲突报错,导致整个事务回滚——产品也不会被创建,且错误信息为底层数据库错误,对调用方不友好。
+- **建议**:在事务开始前先检查用户名是否已存在,给出明确的业务错误提示:
+
+```go
+if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
+    return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
+}
+```
 
 ---
 
-### 7. [Medium] AddMember 并发重复请求错误信息不友好
+### M5. BindRolePerms / SetUserPerms 未校验权限的启用状态
 
-- **文件**:`internal/logic/member/addMemberLogic.go` 第 52-55 行
-- **描述**:使用 check-then-insert 模式检查成员是否已存在。若两个请求并发执行,都通过了检查,其中一个在 Insert 时会触发数据库唯一约束错误(`uk_product_user`),但此错误未被捕获转换为友好提示,而是返回原始的 500 错误。
-- **影响**:并发场景下用户收到 "服务器内部错误" 而非 "该用户已是该产品成员"。
-- **建议**:在 Insert 错误处理中捕获唯一约束冲突:
+- **位置**:`internal/logic/role/bindRolePermsLogic.go` 第 58-64 行、`internal/logic/user/setUserPermsLogic.go` 第 65-74 行
+- **级别**:Low
+- **描述**:绑定权限时只校验了权限 ID 存在且属于同一产品,但未检查权限是否处于启用状态(`status=1`)。已被 `SyncPerms` 禁用的权限仍可被绑定到角色或用户。
+- **实际影响有限**:`loadPerms` 在计算最终权限时会过滤掉 `status != 1` 的权限,所以被禁用的权限不会生效。但绑定关系的存在可能造成管理界面上的困惑。
+- **建议**:在校验循环中增加状态检查:
 
 ```go
-result, err := l.svcCtx.SysProductMemberModel.Insert(l.ctx, &productmember.SysProductMember{...})
-if err != nil {
-    if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") {
-        return nil, response.ErrConflict("该用户已是该产品成员")
+for _, p := range perms {
+    if p.ProductCode != role.ProductCode {
+        return response.ErrBadRequest("不能绑定其他产品的权限")
+    }
+    if p.Status != consts.StatusEnabled {
+        return response.ErrBadRequest(fmt.Sprintf("权限 %s 已被禁用,无法绑定", p.Code))
     }
-    return nil, err
 }
 ```
 
 ---
 
-### 8. [Medium] JWT 中嵌入了完整权限列表,Token 体积随权限数增长
+### M6. ProductDetail / ProductList 缺少产品维度的访问控制
 
-- **文件**:`internal/logic/auth/jwt.go` 第 23-40 行;`internal/middleware/jwtauthMiddleware.go` Claims 结构
-- **描述**:`Claims.Perms` 字段将用户的所有权限 code 列表嵌入 access token。对于拥有数百个权限的用户,Token 体积会显著增长(可能超过 4KB),导致每次 HTTP 请求的 Header 过大。
-- **影响**:增加网络传输开销;某些反向代理(如 Nginx 默认 4KB/8KB header buffer)可能拒绝过大的请求头
-- **建议**:Token 中仅保留 userId、productCode 等核心标识,权限列表通过 `UserDetailsLoader`(已有 Redis 缓存)在需要时加载。当前中间件已经通过 `loader.Load` 重新加载了完整用户信息,Token 中的 Perms 字段实际上未被中间件使用,仅用于前端展示。可以考虑在登录响应中单独返回 perms,从 Token 中移除
+- **位置**:`internal/logic/product/productDetailLogic.go`、`productListLogic.go`
+- **级别**:Low
+- **描述**:任何已登录用户(包括非超管的普通产品成员)都可以查看系统中**所有产品**的列表和详情(除 AppKey 外)。虽然敏感字段 `AppKey` 仅对超管可见、`AppSecret` 不返回,但产品编码、名称等信息对非本产品成员可见
+- **建议**:评估业务需求。如果产品信息确实应该对所有登录用户可见(如产品选择页面),则当前实现合理。否则应增加 `caller.ProductCode` 校验或只返回用户所属产品的列表
 
 ---
 
-### 9. [Medium] RefreshToken 未限制刷新次数,旧 RefreshToken 可无限复用
+### M7. DeptTree 接口无任何权限过滤
 
-- **文件**:`internal/logic/pub/refreshTokenLogic.go`;`internal/server/permserver.go` 第 162-200 行
-- **描述**:`RefreshToken` 接口在签发新的 AccessToken 后,直接将原 RefreshToken 原样返回给客户端。RefreshToken 在有效期内可以被无限次调用,每次都会生成新的 AccessToken。
-- **影响**:若 RefreshToken 泄漏,攻击者可在整个有效期内持续获取新的 AccessToken。通常的最佳实践是 Refresh Token Rotation——每次刷新时同时签发新的 RefreshToken 并使旧的失效
-- **建议**:当前通过 `tokenVersion` 机制可以在修改密码/冻结用户时使所有 token 失效,这提供了基本保障。若需更严格的安全性,可实现 Refresh Token Rotation(每次刷新签发新 RefreshToken),或在 Redis 中维护一个已使用 RefreshToken 的黑名单
+- **位置**:`internal/logic/dept/deptTreeLogic.go`
+- **级别**:Low
+- **描述**:`DeptTree` 加载并返回系统中**全部部门**的树形结构,无论调用者的身份和所属产品。任何已登录用户都能看到完整的组织架构
+- **建议**:如果部门树只应由超管可见,增加 `RequireSuperAdmin` 校验。如果需要按层级裁剪(只看本部门及子部门),应根据 `caller.DeptPath` 过滤
 
 ---
 
-### 10. [Low] 用户不存在时 JwtAuth 中间件返回误导性错误信息
+### M8. loadRoles 全量加载后内存过滤,存在轻微效率损失
 
-- **文件**:`internal/middleware/jwtauthMiddleware.go` 第 74-78 行;`internal/loaders/userDetailsLoader.go` 第 129-133 行
-- **描述**:当 JWT 有效但用户已被删除时,`UserDetailsLoader.Load` 返回一个默认的 `UserDetails`(Status=0)。中间件判断 `ud.Status != StatusEnabled` 后返回"账号已被冻结"。但实际上用户是不存在/已被删除。
-- **影响**:用户收到错误的提示信息,可能导致困惑或误导排查方向
-- **建议**:在中间件中增加用户是否存在的判断
+- **位置**:`internal/loaders/userDetailsLoader.go` `loadRoles` 方法
+- **级别**:Low
+- **描述**:`FindRoleIdsByUserId` 返回用户在所有产品下的全部角色 ID,再通过 `FindByIds` 批量查询角色详情,最后在内存中按 `ProductCode` 和 `Status` 过滤。对于只加入了 1-2 个产品的普通用户不会有问题,但逻辑上可以在 SQL 层面就做好过滤
+- **建议**:新增按产品过滤的查询方法
 
 ```go
-ud := m.loader.Load(r.Context(), claims.UserId, claims.ProductCode)
-if ud.Username == "" {
-    httpx.ErrorCtx(r.Context(), w, response.NewCodeError(401, "用户不存在或已被删除"))
-    return
-}
-if ud.Status != consts.StatusEnabled {
-    httpx.ErrorCtx(r.Context(), w, response.NewCodeError(403, "账号已被冻结"))
-    return
-}
+func FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)
 ```
 
+通过 JOIN `sys_role` 表在查询时过滤 `productCode`,减少不必要的数据传输和内存开销。
+
 ---
 
-### 11. [Low] 缺少操作审计日志
+### M9. DeleteRole 缓存失效存在极小时间窗口遗漏
 
-- **描述**:当前系统在所有写操作(创建用户、绑定角色、修改权限、冻结用户、删除角色等)中未记录操作审计日志。仅有 go-zero 框架默认的请求日志。
-- **影响**:当发生安全事件(如权限被篡改、用户被异常冻结)时,无法追溯是谁在什么时间执行了什么操作。对于权限管理系统,审计追溯能力是合规性的基本要求。
-- **建议**:设计一个 `sys_audit_log` 表,记录 `operator_id`、`action`、`target_type`、`target_id`、`detail`、`ip`、`timestamp` 等字段。在关键业务逻辑中通过异步方式写入审计记录,避免影响主流程性能。
+- **位置**:`internal/logic/role/deleteRoleLogic.go` 第 40-42 行
+- **级别**:Low
+- **描述**:`affectedUserIds` 在事务执行**之前**查询。如果在查询之后、事务执行之前有新的用户被绑定到该角色,这些用户的缓存不会被主动清理(但会在 5 分钟后自然过期)。
+- **实际影响极低**:这个时间窗口极短(微秒级),且角色删除事务内已清除所有 user-role 绑定关系,新绑定的用户在缓存过期后会自动生效。
+- **建议**:可接受当前实现。如需极致一致性,可在事务内查询 affectedUserIds。
 
 ---
 
-### 12. [Low] DeptTree 和 ProductList 对非超管暴露全量数据
+### M10. CreateUser 缺少用户名格式校验
 
-- **文件**:`internal/logic/dept/deptTreeLogic.go`;`internal/logic/product/productListLogic.go`
-- **描述**:`DeptTree` 接口无任何权限过滤,所有已登录用户可看到整个组织架构。`ProductList` 也对所有用户返回全部产品列表(AppKey 已对非超管隐藏)。
-- **影响**:部门结构和产品列表属于组织内部信息,虽然不包含高敏感数据,但在安全要求较高的场景下可能不符合最小暴露原则。
-- **建议**:根据实际业务需求评估是否需要按产品/部门范围过滤。如果当前业务场景下所有用户确实需要查看组织架构(如选择部门),可保持现状。
+- **位置**:`internal/logic/user/createUserLogic.go`
+- **级别**:Low
+- **描述**:`CreateUser` 仅校验了用户名长度(最大 64 字符),未校验格式。用户名可以包含空格、特殊字符、中文等,可能导致:
+  - 与自动生成的 `admin_{code}` 格式冲突
+  - 登录时的编码问题
+  - 日志可读性降低
+- **建议**:增加用户名格式校验(如只允许字母数字下划线):
+
+```go
+if !regexp.MustCompile(`^[a-zA-Z0-9_]{2,64}$`).MatchString(req.Username) {
+    return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
+}
+```
 
 ---
 
-### 13. [Low] UpdateDept 修改 deptType 时缺少影响评估
+### M11. gRPC GetUserPerms 无鉴权保护
 
-- **文件**:`internal/logic/dept/updateDeptLogic.go`
-- **描述**:将部门类型从 `DEV`(研发)改为 `NORMAL`,该部门下所有用户会立即失去"全权限"特权(因 `loadPerms` 中 DEV 部门自动获取全量权限的逻辑不再生效)。代码正确清除了缓存,但未在响应中提示此操作的影响范围。
-- **影响**:超管执行部门类型变更后,该部门及子部门下的用户可能突然失去大量权限,如果未提前通知,可能导致业务中断。
-- **建议**:在响应中返回受影响的用户数量,或在执行前增加确认机制。
+- **位置**:`internal/server/permserver.go` `GetUserPerms` 方法
+- **级别**:Low(取决于部署方式)
+- **描述**:gRPC 端的 `GetUserPerms` 接口没有任何认证或授权校验,任何能访问 gRPC 端口的客户端都可以查询任意用户的权限列表。
+- **现状评估**:如果 gRPC 仅在内网(如 K8s 集群内部)暴露给可信的下游服务,这是合理的设计。但如果 gRPC 端口意外暴露到公网,则构成严重的信息泄露。
+- **建议**:确保 gRPC 端口不暴露到外部网络。如有需要,可增加 gRPC 拦截器进行 mTLS 或 token 校验。
+
+---
+
+### M12. UpdateMember / UpdateRole / UpdateDept 对无效 status 值静默忽略
+
+- **位置**:`updateMemberLogic.go`、`updateRoleLogic.go`、`updateDeptLogic.go` 多处
+- **级别**:Low
+- **描述**:这些接口在处理 `status` 字段时采用"白名单匹配"模式——只有 1 和 2 才会赋值,其他值(如 3、-1)被静默忽略。调用方传入非法值时不会收到任何错误提示,可能导致前端 bug 难以排查。
+- **建议**:对非 0 的非法 status 值返回明确错误:
+
+```go
+if req.Status != 0 {
+    if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+        return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
+    }
+    role.Status = req.Status
+}
+```
 
 ---
 
 ## 总结
 
-| 级别 | 编号 | 问题 | 类型 |
-|------|------|------|------|
-| 🚩 High | #1 | UserList 数据越权泄漏 | 安全漏洞 |
-| 🚩 High | #2 | BindRoles 提权漏洞 | 安全漏洞 |
-| 🚩 High | #3 | appSecret 明文存储 | 安全漏洞 |
-| 🚩 High | #4 | UpdateUser 权限模型不一致 | 逻辑一致性 |
-| ⚠️ Medium | #5 | CreateUser 未加入产品成员 | 数据完整性 |
-| ⚠️ Medium | #6 | 缓存前缀全局可变状态 | 并发安全 |
-| ⚠️ Medium | #7 | AddMember 并发错误信息不友好 | 边界处理 |
-| ⚠️ Medium | #8 | JWT Token 体积过大风险 | 性能 |
-| ⚠️ Medium | #9 | RefreshToken 可无限复用 | 安全加固 |
-| ⚠️ Low | #10 | 用户不存在时错误信息误导 | 边界崩溃 |
-| ⚠️ Low | #11 | 缺少操作审计日志 | 安全合规 |
-| ⚠️ Low | #12 | DeptTree/ProductList 过度暴露 | 安全加固 |
-| ⚠️ Low | #13 | UpdateDept 修改类型缺少影响提示 | 健壮性 |
-
-**优先修复建议**:#1 和 #2 为最高优先级,直接影响数据安全和权限体系的可信度;#3 和 #4 建议在下一个迭代中修复。
+| 级别 | 数量 | 关键发现 |
+|------|------|---------|
+| 🚩 High | 5 | token 失效不一致、跨产品信息泄露、层级定义矛盾、数据完整性、无保护的全量禁用 |
+| ⚠️ Medium | 4 | RefreshToken 无轮转、gRPC/HTTP 逻辑重复、函数越权隐患、用户名冲突 |
+| 💡 Low | 8 | 无效权限绑定、产品访问控制、部门树全量暴露、效率优化、格式校验等 |
+
+**整体评价**:项目的架构设计合理,go-zero 框架的使用规范,核心安全机制(JWT tokenVersion、bcrypt、参数化 SQL、限流)实现到位。主要风险集中在**同一业务操作的多个入口未保持一致性**(如 UpdateUser vs UpdateUserStatus)以及**数据隔离在产品边界上的不完整**。建议优先修复 H1(token 失效不一致)和 H5(空列表全量禁用),这两个问题在生产环境中最有可能造成实际影响。

+ 2 - 2
internal/loaders/userDetailsLoader.go

@@ -315,7 +315,7 @@ func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) {
 	if ud.ProductCode == "" {
 		return
 	}
-	roleIds, err := l.models.SysUserRoleModel.FindRoleIdsByUserId(ctx, ud.UserId)
+	roleIds, err := l.models.SysUserRoleModel.FindRoleIdsByUserIdForProduct(ctx, ud.UserId, ud.ProductCode)
 	if err != nil || len(roleIds) == 0 {
 		return
 	}
@@ -327,7 +327,7 @@ func (l *UserDetailsLoader) loadRoles(ctx context.Context, ud *UserDetails) {
 	ud.Roles = make([]RoleInfo, 0)
 	minLevel := int64(math.MaxInt64)
 	for _, r := range roles {
-		if r.ProductCode == ud.ProductCode && r.Status == consts.StatusEnabled {
+		if r.Status == consts.StatusEnabled {
 			ud.Roles = append(ud.Roles, RoleInfo{
 				Id:         r.Id,
 				Name:       r.Name,

+ 20 - 0
internal/logic/auth/jwt.go

@@ -54,6 +54,26 @@ func GenerateRefreshToken(secret string, expireSeconds int64, userId int64, prod
 	return token.SignedString([]byte(secret))
 }
 
+// GenerateRefreshTokenWithExpiry 签发 refreshToken,使用绝对过期时间(用于 token 轮转场景)。
+func GenerateRefreshTokenWithExpiry(secret string, expiresAt time.Time, userId int64, productCode string, tokenVersion int64) (string, error) {
+	now := time.Now()
+	if !expiresAt.After(now) {
+		return "", errors.New("refresh token has expired")
+	}
+	claims := RefreshClaims{
+		TokenType:    consts.TokenTypeRefresh,
+		UserId:       userId,
+		ProductCode:  productCode,
+		TokenVersion: tokenVersion,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(expiresAt),
+			IssuedAt:  jwt.NewNumericDate(now),
+		},
+	}
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	return token.SignedString([]byte(secret))
+}
+
 func ParseRefreshToken(tokenStr, secret string) (*RefreshClaims, error) {
 	token, err := jwt.ParseWithClaims(tokenStr, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
 		return []byte(secret), nil

+ 8 - 2
internal/logic/dept/updateDeptLogic.go

@@ -47,10 +47,16 @@ func (l *UpdateDeptLogic) UpdateDept(req *types.UpdateDeptReq) error {
 	dept.Name = req.Name
 	dept.Sort = req.Sort
 	dept.Remark = req.Remark
-	if req.DeptType == consts.DeptTypeNormal || req.DeptType == consts.DeptTypeDev {
+	if req.DeptType != "" {
+		if req.DeptType != consts.DeptTypeNormal && req.DeptType != consts.DeptTypeDev {
+			return response.ErrBadRequest("部门类型无效,仅支持 NORMAL 和 DEV")
+		}
 		dept.DeptType = req.DeptType
 	}
-	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+	if req.Status != 0 {
+		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
+		}
 		dept.Status = req.Status
 	}
 	dept.UpdateTime = time.Now().Unix()

+ 9 - 4
internal/logic/dept/updateDeptLogic_test.go

@@ -115,8 +115,8 @@ func TestUpdateDept_DeptType_NormalToDev(t *testing.T) {
 	assert.Equal(t, "DEV", after.DeptType)
 }
 
-// TC-0092: DeptType无效值忽略
-func TestUpdateDept_DeptType_InvalidIgnored(t *testing.T) {
+// TC-0092: DeptType无效值返回错误
+func TestUpdateDept_DeptType_InvalidRejected(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
@@ -131,11 +131,16 @@ func TestUpdateDept_DeptType_InvalidIgnored(t *testing.T) {
 		Name:     "dti_changed_" + testutil.UniqueId(),
 		DeptType: "INVALID_TYPE",
 	})
-	require.NoError(t, err)
+	require.Error(t, err)
+
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Contains(t, ce.Error(), "部门类型无效")
 
 	after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
 	require.NoError(t, err)
-	assert.Equal(t, "NORMAL", after.DeptType)
+	assert.Equal(t, "NORMAL", after.DeptType, "无效DeptType不应修改数据库")
 }
 
 // TC-0512: updateDept非超管拒绝

+ 4 - 1
internal/logic/member/updateMemberLogic.go

@@ -47,7 +47,10 @@ func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 	}
 
 	member.MemberType = req.MemberType
-	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+	if req.Status != 0 {
+		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
+		}
 		member.Status = req.Status
 	}
 	member.UpdateTime = time.Now().Unix()

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

@@ -70,6 +70,9 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	now := time.Now().Unix()
 
 	adminUsername := fmt.Sprintf("admin_%s", req.Code)
+	if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
+		return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
+	}
 	adminPassword, err := generateRandomHex(8)
 	if err != nil {
 		return nil, err

+ 5 - 0
internal/logic/product/createProductLogic_mock_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	productModel "perms-system-server/internal/model/product"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/types"
@@ -39,6 +40,8 @@ func TestCreateProduct_Mock_UserInsertFail(t *testing.T) {
 		Return(fakeResult{id: 1}, nil)
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_test_code").
+		Return(nil, userModel.ErrNotFound)
 	mockUser.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(nil, dbErr)
 
@@ -76,6 +79,8 @@ func TestCreateProduct_Mock_MemberInsertFail(t *testing.T) {
 		Return(fakeResult{id: 1}, nil)
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_test_code").
+		Return(nil, userModel.ErrNotFound)
 	mockUser.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(fakeResult{id: 10}, nil)
 

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

@@ -67,9 +67,18 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, err
 	}
 
+	newRefreshToken, err := authHelper.GenerateRefreshTokenWithExpiry(
+		l.svcCtx.Config.Auth.RefreshSecret,
+		claims.ExpiresAt.Time,
+		ud.UserId, ud.ProductCode, ud.TokenVersion,
+	)
+	if err != nil {
+		return nil, response.ErrUnauthorized("refreshToken已过期,请重新登录")
+	}
+
 	return &types.LoginResp{
 		AccessToken:  accessToken,
-		RefreshToken: tokenStr,
+		RefreshToken: newRefreshToken,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		UserInfo: types.UserInfo{
 			UserId:             ud.UserId,

+ 106 - 2
internal/logic/pub/refreshTokenLogic_test.go

@@ -72,7 +72,7 @@ func TestRefreshToken_Normal(t *testing.T) {
 	require.NoError(t, err)
 	require.NotNil(t, resp)
 	assert.NotEmpty(t, resp.AccessToken)
-	assert.Equal(t, refreshToken, resp.RefreshToken, "refreshToken应原样返回,不重新生成")
+	assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken")
 	assert.NotEqual(t, resp.AccessToken, resp.RefreshToken, "accessToken和refreshToken应不同")
 	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
 	assert.Equal(t, userId, resp.UserInfo.UserId)
@@ -232,6 +232,110 @@ func TestRefreshToken_ProductCodeSwitchRejected(t *testing.T) {
 	assert.Equal(t, "刷新令牌不允许切换产品", codeErr.Error())
 }
 
+// TC-0543: TokenVersion不匹配时拒绝刷新
+func TestRefreshToken_TokenVersionMismatch(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, "", 999,
+	)
+	require.NoError(t, err)
+
+	logic := NewRefreshTokenLogic(ctx, svcCtx)
+	resp, err := logic.RefreshToken(&types.RefreshTokenReq{
+		Authorization: "Bearer " + refreshToken,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "登录状态已失效,请重新登录", codeErr.Error())
+}
+
+// TC-0544: 使用accessToken作为refreshToken被拒绝
+func TestRefreshToken_AccessTokenRejected(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)
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		svcCtx.Config.Auth.RefreshSecret,
+		svcCtx.Config.Auth.AccessExpire,
+		userId, username, "", "", 0,
+	)
+	require.NoError(t, err)
+
+	logic := NewRefreshTokenLogic(ctx, svcCtx)
+	resp, err := logic.RefreshToken(&types.RefreshTokenReq{
+		Authorization: "Bearer " + accessToken,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "refreshToken无效或已过期", codeErr.Error())
+}
+
+// TC-0545: 产品成员已移除时拒绝刷新
+func TestRefreshToken_MemberRemovedRejected(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+	pc := testutil.UniqueId()
+	now := time.Now().Unix()
+
+	userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{
+		ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
+	refreshToken, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret,
+		svcCtx.Config.Auth.RefreshExpire,
+		userId, pc, 0,
+	)
+	require.NoError(t, err)
+
+	testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
+
+	logic := NewRefreshTokenLogic(ctx, svcCtx)
+	resp, err := logic.RefreshToken(&types.RefreshTokenReq{
+		Authorization: "Bearer " + refreshToken,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Equal(t, "您已不是该产品的成员", codeErr.Error())
+}
+
 // TC-0030: 超管+productCode(refreshToken原样返回)
 func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) {
 	ctx := context.Background()
@@ -270,7 +374,7 @@ func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) {
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
-	assert.Equal(t, refreshToken, resp.RefreshToken, "refreshToken应原样返回,不重新生成")
+	assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken")
 	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
 	assert.Contains(t, resp.UserInfo.Perms, permCode)
 	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)

+ 17 - 77
internal/logic/pub/syncPermsLogic.go

@@ -2,17 +2,12 @@ package pub
 
 import (
 	"context"
-	"time"
 
-	"perms-system-server/internal/consts"
-	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
 	"github.com/zeromicro/go-zero/core/logx"
-	"github.com/zeromicro/go-zero/core/stores/sqlx"
-	"golang.org/x/crypto/bcrypt"
 )
 
 type SyncPermsLogic struct {
@@ -30,86 +25,31 @@ func NewSyncPermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncPer
 }
 
 func (l *SyncPermsLogic) SyncPerms(req *types.SyncPermsReq) (resp *types.SyncPermsResp, err error) {
-	product, err := l.svcCtx.SysProductModel.FindOneByAppKey(l.ctx, req.AppKey)
-	if err != nil {
-		return nil, response.ErrUnauthorized("无效的appKey")
-	}
-	if err := bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(req.AppSecret)); err != nil {
-		return nil, response.ErrUnauthorized("appSecret验证失败")
-	}
-	if product.Status != consts.StatusEnabled {
-		return nil, response.ErrForbidden("产品已被禁用")
+	items := make([]SyncPermItem, len(req.Perms))
+	for i, p := range req.Perms {
+		items[i] = SyncPermItem{Code: p.Code, Name: p.Name, Remark: p.Remark}
 	}
 
-	existingMap, err := l.svcCtx.SysPermModel.FindMapByProductCode(l.ctx, product.Code)
+	result, err := ExecuteSyncPerms(l.ctx, l.svcCtx, req.AppKey, req.AppSecret, items)
 	if err != nil {
-		return nil, err
-	}
-
-	now := time.Now().Unix()
-	var added, updated int64
-	codes := make([]string, 0, len(req.Perms))
-
-	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 {
-			toInsert = append(toInsert, &permModel.SysPerm{
-				ProductCode: product.Code,
-				Name:        item.Name,
-				Code:        item.Code,
-				Remark:      item.Remark,
-				Status:      consts.StatusEnabled,
-				CreateTime:  now,
-				UpdateTime:  now,
-			})
-			added++
-			continue
-		}
-		if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
-			existing.Name = item.Name
-			existing.Remark = item.Remark
-			existing.Status = consts.StatusEnabled
-			existing.UpdateTime = now
-			toUpdate = append(toUpdate, existing)
-			updated++
-		}
-	}
-
-	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 se, ok := err.(*SyncPermsError); ok {
+			switch se.Code {
+			case 400:
+				return nil, response.ErrBadRequest(se.Message)
+			case 401:
+				return nil, response.ErrUnauthorized(se.Message)
+			case 403:
+				return nil, response.ErrForbidden(se.Message)
+			default:
+				return nil, err
 			}
 		}
-		if len(toUpdate) > 0 {
-			if err := l.svcCtx.SysPermModel.BatchUpdateWithTx(ctx, session, toUpdate); err != nil {
-				return err
-			}
-		}
-		var err error
-		disabled, err = l.svcCtx.SysPermModel.DisableNotInCodesWithTx(ctx, session, product.Code, codes, now)
-		return err
-	}); err != nil {
 		return nil, err
 	}
 
-	if added > 0 || updated > 0 || disabled > 0 {
-		l.svcCtx.UserDetailsLoader.CleanByProduct(l.ctx, product.Code)
-	}
-
 	return &types.SyncPermsResp{
-		Added:    added,
-		Updated:  updated,
-		Disabled: disabled,
+		Added:    result.Added,
+		Updated:  result.Updated,
+		Disabled: result.Disabled,
 	}, nil
 }

+ 1 - 1
internal/logic/pub/syncPermsLogic_mock_test.go

@@ -74,5 +74,5 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 
 	assert.Nil(t, resp)
 	assert.Error(t, err)
-	assert.ErrorIs(t, err, dbErr)
+	assert.Contains(t, err.Error(), "同步权限事务失败")
 }

+ 8 - 30
internal/logic/pub/syncPermsLogic_test.go

@@ -238,52 +238,30 @@ func TestSyncPerms_DisableNotInList(t *testing.T) {
 	assert.Equal(t, int64(1), kept.Status)
 }
 
-// TC-0037: 空perms数组
-func TestSyncPerms_EmptyPermsDisablesAll(t *testing.T) {
+// TC-0037: 空perms数组应被拒绝
+func TestSyncPerms_EmptyPermsRejected(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
-	conn := testutil.GetTestSqlConn()
 	pc := testutil.UniqueId()
 	appKey := testutil.UniqueId()
 	appSecret := testutil.UniqueId()
-	now := time.Now().Unix()
 
 	_, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1)
 	t.Cleanup(cleanProduct)
 
-	p1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
-		ProductCode: pc, Name: "P1", Code: testutil.UniqueId(), Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	p1Id, _ := p1Res.LastInsertId()
-
-	p2Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
-		ProductCode: pc, Name: "P2", Code: testutil.UniqueId(), Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	p2Id, _ := p2Res.LastInsertId()
-
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", p1Id, p2Id) })
-
 	logic := NewSyncPermsLogic(ctx, svcCtx)
 	resp, err := logic.SyncPerms(&types.SyncPermsReq{
 		AppKey:    appKey,
 		AppSecret: appSecret,
 		Perms:     []types.SyncPermItem{},
 	})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.Equal(t, int64(0), resp.Added)
-	assert.Equal(t, int64(0), resp.Updated)
-	assert.Equal(t, int64(2), resp.Disabled)
-
-	d1, err := svcCtx.SysPermModel.FindOne(ctx, p1Id)
-	require.NoError(t, err)
-	assert.Equal(t, int64(2), d1.Status)
+	require.Nil(t, resp)
+	require.Error(t, err)
 
-	d2, err := svcCtx.SysPermModel.FindOne(ctx, p2Id)
-	require.NoError(t, err)
-	assert.Equal(t, int64(2), d2.Status)
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "权限列表不能为空")
 }
 
 // TC-0039: appKey无效

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

@@ -0,0 +1,123 @@
+package pub
+
+import (
+	"context"
+	"time"
+
+	"perms-system-server/internal/consts"
+	permModel "perms-system-server/internal/model/perm"
+	"perms-system-server/internal/svc"
+
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type SyncPermsResult struct {
+	Added    int64
+	Updated  int64
+	Disabled int64
+}
+
+type SyncPermItem struct {
+	Code   string
+	Name   string
+	Remark string
+}
+
+type SyncPermsError struct {
+	Code    int
+	Message string
+}
+
+func (e *SyncPermsError) Error() string {
+	return e.Message
+}
+
+func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, appSecret string, perms []SyncPermItem) (*SyncPermsResult, error) {
+	product, err := svcCtx.SysProductModel.FindOneByAppKey(ctx, appKey)
+	if err != nil {
+		return nil, &SyncPermsError{Code: 401, Message: "无效的appKey"}
+	}
+	if err := bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(appSecret)); err != nil {
+		return nil, &SyncPermsError{Code: 401, Message: "appSecret验证失败"}
+	}
+	if product.Status != consts.StatusEnabled {
+		return nil, &SyncPermsError{Code: 403, Message: "产品已被禁用"}
+	}
+
+	if len(perms) == 0 {
+		return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空,如需禁用所有权限请使用专用接口"}
+	}
+
+	existingMap, err := svcCtx.SysPermModel.FindMapByProductCode(ctx, product.Code)
+	if err != nil {
+		return nil, &SyncPermsError{Code: 500, Message: "查询权限数据失败"}
+	}
+
+	now := time.Now().Unix()
+	var added, updated int64
+	codes := make([]string, 0, len(perms))
+
+	var toInsert []*permModel.SysPerm
+	var toUpdate []*permModel.SysPerm
+
+	seen := make(map[string]bool, len(perms))
+	for _, item := range perms {
+		if seen[item.Code] {
+			continue
+		}
+		seen[item.Code] = true
+		codes = append(codes, item.Code)
+		existing, ok := existingMap[item.Code]
+		if !ok {
+			toInsert = append(toInsert, &permModel.SysPerm{
+				ProductCode: product.Code,
+				Name:        item.Name,
+				Code:        item.Code,
+				Remark:      item.Remark,
+				Status:      consts.StatusEnabled,
+				CreateTime:  now,
+				UpdateTime:  now,
+			})
+			added++
+			continue
+		}
+		if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
+			existing.Name = item.Name
+			existing.Remark = item.Remark
+			existing.Status = consts.StatusEnabled
+			existing.UpdateTime = now
+			toUpdate = append(toUpdate, existing)
+			updated++
+		}
+	}
+
+	var disabled int64
+	if err := svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
+		if len(toInsert) > 0 {
+			if err := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); err != nil {
+				return err
+			}
+		}
+		if len(toUpdate) > 0 {
+			if err := svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); err != nil {
+				return err
+			}
+		}
+		var err error
+		disabled, err = svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codes, now)
+		return err
+	}); err != nil {
+		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
+	}
+
+	if added > 0 || updated > 0 || disabled > 0 {
+		svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
+	}
+
+	return &SyncPermsResult{
+		Added:    added,
+		Updated:  updated,
+		Disabled: disabled,
+	}, nil
+}

+ 5 - 0
internal/logic/role/bindRolePermsLogic.go

@@ -2,8 +2,10 @@ package role
 
 import (
 	"context"
+	"fmt"
 	"time"
 
+	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/model/roleperm"
 	"perms-system-server/internal/response"
@@ -62,6 +64,9 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 			if p.ProductCode != role.ProductCode {
 				return response.ErrBadRequest("不能绑定其他产品的权限")
 			}
+			if p.Status != consts.StatusEnabled {
+				return response.ErrBadRequest(fmt.Sprintf("权限 %s 已被禁用,无法绑定", p.Code))
+			}
 		}
 	}
 

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

@@ -31,8 +31,8 @@ func TestBindRolePerms_Mock_BatchInsertFail(t *testing.T) {
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
 	mockPerm.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).
 		Return([]*permModel.SysPerm{
-			{Id: 10, ProductCode: pc},
-			{Id: 20, ProductCode: pc},
+			{Id: 10, ProductCode: pc, Code: "perm_10", Status: 1},
+			{Id: 20, ProductCode: pc, Code: "perm_20", Status: 1},
 		}, nil)
 
 	mockRP := mocks.NewMockSysRolePermModel(ctrl)

+ 4 - 1
internal/logic/role/updateRoleLogic.go

@@ -51,7 +51,10 @@ func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 	role.Name = req.Name
 	role.Remark = req.Remark
 	role.PermsLevel = req.PermsLevel
-	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+	if req.Status != 0 {
+		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
+		}
 		role.Status = req.Status
 	}
 	role.UpdateTime = time.Now().Unix()

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

@@ -2,6 +2,7 @@ package user
 
 import (
 	"context"
+	"regexp"
 	"strings"
 	"time"
 
@@ -18,6 +19,8 @@ import (
 	"golang.org/x/crypto/bcrypt"
 )
 
+var usernameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_]{2,64}$`)
+
 type CreateUserLogic struct {
 	logx.Logger
 	ctx    context.Context
@@ -44,8 +47,8 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 	if len(req.Password) > 72 {
 		return nil, response.ErrBadRequest("密码长度不能超过72个字符")
 	}
-	if len(req.Username) > 64 {
-		return nil, response.ErrBadRequest("用户名长度不能超过64个字符")
+	if !usernameRegexp.MatchString(req.Username) {
+		return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
 	}
 	if len(req.Nickname) > 64 {
 		return nil, response.ErrBadRequest("昵称长度不能超过64个字符")
@@ -61,6 +64,12 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		return nil, response.ErrBadRequest("手机号格式不正确")
 	}
 
+	if req.DeptId > 0 {
+		if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId); err != nil {
+			return nil, response.ErrBadRequest("部门不存在")
+		}
+	}
+
 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	if err != nil {
 		return nil, err

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

@@ -374,6 +374,117 @@ func TestCreateUser_MemberRejected(t *testing.T) {
 	assert.Equal(t, 403, ce.Code())
 }
 
+// TC-0546: 用户名含特殊字符被拒绝
+func TestCreateUser_UsernameInvalidChars(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: "user@name!",
+		Password: "pass123456",
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
+}
+
+// TC-0547: 用户名太短(1字符)被拒绝
+func TestCreateUser_UsernameTooShort(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: "a",
+		Password: "pass123456",
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
+}
+
+// TC-0548: 用户名太长(65字符)被拒绝
+func TestCreateUser_UsernameTooLong(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: strings.Repeat("a", 65),
+		Password: "pass123456",
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "用户名只能包含字母、数字和下划线,长度2-64个字符", codeErr.Error())
+}
+
+// TC-0549: 部门不存在被拒绝
+func TestCreateUser_DeptNotExists(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: "pass123456",
+		DeptId:   999999999,
+	})
+	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-0550: 昵称超过64字符被拒绝
+func TestCreateUser_NicknameTooLong(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: "pass123456",
+		Nickname: strings.Repeat("n", 65),
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "昵称长度不能超过64个字符", codeErr.Error())
+}
+
+// TC-0551: 备注超过255字符被拒绝
+func TestCreateUser_RemarkTooLong(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: "pass123456",
+		Remark:   strings.Repeat("r", 256),
+	})
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 400, codeErr.Code())
+	assert.Equal(t, "备注长度不能超过255个字符", codeErr.Error())
+}
+
 // TC-0124: 带完整可选字段
 func TestCreateUser_AllOptionalFields(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()

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

@@ -2,6 +2,7 @@ package user
 
 import (
 	"context"
+	"fmt"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -82,6 +83,9 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 			if p.ProductCode != productCode {
 				return response.ErrBadRequest("不能设置其他产品的权限")
 			}
+			if p.Status != consts.StatusEnabled {
+				return response.ErrBadRequest(fmt.Sprintf("权限 %s 已被禁用,无法设置", p.Code))
+			}
 		}
 	}
 

+ 114 - 0
internal/logic/user/setUserPermsLogic_test.go

@@ -255,6 +255,120 @@ func TestSetUserPerms_PermBelongsToOtherProduct(t *testing.T) {
 	assert.Contains(t, codeErr.Error(), "其他产品的权限")
 }
 
+// TC-0552: 同一权限ID同时为ALLOW和DENY被拒绝
+func TestSetUserPerms_ConflictingEffects(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"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+
+	p1 := insertTestPerm(t, svcCtx, "test_product")
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		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: p1, Effect: "ALLOW"},
+			{PermId: p1, Effect: "DENY"},
+		},
+	})
+	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不能同时为 ALLOW 和 DENY")
+}
+
+// TC-0553: 重复的权限ID相同Effect被去重
+func TestSetUserPerms_DuplicatePermDedup(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"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+
+	p1 := insertTestPerm(t, svcCtx, "test_product")
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		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: p1, Effect: "ALLOW"},
+			{PermId: p1, Effect: "ALLOW"},
+		},
+	})
+	require.NoError(t, err)
+
+	perms, err := svcCtx.SysUserPermModel.FindByUserId(ctx, userId)
+	require.NoError(t, err)
+	assert.Len(t, perms, 1, "重复的权限ID应被去重,只插入一条")
+	assert.Equal(t, "ALLOW", perms[0].Effect)
+}
+
+// TC-0554: 已禁用的权限不能被设置
+func TestSetUserPerms_DisabledPermRejected(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"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+
+	now := time.Now().Unix()
+	res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: "test_product",
+		Name:        "disabled_perm_" + testutil.UniqueId(),
+		Code:        "disabled_" + testutil.UniqueId(),
+		Status:      2,
+		CreateTime:  now,
+		UpdateTime:  now,
+	})
+	require.NoError(t, err)
+	disabledPermId, _ := res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", disabledPermId)
+	})
+
+	logic := NewSetUserPermsLogic(ctx, svcCtx)
+	err = logic.SetUserPerms(&types.SetPermsReq{
+		UserId: userId,
+		Perms: []types.UserPermItem{
+			{PermId: disabledPermId, 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(), "已被禁用")
+}
+
 // TC-0172: 目标用户不是当前产品成员时拒绝设置权限(L-5修复验证)
 func TestSetUserPerms_NonMemberRejected(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()

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

@@ -96,7 +96,14 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		}
 		user.DeptId = *req.DeptId
 	}
-	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
+	statusChanged := false
+	if req.Status != 0 {
+		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(冻结)")
+		}
+		if user.Status != req.Status {
+			statusChanged = true
+		}
 		user.Status = req.Status
 	}
 	user.UpdateTime = time.Now().Unix()
@@ -104,6 +111,11 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	if err := l.svcCtx.SysUserModel.Update(l.ctx, user); err != nil {
 		return err
 	}
+	if statusChanged {
+		if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status); err != nil {
+			return err
+		}
+	}
 
 	l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.Id)
 	return nil

+ 7 - 1
internal/logic/user/userDetailLogic.go

@@ -41,7 +41,13 @@ func (l *UserDetailLogic) UserDetail(req *types.UserDetailReq) (resp *types.User
 		return nil, response.ErrNotFound("用户不存在")
 	}
 
-	roleIds, err := l.svcCtx.SysUserRoleModel.FindRoleIdsByUserId(l.ctx, user.Id)
+	productCode := middleware.GetProductCode(l.ctx)
+	var roleIds []int64
+	if productCode != "" && !caller.IsSuperAdmin {
+		roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(l.ctx, user.Id, productCode)
+	} else {
+		roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserId(l.ctx, user.Id)
+	}
 	if err != nil {
 		return nil, err
 	}

+ 10 - 0
internal/model/userrole/sysUserRoleModel.go

@@ -14,6 +14,7 @@ type (
 	SysUserRoleModel interface {
 		sysUserRoleModel
 		FindRoleIdsByUserId(ctx context.Context, userId int64) ([]int64, error)
+		FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)
 		FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error)
 		FindByUserId(ctx context.Context, userId int64) ([]*SysUserRole, error)
 		DeleteByUserId(ctx context.Context, userId int64) error
@@ -43,6 +44,15 @@ func (m *customSysUserRoleModel) FindRoleIdsByUserId(ctx context.Context, userId
 	return ids, nil
 }
 
+func (m *customSysUserRoleModel) FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT ur.`roleId` FROM %s ur INNER JOIN `sys_role` r ON ur.`roleId` = r.`id` WHERE ur.`userId` = ? AND r.`productCode` = ?", m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &ids, query, userId, productCode); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysUserRoleModel) FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error) {
 	var ids []int64
 	query := fmt.Sprintf("SELECT `userId` FROM %s WHERE `roleId` = ?", m.table)

+ 26 - 75
internal/server/permserver.go

@@ -10,14 +10,11 @@ import (
 	authHelper "perms-system-server/internal/logic/auth"
 	pub "perms-system-server/internal/logic/pub"
 	"perms-system-server/internal/middleware"
-	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/svc"
 	"perms-system-server/pb"
 
 	"github.com/golang-jwt/jwt/v4"
 	"github.com/zeromicro/go-zero/core/limit"
-	"github.com/zeromicro/go-zero/core/stores/sqlx"
-	"golang.org/x/crypto/bcrypt"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/peer"
 	"google.golang.org/grpc/status"
@@ -33,84 +30,29 @@ func NewPermServer(svcCtx *svc.ServiceContext) *PermServer {
 }
 
 func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermissionsReq) (*pb.SyncPermissionsResp, error) {
-	product, err := s.svcCtx.SysProductModel.FindOneByAppKey(ctx, req.AppKey)
-	if err != nil {
-		return nil, status.Error(codes.Unauthenticated, "无效的appKey")
-	}
-	if err := bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(req.AppSecret)); err != nil {
-		return nil, status.Error(codes.Unauthenticated, "appSecret验证失败")
-	}
-	if product.Status != consts.StatusEnabled {
-		return nil, status.Error(codes.PermissionDenied, "产品已被禁用")
+	items := make([]pub.SyncPermItem, len(req.Perms))
+	for i, p := range req.Perms {
+		items[i] = pub.SyncPermItem{Code: p.Code, Name: p.Name, Remark: p.Remark}
 	}
 
-	existingMap, err := s.svcCtx.SysPermModel.FindMapByProductCode(ctx, product.Code)
+	result, err := pub.ExecuteSyncPerms(ctx, s.svcCtx, req.AppKey, req.AppSecret, items)
 	if err != nil {
-		return nil, status.Error(codes.Internal, "查询权限数据失败")
-	}
-
-	now := time.Now().Unix()
-	var added, updated int64
-	codeList := make([]string, 0, len(req.Perms))
-
-	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 {
-			toInsert = append(toInsert, &permModel.SysPerm{
-				ProductCode: product.Code,
-				Name:        item.Name,
-				Code:        item.Code,
-				Remark:      item.Remark,
-				Status:      consts.StatusEnabled,
-				CreateTime:  now,
-				UpdateTime:  now,
-			})
-			added++
-			continue
-		}
-		if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled {
-			existing.Name = item.Name
-			existing.Remark = item.Remark
-			existing.Status = consts.StatusEnabled
-			existing.UpdateTime = now
-			toUpdate = append(toUpdate, existing)
-			updated++
-		}
-	}
-
-	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.BatchUpdateWithTx(txCtx, session, toUpdate); err != nil {
-				return err
+		if se, ok := err.(*pub.SyncPermsError); ok {
+			switch se.Code {
+			case 400:
+				return nil, status.Error(codes.InvalidArgument, se.Message)
+			case 401:
+				return nil, status.Error(codes.Unauthenticated, se.Message)
+			case 403:
+				return nil, status.Error(codes.PermissionDenied, se.Message)
+			default:
+				return nil, status.Error(codes.Internal, se.Message)
 			}
 		}
-		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, "同步权限事务失败")
+		return nil, status.Error(codes.Internal, "同步权限失败")
 	}
 
-	if added > 0 || updated > 0 || disabled > 0 {
-		s.svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
-	}
-
-	return &pb.SyncPermissionsResp{Added: added, Updated: updated, Disabled: disabled}, nil
+	return &pb.SyncPermissionsResp{Added: result.Added, Updated: result.Updated, Disabled: result.Disabled}, nil
 }
 
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
@@ -192,9 +134,18 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		return nil, status.Error(codes.Internal, "生成token失败")
 	}
 
+	newRefreshToken, err := authHelper.GenerateRefreshTokenWithExpiry(
+		s.svcCtx.Config.Auth.RefreshSecret,
+		claims.ExpiresAt.Time,
+		ud.UserId, ud.ProductCode, ud.TokenVersion,
+	)
+	if err != nil {
+		return nil, status.Error(codes.Unauthenticated, "refreshToken已过期,请重新登录")
+	}
+
 	return &pb.RefreshTokenResp{
 		AccessToken:  accessToken,
-		RefreshToken: req.RefreshToken,
+		RefreshToken: newRefreshToken,
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 	}, nil
 }

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

@@ -300,6 +300,21 @@ func (mr *MockSysUserRoleModelMockRecorder) FindOneWithTx(ctx, session, id any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneWithTx", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindOneWithTx), ctx, session, id)
 }
 
+// FindRoleIdsByUserIdForProduct mocks base method.
+func (m *MockSysUserRoleModel) FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindRoleIdsByUserIdForProduct", ctx, userId, productCode)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindRoleIdsByUserIdForProduct indicates an expected call of FindRoleIdsByUserIdForProduct.
+func (mr *MockSysUserRoleModelMockRecorder) FindRoleIdsByUserIdForProduct(ctx, userId, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRoleIdsByUserIdForProduct", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindRoleIdsByUserIdForProduct), ctx, userId, productCode)
+}
+
 // FindRoleIdsByUserId mocks base method.
 func (m *MockSysUserRoleModel) FindRoleIdsByUserId(ctx context.Context, userId int64) ([]int64, error) {
 	m.ctrl.T.Helper()

+ 1 - 1
perm.sql

@@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS `sys_user` (
 --   DEVELOPER = 开发者,自动拥有该产品所有权限
 --   ADMIN     = 管理员,自动拥有该产品所有权限
 --   MEMBER    = 普通成员,权限由角色和用户权限表决定
--- 管理层级顺序:超级管理员 > DEVELOPER > ADMIN > MEMBER
+-- 管理层级顺序:超级管理员 > ADMIN > DEVELOPER > MEMBER
 -- ----------------------------
 CREATE TABLE IF NOT EXISTS `sys_product_member` (
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',

+ 35 - 4
test-design.md

@@ -104,7 +104,7 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0025 | POST /api/auth/refreshToken | 正常刷新 | Header `Authorization: Bearer <refreshToken>` | code=0, 新accessToken, refreshToken原样返回(不重新生成) | 正常路径 | P0 | refreshTokenLogic全路径 |
+| TC-0025 | POST /api/auth/refreshToken | 正常刷新 | Header `Authorization: Bearer <refreshToken>` | code=0, 新accessToken, 新refreshToken(token轮转,保留原始过期时间) | 正常路径 | P0 | refreshTokenLogic全路径 |
 | TC-0026 | POST /api/auth/refreshToken | 不带productCode(回退) | Header Authorization, 无productCode | 使用claims.ProductCode | 分支覆盖 | P1 | productCode=""回退 |
 | TC-0027 | POST /api/auth/refreshToken | token无效 | Header `Authorization: Bearer invalid` | code=401 | 异常路径 | P0 | ParseRefreshToken失败 |
 | TC-0028 | POST /api/auth/refreshToken | 用户已删除 | token中userId不存在 | code=403, "账号已被冻结" | 异常路径 | P1 | UserDetailsLoader返回Status=0 |
@@ -121,14 +121,14 @@ MySQL (InnoDB) + Redis Cache
 | TC-0034 | POST /api/perm/sync | 无变化 | 已存在且name/remark/status均相同 | added=0, updated=0 | 分支覆盖 | P1 | 跳过更新 |
 | TC-0035 | POST /api/perm/sync | 禁用权限重启 | 已status=2的权限在列表中 | updated=1, status恢复1 | 分支覆盖 | P1 | Status!=1条件 |
 | TC-0036 | POST /api/perm/sync | 移除不在列表的权限 | DB有多余权限 | disabled>0 | 正常路径 | P0 | DisableNotInCodes |
-| TC-0037 | POST /api/perm/sync | 空perms数组 | `{"...","perms":[]}` | disabled=全部现有权限数 | 分支覆盖 | P1 | codes空→全部disable |
+| TC-0037 | POST /api/perm/sync | 空perms数组被拒绝 | `{"...","perms":[]}` | code=400, "权限列表不能为空" | 输入校验 | P0 | 空列表校验,防止意外批量禁用 |
 | TC-0038 | POST /api/perm/sync | 验证disabled返回值 | 已知DB有5条,perms仅含2条 | disabled=3 | 功能验证 | P0 | RowsAffected() |
 | TC-0039 | POST /api/perm/sync | appKey无效 | `{"appKey":"invalid"}` | code=401 | 异常路径 | P0 | FindOneByAppKey失败 |
 | TC-0040 | POST /api/perm/sync | appSecret错误 | secret不匹配 | code=401 | 异常路径 | P0 | AppSecret比对 |
 | TC-0041 | POST /api/perm/sync | 产品已禁用 | product.Status!=1 | code=403 | 分支覆盖 | P0 | Status!=1 |
 | TC-0042 | POST /api/perm/sync | 大批量(1000条) | 1000条perms | added=1000 | 性能 | P2 | BatchInsert性能 |
 | TC-0043 | POST /api/perm/sync | 重复code去重 | perms中包含两个相同code | 仅处理一次, added=1(而非2) | 分支覆盖 | P0 | M-09修复: seen去重 |
-| TC-0044 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, DB无变化 | 事务验证 | P0 | H-05修复: TransactCtx |
+| TC-0044 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, 返回SyncPermsError(500,"同步权限事务失败") | 事务验证 | P0 | H-05修复: TransactCtx, 错误包装不透传DB错误 |
 
 ### 2.4 获取用户信息 `POST /api/auth/userInfo`
 
@@ -206,7 +206,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0089 | POST /api/dept/update | 正常更新 | `{"id":1,"name":"新名","sort":5}` | code=0 | 正常路径 | P0 | updateDeptLogic |
 | TC-0090 | POST /api/dept/update | 不存在 | `{"id":9999,"name":"x"}` | code=404 | 异常路径 | P0 | FindOne失败 |
 | TC-0091 | POST /api/dept/update | DeptType NORMAL→DEV | `{"id":1,"deptType":"DEV"}` | DB deptType="DEV" | 正常路径 | P0 | DeptType合法值更新 |
-| TC-0092 | POST /api/dept/update | DeptType无效值忽略 | `{"id":1,"deptType":"INVALID"}` | DB deptType不变 | 分支覆盖 | P0 | DeptType非NORMAL/DEV |
+| TC-0092 | POST /api/dept/update | DeptType无效值返回错误 | `{"id":1,"deptType":"INVALID"}` | code=400, "部门类型无效", DB deptType不变 | 输入校验 | P0 | DeptType校验,仅NORMAL/DEV |
 | TC-0093 | POST /api/dept/update | DeptType变更时级联清除子部门用户缓存 | 部门从NORMAL改为DEV,有子部门含用户 | code=0, 子部门下用户缓存被清除 | 缓存验证 | P0 | M-10修复: 级联缓存失效 |
 | TC-0094 | POST /api/dept/delete | 正常删除(无子部门) | `{"id":5}` | code=0 | 正常路径 | P0 | deleteDeptLogic |
 | TC-0095 | POST /api/dept/delete | 有子部门 | `{"id":1}` | code=400, "存在子部门" | 业务约束 | P0 | len(children)>0 |
@@ -942,3 +942,34 @@ MySQL (InnoDB) + Redis Cache
 | TC-0540 | userList-非超管使用错误productCode被拒绝 | ctx=ADMIN, productCode!=ctx.ProductCode | 403 | 安全 | P0 | Audit#1修复: productCode一致性校验 |
 | TC-0541 | bindRoles-permsLevel越权拒绝 | ctx=ADMIN(MinPermsLevel=50), role.permsLevel=1 | 403 "不能分配权限级别高于自身的角色" | 安全 | P0 | Audit#2修复: 角色权限级别越权防护 |
 | TC-0542 | bindRoles-超管可分配任意级别角色 | ctx=SuperAdmin, role.permsLevel=1 | 绑定成功 | 正常路径 | P0 | Audit#2修复: 超管无permsLevel限制 |
+
+---
+
+## 十五、补充测试用例 — 输入校验 / 安全 / 业务约束
+
+### 15.1 RefreshToken 安全增强
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0543 | TokenVersion不匹配时拒绝刷新 | refreshToken含tokenVersion=999, DB中tokenVersion=0 | 401 "登录状态已失效,请重新登录" | 安全 | P0 | claims.TokenVersion != ud.TokenVersion |
+| TC-0544 | 使用accessToken作为refreshToken被拒绝 | 用accessSecret签发的accessToken作为refreshToken传入 | 401 "refreshToken无效或已过期" | 安全 | P0 | ParseRefreshToken校验TokenType!=refresh |
+| TC-0545 | 产品成员已移除时拒绝刷新 | refreshToken含productCode, 但用户已从该产品移除 | 403 "您已不是该产品的成员" | 安全 | P0 | ud.MemberType=="" && !ud.IsSuperAdmin |
+
+### 15.2 CreateUser 输入校验增强
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0546 | 用户名含特殊字符被拒绝 | `{"username":"user@name!","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 输入校验 | P0 | usernameRegexp不匹配 |
+| TC-0547 | 用户名太短(1字符)被拒绝 | `{"username":"a","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 边界值 | P0 | 最小长度2 |
+| TC-0548 | 用户名太长(65字符)被拒绝 | `{"username":"a*65","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 边界值 | P0 | 最大长度64 |
+| TC-0549 | 部门不存在被拒绝 | `{"username":"x","password":"pass123456","deptId":999999999}` | 400 "部门不存在" | 异常路径 | P1 | DeptId>0时校验FindOne |
+| TC-0550 | 昵称超过64字符被拒绝 | `{"username":"x","password":"pass123456","nickname":"n*65"}` | 400 "昵称长度不能超过64个字符" | 边界值 | P1 | len(Nickname)>64 |
+| TC-0551 | 备注超过255字符被拒绝 | `{"username":"x","password":"pass123456","remark":"r*256"}` | 400 "备注长度不能超过255个字符" | 边界值 | P1 | len(Remark)>255 |
+
+### 15.3 SetUserPerms 业务约束增强
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0552 | 同一权限ID冲突Effect被拒绝 | perms含[{permId:1,effect:"ALLOW"},{permId:1,effect:"DENY"}] | 400 "同一权限ID不能同时为 ALLOW 和 DENY" | 业务约束 | P0 | seen[permId]冲突检测 |
+| TC-0553 | 重复权限ID相同Effect去重 | perms含[{permId:1,effect:"ALLOW"},{permId:1,effect:"ALLOW"}] | 成功, DB仅1条记录 | 数据鲁棒性 | P1 | seen去重,uniquePerms |
+| TC-0554 | 已禁用权限不能被设置 | perm.Status=2(Disabled) | 400 "权限 xxx 已被禁用,无法设置" | 业务约束 | P0 | p.Status != StatusEnabled |

+ 48 - 36
test-report.md

@@ -10,13 +10,13 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 542 |
-| 已覆盖 TC 数 | 541 |
+| 测试用例总数 (test-design.md) | 554 |
+| 已覆盖 TC 数 | 553 |
 | 未实现 TC 数 | 1 (TC-0228 不可达防御分支 t.Skip) |
-| 测试函数总数 | 719 |
-| 测试子用例总数 (含 table-driven) | 800 |
+| 测试函数总数 | 730 |
+| 测试子用例总数 (含 table-driven) | 812 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **799 / 800** |
+| ✅ 通过 | **811 / 812** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0228 — 防御性不可达分支) |
 
@@ -24,29 +24,29 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| handler/pub | ✅ ok | 5.767s |
-| loaders | ✅ ok | 2.478s |
-| logic/auth | ✅ ok | 8.454s |
-| logic/dept | ✅ ok | 6.702s |
-| logic/member | ✅ ok | 7.292s |
-| logic/perm | ✅ ok | 7.868s |
-| logic/product | ✅ ok | 9.872s |
-| logic/pub | ✅ ok | 8.789s |
-| logic/role | ✅ ok | 7.930s |
-| logic/user | ✅ ok | 10.524s |
-| middleware | ✅ ok | 10.611s |
-| model/dept | ✅ ok | 11.221s |
-| model/perm | ✅ ok | 11.891s |
-| model/product | ✅ ok | 12.634s |
-| model/productmember | ✅ ok | 13.451s |
-| model/role | ✅ ok | 13.976s |
-| model/roleperm | ✅ ok | 13.400s |
-| model/user | ✅ ok | 13.624s |
-| model/userperm | ✅ ok | 13.566s |
-| model/userrole | ✅ ok | 12.006s |
-| response | ✅ ok | 11.173s |
-| server | ✅ ok | 12.885s |
-| util | ✅ ok | 12.820s |
+| handler/pub | ✅ ok | 0.766s |
+| loaders | ✅ ok | 1.453s |
+| logic/auth | ✅ ok | 5.574s |
+| logic/dept | ✅ ok | 1.681s |
+| logic/member | ✅ ok | 2.180s |
+| logic/perm | ✅ ok | 2.644s |
+| logic/product | ✅ ok | 3.901s |
+| logic/pub | ✅ ok | 4.191s |
+| logic/role | ✅ ok | 4.644s |
+| logic/user | ✅ ok | 5.318s |
+| middleware | ✅ ok | 4.475s |
+| model/dept | ✅ ok | 4.981s |
+| model/perm | ✅ ok | 5.673s |
+| model/product | ✅ ok | 6.000s |
+| model/productmember | ✅ ok | 6.445s |
+| model/role | ✅ ok | 6.912s |
+| model/roleperm | ✅ ok | 7.449s |
+| model/user | ✅ ok | 7.785s |
+| model/userperm | ✅ ok | 7.862s |
+| model/userrole | ✅ ok | 7.230s |
+| response | ✅ ok | 7.189s |
+| server | ✅ ok | 7.491s |
+| util | ✅ ok | 6.623s |
 
 ---
 
@@ -97,6 +97,9 @@
 | TC-0029 | 账号冻结 | ✅ pass |
 | TC-0030 | 超管+productCode | ✅ pass |
 | TC-0031 | 尝试切换产品被拒绝(H-02) | ✅ pass |
+| TC-0543 | TokenVersion不匹配时拒绝刷新 | ✅ pass |
+| TC-0544 | 使用accessToken作为refreshToken被拒绝 | ✅ pass |
+| TC-0545 | 产品成员已移除时拒绝刷新(403) | ✅ pass |
 
 ### 2.4 REST API — 同步权限 (TC-0032 ~ TC-0044)
 
@@ -107,7 +110,7 @@
 | TC-0034 | 无变化 | ✅ pass |
 | TC-0035 | 禁用权限重启 | ✅ pass |
 | TC-0036 | 移除不在列表的权限 | ✅ pass |
-| TC-0037 | 空perms数组 | ✅ pass |
+| TC-0037 | 空perms数组被拒绝(400) | ✅ pass |
 | TC-0038 | 验证disabled返回值 | ✅ pass |
 | TC-0039 | appKey无效 | ✅ pass |
 | TC-0040 | appSecret错误 | ✅ pass |
@@ -177,7 +180,7 @@
 | TC-0089 | 正常更新 | ✅ pass |
 | TC-0090 | 不存在 | ✅ pass |
 | TC-0091 | DeptType NORMAL→DEV | ✅ pass |
-| TC-0092 | DeptType无效值忽略 | ✅ pass |
+| TC-0092 | DeptType无效值返回错误(400) | ✅ pass |
 | TC-0094 | 正常删除(无子部门) | ✅ pass |
 | TC-0095 | 有子部门 | ✅ pass |
 | TC-0096 | 不存在的部门 | ✅ pass |
@@ -281,6 +284,15 @@
 | TC-0133 | createUser密码少于6字符(H-10) | ✅ pass |
 | TC-0134 | createUser密码超过72字符(H-10) | ✅ pass |
 | TC-0148 | 超管不能冻结另一超管(H-2) | ✅ pass |
+| TC-0546 | createUser用户名含特殊字符被拒绝(400) | ✅ pass |
+| TC-0547 | createUser用户名太短(1字符)被拒绝(400) | ✅ pass |
+| TC-0548 | createUser用户名太长(65字符)被拒绝(400) | ✅ pass |
+| TC-0549 | createUser部门不存在被拒绝(400) | ✅ pass |
+| TC-0550 | createUser昵称超过64字符被拒绝(400) | ✅ pass |
+| TC-0551 | createUser备注超过255字符被拒绝(400) | ✅ pass |
+| TC-0552 | setUserPerms同一权限ID冲突Effect被拒绝(400) | ✅ pass |
+| TC-0553 | setUserPerms重复权限ID相同Effect去重 | ✅ pass |
+| TC-0554 | setUserPerms已禁用权限不能被设置(400) | ✅ pass |
 
 ### 2.11 REST API — 成员管理 (TC-0178 ~ TC-0194)
 
@@ -744,16 +756,16 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| TC 总数 | 542 |
-| 已实现 | 541 (99.8%) |
+| TC 总数 | 554 |
+| 已实现 | 553 (99.8%) |
 | 跳过 | 1 (TC-0228,防御性不可达分支) |
 | 未实现 | 0 |
-| 测试函数 | 719 |
-| 测试子用例 | 800 |
-| ✅ 通过 | **799** |
+| 测试函数 | 730 |
+| 测试子用例 | 812 |
+| ✅ 通过 | **811** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0228) |
-| 通过率 | **100%** (799/799,排除不可达分支) |
+| 通过率 | **100%** (811/811,排除不可达分支) |
 
 ### 3.1 未实现 TC 说明