Kaynağa Gözat

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

BaiLuoYan 4 hafta önce
ebeveyn
işleme
771e7b25a6

+ 199 - 353
audit-report.md

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

+ 19 - 10
internal/handler/routes.go

@@ -140,18 +140,27 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 		rest.WithPrefix("/api/product"),
 		rest.WithPrefix("/api/product"),
 	)
 	)
 
 
+	server.AddRoutes(
+		rest.WithMiddlewares(
+			[]rest.Middleware{serverCtx.LoginRateLimit},
+			[]rest.Route{
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/adminLogin",
+					Handler: pub.AdminLoginHandler(serverCtx),
+				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/login",
+					Handler: pub.LoginHandler(serverCtx),
+				},
+			}...,
+		),
+		rest.WithPrefix("/api"),
+	)
+
 	server.AddRoutes(
 	server.AddRoutes(
 		[]rest.Route{
 		[]rest.Route{
-			{
-				Method:  http.MethodPost,
-				Path:    "/auth/adminLogin",
-				Handler: pub.AdminLoginHandler(serverCtx),
-			},
-			{
-				Method:  http.MethodPost,
-				Path:    "/auth/login",
-				Handler: pub.LoginHandler(serverCtx),
-			},
 			{
 			{
 				Method:  http.MethodPost,
 				Method:  http.MethodPost,
 				Path:    "/auth/refreshToken",
 				Path:    "/auth/refreshToken",

+ 16 - 9
internal/loaders/userDetailsLoader.go

@@ -104,10 +104,12 @@ func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode
 	}
 	}
 
 
 	v, _, _ := l.sf.Do(key, func() (interface{}, error) {
 	v, _, _ := l.sf.Do(key, func() (interface{}, error) {
-		ud := l.loadFromDB(ctx, userId, productCode)
-		if val, err := json.Marshal(ud); err == nil {
-			if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); err != nil {
-				logx.WithContext(ctx).Errorf("set user details cache failed: %v", err)
+		ud, ok := l.loadFromDB(ctx, userId, productCode)
+		if ok {
+			if val, err := json.Marshal(ud); err == nil {
+				if err := l.rds.SetexCtx(ctx, key, string(val), l.ttl); err != nil {
+					logx.WithContext(ctx).Errorf("set user details cache failed: %v", err)
+				}
 			}
 			}
 		}
 		}
 		return ud, nil
 		return ud, nil
@@ -150,6 +152,8 @@ func (l *UserDetailsLoader) BatchDel(ctx context.Context, userIds []int64, produ
 	}
 	}
 }
 }
 
 
+// NOTE: SCAN only works on single-node Redis. For Redis Cluster, consider using hash tags
+// in key design or switching to a different cache invalidation strategy.
 func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
 func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) {
 	var cursor uint64
 	var cursor uint64
 	for {
 	for {
@@ -172,28 +176,30 @@ func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string)
 
 
 // -------- 内部加载逻辑 --------
 // -------- 内部加载逻辑 --------
 
 
-func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, productCode string) *UserDetails {
+func (l *UserDetailsLoader) loadFromDB(ctx context.Context, userId int64, productCode string) (*UserDetails, bool) {
 	ud := &UserDetails{
 	ud := &UserDetails{
 		UserId:        userId,
 		UserId:        userId,
 		ProductCode:   productCode,
 		ProductCode:   productCode,
 		MinPermsLevel: math.MaxInt64,
 		MinPermsLevel: math.MaxInt64,
 	}
 	}
 
 
-	l.loadUser(ctx, ud)
+	if !l.loadUser(ctx, ud) {
+		return ud, false
+	}
 	l.loadDept(ctx, ud)
 	l.loadDept(ctx, ud)
 	l.loadProduct(ctx, ud)
 	l.loadProduct(ctx, ud)
 	l.loadMembership(ctx, ud)
 	l.loadMembership(ctx, ud)
 	l.loadRoles(ctx, ud)
 	l.loadRoles(ctx, ud)
 	l.loadPerms(ctx, ud)
 	l.loadPerms(ctx, ud)
 
 
-	return ud
+	return ud, true
 }
 }
 
 
-func (l *UserDetailsLoader) loadUser(ctx context.Context, ud *UserDetails) {
+func (l *UserDetailsLoader) loadUser(ctx context.Context, ud *UserDetails) bool {
 	u, err := l.models.SysUserModel.FindOne(ctx, ud.UserId)
 	u, err := l.models.SysUserModel.FindOne(ctx, ud.UserId)
 	if err != nil {
 	if err != nil {
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query user %d failed: %v", ud.UserId, err)
 		logx.WithContext(ctx).Errorf("userDetailsLoader: query user %d failed: %v", ud.UserId, err)
-		return
+		return false
 	}
 	}
 	ud.Username = u.Username
 	ud.Username = u.Username
 	ud.Nickname = u.Nickname
 	ud.Nickname = u.Nickname
@@ -207,6 +213,7 @@ func (l *UserDetailsLoader) loadUser(ctx context.Context, ud *UserDetails) {
 	ud.MustChangePwdRaw = u.MustChangePassword
 	ud.MustChangePwdRaw = u.MustChangePassword
 	ud.MustChangePassword = u.MustChangePassword == consts.MustChangePasswordYes
 	ud.MustChangePassword = u.MustChangePassword == consts.MustChangePasswordYes
 	ud.Status = u.Status
 	ud.Status = u.Status
+	return true
 }
 }
 
 
 func (l *UserDetailsLoader) loadDept(ctx context.Context, ud *UserDetails) {
 func (l *UserDetailsLoader) loadDept(ctx context.Context, ud *UserDetails) {

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

@@ -97,6 +97,21 @@ func RequireProductAdmin(ctx context.Context) error {
 	return response.ErrForbidden("仅超级管理员或产品管理员可执行此操作")
 	return response.ErrForbidden("仅超级管理员或产品管理员可执行此操作")
 }
 }
 
 
+// RequireProductAdminFor 要求当前操作者是超级管理员或指定产品的管理员。
+func RequireProductAdminFor(ctx context.Context, targetProductCode string) error {
+	caller := middleware.GetUserDetails(ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+	if caller.IsSuperAdmin {
+		return nil
+	}
+	if caller.MemberType == consts.MemberTypeAdmin && caller.ProductCode == targetProductCode {
+		return nil
+	}
+	return response.ErrForbidden("仅超级管理员或该产品的管理员可执行此操作")
+}
+
 func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64) error {
 func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64) error {
 	if caller.MemberType == consts.MemberTypeAdmin {
 	if caller.MemberType == consts.MemberTypeAdmin {
 		return nil
 		return nil

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

@@ -50,6 +50,8 @@ func (l *CreateDeptLogic) CreateDept(req *types.CreateDeptReq) (resp *types.IdRe
 	deptType := req.DeptType
 	deptType := req.DeptType
 	if deptType == "" {
 	if deptType == "" {
 		deptType = consts.DeptTypeNormal
 		deptType = consts.DeptTypeNormal
+	} else if deptType != consts.DeptTypeNormal && deptType != consts.DeptTypeDev {
+		return nil, response.ErrBadRequest("无效的部门类型")
 	}
 	}
 
 
 	err = l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	err = l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {

+ 12 - 0
internal/logic/pub/loginLogic.go

@@ -50,6 +50,18 @@ func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err erro
 		return nil, response.ErrForbidden("超级管理员不允许通过产品端登录,请使用管理后台")
 		return nil, response.ErrForbidden("超级管理员不允许通过产品端登录,请使用管理后台")
 	}
 	}
 
 
+	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
+	if err != nil {
+		return nil, response.ErrBadRequest("产品不存在")
+	}
+	if product.Status != consts.StatusEnabled {
+		return nil, response.ErrForbidden("该产品已被禁用")
+	}
+
+	if _, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, u.Id); memberErr != nil {
+		return nil, response.ErrForbidden("您不是该产品的成员")
+	}
+
 	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, req.ProductCode)
 	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, req.ProductCode)
 
 
 	accessToken, err := authHelper.GenerateAccessToken(
 	accessToken, err := authHelper.GenerateAccessToken(

+ 18 - 7
internal/logic/pub/loginLogic_test.go

@@ -77,16 +77,25 @@ func insertTestProduct(t *testing.T, ctx context.Context, svcCtx *svc.ServiceCon
 func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	svcCtx := newTestSvcCtx()
+	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	password := "TestPass123"
 	pc := testutil.UniqueId()
 	pc := testutil.UniqueId()
+	now := time.Now().Unix()
 
 
-	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	userId, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
 	t.Cleanup(cleanUser)
 
 
 	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
 	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
 	t.Cleanup(cleanProduct)
 	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()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) })
+
 	logic := NewLoginLogic(ctx, svcCtx)
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
 	resp, err := logic.Login(&types.LoginReq{
 		Username:    username,
 		Username:    username,
@@ -99,8 +108,7 @@ func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 	assert.NotEmpty(t, resp.RefreshToken)
 	assert.NotEmpty(t, resp.RefreshToken)
 	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
 	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
 	assert.Equal(t, username, resp.UserInfo.Username)
 	assert.Equal(t, username, resp.UserInfo.Username)
-	assert.Empty(t, resp.UserInfo.MemberType)
-	assert.Nil(t, resp.UserInfo.Perms)
+	assert.Equal(t, "MEMBER", resp.UserInfo.MemberType)
 }
 }
 
 
 // TC-0002: 正常登录-带productCode
 // TC-0002: 正常登录-带productCode
@@ -284,10 +292,13 @@ func TestLogin_NonMemberWithProductCode(t *testing.T) {
 		Password:    password,
 		Password:    password,
 		ProductCode: pc,
 		ProductCode: pc,
 	})
 	})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.Empty(t, resp.UserInfo.MemberType)
-	assert.Nil(t, resp.UserInfo.Perms)
+	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-0010: DEVELOPER成员
 // TC-0010: DEVELOPER成员

+ 4 - 0
internal/logic/pub/refreshTokenLogic.go

@@ -50,6 +50,10 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, response.ErrForbidden("账号已被冻结")
 		return nil, response.ErrForbidden("账号已被冻结")
 	}
 	}
 
 
+	if productCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+		return nil, response.ErrForbidden("您已不是该产品的成员")
+	}
+
 	accessToken, err := authHelper.GenerateAccessToken(
 	accessToken, err := authHelper.GenerateAccessToken(
 		l.svcCtx.Config.Auth.AccessSecret,
 		l.svcCtx.Config.Auth.AccessSecret,
 		l.svcCtx.Config.Auth.AccessExpire,
 		l.svcCtx.Config.Auth.AccessExpire,

+ 19 - 4
internal/logic/role/bindRolePermsLogic.go

@@ -29,15 +29,30 @@ func NewBindRolePermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Bin
 }
 }
 
 
 func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
-	if err := authHelper.RequireProductAdmin(l.ctx); err != nil {
-		return err
-	}
-
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.RoleId)
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.RoleId)
 	if err != nil {
 	if err != nil {
 		return response.ErrNotFound("角色不存在")
 		return response.ErrNotFound("角色不存在")
 	}
 	}
 
 
+	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {
+		return err
+	}
+
+	if len(req.PermIds) > 0 {
+		perms, err := l.svcCtx.SysPermModel.FindByIds(l.ctx, req.PermIds)
+		if err != nil {
+			return err
+		}
+		if len(perms) != len(req.PermIds) {
+			return response.ErrBadRequest("包含无效的权限ID")
+		}
+		for _, p := range perms {
+			if p.ProductCode != role.ProductCode {
+				return response.ErrBadRequest("不能绑定其他产品的权限")
+			}
+		}
+	}
+
 	if err := l.svcCtx.SysRolePermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	if err := l.svcCtx.SysRolePermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdTx(ctx, session, req.RoleId); err != nil {
 		if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdTx(ctx, session, req.RoleId); err != nil {
 			return err
 			return err

+ 11 - 1
internal/logic/role/bindRolePermsLogic_mock_test.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"errors"
 	"testing"
 	"testing"
 
 
+	permModel "perms-system-server/internal/model/perm"
 	roleModel "perms-system-server/internal/model/role"
 	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/testutil/mocks"
@@ -21,10 +22,18 @@ func TestBindRolePerms_Mock_BatchInsertFail(t *testing.T) {
 	defer ctrl.Finish()
 	defer ctrl.Finish()
 
 
 	dbErr := errors.New("db error")
 	dbErr := errors.New("db error")
+	pc := "test_product"
 
 
 	mockRole := mocks.NewMockSysRoleModel(ctrl)
 	mockRole := mocks.NewMockSysRoleModel(ctrl)
 	mockRole.EXPECT().FindOne(gomock.Any(), int64(1)).
 	mockRole.EXPECT().FindOne(gomock.Any(), int64(1)).
-		Return(&roleModel.SysRole{Id: 1}, nil)
+		Return(&roleModel.SysRole{Id: 1, ProductCode: pc}, nil)
+
+	mockPerm := mocks.NewMockSysPermModel(ctrl)
+	mockPerm.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).
+		Return([]*permModel.SysPerm{
+			{Id: 10, ProductCode: pc},
+			{Id: 20, ProductCode: pc},
+		}, nil)
 
 
 	mockRP := mocks.NewMockSysRolePermModel(ctrl)
 	mockRP := mocks.NewMockSysRolePermModel(ctrl)
 	mockRP.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 	mockRP.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
@@ -36,6 +45,7 @@ func TestBindRolePerms_Mock_BatchInsertFail(t *testing.T) {
 
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Role:     mockRole,
 		Role:     mockRole,
+		Perm:     mockPerm,
 		RolePerm: mockRP,
 		RolePerm: mockRP,
 	})
 	})
 
 

+ 14 - 2
internal/logic/role/bindRolePermsLogic_test.go

@@ -221,10 +221,22 @@ func TestBindRolePerms_DuplicatePermId(t *testing.T) {
 
 
 // TC-0490: bindRolePerms非管理员拒绝
 // TC-0490: bindRolePerms非管理员拒绝
 func TestBindRolePerms_MemberRejected(t *testing.T) {
 func TestBindRolePerms_MemberRejected(t *testing.T) {
-	ctx := ctxhelper.MemberCtx("test_product")
+	pc := "test_product"
+	ctx := ctxhelper.MemberCtx(pc)
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := roleRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
 	logic := NewBindRolePermsLogic(ctx, svcCtx)
 	logic := NewBindRolePermsLogic(ctx, svcCtx)
-	err := logic.BindRolePerms(&types.BindPermsReq{RoleId: 1, PermIds: []int64{1}})
+	err = logic.BindRolePerms(&types.BindPermsReq{RoleId: roleId, PermIds: []int64{1}})
 	require.Error(t, err)
 	require.Error(t, err)
 	var ce *response.CodeError
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	require.True(t, errors.As(err, &ce))

+ 5 - 1
internal/logic/role/createRoleLogic.go

@@ -30,10 +30,14 @@ func NewCreateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
 }
 }
 
 
 func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdResp, err error) {
 func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdResp, err error) {
-	if err := authHelper.RequireProductAdmin(l.ctx); err != nil {
+	if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	if req.PermsLevel < 1 || req.PermsLevel > 999 {
+		return nil, response.ErrBadRequest("权限级别必须在 1-999 之间")
+	}
+
 	now := time.Now().Unix()
 	now := time.Now().Unix()
 	result, err := l.svcCtx.SysRoleModel.Insert(l.ctx, &roleModel.SysRole{
 	result, err := l.svcCtx.SysRoleModel.Insert(l.ctx, &roleModel.SysRole{
 		ProductCode: req.ProductCode,
 		ProductCode: req.ProductCode,

+ 4 - 4
internal/logic/role/deleteRoleLogic.go

@@ -27,15 +27,15 @@ func NewDeleteRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
 }
 }
 
 
 func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleReq) error {
 func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleReq) error {
-	if err := authHelper.RequireProductAdmin(l.ctx); err != nil {
-		return err
-	}
-
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 	if err != nil {
 		return response.ErrNotFound("角色不存在")
 		return response.ErrNotFound("角色不存在")
 	}
 	}
 
 
+	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {
+		return err
+	}
+
 	affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
 	affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
 
 
 	if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	if err := l.svcCtx.SysRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {

+ 14 - 2
internal/logic/role/deleteRoleLogic_test.go

@@ -105,10 +105,22 @@ func TestDeleteRole_NoAssociations(t *testing.T) {
 
 
 // TC-0489: deleteRole非管理员拒绝
 // TC-0489: deleteRole非管理员拒绝
 func TestDeleteRole_MemberRejected(t *testing.T) {
 func TestDeleteRole_MemberRejected(t *testing.T) {
-	ctx := ctxhelper.MemberCtx("test_product")
+	pc := "test_product"
+	ctx := ctxhelper.MemberCtx(pc)
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := roleRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
 	logic := NewDeleteRoleLogic(ctx, svcCtx)
 	logic := NewDeleteRoleLogic(ctx, svcCtx)
-	err := logic.DeleteRole(&types.DeleteRoleReq{Id: 1})
+	err = logic.DeleteRole(&types.DeleteRoleReq{Id: roleId})
 	require.Error(t, err)
 	require.Error(t, err)
 	var ce *response.CodeError
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	require.True(t, errors.As(err, &ce))

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

@@ -28,15 +28,19 @@ func NewUpdateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Update
 }
 }
 
 
 func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
 func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleReq) error {
-	if err := authHelper.RequireProductAdmin(l.ctx); err != nil {
-		return err
-	}
-
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
 	role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 	if err != nil {
 		return response.ErrNotFound("角色不存在")
 		return response.ErrNotFound("角色不存在")
 	}
 	}
 
 
+	if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil {
+		return err
+	}
+
+	if req.PermsLevel < 1 || req.PermsLevel > 999 {
+		return response.ErrBadRequest("权限级别必须在 1-999 之间")
+	}
+
 	role.Name = req.Name
 	role.Name = req.Name
 	role.Remark = req.Remark
 	role.Remark = req.Remark
 	role.PermsLevel = req.PermsLevel
 	role.PermsLevel = req.PermsLevel

+ 14 - 2
internal/logic/role/updateRoleLogic_test.go

@@ -75,10 +75,22 @@ func TestUpdateRole_NotFound(t *testing.T) {
 
 
 // TC-0488: updateRole非管理员拒绝
 // TC-0488: updateRole非管理员拒绝
 func TestUpdateRole_MemberRejected(t *testing.T) {
 func TestUpdateRole_MemberRejected(t *testing.T) {
-	ctx := ctxhelper.MemberCtx("test_product")
+	pc := "test_product"
+	ctx := ctxhelper.MemberCtx(pc)
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := roleRes.LastInsertId()
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role`", roleId) })
+
 	logic := NewUpdateRoleLogic(ctx, svcCtx)
 	logic := NewUpdateRoleLogic(ctx, svcCtx)
-	err := logic.UpdateRole(&types.UpdateRoleReq{Id: 1, Name: "test", PermsLevel: 1})
+	err = logic.UpdateRole(&types.UpdateRoleReq{Id: roleId, Name: "test", PermsLevel: 1})
 	require.Error(t, err)
 	require.Error(t, err)
 	var ce *response.CodeError
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
 	require.True(t, errors.As(err, &ce))

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

@@ -40,6 +40,18 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return err
 		return err
 	}
 	}
 
 
+	if len(req.RoleIds) > 0 {
+		seen := make(map[int64]bool, len(req.RoleIds))
+		uniqueIds := make([]int64, 0, len(req.RoleIds))
+		for _, id := range req.RoleIds {
+			if !seen[id] {
+				seen[id] = true
+				uniqueIds = append(uniqueIds, id)
+			}
+		}
+		req.RoleIds = uniqueIds
+	}
+
 	if len(req.RoleIds) > 0 {
 	if len(req.RoleIds) > 0 {
 		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
 		roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds)
 		if err != nil {
 		if err != nil {
@@ -59,7 +71,7 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 	}
 	}
 
 
 	if err := l.svcCtx.SysUserRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	if err := l.svcCtx.SysUserRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdTx(ctx, session, req.UserId); err != nil {
+		if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
 			return err
 			return err
 		}
 		}
 		if len(req.RoleIds) == 0 {
 		if len(req.RoleIds) == 0 {

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

@@ -39,7 +39,7 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
 			return fn(ctx, nil)
 			return fn(ctx, nil)
 		})
 		})
-	mockUR.EXPECT().DeleteByUserIdTx(gomock.Any(), nil, int64(1)).Return(nil)
+	mockUR.EXPECT().DeleteByUserIdForProductTx(gomock.Any(), nil, int64(1), "test_product").Return(nil)
 	mockUR.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(dbErr)
 	mockUR.EXPECT().BatchInsertWithTx(gomock.Any(), nil, gomock.Any()).Return(dbErr)
 
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{

+ 3 - 6
internal/logic/user/createUserLogic.go

@@ -7,6 +7,7 @@ import (
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
 	userModel "perms-system-server/internal/model/user"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
@@ -32,7 +33,8 @@ func NewCreateUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
 }
 }
 
 
 func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdResp, err error) {
 func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdResp, err error) {
-	if err := authHelper.RequireProductAdmin(l.ctx); err != nil {
+	productCode := middleware.GetProductCode(l.ctx)
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
@@ -50,11 +52,6 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserReq) (resp *types.IdRe
 		return nil, response.ErrBadRequest("手机号格式不正确")
 		return nil, response.ErrBadRequest("手机号格式不正确")
 	}
 	}
 
 
-	_, findErr := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, req.Username)
-	if findErr == nil {
-		return nil, response.ErrConflict("用户名已存在")
-	}
-
 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err

+ 0 - 3
internal/logic/user/createUserLogic_mock_test.go

@@ -4,7 +4,6 @@ import (
 	"errors"
 	"errors"
 	"testing"
 	"testing"
 
 
-	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/ctxhelper"
 	"perms-system-server/internal/testutil/mocks"
 	"perms-system-server/internal/testutil/mocks"
@@ -22,8 +21,6 @@ func TestCreateUser_Mock_InsertDuplicate1062(t *testing.T) {
 	defer ctrl.Finish()
 	defer ctrl.Finish()
 
 
 	mockUser := mocks.NewMockSysUserModel(ctrl)
 	mockUser := mocks.NewMockSysUserModel(ctrl)
-	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "dupuser").
-		Return(nil, userModel.ErrNotFound)
 	mockUser.EXPECT().Insert(gomock.Any(), gomock.Any()).
 	mockUser.EXPECT().Insert(gomock.Any(), gomock.Any()).
 		Return(nil, &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'dupuser' for key 'username'"})
 		Return(nil, &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'dupuser' for key 'username'"})
 
 

+ 17 - 1
internal/logic/user/setUserPermsLogic.go

@@ -46,6 +46,22 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		}
 		}
 	}
 	}
 
 
+	if len(req.Perms) > 0 {
+		seen := make(map[int64]string, len(req.Perms))
+		uniquePerms := make([]types.UserPermItem, 0, len(req.Perms))
+		for _, p := range req.Perms {
+			if prev, ok := seen[p.PermId]; ok {
+				if prev != p.Effect {
+					return response.ErrBadRequest("同一权限ID不能同时为 ALLOW 和 DENY")
+				}
+				continue
+			}
+			seen[p.PermId] = p.Effect
+			uniquePerms = append(uniquePerms, p)
+		}
+		req.Perms = uniquePerms
+	}
+
 	if len(req.Perms) > 0 {
 	if len(req.Perms) > 0 {
 		permIds := make([]int64, 0, len(req.Perms))
 		permIds := make([]int64, 0, len(req.Perms))
 		for _, p := range req.Perms {
 		for _, p := range req.Perms {
@@ -66,7 +82,7 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 	}
 	}
 
 
 	if err := l.svcCtx.SysUserPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	if err := l.svcCtx.SysUserPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
-		if err := l.svcCtx.SysUserPermModel.DeleteByUserIdTx(ctx, session, req.UserId); err != nil {
+		if err := l.svcCtx.SysUserPermModel.DeleteByUserIdForProductTx(ctx, session, req.UserId, productCode); err != nil {
 			return err
 			return err
 		}
 		}
 		if len(req.Perms) == 0 {
 		if len(req.Perms) == 0 {

+ 5 - 0
internal/logic/user/updateUserLogic.go

@@ -68,6 +68,11 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 		user.Remark = *req.Remark
 		user.Remark = *req.Remark
 	}
 	}
 	if req.DeptId != nil {
 	if req.DeptId != nil {
+		if *req.DeptId > 0 {
+			if _, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, *req.DeptId); err != nil {
+				return response.ErrBadRequest("部门不存在")
+			}
+		}
 		user.DeptId = *req.DeptId
 		user.DeptId = *req.DeptId
 	}
 	}
 	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {
 	if req.Status == consts.StatusEnabled || req.Status == consts.StatusDisabled {

+ 39 - 7
internal/logic/user/updateUserLogic_test.go

@@ -4,9 +4,11 @@ import (
 	"context"
 	"context"
 	"errors"
 	"errors"
 	"testing"
 	"testing"
+	"time"
 
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/loaders"
+	deptModel "perms-system-server/internal/model/dept"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil"
@@ -17,6 +19,24 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
 )
 )
 
 
+func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
+		Name:       testutil.UniqueId(),
+		ParentId:   0,
+		Path:       "/",
+		Sort:       0,
+		DeptType:   "NORMAL",
+		Status:     consts.StatusEnabled,
+		CreateTime: now,
+		UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
 // TC-0112: 正常更新
 // TC-0112: 正常更新
 func TestUpdateUser_Success(t *testing.T) {
 func TestUpdateUser_Success(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	ctx := ctxhelper.SuperAdminCtx()
@@ -27,6 +47,9 @@ func TestUpdateUser_Success(t *testing.T) {
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 
+	deptId := insertTestDept(t, ctx, svcCtx)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	err := logic.UpdateUser(&types.UpdateUserReq{
 	err := logic.UpdateUser(&types.UpdateUserReq{
 		Id:       userId,
 		Id:       userId,
@@ -34,7 +57,7 @@ func TestUpdateUser_Success(t *testing.T) {
 		Email:    strPtr("[email protected]"),
 		Email:    strPtr("[email protected]"),
 		Phone:    strPtr("13900139000"),
 		Phone:    strPtr("13900139000"),
 		Remark:   strPtr("更新备注"),
 		Remark:   strPtr("更新备注"),
-		DeptId:   int64Ptr(5),
+		DeptId:   int64Ptr(deptId),
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
@@ -44,7 +67,7 @@ func TestUpdateUser_Success(t *testing.T) {
 	assert.Equal(t, "[email protected]", user.Email)
 	assert.Equal(t, "[email protected]", user.Email)
 	assert.Equal(t, "13900139000", user.Phone)
 	assert.Equal(t, "13900139000", user.Phone)
 	assert.Equal(t, "更新备注", user.Remark)
 	assert.Equal(t, "更新备注", user.Remark)
-	assert.Equal(t, int64(5), user.DeptId)
+	assert.Equal(t, deptId, user.DeptId)
 }
 }
 
 
 // TC-0113: 不存在
 // TC-0113: 不存在
@@ -167,10 +190,13 @@ func TestUpdateUser_DeptIdZero_Clear(t *testing.T) {
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 
+	deptId := insertTestDept(t, ctx, svcCtx)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	err := logic.UpdateUser(&types.UpdateUserReq{
 	err := logic.UpdateUser(&types.UpdateUserReq{
 		Id:     userId,
 		Id:     userId,
-		DeptId: int64Ptr(5),
+		DeptId: int64Ptr(deptId),
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
@@ -195,16 +221,19 @@ func TestUpdateUser_DeptIdSet(t *testing.T) {
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 
+	deptId := insertTestDept(t, ctx, svcCtx)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	err := logic.UpdateUser(&types.UpdateUserReq{
 	err := logic.UpdateUser(&types.UpdateUserReq{
 		Id:     userId,
 		Id:     userId,
-		DeptId: int64Ptr(5),
+		DeptId: int64Ptr(deptId),
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	require.NoError(t, err)
 	require.NoError(t, err)
-	assert.Equal(t, int64(5), user.DeptId)
+	assert.Equal(t, deptId, user.DeptId)
 }
 }
 
 
 // TC-0124: DeptId不传(nil)
 // TC-0124: DeptId不传(nil)
@@ -217,10 +246,13 @@ func TestUpdateUser_NilDeptId_Unchanged(t *testing.T) {
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) })
 
 
+	deptId := insertTestDept(t, ctx, svcCtx)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
+
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	logic := NewUpdateUserLogic(ctx, svcCtx)
 	err := logic.UpdateUser(&types.UpdateUserReq{
 	err := logic.UpdateUser(&types.UpdateUserReq{
 		Id:     userId,
 		Id:     userId,
-		DeptId: int64Ptr(7),
+		DeptId: int64Ptr(deptId),
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
@@ -232,7 +264,7 @@ func TestUpdateUser_NilDeptId_Unchanged(t *testing.T) {
 
 
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	user, err := svcCtx.SysUserModel.FindOne(ctx, userId)
 	require.NoError(t, err)
 	require.NoError(t, err)
-	assert.Equal(t, int64(7), user.DeptId)
+	assert.Equal(t, deptId, user.DeptId)
 	assert.Equal(t, "changed", user.Nickname)
 	assert.Equal(t, "changed", user.Nickname)
 }
 }
 
 

+ 41 - 0
internal/middleware/ratelimitMiddleware.go

@@ -0,0 +1,41 @@
+package middleware
+
+import (
+	"fmt"
+	"net/http"
+
+	"perms-system-server/internal/response"
+
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+	"github.com/zeromicro/go-zero/rest/httpx"
+)
+
+type RateLimitMiddleware struct {
+	limiter *limit.PeriodLimit
+}
+
+func NewRateLimitMiddleware(rds *redis.Redis, period int, quota int, keyPrefix string) *RateLimitMiddleware {
+	limiter := limit.NewPeriodLimit(period, quota, rds, keyPrefix)
+	return &RateLimitMiddleware{limiter: limiter}
+}
+
+func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		ip := r.Header.Get("X-Forwarded-For")
+		if ip == "" {
+			ip = r.Header.Get("X-Real-IP")
+		}
+		if ip == "" {
+			ip = r.RemoteAddr
+		}
+
+		key := fmt.Sprintf("ip:%s", ip)
+		code, _ := m.limiter.Take(key)
+		if code == limit.OverQuota {
+			httpx.ErrorCtx(r.Context(), w, response.ErrTooManyRequests("请求过于频繁,请稍后再试"))
+			return
+		}
+		next(w, r)
+	}
+}

+ 202 - 0
internal/middleware/ratelimitMiddleware_test.go

@@ -0,0 +1,202 @@
+package middleware
+
+import (
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil"
+
+	"github.com/zeromicro/go-zero/core/stores/redis"
+	"github.com/zeromicro/go-zero/rest/httpx"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func init() {
+	response.Setup()
+}
+
+func uniqueIP() string {
+	return fmt.Sprintf("10.%d.%d.%d", rand.Intn(256), rand.Intn(256), rand.Intn(256))
+}
+
+func newTestRedis() *redis.Redis {
+	cfg := testutil.GetTestConfig()
+	return redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+}
+
+func newTestMiddleware(rds *redis.Redis, quota int) *RateLimitMiddleware {
+	prefix := fmt.Sprintf("test_rl_%d_%d", time.Now().UnixNano(), rand.Intn(100000))
+	return NewRateLimitMiddleware(rds, 60, quota, prefix)
+}
+
+// TC-0536: 正常请求(未超限)
+func TestRateLimit_NormalRequest(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 10)
+
+	nextCalled := false
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		nextCalled = true
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req.Header.Set("X-Forwarded-For", uniqueIP())
+	w := httptest.NewRecorder()
+
+	handler(w, req)
+	assert.True(t, nextCalled, "next handler should be called")
+	assert.Equal(t, http.StatusOK, w.Code)
+}
+
+// TC-0537: 超限请求被拒绝
+func TestRateLimit_OverQuotaRejected(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 2)
+	ip := uniqueIP()
+
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		httpx.OkJson(w, nil)
+	})
+
+	for i := 0; i < 2; i++ {
+		req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+		req.Header.Set("X-Forwarded-For", ip)
+		w := httptest.NewRecorder()
+		handler(w, req)
+	}
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req.Header.Set("X-Forwarded-For", ip)
+	w := httptest.NewRecorder()
+	handler(w, req)
+
+	var body response.Body
+	err := json.Unmarshal(w.Body.Bytes(), &body)
+	require.NoError(t, err)
+	assert.Equal(t, 429, body.Code)
+	assert.Equal(t, "请求过于频繁,请稍后再试", body.Msg)
+}
+
+// TC-0538: IP从X-Forwarded-For获取
+func TestRateLimit_IPFromXForwardedFor(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 1)
+	ip := uniqueIP()
+
+	var gotNext bool
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		gotNext = true
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req.Header.Set("X-Forwarded-For", ip)
+	req.Header.Set("X-Real-IP", uniqueIP())
+	w := httptest.NewRecorder()
+	handler(w, req)
+	assert.True(t, gotNext)
+
+	gotNext = false
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.Header.Set("X-Forwarded-For", ip)
+	req2.Header.Set("X-Real-IP", uniqueIP())
+	w2 := httptest.NewRecorder()
+	handler(w2, req2)
+	assert.False(t, gotNext, "should be rate limited by X-Forwarded-For IP")
+}
+
+// TC-0539: IP从X-Real-IP获取
+func TestRateLimit_IPFromXRealIP(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 1)
+	ip := uniqueIP()
+
+	var gotNext bool
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		gotNext = true
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req.Header.Set("X-Real-IP", ip)
+	w := httptest.NewRecorder()
+	handler(w, req)
+	assert.True(t, gotNext)
+
+	gotNext = false
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.Header.Set("X-Real-IP", ip)
+	w2 := httptest.NewRecorder()
+	handler(w2, req2)
+	assert.False(t, gotNext, "should be rate limited by X-Real-IP")
+}
+
+// TC-0540: IP从RemoteAddr获取
+func TestRateLimit_IPFromRemoteAddr(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 1)
+	ip := uniqueIP()
+	remoteAddr := ip + ":12345"
+
+	var gotNext bool
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		gotNext = true
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req.RemoteAddr = remoteAddr
+	w := httptest.NewRecorder()
+	handler(w, req)
+	assert.True(t, gotNext)
+
+	gotNext = false
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.RemoteAddr = remoteAddr
+	w2 := httptest.NewRecorder()
+	handler(w2, req2)
+	assert.False(t, gotNext, "should be rate limited by RemoteAddr")
+}
+
+// TC-0541: 不同IP独立限流
+func TestRateLimit_DifferentIPsIndependent(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddleware(rds, 1)
+	ip1 := uniqueIP()
+	ip2 := uniqueIP()
+
+	var nextCount int
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		nextCount++
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req1.Header.Set("X-Forwarded-For", ip1)
+	handler(httptest.NewRecorder(), req1)
+	assert.Equal(t, 1, nextCount)
+
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.Header.Set("X-Forwarded-For", ip2)
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 2, nextCount, "different IPs should have independent quotas")
+
+	req3 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req3.Header.Set("X-Forwarded-For", ip1)
+	handler(httptest.NewRecorder(), req3)
+	assert.Equal(t, 2, nextCount, "ip1 should be over quota")
+
+	req4 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req4.Header.Set("X-Forwarded-For", ip2)
+	handler(httptest.NewRecorder(), req4)
+	assert.Equal(t, 2, nextCount, "ip2 should be over quota")
+}

+ 2 - 1
internal/response/response.go

@@ -31,7 +31,8 @@ func ErrBadRequest(msg string) *CodeError   { return NewCodeError(400, msg) }
 func ErrUnauthorized(msg string) *CodeError { return NewCodeError(401, msg) }
 func ErrUnauthorized(msg string) *CodeError { return NewCodeError(401, msg) }
 func ErrForbidden(msg string) *CodeError    { return NewCodeError(403, msg) }
 func ErrForbidden(msg string) *CodeError    { return NewCodeError(403, msg) }
 func ErrNotFound(msg string) *CodeError     { return NewCodeError(404, msg) }
 func ErrNotFound(msg string) *CodeError     { return NewCodeError(404, msg) }
-func ErrConflict(msg string) *CodeError     { return NewCodeError(409, msg) }
+func ErrConflict(msg string) *CodeError        { return NewCodeError(409, msg) }
+func ErrTooManyRequests(msg string) *CodeError { return NewCodeError(429, msg) }
 
 
 func Setup() {
 func Setup() {
 	httpx.SetOkHandler(func(_ context.Context, v any) any {
 	httpx.SetOkHandler(func(_ context.Context, v any) any {

+ 16 - 0
internal/server/permserver.go

@@ -129,6 +129,18 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 		return nil, status.Error(codes.PermissionDenied, "超级管理员不允许通过产品端登录")
 		return nil, status.Error(codes.PermissionDenied, "超级管理员不允许通过产品端登录")
 	}
 	}
 
 
+	product, err := s.svcCtx.SysProductModel.FindOneByCode(ctx, req.ProductCode)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, "产品不存在")
+	}
+	if product.Status != consts.StatusEnabled {
+		return nil, status.Error(codes.PermissionDenied, "该产品已被禁用")
+	}
+
+	if _, memberErr := s.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, req.ProductCode, user.Id); memberErr != nil {
+		return nil, status.Error(codes.PermissionDenied, "您不是该产品的成员")
+	}
+
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, user.Id, req.ProductCode)
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, user.Id, req.ProductCode)
 
 
 	accessToken, err := authHelper.GenerateAccessToken(
 	accessToken, err := authHelper.GenerateAccessToken(
@@ -175,6 +187,10 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		return nil, status.Error(codes.PermissionDenied, "账号已被冻结")
 		return nil, status.Error(codes.PermissionDenied, "账号已被冻结")
 	}
 	}
 
 
+	if productCode != "" && !ud.IsSuperAdmin && ud.MemberType == "" {
+		return nil, status.Error(codes.PermissionDenied, "您已不是该产品的成员")
+	}
+
 	accessToken, err := authHelper.GenerateAccessToken(
 	accessToken, err := authHelper.GenerateAccessToken(
 		s.svcCtx.Config.Auth.AccessSecret, s.svcCtx.Config.Auth.AccessExpire,
 		s.svcCtx.Config.Auth.AccessSecret, s.svcCtx.Config.Auth.AccessExpire,
 		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,
 		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,

+ 8 - 0
internal/server/permserver_test.go

@@ -177,7 +177,15 @@ func TestLogin_Normal(t *testing.T) {
 	require.NoError(t, err)
 	require.NoError(t, err)
 	pId, _ := pRes.LastInsertId()
 	pId, _ := pRes.LastInsertId()
 
 
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
 	t.Cleanup(func() {
 	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId)
 		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
 		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
 	})
 	})

+ 3 - 0
internal/svc/servicecontext.go

@@ -14,6 +14,7 @@ import (
 type ServiceContext struct {
 type ServiceContext struct {
 	Config            config.Config
 	Config            config.Config
 	JwtAuth           rest.Middleware
 	JwtAuth           rest.Middleware
+	LoginRateLimit    rest.Middleware
 	UserDetailsLoader *loaders.UserDetailsLoader
 	UserDetailsLoader *loaders.UserDetailsLoader
 	*model.Models
 	*model.Models
 }
 }
@@ -23,10 +24,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	rds := redis.MustNewRedis(c.CacheRedis.Nodes[0].RedisConf)
 	rds := redis.MustNewRedis(c.CacheRedis.Nodes[0].RedisConf)
 	models := model.NewModels(conn, c.CacheRedis.Nodes, c.CacheRedis.KeyPrefix)
 	models := model.NewModels(conn, c.CacheRedis.Nodes, c.CacheRedis.KeyPrefix)
 	udLoader := loaders.NewUserDetailsLoader(rds, c.CacheRedis.KeyPrefix, models)
 	udLoader := loaders.NewUserDetailsLoader(rds, c.CacheRedis.KeyPrefix, models)
+	rlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login")
 
 
 	return &ServiceContext{
 	return &ServiceContext{
 		Config:            c,
 		Config:            c,
 		JwtAuth:           middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
 		JwtAuth:           middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
+		LoginRateLimit:    rlMiddleware.Handle,
 		UserDetailsLoader: udLoader,
 		UserDetailsLoader: udLoader,
 		Models:            models,
 		Models:            models,
 	}
 	}

+ 14 - 1
test-design.md

@@ -78,7 +78,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-0006 | POST /api/auth/login | DB异常(非ErrNotFound) | FindOneByUsername连接失败 | code=500, "服务器内部错误" | 异常路径 | P1 | 透传err→Setup兜底 |
 | TC-0006 | POST /api/auth/login | DB异常(非ErrNotFound) | FindOneByUsername连接失败 | code=500, "服务器内部错误" | 异常路径 | P1 | 透传err→Setup兜底 |
 | TC-0007 | POST /api/auth/login | 密码错误 | `{"username":"admin","password":"wrong"}` | code=401 | 异常路径 | P0 | bcrypt比对失败 |
 | TC-0007 | POST /api/auth/login | 密码错误 | `{"username":"admin","password":"wrong"}` | code=401 | 异常路径 | P0 | bcrypt比对失败 |
 | TC-0008 | POST /api/auth/login | 账号冻结 | status=2用户 | code=403, "账号已被冻结" | 分支覆盖 | P0 | u.Status!=1 |
 | TC-0008 | POST /api/auth/login | 账号冻结 | status=2用户 | code=403, "账号已被冻结" | 分支覆盖 | P0 | u.Status!=1 |
-| TC-0009 | POST /api/auth/login | 非产品成员 | productCode指向用户不属于的产品 | code=0, perms=[], memberType="" | 分支覆盖 | P1 | ErrNotFound→nil |
+| TC-0009 | POST /api/auth/login | 非产品成员 | productCode指向用户不属于的产品 | code=403, "您不是该产品的成员" | 安全 | P0 | 非成员禁止登录 |
 | TC-0010 | POST /api/auth/login | DEVELOPER成员 | DEVELOPER类型成员 | perms全量, memberType="DEVELOPER" | 分支覆盖 | P1 | perms.go DEVELOPER分支 |
 | TC-0010 | POST /api/auth/login | DEVELOPER成员 | DEVELOPER类型成员 | perms全量, memberType="DEVELOPER" | 分支覆盖 | P1 | perms.go DEVELOPER分支 |
 | TC-0011 | POST /api/auth/login | SQL注入 | `{"username":"' OR 1=1 --","password":"x"}` | code=401 | 安全 | P0 | 参数化查询 |
 | TC-0011 | POST /api/auth/login | SQL注入 | `{"username":"' OR 1=1 --","password":"x"}` | code=401 | 安全 | P0 | 参数化查询 |
 | TC-0012 | POST /api/auth/login | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验(productCode现为必填) |
 | TC-0012 | POST /api/auth/login | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验(productCode现为必填) |
@@ -917,3 +917,16 @@ MySQL (InnoDB) + Redis Cache
 | TC-0499 | FindUserIdsByRoleId无绑定 | 无绑定角色 | 空slice | 边界 | P1 | |
 | TC-0499 | FindUserIdsByRoleId无绑定 | 无绑定角色 | 空slice | 边界 | P1 | |
 
 
 ---
 ---
+
+## 十四、限流中间件 (middleware/ratelimitMiddleware.go)
+
+| TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0536 | 正常请求(未超限) | 首次请求 | 请求正常通过, next被调用 | 正常路径 | P0 | code!=OverQuota→next |
+| TC-0537 | 超限请求被拒绝 | 超出配额后的请求 | code=429, "请求过于频繁,请稍后再试" | 异常路径 | P0 | code==OverQuota→ErrTooManyRequests |
+| TC-0538 | IP从X-Forwarded-For获取 | 设置X-Forwarded-For头 | 使用该头中的IP作为限流key | 分支覆盖 | P0 | 第一个ip获取分支 |
+| TC-0539 | IP从X-Real-IP获取 | 仅设置X-Real-IP头 | 使用该头中的IP作为限流key | 分支覆盖 | P0 | 第二个ip获取分支 |
+| TC-0540 | IP从RemoteAddr获取 | 无代理头 | 使用RemoteAddr作为限流key | 分支覆盖 | P0 | 第三个ip获取分支(fallback) |
+| TC-0541 | 不同IP独立限流 | 两个不同IP | 各自独立计数, 互不影响 | 功能验证 | P0 | key隔离 |
+
+---

+ 49 - 14
test-report.md

@@ -1,7 +1,7 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 # 权限管理系统 (perms-system-server) — 测试报告
 
 
-> 报告日期: 2026-04-16
-> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader
+> 报告日期: 2026-04-17
+> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 测试用例设计详见 [test-design.md](./test-design.md)
 
 
 ---
 ---
@@ -10,13 +10,13 @@
 
 
 | 指标 | 数值 |
 | 指标 | 数值 |
 | :--- | :--- |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 535 |
-| 已覆盖 TC 数 | 534 |
+| 测试用例总数 (test-design.md) | 541 |
+| 已覆盖 TC 数 | 540 |
 | 未实现 TC 数 | 1 (TC-0189 不可达防御分支 t.Skip) |
 | 未实现 TC 数 | 1 (TC-0189 不可达防御分支 t.Skip) |
-| 测试函数总数 | 684 |
-| 测试子用例总数 (含 table-driven) | 766 |
+| 测试函数总数 | 690 |
+| 测试子用例总数 (含 table-driven) | 772 |
 | 测试包数量 | 23 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **765 / 766** |
+| ✅ 通过 | **771 / 772** |
 | ❌ 失败 | **0** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189 — 防御性不可达分支) |
 | ⏭️ 跳过 | **1** (TC-0189 — 防御性不可达分支) |
 
 
@@ -328,7 +328,7 @@
 | TC-0182 | 超管 | ✅ pass |
 | TC-0182 | 超管 | ✅ pass |
 | TC-0183 | MEMBER-DENY覆盖 | ✅ pass |
 | TC-0183 | MEMBER-DENY覆盖 | ✅ pass |
 
 
-### 2.13 中间件 / 统一响应 (TC-0184 ~ TC-0193, TC-0434, TC-0478 ~ TC-0480)
+### 2.13 中间件 / 统一响应 (TC-0184 ~ TC-0193, TC-0434, TC-0478 ~ TC-0480, TC-0536 ~ TC-0541)
 
 
 | TC编号 | 测试场景 | 测试结果 |
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
 | :--- | :--- | :--- |
@@ -346,6 +346,12 @@
 | TC-0191 | 内部错误 | ✅ pass |
 | TC-0191 | 内部错误 | ✅ pass |
 | TC-0192 | 成功(有data) | ✅ pass |
 | TC-0192 | 成功(有data) | ✅ pass |
 | TC-0193 | 成功(无data) | ✅ pass |
 | TC-0193 | 成功(无data) | ✅ pass |
+| TC-0536 | 限流-正常请求(未超限) | ✅ pass |
+| TC-0537 | 限流-超限请求被拒绝(429) | ✅ pass |
+| TC-0538 | 限流-IP从X-Forwarded-For获取 | ✅ pass |
+| TC-0539 | 限流-IP从X-Real-IP获取 | ✅ pass |
+| TC-0540 | 限流-IP从RemoteAddr获取 | ✅ pass |
+| TC-0541 | 限流-不同IP独立限流 | ✅ pass |
 
 
 ### 2.14 util 层 (TC-0194 ~ TC-0216)
 ### 2.14 util 层 (TC-0194 ~ TC-0216)
 
 
@@ -709,19 +715,48 @@
 
 
 | 指标 | 数值 |
 | 指标 | 数值 |
 | :--- | :--- |
 | :--- | :--- |
-| TC 总数 | 535 |
-| 已实现 | 534 (99.8%) |
+| TC 总数 | 541 |
+| 已实现 | 540 (99.8%) |
 | 跳过 | 1 (TC-0189,防御性不可达分支) |
 | 跳过 | 1 (TC-0189,防御性不可达分支) |
 | 未实现 | 0 |
 | 未实现 | 0 |
-| 测试函数 | 684 |
-| 测试子用例 | 766 |
-| ✅ 通过 | **763** |
+| 测试函数 | 690 |
+| 测试子用例 | 772 |
+| ✅ 通过 | **771** |
 | ❌ 失败 | **0** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189) |
 | ⏭️ 跳过 | **1** (TC-0189) |
-| 通过率 | **100%** (763/763,排除不可达分支) |
+| 通过率 | **100%** (771/771,排除不可达分支) |
 
 
 ### 3.1 未实现 TC 说明
 ### 3.1 未实现 TC 说明
 
 
 | TC编号 | 原因 |
 | TC编号 | 原因 |
 | :--- | :--- |
 | :--- | :--- |
 | TC-0189 | 防御性不可达分支,claims类型断言失败场景在正常运行时无法触发,已 t.Skip |
 | TC-0189 | 防御性不可达分支,claims类型断言失败场景在正常运行时无法触发,已 t.Skip |
+
+---
+
+## 四、本次测试调整记录 (2026-04-17)
+
+### 4.1 新增测试用例
+
+| TC编号 | 测试文件 | 说明 |
+| :--- | :--- | :--- |
+| TC-0536 ~ TC-0541 | ratelimitMiddleware_test.go | 新增 IP 限流中间件测试,覆盖正常通过、超限拒绝(429)、IP 获取三个分支(X-Forwarded-For / X-Real-IP / RemoteAddr)、不同 IP 独立限流 |
+
+### 4.2 测试数据修复(非源码 BUG)
+
+以下测试因源码新增业务校验而需要完善测试数据准备,均属于**测试 setup 不完整**,非源码逻辑错误:
+
+| 测试用例 | 修复内容 | 原因 |
+| :--- | :--- | :--- |
+| TC-0001 (loginLogic_test.go) | 新增 `insertTestProductMember` | 源码新增产品成员校验,正常登录需先建立成员关系 |
+| TC-0009 (loginLogic_test.go) | 改为断言 code=403 "您不是该产品的成员" | 非成员登录现返回 403 拒绝(安全增强),test-design.md 已同步更新 |
+| TC-0166 (permserver_test.go) | 新增 `SysProductMember` 插入 | gRPC 登录同样需要成员关系 |
+| TC-0112, TC-0122, TC-0123, TC-0124 (updateUserLogic_test.go) | 新增 `insertTestDept` 创建真实部门 | 源码新增部门存在性校验,DeptId>0 时必须指向真实部门 |
+| TC-0111 (createUserLogic_mock_test.go) | 移除 `FindOneByUsername` mock | 源码优化:移除预查重,改为依赖 DB 唯一约束 |
+| TC-0100 (bindRolePermsLogic_mock_test.go) | 新增 `SysPermModel.FindByIds` mock | 源码新增权限 ID 验证(存在性+产品归属) |
+| TC-0136 (bindRolesLogic_mock_test.go) | `DeleteByUserIdTx` → `DeleteByUserIdForProductTx` | 源码优化:按产品隔离删除用户角色绑定 |
+| TC-0488, TC-0489, TC-0490 | 创建真实角色后再测试权限拒绝 | 源码先检查角色存在性再检查权限,需用存在的角色触发 403 |
+
+### 4.3 发现的源码 BUG
+
+本次测试未发现源码 BUG。所有测试失败均为测试数据准备不完整导致,源码的新增校验逻辑(产品成员检查、部门存在性验证、权限 ID 验证、产品隔离删除等)均为合理的安全增强。