Browse Source

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

BaiLuoYan 4 weeks ago
parent
commit
b481ee3d26

+ 171 - 192
audit-report.md

@@ -1,301 +1,280 @@
-# 权限系统深度代码审计报告
+# 权限系统 (perms-system-server) 代码审计报告
 
-> 审计范围:`internal/logic`、`internal/model`、`internal/middleware`、`internal/loaders`、`internal/server` 全部非测试业务代码
-> 审计时间:2026-04-17
+> 审计范围:`internal/` 下所有非测试 `.go` 文件、`perm.sql`、`perm.api`、`pb/` gRPC 服务  
+> 审计日期:2026-04-18  
+> 审计维度:逻辑一致性、并发竞态、资源管理、数据完整性、安全漏洞、边界崩溃
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-### H1. UpdateUser 修改状态时未递增 tokenVersion,导致冻结用户可能仍持有有效令牌
+### H1. gRPC `GetUserPerms` 接口无任何鉴权
 
-- **位置**:`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`:
+- **位置**:`internal/server/permserver.go:186-197`
+- **描述**:`GetUserPerms` 方法直接调用 `UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode)`,没有任何身份校验逻辑——不要求 JWT、不校验调用方服务身份、不验证 mTLS。任何能连接 gRPC 端口的客户端,只需知道 `userId` 和 `productCode` 即可获取该用户的 `MemberType` 与完整权限列表
+- **影响**:攻击者可枚举用户 ID 和产品编码,批量拉取所有用户的权限数据,属于严重信息泄露与越权。
+- **修复方案**:
+  - 方案 A:为 gRPC 服务增加 Interceptor,校验调用方携带的内部服务 Token(如 gRPC Metadata 中传递一个共享密钥或签名)
+  - 方案 B:部署层面使用 mTLS,确保只有可信服务节点能连接 gRPC 端口
+  - 方案 C:将 `GetUserPerms` 改为要求传入一个有效的 AccessToken,在方法内部验证 token 后,仅返回该 token 对应用户的权限。
 
 ```go
-// 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 不会覆盖回旧值
+// 建议在 GetUserPerms 中增加调用方鉴权
+func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq) (*pb.GetUserPermsResp, error) {
+    // 校验内部调用凭证
+    if err := s.verifyInternalCaller(ctx); err != nil {
+        return nil, status.Error(codes.Unauthenticated, "未授权的调用方")
     }
+    // ... 原有逻辑
 }
 ```
 
-或者更彻底的方案:移除 `UpdateUser` 中的 `status` 字段支持,强制状态变更只能通过 `updateUserStatus` 接口。
-
 ---
 
-### H2. UserDetail 接口返回的 roleIds 未按产品隔离,存在跨产品信息泄露
+### H2. `AddMember` 缺乏产品归属校验,可跨产品添加成员
 
-- **位置**:`internal/logic/user/userDetailLogic.go` 第 44 行
-- **描述**:`FindRoleIdsByUserId` 查询 `sys_user_role` 时没有关联产品过滤,返回了用户在**所有产品**下的全部角色 ID。非超管的产品 A 成员查看某用户详情时,能看到该用户在产品 B 下的角色 ID 列表。
-- **影响**:虽然角色 ID 本身只是数字,但结合角色列表接口可反推出目标用户在其他产品中的角色配置,构成越权信息泄露。
-- **修复方案**:在查询时加入产品维度的过滤:
+- **位置**:`internal/logic/member/addMemberLogic.go:46`
+- **描述**:`AddMember` 使用 `CheckManageAccess(ctx, svcCtx, req.UserId, req.ProductCode)` 进行鉴权。然而 `CheckManageAccess`(`access.go:47`)对"操作自己"无条件放行(`caller.UserId == targetUserId → return nil`),**不校验** `req.ProductCode` 是否等于 `caller.ProductCode`。这意味着:
+  1. 用户 A(产品 X 的 ADMIN)可以将**自己**添加为产品 Y 的成员(只要 `req.UserId == caller.UserId`)。
+  2. `CheckMemberTypeAssignment` 只校验 `caller.MemberType` 的优先级,**不区分产品上下文**——A 产品的 ADMIN 身份被用来判断是否可分配 B 产品的 MEMBER 类型。
+- **影响**:用户可将自己"自举"到不属于自己的产品中,绕过产品隔离边界。
+- **修复方案**:在 `AddMember` 中增加显式的产品归属校验:
 
 ```go
-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)
+func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp, err error) {
+    // 新增:要求操作者必须是目标产品的管理员(或超管)
+    if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
+        return nil, err
+    }
+    // ... 其余逻辑保持不变
 }
 ```
 
-需要在 `SysUserRoleModel` 中新增 `FindRoleIdsByUserIdForProduct` 方法,关联 `sys_role` 表按 `productCode` 过滤。
-
 ---
 
-### H3. CreateUser 未校验 DeptId 是否存在,可写入不存在的部门关联
+### H3. `UpdateUserStatus` 缺少"目标用户属于当前产品"的校验
 
-- **位置**:`internal/logic/user/createUserLogic.go` 第 69-80 行
-- **描述**:`CreateUser` 直接将 `req.DeptId` 写入数据库,未校验该部门是否存在。而 `UpdateUser` 在修改 `DeptId` 时正确地进行了存在性校验
-- **影响**:创建用户时传入不存在的 `deptId`,用户记录会关联到一个"幽灵部门"。后续 `UserDetailsLoader.loadDept` 会查询失败并静默跳过(`DeptPath` 为空),导致该用户在部门层级校验 `checkDeptHierarchy` 中行为异常——具体表现为 `caller.DeptPath == ""`,触发 `"您的部门信息异常"` 错误,无法管理任何其他用户
-- **修复方案**:
+- **位置**:`internal/logic/user/updateUserStatusLogic.go:32-60`
+- **描述**:`UpdateUserStatus` 仅调用 `CheckManageAccess` 校验管理权限,**没有**像 `SetUserPerms` / `BindRoles` 那样先验证 `SysProductMemberModel.FindOneByProductCodeUserId`(目标用户必须是当前产品的成员)。而 `CheckManageAccess` 中的 `checkPermLevel` 在目标无成员记录时,`targetMemberType=""` → `memberTypePriority=MaxInt32` → `callerPri < targetPri` → **直接放行**
+- **影响**:产品 A 的 ADMIN 可以修改**不属于产品 A 的全局用户**的账号状态(启用/冻结),因为 `SysUser.Status` 是全局字段,冻结后影响用户在**所有产品**下的登录
+- **修复方案**:增加产品成员校验,与 `SetUserPerms` / `BindRoles` 保持一致:
 
 ```go
-if req.DeptId > 0 {
-    if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.DeptId); err != nil {
-        return nil, response.ErrBadRequest("部门不存在")
+func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq) error {
+    // ... 现有校验 ...
+
+    productCode := middleware.GetProductCode(l.ctx)
+    // 新增:确保目标用户是当前产品的成员
+    if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.Id); err != nil {
+        return response.ErrBadRequest("目标用户不是当前产品的成员")
     }
+
+    if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
+        return err
+    }
+    // ...
 }
 ```
 
 ---
 
-### H4. 成员类型管理层级与 SQL 设计文档不一致
+### H4. `UpdateUser` 中 `Update` + `UpdateStatus` 非原子操作
 
-- **位置**:`internal/logic/auth/access.go` `memberTypePriority` 函数 vs `perm.sql` 第 151-155 行注释
-- **描述**:`perm.sql` 中明确标注管理层级顺序为 `超级管理员 > DEVELOPER > ADMIN > MEMBER`,但代码中 `memberTypePriority` 的实现为:
+- **位置**:`internal/logic/user/updateUserLogic.go:111-118`
+- **描述**:当修改用户信息且包含状态变更时,代码先调用 `SysUserModel.Update(ctx, user)` 更新所有字段(包括 `Status`),若状态确实变更又额外调用 `SysUserModel.UpdateStatus(ctx, req.Id, req.Status)`。这两次写操作没有在同一事务中:
+  1. 第一次 `Update` 已经将 `Status` 写入了 DB,第二次 `UpdateStatus` 是冗余的(其主要目的是递增 `tokenVersion` 使旧 token 失效)。
+  2. 如果第一次 `Update` 成功但第二次 `UpdateStatus` 失败,用户状态已被冻结但 `tokenVersion` **未递增**,导致用户的旧 token 仍然有效——**被冻结的用户仍可继续访问系统**直到 token 过期。
+- **影响**:冻结用户操作可能只部分生效,导致被冻结的用户仍可在 token 有效期内继续使用系统。
+- **修复方案**:将两步操作合并到一个事务中,或直接只调用 `UpdateStatus`(它内部已经包含了 `tokenVersion` 递增逻辑):
 
-| 类型 | 代码优先级 | SQL 文档预期 |
-|------|-----------|-------------|
-| SUPER_ADMIN | 0 (最高) | 最高 |
-| ADMIN | 1 | 2 |
-| DEVELOPER | 2 | 1 |
-| MEMBER | 3 (最低) | 最低 |
+```go
+// 方案:移除冗余的双重写入,在 Update 中统一处理
+if statusChanged {
+    // 使用事务确保 status 变更和 tokenVersion 递增的原子性
+    if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+        if err := l.svcCtx.SysUserModel.UpdateWithTx(ctx, session, user); err != nil {
+            return err
+        }
+        return l.svcCtx.SysUserModel.UpdateStatusWithTx(ctx, session, req.Id, req.Status)
+    }); err != nil {
+        return err
+    }
+} else {
+    if err := l.svcCtx.SysUserModel.Update(l.ctx, user); err != nil {
+        return err
+    }
+}
+```
+
+---
 
-  代码中 ADMIN(1) > DEVELOPER(2),而文档描述 DEVELOPER > ADMIN。
-- **影响**:如果文档是正确的设计意图,那么当前实现存在以下问题:
-  - DEVELOPER 无法管理同产品下的 ADMIN 用户(被 `checkPermLevel` 拦截)
-  - ADMIN 可以管理 DEVELOPER 用户(不应被允许)
-  - `CheckMemberTypeAssignment` 中 DEVELOPER 无法分配 ADMIN 类型(若按文档应该可以)
+### H5. `checkPermLevel` 对非产品成员目标默认放行
 
-  如果代码是正确的、文档是过时的,则应更新 SQL 注释以避免后续维护者误解。
-- **修复方案**:确认真实的业务层级意图,统一代码与文档。若 DEVELOPER 应高于 ADMIN:
+- **位置**:`internal/logic/auth/access.go:147-177`
+- **描述**:当目标用户**不是**当前产品的成员时,`FindOneByProductCodeUserId` 返回错误,`targetMemberType` 保持为空字符串 `""`。`memberTypePriority("")` 返回 `math.MaxInt32`(即优先级最低)。接下来比较 `callerPri < targetPri`(例如 ADMIN 的 1 < MaxInt32)→ **直接 return nil 放行**。这意味着:任何低级别管理者都可以"管理"一个不属于本产品的用户。
+- **影响**:与 H3 联动,扩大了越权操作的范围。非产品成员的目标被视为"权限最低的人",反而最容易被操作。
+- **修复方案**:当目标用户不是当前产品成员时,应拒绝而非放行:
 
 ```go
-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
+func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, productCode string) error {
+    if productCode == "" {
+        return response.ErrBadRequest("缺少产品上下文,无法进行权限级别判定")
+    }
+
+    targetMember, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, targetUserId)
+    if err != nil {
+        // 目标不是当前产品成员,应拒绝操作而非放行
+        return response.ErrForbidden("目标用户不是当前产品的成员,无法执行管理操作")
     }
+    targetMemberType := targetMember.MemberType
+    // ... 后续比较逻辑不变
 }
 ```
 
-若代码实现是正确的(ADMIN > DEVELOPER),则更新 `perm.sql` 注释为:`超级管理员 > ADMIN > DEVELOPER > MEMBER`。
-
 ---
 
-### H5. SyncPerms 传入空权限列表会禁用产品全部权限,缺乏防护
+### H6. gRPC Reflection 在生产环境中开启
 
-- **位置**:`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` 入口处增加空数组保护:
+- **位置**:`perm.go:38`
+- **描述**:`reflection.Register(grpcServer)` 无条件开启了 gRPC Server Reflection。攻击者可使用 `grpcurl` 等工具发现所有 RPC 方法、请求/响应结构,大幅降低攻击门槛。
+- **影响**:结合 H1(`GetUserPerms` 无鉴权),攻击者可自动化发现并利用无保护接口。
+- **修复方案**:仅在开发/测试环境开启反射:
 
 ```go
-if len(req.Perms) == 0 {
-    return nil, response.ErrBadRequest("权限列表不能为空,如需禁用所有权限请使用专用接口")
+if c.Mode == "dev" {
+    reflection.Register(grpcServer)
 }
 ```
 
-gRPC 端的 `SyncPermissions` 也需要同步添加此校验。
-
 ---
 
-## ⚠️ 健壮性与性能建议 (Medium/Low)
+## ⚠️ 健壮性与性能建议 (Medium / Low)
 
-### M1. RefreshToken 未实现轮转,被盗令牌在有效期内可无限复用
+### M1. `RequireProductAdmin` 未绑定具体产品(Medium)
 
-- **位置**:`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 黑名单或版本号实现)。
+- **位置**:`internal/logic/auth/access.go:86-98`
+- **描述**:`RequireProductAdmin` 只检查 `caller.MemberType == MemberTypeAdmin`,**不校验** `caller.ProductCode` 是否与目标操作的产品一致。虽然目前代码中大多数场景使用的是 `RequireProductAdminFor`(带产品校验),但如果未来有开发者误用 `RequireProductAdmin`,将产生跨产品越权。
+- **建议**:标记 `RequireProductAdmin` 为 deprecated,或重构为必须传入 `targetProductCode`。
 
 ---
 
-### M2. HTTP 与 gRPC 的 SyncPermissions 逻辑重复,存在不同步风险
+### M2. `UserDetail` 对 `ProductCode=""` 的非超管跳过成员校验(Medium)
 
-- **位置**:`internal/logic/pub/syncPermsLogic.go` vs `internal/server/permserver.go` `SyncPermissions` 方法
-- **级别**:Medium
-- **描述**:HTTP 端走 `SyncPermsLogic`,gRPC 端在 `permserver.go` 中内联实现了几乎相同的逻辑。两处代码维护相同的业务语义(认证、去重、事务批量更新、缓存清理),但互相独立。
-- **建议**:将核心逻辑抽取为共享的 service 函数(类似 `ValidateProductLogin` 的做法),让 HTTP Logic 和 gRPC Server 都调用同一份代码。
+- **位置**:`internal/logic/user/userDetailLogic.go:35-38`
+- **描述**:仅当 `!caller.IsSuperAdmin && caller.ProductCode != ""` 时才校验目标是否为本产品成员。如果运行时出现 `ProductCode == ""` 的非超管用户(例如直接通过 adminLogin 且未指定产品),则可读取任意用户的基础信息。
+- **建议**:非超管用户在 `ProductCode` 为空时应直接拒绝访问其他用户详情。
 
 ---
 
-### M3. RequireProductAdmin 函数未绑定具体产品,存在跨产品越权隐患
+### M3. 超管 `UserList` 与 `ProductCode` 筛选逻辑不一致(Medium)
 
-- **位置**:`internal/logic/auth/access.go` `RequireProductAdmin` 函数
-- **级别**:Medium
-- **描述**:`RequireProductAdmin` 只检查 `caller.MemberType == ADMIN`,不验证操作者是否是**目标产品**的管理员。如果产品 A 的 ADMIN 调用了使用此函数鉴权的接口来操作产品 B 的数据,会被错误放行。
-- **现状**:当前所有业务代码使用的是带产品校验的 `RequireProductAdminFor`,`RequireProductAdmin` 实际未被引用。
-- **建议**:删除 `RequireProductAdmin` 函数或标记为 deprecated,避免后续开发者误用。
+- **位置**:`internal/logic/user/userListLogic.go:55-62`
+- **描述**:超管无论是否传了 `ProductCode`,都走 `FindListByPage`(全量分页),而不是按产品筛选。但后续又用 `ProductCode` 去查 `MemberType` 并附加到每条记录上。用户在前端选择按产品过滤时,列表仍然是全库数据,仅是 `MemberType` 字段有值,**与"按产品筛选"的语义不一致**。
+- **建议**:超管也应支持 `ProductCode` 作为筛选条件,按产品成员过滤列表。
 
 ---
 
-### M4. CreateProduct 事务中管理员用户名可能冲突导致整体回滚
+### M4. `loadPerms` 静默忽略数据库错误(Medium)
 
-- **位置**:`internal/logic/product/createProductLogic.go` 第 87 行
-- **级别**:Medium
-- **描述**:创建产品时自动生成管理员用户名为 `admin_{productCode}`。如果系统中已存在同名用户(如手动创建或之前产品删除后遗留),`InsertWithTx` 会因唯一索引冲突报错,导致整个事务回滚——产品也不会被创建,且错误信息为底层数据库错误,对调用方不友好。
-- **建议**:在事务开始前先检查用户名是否已存在,给出明确的业务错误提示:
+- **位置**:`internal/loaders/userDetailsLoader.go:373-379`
+- **描述**:`allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...)` 忽略了错误。如果数据库查询失败,用户的 ALLOW 权限列表为空,最终计算出的权限集合会**比实际少**——用户会被"静默降权"而非收到错误提示。
+- **建议**:DB 错误应向上传递或记录日志并返回错误,避免静默降权:
 
 ```go
-if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
-    return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
+allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...)
+if err != nil {
+    logx.WithContext(ctx).Errorf("load allow perms failed: %v", err)
+    return // 或给 ud.Perms 设置默认空值并标记加载失败
 }
 ```
 
 ---
 
-### M5. BindRolePerms / SetUserPerms 未校验权限的启用状态
-
-- **位置**:`internal/logic/role/bindRolePermsLogic.go` 第 58-64 行、`internal/logic/user/setUserPermsLogic.go` 第 65-74 行
-- **级别**:Low
-- **描述**:绑定权限时只校验了权限 ID 存在且属于同一产品,但未检查权限是否处于启用状态(`status=1`)。已被 `SyncPerms` 禁用的权限仍可被绑定到角色或用户。
-- **实际影响有限**:`loadPerms` 在计算最终权限时会过滤掉 `status != 1` 的权限,所以被禁用的权限不会生效。但绑定关系的存在可能造成管理界面上的困惑。
-- **建议**:在校验循环中增加状态检查:
+### M5. 密码策略偏弱(Medium)
 
-```go
-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))
-    }
-}
-```
+- **位置**:`internal/logic/user/createUserLogic.go:48-54`、`internal/logic/auth/changePasswordLogic.go`
+- **描述**:密码最低长度仅 6 字符,无大小写混合、数字、特殊字符等复杂度要求。对于后台管理系统,安全性偏弱。
+- **建议**:至少增加「包含大小写字母和数字」要求,或将最低长度提高至 8 位。
 
 ---
 
-### M6. ProductDetail / ProductList 缺少产品维度的访问控制
+### M6. 登录仅 IP 维度限流,无账户级锁定(Medium)
 
-- **位置**:`internal/logic/product/productDetailLogic.go`、`productListLogic.go`
-- **级别**:Low
-- **描述**:任何已登录用户(包括非超管的普通产品成员)都可以查看系统中**所有产品**的列表和详情(除 AppKey 外)。虽然敏感字段 `AppKey` 仅对超管可见、`AppSecret` 不返回,但产品编码、名称等信息对非本产品成员可见。
-- **建议**:评估业务需求。如果产品信息确实应该对所有登录用户可见(如产品选择页面),则当前实现合理。否则应增加 `caller.ProductCode` 校验或只返回用户所属产品的列表。
+- **位置**:`internal/svc/servicecontext.go` 中 `LoginRateLimit` 为 60秒/IP/20次
+- **描述**:限流仅基于 IP,攻击者可通过分布式 IP 绕过。对同一用户名的暴力破解尝试没有账户级锁定机制。
+- **建议**:增加按用户名维度的登录失败计数,连续 N 次失败后临时锁定账户或要求验证码。
 
 ---
 
-### M7. DeptTree 接口无任何权限过滤
+### M7. 数据库无外键约束,完全依赖应用层维护引用完整性(Low)
 
-- **位置**:`internal/logic/dept/deptTreeLogic.go`
-- **级别**:Low
-- **描述**:`DeptTree` 加载并返回系统中**全部部门**的树形结构,无论调用者的身份和所属产品。任何已登录用户都能看到完整的组织架构
-- **建议**:如果部门树只应由超管可见,增加 `RequireSuperAdmin` 校验。如果需要按层级裁剪(只看本部门及子部门),应根据 `caller.DeptPath` 过滤
+- **位置**:`perm.sql`
+- **描述**:所有表之间通过 `userId`、`roleId`、`permId`、`productCode` 等字段关联,均**无 FOREIGN KEY**。如果应用层代码遗漏了级联清理(如删除产品时忘记清理成员表),会产生孤儿数据。
+- **当前状态**:`DeleteRole` 有事务内级联清理 `role_perm` 和 `user_role`;`RemoveMember` 有事务内清理 `user_role` 和 `user_perm`。但**无删除产品**和**删除用户**的接口,若未来添加需注意
+- **建议**:至少通过定期数据校验脚本排查孤儿行,或在关键关联字段上加外键
 
 ---
 
-### M8. loadRoles 全量加载后内存过滤,存在轻微效率损失
+### M8. `sys_user_role` 表缺少 `roleId` 的单独索引(Low)
 
-- **位置**:`internal/loaders/userDetailsLoader.go` `loadRoles` 方法
-- **级别**:Low
-- **描述**:`FindRoleIdsByUserId` 返回用户在所有产品下的全部角色 ID,再通过 `FindByIds` 批量查询角色详情,最后在内存中按 `ProductCode` 和 `Status` 过滤。对于只加入了 1-2 个产品的普通用户不会有问题,但逻辑上可以在 SQL 层面就做好过滤。
-- **建议**:新增按产品过滤的查询方法:
+- **位置**:`perm.sql` 中 `sys_user_role` 表唯一索引为 `(userId, roleId)`
+- **描述**:`FindUserIdsByRoleId`(删除角色、更新角色时批量清除缓存用到)按 `roleId` 单列查询,复合索引左前缀为 `userId`,无法高效利用。在角色关联用户数较多时可能影响查询性能。
+- **建议**:增加 `KEY idx_role (roleId)` 索引。
 
-```go
-func FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)
-```
+---
 
-通过 JOIN `sys_role` 表在查询时过滤 `productCode`,减少不必要的数据传输和内存开销。
+### M9. `CreateProduct` 中 TOCTOU 竞态(Low)
+
+- **位置**:`internal/logic/product/createProductLogic.go:53-56`、`72-75`
+- **描述**:先 `FindOneByCode` 检查产品编码是否存在、再 `FindOneByUsername` 检查管理员用户名是否存在,最后在事务中执行插入。预检与插入之间存在时间窗口,极低概率下两个并发请求可同时通过预检,但实际上 DB 层唯一约束会兜底(返回 1062 错误),不会导致数据损坏。
+- **影响**:极低风险,唯一约束可兜底,但错误信息可能不够友好(返回原始 DB 错误而非"产品编码已存在")。
+- **建议**:在事务内的 Insert 错误中也做 `Duplicate entry` 判断并返回友好错误信息。
 
 ---
 
-### M9. DeleteRole 缓存失效存在极小时间窗口遗漏
+### M10. `BindRoles` 中 `MinPermsLevel == 0` 时绕过角色级别约束(Low)
 
-- **位置**:`internal/logic/role/deleteRoleLogic.go` 第 40-42 行
-- **级别**:Low
-- **描述**:`affectedUserIds` 在事务执行**之前**查询。如果在查询之后、事务执行之前有新的用户被绑定到该角色,这些用户的缓存不会被主动清理(但会在 5 分钟后自然过期)。
-- **实际影响极低**:这个时间窗口极短(微秒级),且角色删除事务内已清除所有 user-role 绑定关系,新绑定的用户在缓存过期后会自动生效。
-- **建议**:可接受当前实现。如需极致一致性,可在事务内查询 affectedUserIds。
+- **位置**:`internal/logic/user/bindRolesLogic.go:78-80`
+- **描述**:约束条件为 `caller.MinPermsLevel > 0 && r.PermsLevel < caller.MinPermsLevel`。当调用者的 `MinPermsLevel` 为 0(即无角色绑定或角色 `PermsLevel` 为 0)时,整个条件不生效,允许绑定任意 `PermsLevel` 的角色。
+- **影响**:取决于业务设计——如果 `PermsLevel=0` 意味着"无限制"则合理;否则应补充边界处理。
+- **建议**:明确 `MinPermsLevel == 0` 的业务语义。如果 0 不是合法值,应在 `loadRoles` 或角色创建时强制 `PermsLevel >= 1`(目前 `CreateRole` 已做 1-999 校验,故此问题风险较低)。
 
 ---
 
-### M10. CreateUser 缺少用户名格式校验
-
-- **位置**:`internal/logic/user/createUserLogic.go`
-- **级别**:Low
-- **描述**:`CreateUser` 仅校验了用户名长度(最大 64 字符),未校验格式。用户名可以包含空格、特殊字符、中文等,可能导致:
-  - 与自动生成的 `admin_{code}` 格式冲突
-  - 登录时的编码问题
-  - 日志可读性降低
-- **建议**:增加用户名格式校验(如只允许字母数字下划线):
+### M11. Redis 缓存索引集合与数据键 TTL 不同步(Low)
 
-```go
-if !regexp.MustCompile(`^[a-zA-Z0-9_]{2,64}$`).MatchString(req.Username) {
-    return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")
-}
-```
+- **位置**:`internal/loaders/userDetailsLoader.go:190-207`
+- **描述**:缓存数据键 TTL 为 300s,索引集合 TTL 为 360s。在 300-360s 之间的时间窗口内,索引集合中引用的数据键已过期但索引还在,`cleanByIndex` 会尝试删除已不存在的键(不会报错但产生无效 DEL 命令)。反向情况:若 `Clean` 在数据键写入后、索引 `SADD` 前被调用(极低概率),则数据键不会被清理直到自然过期。
+- **影响**:极低风险,不影响正确性,仅可能导致少量无效 Redis 操作。
 
 ---
 
-### M11. gRPC GetUserPerms 无鉴权保护
+### M12. 部门树对异常数据的静默处理(Low)
 
-- **位置**:`internal/server/permserver.go` `GetUserPerms` 方法
-- **级别**:Low(取决于部署方式)
-- **描述**:gRPC 端的 `GetUserPerms` 接口没有任何认证或授权校验,任何能访问 gRPC 端口的客户端都可以查询任意用户的权限列表。
-- **现状评估**:如果 gRPC 仅在内网(如 K8s 集群内部)暴露给可信的下游服务,这是合理的设计。但如果 gRPC 端口意外暴露到公网,则构成严重的信息泄露。
-- **建议**:确保 gRPC 端口不暴露到外部网络。如有需要,可增加 gRPC 拦截器进行 mTLS 或 token 校验。
+- **位置**:`internal/logic/dept/deptTreeLogic.go`
+- **描述**:构建部门树时,如果子节点的 `ParentId` 对应的父节点不在查询结果中(数据不一致),该节点会被当作根节点挂到 `roots`,而不是报错。这会掩盖数据损坏问题。
+- **建议**:对 `ParentId != 0` 但找不到父节点的情况,记录告警日志。
 
 ---
 
-### M12. UpdateMember / UpdateRole / UpdateDept 对无效 status 值静默忽略
+### M13. `response.Setup` 中内部错误信息可能通过日志泄露(Low)
 
-- **位置**:`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
-}
-```
+- **位置**:`internal/response/response.go:46`
+- **描述**:`logx.WithContext(ctx).Errorf("internal error: %+v", err)` 使用 `%+v` 打印了完整的错误堆栈到服务端日志。虽然不会返回给客户端(客户端只收到"服务器内部错误"),但日志中可能包含 SQL 语句、表结构等敏感信息。
+- **建议**:确保日志收集系统有适当的访问控制;考虑对 SQL 相关错误做脱敏处理。
 
 ---
 
-## 总结
+## 审计总结
 
-| 级别 | 数量 | 关键发现 |
-|------|------|---------|
-| 🚩 High | 5 | token 失效不一致、跨产品信息泄露、层级定义矛盾、数据完整性、无保护的全量禁用 |
-| ⚠️ Medium | 4 | RefreshToken 无轮转、gRPC/HTTP 逻辑重复、函数越权隐患、用户名冲突 |
-| 💡 Low | 8 | 无效权限绑定、产品访问控制、部门树全量暴露、效率优化、格式校验等 |
+| 维度 | 评估 |
+|------|------|
+| **逻辑一致性** | `SetUserPerms`/`BindRoles` 与 `UpdateUserStatus`/`AddMember` 在产品成员校验上**不一致**(H2/H3),是主要风险点 |
+| **并发与竞态** | `CreateProduct` 存在 TOCTOU 但有唯一约束兜底(M9);`UpdateUser` 的双重写入有部分失败风险(H4) |
+| **资源管理** | go-zero 框架层面管理连接池和 Redis,未发现泄漏;`singleflight` 有效防止缓存穿透 |
+| **数据完整性** | 关键写操作使用了事务(角色删除、成员删除、权限同步);**无外键**依赖应用层级联(M7) |
+| **安全漏洞** | gRPC `GetUserPerms` 无鉴权是**最高优先级**修复项(H1);跨产品成员添加(H2)和状态越权修改(H3)次之 |
+| **边界崩溃** | 整体处理较好,`FindOne` 错误统一返回 `ErrNotFound`;`loadPerms` 静默忽略错误有隐患(M4) |
+| **SQL 注入** | 所有自定义 SQL 均使用参数化查询,LIKE 做了通配符转义,**未发现注入风险** |
 
-**整体评价**:项目的架构设计合理,go-zero 框架的使用规范,核心安全机制(JWT tokenVersion、bcrypt、参数化 SQL、限流)实现到位。主要风险集中在**同一业务操作的多个入口未保持一致性**(如 UpdateUser vs UpdateUserStatus)以及**数据隔离在产品边界上的不完整**。建议优先修复 H1(token 失效不一致)和 H5(空列表全量禁用),这两个问题在生产环境中最有可能造成实际影响。
+**建议修复优先级**:H1 > H2 = H3 = H5 > H4 > H6 > M1 ~ M6

+ 18 - 11
internal/loaders/userDetailsLoader.go

@@ -30,17 +30,17 @@ type UserDetails struct {
 	Email              string `json:"email"`
 	Phone              string `json:"phone"`
 	Remark             string `json:"remark"`
-	IsSuperAdmin       bool  `json:"isSuperAdmin"`
-	IsSuperAdminRaw    int64 `json:"isSuperAdminRaw"`
-	MustChangePassword bool  `json:"mustChangePassword"`
-	MustChangePwdRaw   int64 `json:"mustChangePwdRaw"`
-	Status             int64 `json:"status"`
-	TokenVersion       int64 `json:"tokenVersion"`
+	IsSuperAdmin       bool   `json:"isSuperAdmin"`
+	IsSuperAdminRaw    int64  `json:"isSuperAdminRaw"`
+	MustChangePassword bool   `json:"mustChangePassword"`
+	MustChangePwdRaw   int64  `json:"mustChangePwdRaw"`
+	Status             int64  `json:"status"`
+	TokenVersion       int64  `json:"tokenVersion"`
 
 	// 部门信息 (sys_dept)
-	DeptId   int64  `json:"deptId"`
-	DeptName string `json:"deptName"`
-	DeptPath string `json:"deptPath"`
+	DeptId     int64  `json:"deptId"`
+	DeptName   string `json:"deptName"`
+	DeptPath   string `json:"deptPath"`
 	DeptType   string `json:"deptType"`
 	DeptStatus int64  `json:"deptStatus"`
 
@@ -375,8 +375,15 @@ func (l *UserDetailsLoader) loadPerms(ctx context.Context, ud *UserDetails) {
 		}
 	}
 
-	allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
-	denyIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
+	allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectAllow, ud.ProductCode)
+	if err != nil {
+		logx.WithContext(ctx).Errorf("userDetailsLoader: load allow perms failed: %v", err)
+		return
+	}
+	denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
+	if err != nil {
+		logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
+	}
 
 	denySet := make(map[int64]bool, len(denyIds))
 	for _, id := range denyIds {

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

@@ -82,21 +82,6 @@ func RequireSuperAdmin(ctx context.Context) error {
 	return nil
 }
 
-// RequireProductAdmin 要求当前操作者是超级管理员或当前产品的管理员(ADMIN)。
-func RequireProductAdmin(ctx context.Context) error {
-	caller := middleware.GetUserDetails(ctx)
-	if caller == nil {
-		return response.ErrUnauthorized("未登录")
-	}
-	if caller.IsSuperAdmin {
-		return nil
-	}
-	if caller.MemberType == consts.MemberTypeAdmin {
-		return nil
-	}
-	return response.ErrForbidden("仅超级管理员或产品管理员可执行此操作")
-}
-
 // RequireProductAdminFor 要求当前操作者是超级管理员或指定产品的管理员。
 func RequireProductAdminFor(ctx context.Context, targetProductCode string) error {
 	caller := middleware.GetUserDetails(ctx)
@@ -149,11 +134,11 @@ func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loa
 		return response.ErrBadRequest("缺少产品上下文,无法进行权限级别判定")
 	}
 
-	targetMemberType := ""
 	targetMember, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, targetUserId)
-	if err == nil {
-		targetMemberType = targetMember.MemberType
+	if err != nil {
+		return response.ErrForbidden("目标用户不是当前产品的成员,无法执行管理操作")
 	}
+	targetMemberType := targetMember.MemberType
 
 	callerPri := memberTypePriority(caller.MemberType)
 	targetPri := memberTypePriority(targetMemberType)

+ 20 - 11
internal/logic/auth/access_test.go

@@ -68,20 +68,20 @@ func TestRequireSuperAdmin_NoUserDetails(t *testing.T) {
 // =====================================================================
 
 // TC-0465: SuperAdmin → nil
-func TestRequireProductAdmin_SuperAdmin(t *testing.T) {
-	err := RequireProductAdmin(ctxhelper.SuperAdminCtx())
+func TestRequireProductAdminFor_SuperAdmin(t *testing.T) {
+	err := RequireProductAdminFor(ctxhelper.SuperAdminCtx(), "p1")
 	assert.NoError(t, err)
 }
 
-// TC-0466: ADMIN → nil
-func TestRequireProductAdmin_Admin(t *testing.T) {
-	err := RequireProductAdmin(ctxhelper.AdminCtx("p1"))
+// TC-0466: ADMIN → nil (same product)
+func TestRequireProductAdminFor_Admin(t *testing.T) {
+	err := RequireProductAdminFor(ctxhelper.AdminCtx("p1"), "p1")
 	assert.NoError(t, err)
 }
 
 // TC-0467: DEVELOPER → 403
-func TestRequireProductAdmin_Developer(t *testing.T) {
-	err := RequireProductAdmin(ctxhelper.DeveloperCtx("p1"))
+func TestRequireProductAdminFor_Developer(t *testing.T) {
+	err := RequireProductAdminFor(ctxhelper.DeveloperCtx("p1"), "p1")
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -89,8 +89,8 @@ func TestRequireProductAdmin_Developer(t *testing.T) {
 }
 
 // TC-0468: MEMBER → 403
-func TestRequireProductAdmin_Member(t *testing.T) {
-	err := RequireProductAdmin(ctxhelper.MemberCtx("p1"))
+func TestRequireProductAdminFor_Member(t *testing.T) {
+	err := RequireProductAdminFor(ctxhelper.MemberCtx("p1"), "p1")
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -98,8 +98,8 @@ func TestRequireProductAdmin_Member(t *testing.T) {
 }
 
 // TC-0469: 无 UserDetails → 401
-func TestRequireProductAdmin_NoUserDetails(t *testing.T) {
-	err := RequireProductAdmin(context.Background())
+func TestRequireProductAdminFor_NoUserDetails(t *testing.T) {
+	err := RequireProductAdminFor(context.Background(), "p1")
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -107,6 +107,15 @@ func TestRequireProductAdmin_NoUserDetails(t *testing.T) {
 	assert.Contains(t, ce.Error(), "未登录")
 }
 
+// TC-0555: ADMIN 跨产品被拒绝
+func TestRequireProductAdminFor_AdminCrossProduct(t *testing.T) {
+	err := RequireProductAdminFor(ctxhelper.AdminCtx("p1"), "other_product")
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+}
+
 // =====================================================================
 // CheckMemberTypeAssignment
 // =====================================================================

+ 3 - 5
internal/logic/auth/changePasswordLogic.go

@@ -8,6 +8,7 @@ import (
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
+	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
 	"golang.org/x/crypto/bcrypt"
@@ -28,11 +29,8 @@ func NewChangePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ch
 }
 
 func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error {
-	if len(req.NewPassword) < 6 {
-		return response.ErrBadRequest("密码长度不能少于6个字符")
-	}
-	if len(req.NewPassword) > 72 {
-		return response.ErrBadRequest("密码长度不能超过72个字符")
+	if msg := util.ValidatePassword(req.NewPassword); msg != "" {
+		return response.ErrBadRequest(msg)
 	}
 
 	userId := middleware.GetUserId(l.ctx)

+ 21 - 21
internal/logic/auth/changePasswordLogic_test.go

@@ -55,8 +55,8 @@ func TestChangePassword_Success(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	ctx := context.Background()
 
-	oldPwd := "oldpass123"
-	newPwd := "newpass456"
+	oldPwd := "Oldpass123"
+	newPwd := "Newpass456"
 	username := testutil.UniqueId()
 	hashed := testutil.HashPassword(oldPwd)
 	userId := insertTestUser(t, ctx, username, hashed)
@@ -80,8 +80,8 @@ func TestChangePassword_MustChangePasswordReset(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	ctx := context.Background()
 
-	oldPwd := "oldpass123"
-	newPwd := "newpass456"
+	oldPwd := "Oldpass123"
+	newPwd := "Newpass456"
 	username := testutil.UniqueId()
 	hashed := testutil.HashPassword(oldPwd)
 	userId := insertTestUser(t, ctx, username, hashed)
@@ -106,14 +106,14 @@ func TestChangePassword_WrongOldPassword(t *testing.T) {
 	ctx := context.Background()
 
 	username := testutil.UniqueId()
-	hashed := testutil.HashPassword("realpass")
+	hashed := testutil.HashPassword("Realpass1")
 	userId := insertTestUser(t, ctx, username, hashed)
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 	logic := NewChangePasswordLogic(ctxWithUserId(userId), svcCtx)
 	err := logic.ChangePassword(&types.ChangePasswordReq{
-		OldPassword: "wrongpass",
-		NewPassword: "newpass456",
+		OldPassword: "Wrongpass1",
+		NewPassword: "Newpass456",
 	})
 
 	var codeErr *response.CodeError
@@ -122,30 +122,30 @@ func TestChangePassword_WrongOldPassword(t *testing.T) {
 	assert.Equal(t, "原密码错误", codeErr.Error())
 }
 
-// TC-0053: 新密码少于6字符
+// TC-0053: 新密码少于8字符
 func TestChangePassword_NewPasswordTooShort(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 	logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
 	err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "oldpass",
-		NewPassword: "12345",
+		NewPassword: "Pas1234",
 	})
 
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码长度不能少于6个字符", codeErr.Error())
+	assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
 }
 
-// TC-0054: 新密码恰好6字符
-func TestChangePassword_NewPasswordExactly6Chars(t *testing.T) {
+// TC-0054: 新密码恰好8字符(含大小写+数字)
+func TestChangePassword_NewPasswordExactly8Chars(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
 	ctx := context.Background()
 
-	oldPwd := "oldpass123"
-	newPwd := "abcdef"
+	oldPwd := "Oldpass123"
+	newPwd := "Abcdef1x"
 	username := testutil.UniqueId()
 	hashed := testutil.HashPassword(oldPwd)
 	userId := insertTestUser(t, ctx, username, hashed)
@@ -176,14 +176,14 @@ func TestChangePassword_NewPasswordEmpty(t *testing.T) {
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 400, codeErr.Code())
-	assert.Equal(t, "密码长度不能少于6个字符", codeErr.Error())
+	assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
 }
 
 // TC-0056: 新密码超过72字符
 func TestChangePassword_NewPasswordTooLong(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
-	longPwd := strings.Repeat("a", 73)
+	longPwd := "A" + strings.Repeat("a", 71) + "1"
 	logic := NewChangePasswordLogic(ctxWithUserId(1), svcCtx)
 	err := logic.ChangePassword(&types.ChangePasswordReq{
 		OldPassword: "oldpass",
@@ -202,8 +202,8 @@ func TestChangePassword_NewPasswordExactly72Chars(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	ctx := context.Background()
 
-	oldPwd := "oldpass123"
-	newPwd := strings.Repeat("b", 72)
+	oldPwd := "Oldpass123"
+	newPwd := "B" + strings.Repeat("b", 70) + "1"
 	username := testutil.UniqueId()
 	hashed := testutil.HashPassword(oldPwd)
 	userId := insertTestUser(t, ctx, username, hashed)
@@ -227,7 +227,7 @@ func TestChangePassword_SameOldAndNew(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	ctx := context.Background()
 
-	pwd := "samepass123"
+	pwd := "Samepass123"
 	username := testutil.UniqueId()
 	hashed := testutil.HashPassword(pwd)
 	userId := insertTestUser(t, ctx, username, hashed)
@@ -251,8 +251,8 @@ func TestChangePassword_UserNotFound(t *testing.T) {
 
 	logic := NewChangePasswordLogic(ctxWithUserId(99999999), svcCtx)
 	err := logic.ChangePassword(&types.ChangePasswordReq{
-		OldPassword: "oldpass",
-		NewPassword: "newpass456",
+		OldPassword: "Oldpass123",
+		NewPassword: "Newpass456",
 	})
 
 	var codeErr *response.CodeError

+ 1 - 0
internal/logic/dept/deptTreeLogic.go

@@ -57,6 +57,7 @@ func (l *DeptTreeLogic) DeptTree() (resp []*types.DeptItem, err error) {
 		} else if parent, ok := itemMap[item.ParentId]; ok {
 			parent.Children = append(parent.Children, item)
 		} else {
+			l.Errorf("DeptTree: dept id=%d has parentId=%d which does not exist, treated as root", item.Id, item.ParentId)
 			roots = append(roots, item)
 		}
 	}

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

@@ -43,7 +43,7 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		return nil, response.ErrBadRequest("无效的成员类型")
 	}
 
-	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, req.ProductCode); err != nil {
+	if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
 		return nil, err
 	}
 	if err := authHelper.CheckMemberTypeAssignment(l.ctx, req.MemberType); err != nil {

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

@@ -5,6 +5,7 @@ import (
 	"crypto/rand"
 	"encoding/hex"
 	"fmt"
+	"strings"
 	"time"
 
 	"perms-system-server/internal/consts"
@@ -127,6 +128,16 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	})
 
 	if err != nil {
+		errMsg := err.Error()
+		if strings.Contains(errMsg, "1062") || strings.Contains(errMsg, "Duplicate entry") {
+			if strings.Contains(errMsg, "uk_code") || strings.Contains(errMsg, req.Code) {
+				return nil, response.ErrConflict("产品编码已存在")
+			}
+			if strings.Contains(errMsg, "uk_username") || strings.Contains(errMsg, adminUsername) {
+				return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在", adminUsername))
+			}
+			return nil, response.ErrConflict("数据冲突,请稍后重试")
+		}
 		return nil, err
 	}
 

+ 10 - 8
internal/logic/pub/loginLogic.go

@@ -29,14 +29,16 @@ func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err erro
 	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode)
 	if err != nil {
 		if le, ok := err.(*LoginError); ok {
-			switch le.Code {
-			case 400:
-				return nil, response.ErrBadRequest(le.Message)
-			case 401:
-				return nil, response.ErrUnauthorized(le.Message)
-			case 403:
-				return nil, response.ErrForbidden(le.Message)
-			}
+		switch le.Code {
+		case 400:
+			return nil, response.ErrBadRequest(le.Message)
+		case 401:
+			return nil, response.ErrUnauthorized(le.Message)
+		case 403:
+			return nil, response.ErrForbidden(le.Message)
+		case 429:
+			return nil, response.NewCodeError(429, le.Message)
+		}
 		}
 		return nil, err
 	}

+ 8 - 0
internal/logic/pub/loginService.go

@@ -10,6 +10,7 @@ import (
 	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/svc"
 
+	"github.com/zeromicro/go-zero/core/limit"
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -29,6 +30,13 @@ func (e *LoginError) Error() string {
 }
 
 func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode string) (*LoginResult, error) {
+	if svcCtx.UsernameLoginLimit != nil {
+		code, _ := svcCtx.UsernameLoginLimit.Take(username)
+		if code == limit.OverQuota {
+			return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"}
+		}
+	}
+
 	u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
 	if err != nil {
 		if errors.Is(err, user.ErrNotFound) {

+ 4 - 2
internal/logic/user/bindRolesLogic.go

@@ -73,8 +73,10 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
-			if caller != nil && !caller.IsSuperAdmin && caller.MinPermsLevel > 0 && r.PermsLevel < caller.MinPermsLevel {
-				return response.ErrForbidden("不能分配权限级别高于自身的角色")
+			if caller != nil && !caller.IsSuperAdmin {
+				if caller.MinPermsLevel == 0 || r.PermsLevel < caller.MinPermsLevel {
+					return response.ErrForbidden("不能分配权限级别高于自身的角色")
+				}
 			}
 		}
 	}

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

@@ -41,11 +41,8 @@ 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 msg := util.ValidatePassword(req.Password); msg != "" {
+		return nil, response.ErrBadRequest(msg)
 	}
 	if !usernameRegexp.MatchString(req.Username) {
 		return nil, response.ErrBadRequest("用户名只能包含字母、数字和下划线,长度2-64个字符")

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

@@ -31,7 +31,7 @@ func TestCreateUser_Mock_InsertDuplicate1062(t *testing.T) {
 	logic := NewCreateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "dupuser",
-		Password: "pass123456",
+		Password: "Pass123456",
 	})
 
 	require.Error(t, err)

+ 77 - 21
internal/logic/user/createUserLogic_test.go

@@ -75,7 +75,7 @@ func TestCreateUser_Success(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123456",
+		Password: "Pass123456",
 		Nickname: "测试用户",
 		Email:    username + "@test.com",
 		Phone:    "13800138000",
@@ -110,7 +110,7 @@ func TestCreateUser_UsernameExists(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass456",
+		Password: "Pass456789",
 	})
 	require.Error(t, err)
 
@@ -128,7 +128,7 @@ func TestCreateUser_InvalidEmail(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "pass123",
+		Password: "Pass123456",
 		Email:    "not-an-email",
 	})
 	require.Error(t, err)
@@ -149,7 +149,7 @@ func TestCreateUser_ValidEmail(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123",
+		Password: "Pass123456",
 		Email:    username + "@example.com",
 	})
 	require.NoError(t, err)
@@ -172,7 +172,7 @@ func TestCreateUser_EmptyEmailSkipsValidation(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123",
+		Password: "Pass123456",
 		Email:    "",
 	})
 	require.NoError(t, err)
@@ -193,7 +193,7 @@ func TestCreateUser_InvalidPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "pass123",
+		Password: "Pass123456",
 		Phone:    "abc",
 	})
 	require.Error(t, err)
@@ -214,7 +214,7 @@ func TestCreateUser_ValidPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123",
+		Password: "Pass123456",
 		Phone:    "13900139000",
 	})
 	require.NoError(t, err)
@@ -237,7 +237,7 @@ func TestCreateUser_EmptyPhoneSkipsValidation(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123",
+		Password: "Pass123456",
 		Phone:    "",
 	})
 	require.NoError(t, err)
@@ -270,7 +270,7 @@ func TestCreateUser_ConcurrentSameUsername(t *testing.T) {
 			logic := NewCreateUserLogic(ctx, svcCtx)
 			_, err := logic.CreateUser(&types.CreateUserReq{
 				Username: username,
-				Password: "pass123456",
+				Password: "Pass123456",
 				Nickname: "并发测试用户",
 			})
 			results <- err
@@ -312,7 +312,7 @@ func TestCreateUser_ValidInternationalPhone(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123",
+		Password: "Pass123456",
 		Phone:    "+8613800138000",
 	})
 	require.NoError(t, err)
@@ -325,7 +325,7 @@ func TestCreateUser_ValidInternationalPhone(t *testing.T) {
 	assert.Equal(t, "+8613800138000", user.Phone)
 }
 
-// TC-0133: 密码少于6字符
+// TC-0133: 密码少于8字符
 func TestCreateUser_PasswordTooShort(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -333,14 +333,70 @@ func TestCreateUser_PasswordTooShort(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "12345",
+		Password: "Pas1234",
 	})
 	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())
+	assert.Equal(t, "密码长度不能少于8个字符", codeErr.Error())
+}
+
+// TC-0556: 密码缺少大写字母
+func TestCreateUser_PasswordNoUppercase(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+
+	longPwd := "A" + strings.Repeat("a", 71) + "1"
+	_, 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-0557: 密码缺少小写字母
+func TestCreateUser_PasswordNoLowercase(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",
+	})
+	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-0558: 密码缺少数字
+func TestCreateUser_PasswordNoDigit(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateUserLogic(ctx, svcCtx)
+	_, err := logic.CreateUser(&types.CreateUserReq{
+		Username: testutil.UniqueId(),
+		Password: "Passpasspass",
+	})
+	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-0134: 密码超过72字符
@@ -367,7 +423,7 @@ func TestCreateUser_MemberRejected(t *testing.T) {
 	ctx := ctxhelper.MemberCtx("test_product")
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	logic := NewCreateUserLogic(ctx, svcCtx)
-	_, err := logic.CreateUser(&types.CreateUserReq{Username: "test", Password: "pass123"})
+	_, err := logic.CreateUser(&types.CreateUserReq{Username: "test", Password: "Pass123456"})
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
@@ -382,7 +438,7 @@ func TestCreateUser_UsernameInvalidChars(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "user@name!",
-		Password: "pass123456",
+		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -400,7 +456,7 @@ func TestCreateUser_UsernameTooShort(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: "a",
-		Password: "pass123456",
+		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -418,7 +474,7 @@ func TestCreateUser_UsernameTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: strings.Repeat("a", 65),
-		Password: "pass123456",
+		Password: "Pass123456",
 	})
 	require.Error(t, err)
 
@@ -436,7 +492,7 @@ func TestCreateUser_DeptNotExists(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "pass123456",
+		Password: "Pass123456",
 		DeptId:   999999999,
 	})
 	require.Error(t, err)
@@ -455,7 +511,7 @@ func TestCreateUser_NicknameTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "pass123456",
+		Password: "Pass123456",
 		Nickname: strings.Repeat("n", 65),
 	})
 	require.Error(t, err)
@@ -474,7 +530,7 @@ func TestCreateUser_RemarkTooLong(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	_, err := logic.CreateUser(&types.CreateUserReq{
 		Username: testutil.UniqueId(),
-		Password: "pass123456",
+		Password: "Pass123456",
 		Remark:   strings.Repeat("r", 256),
 	})
 	require.Error(t, err)
@@ -512,7 +568,7 @@ func TestCreateUser_AllOptionalFields(t *testing.T) {
 	logic := NewCreateUserLogic(ctx, svcCtx)
 	resp, err := logic.CreateUser(&types.CreateUserReq{
 		Username: username,
-		Password: "pass123456",
+		Password: "Pass123456",
 		Nickname: "全字段用户",
 		Email:    username + "@example.com",
 		Phone:    "13900001111",

+ 2 - 8
internal/logic/user/updateUserLogic.go

@@ -96,26 +96,20 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		}
 		user.DeptId = *req.DeptId
 	}
-	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.TokenVersion++
 		}
-		user.Status = req.Status
 	}
 	user.UpdateTime = time.Now().Unix()
 
 	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

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

@@ -47,6 +47,14 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
+	if productCode != "" {
+		caller := middleware.GetUserDetails(l.ctx)
+		if caller != nil && !caller.IsSuperAdmin {
+			if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.Id); err != nil {
+				return response.ErrBadRequest("目标用户不是当前产品的成员")
+			}
+		}
+	}
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
 		return err
 	}

+ 9 - 3
internal/logic/user/userDetailLogic.go

@@ -30,9 +30,15 @@ func (l *UserDetailLogic) UserDetail(req *types.UserDetailReq) (resp *types.User
 	if caller == nil {
 		return nil, response.ErrUnauthorized("未登录")
 	}
-	if !caller.IsSuperAdmin && caller.ProductCode != "" {
-		if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
-			return nil, response.ErrForbidden("无权查看非本产品成员的用户信息")
+	if !caller.IsSuperAdmin {
+		if caller.ProductCode == "" {
+			if caller.UserId != req.Id {
+				return nil, response.ErrForbidden("缺少产品上下文,仅可查看自己的信息")
+			}
+		} else {
+			if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, caller.ProductCode, req.Id); err != nil {
+				return nil, response.ErrForbidden("无权查看非本产品成员的用户信息")
+			}
 		}
 	}
 

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

@@ -47,7 +47,7 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 	var total int64
 	var memberMap map[int64]string
 
-	if req.ProductCode != "" && !caller.IsSuperAdmin {
+	if req.ProductCode != "" {
 		list, total, err = l.svcCtx.SysUserModel.FindListByProductMembers(l.ctx, req.ProductCode, page, pageSize)
 		if err != nil {
 			return nil, err

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

@@ -21,7 +21,7 @@ func TestUserList_Mock_FindMapError(t *testing.T) {
 	dbErr := errors.New("db error")
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
-	mockUser.EXPECT().FindListByPage(gomock.Any(), int64(1), int64(20)).
+	mockUser.EXPECT().FindListByProductMembers(gomock.Any(), "pc", int64(1), int64(20)).
 		Return([]*userModel.SysUser{
 			{Id: 1, Username: "u1"},
 			{Id: 2, Username: "u2"},

+ 24 - 5
internal/server/permserver.go

@@ -15,6 +15,7 @@ import (
 
 	"github.com/golang-jwt/jwt/v4"
 	"github.com/zeromicro/go-zero/core/limit"
+	"golang.org/x/crypto/bcrypt"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/peer"
 	"google.golang.org/grpc/status"
@@ -84,6 +85,8 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 				return nil, status.Error(codes.Unauthenticated, le.Message)
 			case 403:
 				return nil, status.Error(codes.PermissionDenied, le.Message)
+			case 429:
+				return nil, status.Error(codes.ResourceExhausted, le.Message)
 			}
 		}
 		return nil, status.Error(codes.Internal, "登录失败")
@@ -96,6 +99,7 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 		UserId:       ud.UserId,
 		Username:     ud.Username,
+		Nickname:     ud.Nickname,
 		MemberType:   ud.MemberType,
 		Perms:        ud.Perms,
 	}, nil
@@ -175,15 +179,30 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 	}
 
 	return &pb.VerifyTokenResp{
-		Valid:      true,
-		UserId:     ud.UserId,
-		Username:   ud.Username,
-		MemberType: ud.MemberType,
-		Perms:      ud.Perms,
+		Valid:       true,
+		UserId:      ud.UserId,
+		Username:    ud.Username,
+		MemberType:  ud.MemberType,
+		Perms:       ud.Perms,
+		ProductCode: claims.ProductCode,
 	}, nil
 }
 
 func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq) (*pb.GetUserPermsResp, 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, "产品已被禁用")
+	}
+	if product.Code != req.ProductCode {
+		return nil, status.Error(codes.InvalidArgument, "appKey与productCode不匹配")
+	}
+
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode)
 
 	if ud.Username == "" {

+ 26 - 5
internal/server/permserver_test.go

@@ -210,6 +210,8 @@ func TestLogin_Normal(t *testing.T) {
 	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
 	assert.Equal(t, uId, resp.UserId)
 	assert.Equal(t, uid, resp.Username)
+	// BUG-01: proto定义了nickname字段,实现应返回用户昵称
+	assert.Equal(t, "nick", resp.Nickname, "BUG-01: LoginResp.Nickname 应返回用户昵称而非空字符串")
 }
 
 // TC-0201: 用户不存在
@@ -750,6 +752,8 @@ func TestVerifyToken_Valid(t *testing.T) {
 	assert.Equal(t, uid, resp.Username)
 	assert.Equal(t, "ADMIN", resp.MemberType)
 	assert.ElementsMatch(t, []string{"perm_a", "perm_b"}, resp.Perms)
+	// BUG-02: proto定义了productCode字段,实现应返回产品编码
+	assert.Equal(t, uid, resp.ProductCode, "BUG-02: VerifyTokenResp.ProductCode 应返回产品编码而非空字符串")
 }
 
 // TC-0215: 无效token
@@ -784,11 +788,24 @@ func TestVerifyToken_MissingUserId(t *testing.T) {
 func TestGetUserPerms_UserNotFound(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	srv := NewPermServer(svcCtx)
+	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: bcryptHash(t, "secret1"),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) })
 
-	_, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{
+	srv := NewPermServer(svcCtx)
+	_, err = srv.GetUserPerms(ctx, &pb.GetUserPermsReq{
 		UserId:      999999999,
-		ProductCode: "any_product",
+		ProductCode: uid,
+		AppKey:      uid,
+		AppSecret:   "secret1",
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.NotFound, status.Code(err))
@@ -812,7 +829,7 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	uId, _ := uRes.LastInsertId()
 
 	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1",
+		Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"),
 		Status: 1, CreateTime: now, UpdateTime: now,
 	})
 	require.NoError(t, err)
@@ -843,6 +860,8 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	resp, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{
 		UserId:      uId,
 		ProductCode: uid,
+		AppKey:      uid,
+		AppSecret:   "secret1",
 	})
 	require.NoError(t, err)
 	assert.Equal(t, "SUPER_ADMIN", resp.MemberType)
@@ -910,7 +929,7 @@ func TestGetUserPerms_MemberDENYOverride(t *testing.T) {
 	uId, _ := uRes.LastInsertId()
 
 	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1",
+		Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "secret1"),
 		Status: 1, CreateTime: now, UpdateTime: now,
 	})
 	require.NoError(t, err)
@@ -984,6 +1003,8 @@ func TestGetUserPerms_MemberDENYOverride(t *testing.T) {
 	resp, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{
 		UserId:      uId,
 		ProductCode: uid,
+		AppKey:      uid + "_k",
+		AppSecret:   "secret1",
 	})
 	require.NoError(t, err)
 	assert.Equal(t, "MEMBER", resp.MemberType)

+ 16 - 13
internal/svc/servicecontext.go

@@ -13,12 +13,13 @@ import (
 )
 
 type ServiceContext struct {
-	Config            config.Config
-	JwtAuth           rest.Middleware
-	LoginRateLimit    rest.Middleware
-	SyncRateLimit     rest.Middleware
-	GrpcLoginLimiter  *limit.PeriodLimit
-	UserDetailsLoader *loaders.UserDetailsLoader
+	Config              config.Config
+	JwtAuth             rest.Middleware
+	LoginRateLimit      rest.Middleware
+	SyncRateLimit       rest.Middleware
+	GrpcLoginLimiter    *limit.PeriodLimit
+	UsernameLoginLimit  *limit.PeriodLimit
+	UserDetailsLoader   *loaders.UserDetailsLoader
 	*model.Models
 }
 
@@ -30,14 +31,16 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	rlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login", c.BehindProxy)
 	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
+	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 
 	return &ServiceContext{
-		Config:            c,
-		JwtAuth:           middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
-		LoginRateLimit:    rlMiddleware.Handle,
-		SyncRateLimit:     syncRlMiddleware.Handle,
-		GrpcLoginLimiter:  grpcLimiter,
-		UserDetailsLoader: udLoader,
-		Models:            models,
+		Config:             c,
+		JwtAuth:            middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
+		LoginRateLimit:     rlMiddleware.Handle,
+		SyncRateLimit:      syncRlMiddleware.Handle,
+		GrpcLoginLimiter:   grpcLimiter,
+		UsernameLoginLimit: usernameLimiter,
+		UserDetailsLoader:  udLoader,
+		Models:             models,
 	}
 }

+ 29 - 1
internal/util/validate.go

@@ -1,6 +1,9 @@
 package util
 
-import "regexp"
+import (
+	"regexp"
+	"unicode"
+)
 
 var (
 	emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
@@ -15,6 +18,31 @@ func IsValidPhone(phone string) bool {
 	return phoneRegex.MatchString(phone)
 }
 
+// ValidatePassword 校验密码强度:至少 8 位,且包含大写字母、小写字母和数字。
+func ValidatePassword(password string) string {
+	if len(password) < 8 {
+		return "密码长度不能少于8个字符"
+	}
+	if len(password) > 72 {
+		return "密码长度不能超过72个字符"
+	}
+	var hasUpper, hasLower, hasDigit bool
+	for _, c := range password {
+		switch {
+		case unicode.IsUpper(c):
+			hasUpper = true
+		case unicode.IsLower(c):
+			hasLower = true
+		case unicode.IsDigit(c):
+			hasDigit = true
+		}
+	}
+	if !hasUpper || !hasLower || !hasDigit {
+		return "密码必须包含大写字母、小写字母和数字"
+	}
+	return ""
+}
+
 func NormalizePage(page, pageSize int64) (int64, int64) {
 	if page <= 0 {
 		page = 1

+ 20 - 2
pb/perm.pb.go

@@ -613,6 +613,8 @@ type GetUserPermsReq struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	UserId        int64                  `protobuf:"varint,1,opt,name=userId,proto3" json:"userId,omitempty"`
 	ProductCode   string                 `protobuf:"bytes,2,opt,name=productCode,proto3" json:"productCode,omitempty"`
+	AppKey        string                 `protobuf:"bytes,3,opt,name=appKey,proto3" json:"appKey,omitempty"`
+	AppSecret     string                 `protobuf:"bytes,4,opt,name=appSecret,proto3" json:"appSecret,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -661,6 +663,20 @@ func (x *GetUserPermsReq) GetProductCode() string {
 	return ""
 }
 
+func (x *GetUserPermsReq) GetAppKey() string {
+	if x != nil {
+		return x.AppKey
+	}
+	return ""
+}
+
+func (x *GetUserPermsReq) GetAppSecret() string {
+	if x != nil {
+		return x.AppSecret
+	}
+	return ""
+}
+
 type GetUserPermsResp struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Perms         []string               `protobuf:"bytes,1,rep,name=perms,proto3" json:"perms,omitempty"`
@@ -763,10 +779,12 @@ const file_pb_perm_proto_rawDesc = "" +
 	"\n" +
 	"memberType\x18\x05 \x01(\tR\n" +
 	"memberType\x12\x14\n" +
-	"\x05perms\x18\x06 \x03(\tR\x05perms\"K\n" +
+	"\x05perms\x18\x06 \x03(\tR\x05perms\"\x81\x01\n" +
 	"\x0fGetUserPermsReq\x12\x16\n" +
 	"\x06userId\x18\x01 \x01(\x03R\x06userId\x12 \n" +
-	"\vproductCode\x18\x02 \x01(\tR\vproductCode\"H\n" +
+	"\vproductCode\x18\x02 \x01(\tR\vproductCode\x12\x16\n" +
+	"\x06appKey\x18\x03 \x01(\tR\x06appKey\x12\x1c\n" +
+	"\tappSecret\x18\x04 \x01(\tR\tappSecret\"H\n" +
 	"\x10GetUserPermsResp\x12\x14\n" +
 	"\x05perms\x18\x01 \x03(\tR\x05perms\x12\x1e\n" +
 	"\n" +

+ 2 - 0
pb/perm.proto

@@ -75,6 +75,8 @@ message VerifyTokenResp {
 message GetUserPermsReq {
   int64 userId = 1;
   string productCode = 2;
+  string appKey = 3;
+  string appSecret = 4;
 }
 
 message GetUserPermsResp {

+ 3 - 1
perm.go

@@ -35,7 +35,9 @@ func main() {
 
 	rpcServer := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
 		pb.RegisterPermServiceServer(grpcServer, server.NewPermServer(svcCtx))
-		reflection.Register(grpcServer)
+		if c.Mode == "dev" {
+			reflection.Register(grpcServer)
+		}
 	})
 	defer rpcServer.Stop()
 

+ 2 - 1
perm.sql

@@ -163,7 +163,8 @@ CREATE TABLE IF NOT EXISTS `sys_user_role` (
   `createTime` int NOT NULL DEFAULT '0' COMMENT '创建时间',
   `updateTime` int NOT NULL DEFAULT '0' COMMENT '修改时间',
   PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_user_role` (`userId`, `roleId`) USING BTREE
+  UNIQUE KEY `uk_user_role` (`userId`, `roleId`) USING BTREE,
+  KEY `idx_role` (`roleId`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 7 DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
 
 SET

+ 24 - 20
test-design.md

@@ -147,9 +147,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0050 | POST /api/auth/changePassword | 正常修改 | `{"oldPassword":"123456","newPassword":"654321"}` | code=0 | 正常路径 | P0 | changePasswordLogic全路径 |
 | TC-0051 | POST /api/auth/changePassword | mustChangePassword重置 | 正常修改后 | DB中mustChangePassword=2 | 功能验证 | P0 | user.MustChangePassword=2 |
 | TC-0052 | POST /api/auth/changePassword | 原密码错误 | `{"oldPassword":"wrong","newPassword":"newpwd"}` | code=400, "原密码错误" | 异常路径 | P0 | bcrypt失败 |
-| TC-0053 | POST /api/auth/changePassword | 新密码少于6字符 | `{"oldPassword":"old","newPassword":"12345"}` | code=400, "密码长度不能少于6个字符" | 输入校验 | P0 | len<6 |
-| TC-0054 | POST /api/auth/changePassword | 新密码恰好6字符 | `{"oldPassword":"old","newPassword":"123456"}` | code=0 | 边界 | P1 | len==6 |
-| TC-0055 | POST /api/auth/changePassword | 新密码空字符串 | `{"oldPassword":"old","newPassword":""}` | code=400 | 边界 | P0 | len("")=0<6 |
+| TC-0053 | POST /api/auth/changePassword | 新密码少于8字符 | `{"oldPassword":"old","newPassword":"Pas1234"}` | code=400, "密码长度不能少于8个字符" | 输入校验 | P0 | len<8 |
+| TC-0054 | POST /api/auth/changePassword | 新密码恰好8字符(含大小写+数字) | `{"oldPassword":"old","newPassword":"Abcdef1x"}` | code=0 | 边界 | P1 | len==8,含大小写+数字 |
+| TC-0055 | POST /api/auth/changePassword | 新密码空字符串 | `{"oldPassword":"old","newPassword":""}` | code=400 | 边界 | P0 | len("")=0<8 |
 | TC-0056 | POST /api/auth/changePassword | 新密码超过72字符 | `{"oldPassword":"old","newPassword":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | len>72 |
 | TC-0057 | POST /api/auth/changePassword | 新密码恰好72字符 | `{"oldPassword":"old","newPassword":"a*72"}` | code=0 | 边界 | P1 | len==72 |
 | TC-0058 | POST /api/auth/changePassword | 新旧密码相同 | `{"oldPassword":"123456","newPassword":"123456"}` | code=400, "新密码不能与原密码相同" | 输入校验 | P0 | OldPassword==NewPassword |
@@ -272,7 +272,10 @@ MySQL (InnoDB) + Redis Cache
 | TC-0130 | POST /api/user/create | phone为空(可选) | `{"...","phone":""}` | code=0, 跳过校验 | 分支覆盖 | P1 | phone!=""判断 |
 | TC-0131 | POST /api/user/create | 并发同username(TOCTOU) | 两请求同时 | 一成功一冲突(1062) | 并发 | P0 | Duplicate entry→ErrConflict |
 | TC-0132 | POST /api/user/create | 唯一索引冲突消息 | 预检通过后DB冲突 | code=409, "用户名已存在" | 异常路径 | P0 | strings.Contains "1062" |
-| TC-0133 | POST /api/user/create | 密码少于6字符 | `{"username":"x","password":"12345"}` | code=400, "密码长度不能少于6个字符" | 输入校验 | P0 | H-10修复: 密码强度校验 |
+| TC-0133 | POST /api/user/create | 密码少于8字符 | `{"username":"x","password":"Pas1234"}` | code=400, "密码长度不能少于8个字符" | 输入校验 | P0 | H-10修复: 密码强度校验(8+字符,含大小写+数字) |
+| TC-0556 | POST /api/user/create | 密码缺少大写字母 | `{"username":"x","password":"pass123456"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无大写 |
+| TC-0557 | POST /api/user/create | 密码缺少小写字母 | `{"username":"x","password":"PASS123456"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无小写 |
+| TC-0558 | POST /api/user/create | 密码缺少数字 | `{"username":"x","password":"Passpasspass"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无数字 |
 | TC-0134 | POST /api/user/create | 密码超过72字符 | `{"username":"x","password":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | H-10修复: 密码强度校验 |
 
 ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零)
@@ -368,7 +371,7 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 接口/方法 | 测试场景 | 输入 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0200 | Login | 正常登录(普通用户+productCode) | valid credentials + productCode | token对+userInfo | 正常路径 | P0 | permserver.go Login |
+| TC-0200 | Login | 正常登录(普通用户+productCode) | valid credentials + productCode | token对+userInfo(含nickname) | 正常路径 | P0 | permserver.go Login; resp.Nickname应返回用户昵称 |
 | TC-0201 | Login | 用户不存在 | wrong username | codes.Unauthenticated | 异常路径 | P0 | status.Error |
 | TC-0202 | Login | 密码错误 | wrong password | codes.Unauthenticated | 异常路径 | P0 | status.Error |
 | TC-0203 | Login | 账号冻结 | frozen user | codes.PermissionDenied | 分支覆盖 | P0 | status.Error |
@@ -382,15 +385,15 @@ MySQL (InnoDB) + Redis Cache
 | TC-0211 | RefreshToken | productCode回退到claims | req.ProductCode="", claims含productCode | 使用claims.ProductCode | 分支覆盖 | P0 | productCode==""回退 |
 | TC-0212 | RefreshToken | 超管+productCode | isSuperAdmin=1+productCode | memberType="SUPER_ADMIN", perms全量 | 分支覆盖 | P0 | isSuperAdmin && productCode!="" |
 | TC-0213 | RefreshToken | 普通用户+productCode | 普通MEMBER+productCode | perms含角色权限 | 分支覆盖 | P0 | !isSuperAdmin && productCode!="" |
-| TC-0214 | VerifyToken | 有效token | valid | valid=true, userId/perms正确 | 正常路径 | P0 | VerifyToken |
+| TC-0214 | VerifyToken | 有效token | valid | valid=true, userId/perms/productCode正确 | 正常路径 | P0 | VerifyToken; resp.ProductCode应返回产品编码 |
 | TC-0215 | VerifyToken | 无效token | invalid | valid=false | 异常路径 | P0 | err或!Valid |
 | TC-0216 | VerifyToken | 缺少userId | 伪造claims | valid=false | 安全 | P0 | !ok断言保护 |
 | TC-0217 | VerifyToken | 冻结用户token返回Invalid | user.status=Disabled | Valid=false | 安全 | P0 | H-4: 实时查DB |
 | TC-0218 | VerifyToken | 非成员token返回Invalid | user非产品成员 | Valid=false | 安全 | P0 | H-4: 实时查成员状态 |
 | TC-0219 | VerifyToken | 返回实时MemberType和Perms | DB中ADMIN+自定义权限 | 返回实时数据而非token中旧数据 | 安全 | P0 | H-4: 实时数据 |
-| TC-0220 | GetUserPerms | 用户不存在 | userId=9999 | codes.NotFound | 异常路径 | P0 | status.Error |
-| TC-0221 | GetUserPerms | 超管 | isSuperAdmin | perms全量, "SUPER_ADMIN" | 正常路径 | P0 | GetUserPerms(true) |
-| TC-0222 | GetUserPerms | MEMBER-DENY覆盖 | 角色有permA, DENY permA | perms不含permA | 深度业务 | P0 | denySet过滤 |
+| TC-0220 | GetUserPerms | 用户不存在(需先通过AppKey/Secret认证) | userId=9999, 合法AppKey/AppSecret | codes.NotFound | 异常路径 | P0 | status.Error; 先认证再查用户 |
+| TC-0221 | GetUserPerms | 超管(需先通过AppKey/Secret认证) | isSuperAdmin, 合法AppKey/AppSecret | perms全量, "SUPER_ADMIN" | 正常路径 | P0 | GetUserPerms(true); AppKey认证前置 |
+| TC-0222 | GetUserPerms | MEMBER-DENY覆盖(需先通过AppKey/Secret认证) | 角色有permA, DENY permA, 合法AppKey/AppSecret | perms不含permA | 深度业务 | P0 | denySet过滤; AppKey认证前置 |
 
 ---
 
@@ -794,15 +797,16 @@ MySQL (InnoDB) + Redis Cache
 | TC-0463 | MEMBER拒绝 | ctx含MEMBER UserDetails | 403 "仅超级管理员" | 异常路径 | P0 | |
 | TC-0464 | 未登录 | ctx无UserDetails | 401 "未登录" | 边界 | P0 | caller==nil |
 
-### 9.2 RequireProductAdmin
+### 9.2 RequireProductAdminFor(ctx, targetProductCode)
 
 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0465 | 超管通过 | ctx含SuperAdmin | nil | 正常路径 | P0 | IsSuperAdmin |
-| TC-0466 | ADMIN通过 | ctx含ADMIN | nil | 正常路径 | P0 | MemberType==ADMIN |
-| TC-0467 | DEVELOPER拒绝 | ctx含DEVELOPER | 403 | 异常路径 | P0 | 非Admin |
-| TC-0468 | MEMBER拒绝 | ctx含MEMBER | 403 | 异常路径 | P0 | 非Admin |
-| TC-0469 | 未登录 | ctx无UserDetails | 401 | 边界 | P0 | caller==nil |
+| TC-0465 | 超管通过 | ctx含SuperAdmin, productCode="p1" | nil | 正常路径 | P0 | IsSuperAdmin |
+| TC-0466 | ADMIN通过(同产品) | ctx含ADMIN(p1), productCode="p1" | nil | 正常路径 | P0 | MemberType==ADMIN且productCode匹配 |
+| TC-0467 | DEVELOPER拒绝 | ctx含DEVELOPER(p1), productCode="p1" | 403 | 异常路径 | P0 | 非Admin |
+| TC-0468 | MEMBER拒绝 | ctx含MEMBER(p1), productCode="p1" | 403 | 异常路径 | P0 | 非Admin |
+| TC-0469 | 未登录 | ctx无UserDetails, productCode="p1" | 401 | 边界 | P0 | caller==nil |
+| TC-0555 | ADMIN跨产品拒绝 | ctx含ADMIN(p1), productCode="other" | 403 | 安全 | P0 | productCode不匹配→拒绝 |
 
 ### 9.3 CheckMemberTypeAssignment
 
@@ -901,11 +905,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-0513 | deleteDept非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin |
 | TC-0514 | createProduct非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin |
 | TC-0515 | updateProduct非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin |
-| TC-0516 | createUser非产品管理员拒绝 | ctx=MEMBER | 403 "仅超级管理员或产品管理员" | 安全 | P0 | RequireProductAdmin |
-| TC-0517 | createRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
-| TC-0518 | updateRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
-| TC-0519 | deleteRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
-| TC-0520 | bindRolePerms非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdmin |
+| TC-0516 | createUser非产品管理员拒绝 | ctx=MEMBER | 403 "仅超级管理员或产品管理员" | 安全 | P0 | RequireProductAdminFor(ctx, productCode) |
+| TC-0517 | createRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) |
+| TC-0518 | updateRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) |
+| TC-0519 | deleteRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) |
+| TC-0520 | bindRolePerms非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) |
 | TC-0521 | updateUser-MEMBER不能管理他人 | ctx=MEMBER, id!=self | 403 (CheckManageAccess拒绝) | 安全 | P0 | Audit#4修复: CheckManageAccess权限校验 |
 | TC-0522 | updateUser自己修改DeptId被拒绝 | ctx含userId=X, req.Id=X, req.DeptId!=nil | 403 "不允许修改自己的部门和状态" | 安全 | P0 | H-01修复: 自编辑限制DeptId |
 | TC-0523 | updateUser自己修改Status被拒绝 | ctx含userId=X, req.Id=X, req.Status!=0 | 403 "不允许修改自己的部门和状态" | 安全 | P0 | H-01修复: 自编辑限制Status |

+ 50 - 46
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-17
+> 报告日期: 2026-04-18
 > 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 
@@ -10,13 +10,13 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 554 |
-| 已覆盖 TC 数 | 553 |
+| 测试用例总数 (test-design.md) | 558 |
+| 已覆盖 TC 数 | 557 |
 | 未实现 TC 数 | 1 (TC-0228 不可达防御分支 t.Skip) |
-| 测试函数总数 | 730 |
-| 测试子用例总数 (含 table-driven) | 812 |
+| 测试函数总数 | 735 |
+| 测试子用例总数 (含 table-driven) | 816 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **811 / 812** |
+| ✅ 通过 | **734 / 735** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0228 — 防御性不可达分支) |
 
@@ -24,29 +24,29 @@
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| 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 |
+| handler/pub | ✅ ok | 3.887s |
+| loaders | ✅ ok | 4.451s |
+| logic/auth | ✅ ok | 12.255s |
+| logic/dept | ✅ ok | 5.968s |
+| logic/member | ✅ ok | 6.744s |
+| logic/perm | ✅ ok | 5.111s |
+| logic/product | ✅ ok | 11.583s |
+| logic/pub | ✅ ok | 9.044s |
+| logic/role | ✅ ok | 9.519s |
+| logic/user | ✅ ok | 11.197s |
+| middleware | ✅ ok | 10.859s |
+| model/dept | ✅ ok | 11.690s |
+| model/perm | ✅ ok | 12.527s |
+| model/product | ✅ ok | 13.272s |
+| model/productmember | ✅ ok | 12.531s |
+| model/role | ✅ ok | 12.742s |
+| model/roleperm | ✅ ok | 12.897s |
+| model/user | ✅ ok | 13.014s |
+| model/userperm | ✅ ok | 12.975s |
+| model/userrole | ✅ ok | 11.406s |
+| response | ✅ ok | 9.247s |
+| server | ✅ ok | 10.234s |
+| util | ✅ ok | 9.563s |
 
 ---
 
@@ -131,8 +131,8 @@
 | TC-0050 | 正常修改 | ✅ pass |
 | TC-0051 | mustChangePassword重置 | ✅ pass |
 | TC-0052 | 原密码错误 | ✅ pass |
-| TC-0053 | 新密码少于6字符 | ✅ pass |
-| TC-0054 | 新密码恰好6字符 | ✅ pass |
+| TC-0053 | 新密码少于8字符 | ✅ pass |
+| TC-0054 | 新密码恰好8字符(含大小写+数字) | ✅ pass |
 | TC-0055 | 新密码空字符串 | ✅ pass |
 | TC-0056 | 新密码超过72字符 | ✅ pass |
 | TC-0057 | 新密码恰好72字符 | ✅ pass |
@@ -281,7 +281,10 @@
 | TC-0170 | setUserPerms PermId不存在(H-04) | ✅ pass |
 | TC-0171 | setUserPerms权限不属于当前产品(H-04) | ✅ pass |
 | TC-0172 | SetUserPerms非产品成员被拒绝(L-5) | ✅ pass |
-| TC-0133 | createUser密码少于6字符(H-10) | ✅ pass |
+| TC-0133 | createUser密码少于8字符(H-10) | ✅ pass |
+| TC-0556 | createUser密码缺少大写字母 | ✅ pass |
+| TC-0557 | createUser密码缺少小写字母 | ✅ pass |
+| TC-0558 | createUser密码缺少数字 | ✅ pass |
 | TC-0134 | createUser密码超过72字符(H-10) | ✅ pass |
 | TC-0148 | 超管不能冻结另一超管(H-2) | ✅ pass |
 | TC-0546 | createUser用户名含特殊字符被拒绝(400) | ✅ pass |
@@ -345,9 +348,9 @@
 | TC-0217 | gRPC VerifyToken冻结用户返回Invalid(H-4) | ✅ pass |
 | TC-0218 | gRPC VerifyToken非成员返回Invalid(H-4) | ✅ pass |
 | TC-0219 | gRPC VerifyToken返回实时数据(H-4) | ✅ pass |
-| TC-0220 | 用户不存在 | ✅ pass |
-| TC-0221 | 超管 | ✅ pass |
-| TC-0222 | MEMBER-DENY覆盖 | ✅ pass |
+| TC-0220 | 用户不存在(AppKey/Secret认证前置) | ✅ pass |
+| TC-0221 | 超管(AppKey/Secret认证前置) | ✅ pass |
+| TC-0222 | MEMBER-DENY覆盖(AppKey/Secret认证前置) | ✅ pass |
 
 ### 2.13 中间件 / 统一响应 (TC-0223 ~ TC-0233, TC-0508 ~ TC-0510, TC-0525 ~ TC-0534)
 
@@ -672,11 +675,12 @@
 | TC-0462 | RequireSuperAdmin-ADMIN拒绝 | ✅ pass |
 | TC-0463 | RequireSuperAdmin-MEMBER拒绝 | ✅ pass |
 | TC-0464 | RequireSuperAdmin-未登录 | ✅ pass |
-| TC-0465 | RequireProductAdmin-超管通过 | ✅ pass |
-| TC-0466 | RequireProductAdmin-ADMIN通过 | ✅ pass |
-| TC-0467 | RequireProductAdmin-DEVELOPER拒绝 | ✅ pass |
-| TC-0468 | RequireProductAdmin-MEMBER拒绝 | ✅ pass |
-| TC-0469 | RequireProductAdmin-未登录 | ✅ pass |
+| TC-0465 | RequireProductAdminFor-超管通过 | ✅ pass |
+| TC-0466 | RequireProductAdminFor-ADMIN同产品通过 | ✅ pass |
+| TC-0467 | RequireProductAdminFor-DEVELOPER拒绝 | ✅ pass |
+| TC-0468 | RequireProductAdminFor-MEMBER拒绝 | ✅ pass |
+| TC-0469 | RequireProductAdminFor-未登录 | ✅ pass |
+| TC-0555 | RequireProductAdminFor-ADMIN跨产品拒绝 | ✅ pass |
 | TC-0470 | CheckMemberTypeAssignment-超管分配任何类型 | ✅ pass |
 | TC-0471 | CheckMemberTypeAssignment-ADMIN分配DEVELOPER | ✅ pass |
 | TC-0472 | CheckMemberTypeAssignment-同级拒绝 | ✅ pass |
@@ -756,16 +760,16 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| TC 总数 | 554 |
-| 已实现 | 553 (99.8%) |
+| TC 总数 | 558 |
+| 已实现 | 557 (99.8%) |
 | 跳过 | 1 (TC-0228,防御性不可达分支) |
 | 未实现 | 0 |
-| 测试函数 | 730 |
-| 测试子用例 | 812 |
-| ✅ 通过 | **811** |
+| 测试函数 | 735 |
+| 测试子用例 | 816 |
+| ✅ 通过 | **734** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0228) |
-| 通过率 | **100%** (811/811,排除不可达分支) |
+| 通过率 | **100%** (734/734,排除不可达分支) |
 
 ### 3.1 未实现 TC 说明