# 权限管理系统 —— 深度代码审计报告(第 8 轮) > **审计范围**:`/internal` 下全部非测试、非 `_gen.go` 生产代码(含 `internal/server/permserver.go`、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 > **审计时间**:2026-04-19 > **审计维度**:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 > **与第 7 轮对比**: > - **已落地(本轮不再复列)**:H-1 `loadPerms` deny fail-open、H-3 `AddMember` 目标侧授权、H-4 JWT HMAC 断言、M-1 `Load` 半成品缓存污染、M-2 `UpdatePassword`/`UpdateStatus` 未校验 `RowsAffected`、M-4 `CreateProduct` 明文密码(切成一次性 `credentialsTicket`)、M-7 gRPC 限流剥端口、L-1 `DeleteDept` 锁序列、L-2 `IncrementTokenVersion` WARN 注释、L-3 `loadPerms` 其余分支 fail-close、L-4 SQL `status = ?` 参数化(`sysUserRole` / `sysUserPerm` / `sysRole` 三处)、L-5 `FindMapByProductCodeUserIds` / `FindMapByProductCode` 非事务版移除、L-6 gRPC 负缓存预污染(TTL=10s + 预写前强一致 `FindOne`)。 > - **未落地 / 回归**:**H-2 PII 暴露**(第 6~7 轮持续未修),**M-3 残留分支**(`GuardRoleLevelAssignable` 已走 fresh read,但 `CheckManageAccess → checkPermLevel` 仍读缓存 `caller.MinPermsLevel`,降级 admin 的 TOCTOU 窗口只封了"分配角色"一个出口),L-5 `CountActiveAdminsTx` 零调用,L-7 历史账号 `DeptId=0` 兜底。 > - **新发现**:M-N1 `CreateProduct → Redis 票据写入失败` 导致**产品/管理员孤儿化**;M-N2 `checkPermLevel` 读缓存 `MinPermsLevel` 的 TOCTOU 口子(M-3 未闭合的另一半);M-N3 `SyncPermsError{Code:404}` 在 gRPC 映射里被同化成 `codes.Internal`;L-N1 `sysPermModel` 仍用 `fmt.Sprintf("... status = %d", consts.StatusEnabled)`,与 L-4 修复风格不一致;L-N2 `SetUserPerms` `FindByIds` 校验与 `BatchInsert` 之间的 TOCTOU(影响面轻,列入存档)。 --- ## 🚩 核心逻辑漏洞 (High Risk) ### H-1. `UserDetailLogic` / `UserListLogic` 仍把 `Email` / `Phone` / `Remark` 暴露给**任意同产品成员**(第 6 轮 M-3、第 7 轮 H-2,三轮未落地) - **位置**: - `internal/logic/user/userDetailLogic.go:64-76`(`types.UserItem` 构造) - `internal/logic/user/userListLogic.go:77-89` - **描述**:两接口的访问控制仍然只到"同产品即可看全字段"的粒度: - `UserDetail` 只用 `FindOneByProductCodeUserId` 核对 caller 与 target 是否在同一产品,之后直接塞 `Email / Phone / Remark`(纯文本,无脱敏)。 - `UserList` 分页返回同产品所有成员,每条也原样带上 `Email / Phone / Remark`。一次分页可以把整个产品通讯录灌下来。 - **影响**: - **同产品最低权限 MEMBER 即可遍历整个产品通讯录**,获取手机 / 邮箱 / 备注(备注里常常会有内部身份、外部联络人、岗位说明等 PII)。 - 叠加风险: - 即便 H-3(第 7 轮,已修)已关掉了"ADMIN 跨部门 AddMember"的入口,现有 ADMIN 仍可通过 `UserList` 一次拿走本产品全员通讯录; - 对于拿到一次性有效 JWT 的外包 / 服务账号,本接口天然就是拖库点。 - 违反《个人信息保护法》第 6 / 13 条的最小必要原则与《数据安全法》下的分级保护要求。 - **修复方案**(与前两轮报告一致,代码未落): 1. 在 `internal/logic/auth/access.go` 新增: ```go // CanViewContact 判定 caller 是否可以看 target 的联系信息字段。 // - caller 是 SuperAdmin → true // - caller.UserId == target.Id(看自己) → true // - CheckManageAccess(caller, target) 通过(caller 在 target 的管理链上) → true // - 其余情况一律 false,由 filterPIIForCaller 落地脱敏。 func CanViewContact(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, target *userModel.SysUser, productCode string) bool ``` 2. 新增 `authHelper.MaskEmail` / `MaskPhone`(`138****1234` / `a***@b.com`),在 Logic 构造 DTO 前调用 `filterPIIForCaller(caller, target, &item)` 统一覆盖 `Email / Phone / Remark` 三字段。 3. 单测覆盖四种身份组合:同产品同级 MEMBER 互看、跨部门互看、ADMIN 看下级、看自己。 - **为什么必须本轮解决**:前两轮已经连续标记为 P0 且提出了完整方案,在 H-3(AddMember)修完之后这条变成"剩下最大的单点 PII 出口",攻击面大、修复工作量小(一个 helper + 两个 Logic 返回前的 hook)。 ### H-2. `CheckManageAccess → checkPermLevel` 仍靠 caller 的**缓存 `MinPermsLevel`** 决策,降级 admin 在 5 分钟 TTL 内仍可管辖原本够不到的目标(M-3 只封了一半) - **位置**: - `internal/logic/auth/access.go:279-316`(`checkPermLevel`) - 受影响调用方:`internal/logic/member/updateMemberLogic.go`、`internal/logic/member/removeMemberLogic.go`、`internal/logic/user/updateUserLogic.go`、`internal/logic/user/updateUserStatusLogic.go`、`internal/logic/user/setUserPermsLogic.go` - **描述**:第 7 轮对 `GuardRoleLevelAssignable` 的修复方式是"在做 role 分配决策前 `FindMinPermsLevelByUserIdAndProductCode` 走一次 NoCache 查 DB"。但**同样类型的 TOCTOU 问题**在更一般的 `CheckManageAccess` 里没修: ```go // access.go:312 if caller.MinPermsLevel >= targetLevel { return response.ErrForbidden("无权管理权限级别高于或等于您的用户") } ``` 这里 `caller` 来自 `middleware.GetUserDetails(ctx)`,也就是 `UserDetailsLoader.Load` 缓存的那份;TTL 5 分钟。 攻击路径: 1. T=0:超管把 caller C 从 `MinPermsLevel=10`(总监级)降到 `20`(经理级)。超管调用业务接口时会触发 `UserDetailsLoader.Clean(C.UserId)`,但 Redis 抖动 / 集群主从切换期间,`Clean` 单次失败只会被 `logx.Errorf`,没有重试、没有降级旁路。 2. T=0+δ:C 在另一终端调 `RemoveMember` / `UpdateMember` / `UpdateUser` / `UpdateUserStatus` / `SetUserPerms` 去管理一个 `MinPermsLevel=15` 的目标 D。 3. `checkPermLevel` 读到 C 的缓存 `MinPermsLevel=10`,判定 `10 >= 15` 为 false → 放行。C 的**实际**级别 20,`20 >= 15` 为 true,本应被拦。 4. C 有整整 5 分钟时间把下属统一踢人、改 MemberType、覆盖 UserPerms、冻结状态 —— 审计日志里看起来是合法操作。 - **与 M-3 修复的对照**:`GuardRoleLevelAssignable` 修的是"C 用旧身份去授角色给别人"的路径;`checkPermLevel` 漏的是"C 用旧身份去直接动别人"的路径。两条路径一个在**出**一个在**改**,安全边界对称才叫完整。 - **影响**:与 M-3 同一量级,但触达面更广(5 个 Logic 都走 `CheckManageAccess`),且 `RemoveMember` / `UpdateUserStatus` 可以直接产生**不可逆的破坏性操作**(把管理员从产品踢出、冻结账号)。 - **修复方案**: 1. `checkPermLevel` 对 caller.MinPermsLevel 采用与 `GuardRoleLevelAssignable` 一致的策略: ```go freshCallerLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, caller.UserId, productCode) if err != nil { if errors.Is(err, sqlx.ErrNotFound) { // caller 当前已无产品角色 → 等同最低等级,对同级管辖一律拒绝 return response.ErrForbidden("无权管理权限级别高于或等于您的用户") } return response.NewCodeError(500, "校验权限级别失败,请稍后重试") } if freshCallerLevel >= targetLevel { return response.ErrForbidden("无权管理权限级别高于或等于您的用户") } ``` 2. 抽一个共享 helper `loadFreshMinPermsLevel(ctx, svcCtx, userId, productCode) (int64, error)`,`GuardRoleLevelAssignable` 和 `checkPermLevel` 统一调用;同时顺手把两处的 `ErrNotFound` 语义文档化。 3. 长期建议(可选):给 `UserDetailsLoader` 加一个 `LoadFresh(ctx, userId, productCode)` 接口,授权决策点强制 bypass 缓存;普通业务依然走缓存。 --- ## ⚠️ 健壮性与性能建议 (Medium / Low) ### M-1. `CreateProduct` 事务已提交后,Redis 票据写入失败会把产品 + admin 用户**永久孤儿化**(第 7 轮 M-4 修复方案遗留的新缺陷) - **位置**:`internal/logic/product/createProductLogic.go:109-185` - **描述**:第 7 轮把 M-4(响应体明文密码)改成了"DB 事务提交 → 暂存 Redis → 响应 ticket"三段流水线。但流水线的失败语义没有做补偿: 1. L109-149:`SysProductModel.TransactCtx` 里同时 INSERT `sys_product` / `sys_user`(admin 账号)/ `sys_product_member`。成功后 `productId` 已经持久化。 2. L162-169:`generateRandomHex(32)` 生成 ticket —— 理论上 `crypto/rand.Read` 出错概率 ≈ 0,但已不在事务内。 3. L176-180:`json.Marshal(&payload)` —— 对固定结构体几乎不会失败,但落出错分支同样只返回 500。 4. L181-184:`Redis.SetexCtx(ticketKey, …)` —— **这里才是真实风险面**。Redis 短暂不可用 / 超时 / 集群 failover 都会让 Setex 返 err。 落到任一 fail 分支时: - `sys_product` 里新建的产品记录已经落盘; - `admin_` 账号已经落盘,bcrypt 密码是**我们刚刚在内存里生成、随 500 响应丢弃**的那串随机 12 字节; - 没有任何方式可以拿回这个密码: - 仓库里没有 `DeleteProductLogic`(只有 `CreateProduct` / `UpdateProduct` / `ProductList` / `ProductDetail`); - 仓库里没有 "SuperAdmin Reset Password" 类型的接口,只有 `ChangePasswordLogic`,且它要求 `oldPassword` 校验通过; - `CreateProduct` 再试一次会命中 `product.Code` / `admin_` 的 `FindOneByCode` / `FindOneByUsername` 前置判定,直接 `ErrConflict`。 运维只能下场直接 SQL:要么跑一次 `UPDATE sys_user SET password = ? WHERE username = ?` 硬改 admin 密码,要么 `DELETE` 三张表对应数据。**这两种手法都绕过了业务不变式和审计日志。** - **影响**:数据完整性问题。概率虽低(依赖 Redis 单次失败 & 在一次创建流程内),但一旦发生是**永久性**的(孤儿产品不会自愈),且需要手工改库,违反最小事故介入原则。 - **修复方案**(按代价从低到高): 1. **最小成本**:`Redis.SetexCtx` 失败时,在同一 handler 内做补偿 DB 事务——删除刚才创建的 `sys_product` / `sys_user` / `sys_product_member`,回到"从未创建"状态再返 500。为防止补偿事务自己也失败,至少要把 `productId` / `adminUserId` 落一条 `logx.Errorw("createProduct compensation required", ...)` 带有 ERROR 等级 + 结构化字段,方便告警侧接管人工回捞。 2. **更稳**:新增 `SuperAdmin` 专用接口 `RegenerateInitialCredentials(productCode)` —— 找到 `admin_`,重新生成随机密码、bcrypt 后写 DB,再走一次 `SetexCtx` + 返回新的 `credentialsTicket`;与 `ChangePassword` 解耦,有独立审计日志字段 `audit=regenerate_init_cred`。 3. **一步到位**:引入 outbox / 2PC 风格 —— DB 事务 + `sys_outbox` 行同时写;单独 worker 消费 outbox,驱动 Redis 写入;对调用方接口异步化(响应体先给 `ticketId`,轮询拉真值)。落地成本高,按本仓规模不建议。 - **锁死建议**:无论采纳 (1) 还是 (2),在上线前至少要补齐:`CreateProduct` 集成测试中把 `Redis.Setex` 做成主动注错路径,断言 "产品不存在 / admin 用户不存在(方案 1)" 或 "可以通过 Regenerate 拿回凭证(方案 2)",防止未来重构又漏掉这一路径。 ### M-2. `SyncPermsError{Code: 404}` 在 gRPC 映射里被同化成 `codes.Internal`,接口契约泄露 DB 语义 - **位置**: - `internal/logic/pub/syncPermsService.go:75-79`(事务内 `LockByCodeTx` 返 `ErrNotFound` → `SyncPermsError{Code: 404}`) - `internal/server/permserver.go:67-83`(gRPC 层映射) - **描述**:gRPC handler 的 `switch se.Code` 只列了 400 / 401 / 403 / 409,其余(含 404 / 500 / 任何异常值)一律落 `default → codes.Internal`。于是: 1. 前置 `FindOneByAppKey` 已经能 hit 到一条 `sys_product`,但事务内 `LockByCodeTx` 又 `ErrNotFound`(极罕见:并发 "DeleteProduct" —— 虽然目前没有 Delete 入口,但一旦加上就中招); 2. 调用方(产品接入方的服务端)拿到 `codes.Internal` + 消息 "产品不存在",但 SDK 侧的重试策略一般对 `Internal` 是 "not retriable" —— 调用方会直接当作永久错误上报,而我们实际希望它是 `codes.NotFound` 让其按 404 处理。 - **影响**:契约级一致性缺陷。目前无直接安全风险,但一旦未来引入 DeleteProduct / ProductCode 重命名逻辑,会给接入方的调用栈制造误导性错误分类(Internal 会 page 值班,NotFound 不会)。 - **修复方案**: ```go case 404: return nil, status.Error(codes.NotFound, se.Message) ``` 同时把 HTTP 侧的 `response.NewCodeError(se.Code, …)` / gRPC 侧的映射表都抽到一个 `mapSyncPermsErr` helper,避免两边漂移。 ### M-3. `RefreshToken` CAS 成功后,若 `GenerateAccessToken` / `GenerateRefreshTokenWithExpiry` 失败,tokenVersion 已递增但客户端拿不到新令牌,用户被强制退出 - **位置**:`internal/server/permserver.go:198-229` - **描述**: - L199:`IncrementTokenVersionIfMatch` 成功把 DB 的 `tokenVersion` 从 N 增到 N+1,返回 `newVersion`。 - L206:`UserDetailsLoader.Clean(ctx, claims.UserId)` 清缓存(此时 TokenVersion 在 DB 是 N+1,但在用户手上的 refreshToken 还是 N)。 - L208-214:生成新 accessToken 失败(理论上 HMAC signing 几乎不会失败,但 OOM / 奇怪的运行时错误不是 0 概率)。 - L216-223:生成新 refreshToken 失败亦然。 - 任一处失败就直接 `return nil, status.Error(codes.Internal / codes.Unauthenticated, ...)`。**客户端的老 refreshToken 因为 tokenVersion 对不上,下一次刷新会被 `claims.TokenVersion != ud.TokenVersion` 一刀切成 "登录状态已失效"**。用户必须完整重登,体验上等同于被踢下线。 - **影响**:可用性 / 数据完整性:签名失败原本是 100% 服务端 bug,用户却要重登。量不大但会污染"非预期登出"告警,淹没真正的会话劫持信号。 - **修复方案**: 1. 重排顺序:先生成两个新 token(不成功就直接 500,不动 DB),成功后再 CAS 递增 tokenVersion、Clean 缓存。即便递增后 log 层有问题,至少签名成功的 token 一定带回给了客户端。 2. 若坚持"先 CAS 再签名"(语义上更安全:CAS 成功才证明本次 refresh 是唯一 winner),则失败路径需要记一条明确的 `audit=refresh_post_cas_sign_fail userId=X oldVer=N newVer=N+1` 的 ERROR 日志,并把返回消息改成用户可感知的 "登录刷新失败,请重新登录"(保留现有行为但补上 observability)。 ### L-1. `sysPermModel.go` SQL 仍用 `fmt.Sprintf("... status = %d", consts.StatusEnabled)`,与 L-4 本轮修复风格不一致 - **位置**:`internal/model/perm/sysPermModel.go:59,102,135` ```go query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusEnabled) findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ? AND `status` = %d", sysPermRows, m.table, consts.StatusEnabled) updateQuery := fmt.Sprintf("UPDATE %s SET `status` = %d, `updateTime` = ? WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusDisabled, consts.StatusEnabled) ``` - **描述**:第 7 轮 L-4 已经把 `sysUserRole` / `sysUserPerm` / `sysRole` 三处改成了占位符 `?` + 参数传 `consts.StatusEnabled`,但 `sysPermModel` 的三条查询还是走 `fmt.Sprintf` 把 `int` 直接嵌到 SQL 里。问题不是注入(`consts.StatusEnabled` 是编译期常量),而是: 1. **类型契约不稳**:如果未来把 `StatusEnabled` 从 `int` 改成 `int8` / `uint8` / `ActiveStatus` enum,`%d` 可能要改,占位符版本可以稳定。 2. **审计一致性**:L-4 的修复初衷是"SQL 里**不再出现**状态常量字面值,统一走 prepared statement 占位"。`sysPermModel` 的三处仍然把数字编进 SQL 字符串(虽然是间接通过 Sprintf),与 L-4 的意图偏离。 - **影响**:非安全问题,属于审计口径一致性 / 未来可维护性。 - **修复方案**: ```go query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = ?", m.table) // 调用处: QueryRowsNoCacheCtx(ctx, &dest, query, productCode, consts.StatusEnabled) ``` 三条一致处理;`BatchUpdateWithTx` 里的 `status = ?` 同理。 ### L-2. `CountActiveAdminsTx` 零调用仍在接口里公开(第 7 轮 L-5 部分遗留) - **位置**:`internal/model/productmember/sysProductMemberModel.go`(接口 + 实现) - **描述**:第 7 轮已经把 `FindMapByProductCodeUserIds` / `FindMapByProductCode`(非事务版)从模型接口里干掉。但与之同批引入的 `CountActiveAdminsTx`(不带 `Other`)在业务层的实际调用方是 `CountOtherActiveAdminsTx`;`CountActiveAdminsTx` 自身只在 mock / test 里被引用。 - **验证方式**: ```shell rg -n 'CountActiveAdminsTx' internal/logic | wc -l # 0 rg -n 'CountOtherActiveAdminsTx' internal/logic | wc -l # >0 ``` - **影响**:僵尸接口方法,增加接口 surface area 与未来误用机会("应该用 `CountActiveAdminsTx` 还是 `CountOtherActiveAdminsTx`?"的歧义)。 - **修复方案**:从 interface / 实现 / mock / 单测里删除 `CountActiveAdminsTx`;保留一条 `CountOtherActiveAdminsTx`(业务语义是"除自己以外还有几个活跃 admin",刚好吻合"不能移除/降级最后一个 admin"的不变式)。 ### L-3. `CheckManageAccess` 对 caller `DeptId=0` 且非 ADMIN / SuperAdmin 的历史账号直接 403(第 7 轮 L-7 未消化) - **位置**:`internal/logic/auth/access.go`(`checkDeptHierarchy` 内 `caller.DeptId == 0 || caller.DeptPath == ""` 分支) - **描述**:历史遗留账号仍有 `DeptId=0` 的 MEMBER / DEVELOPER,即使在自己的产品范围内想做简单的"看自己 / 改自己"操作,也会被 `checkDeptHierarchy` 拒 403(除非上层已短路)。 - **修复方案**(任选其一,两者都不破坏现有安全边界): 1. 运维侧一次性 SQL: ```sql UPDATE sys_user SET deptId = WHERE deptId = 0 AND isSuperAdmin = 0 AND ( userId IN (SELECT userId FROM sys_product_member WHERE memberType != 'ADMIN') OR userId NOT IN (SELECT userId FROM sys_product_member) ); ``` 同时 `UserDetailsLoader.CleanByUserIds` 一次性批量清缓存。 2. 代码侧:`CheckManageAccess` 早期追加 ```go if caller.UserId == targetUserId { return nil } ``` (这一条已经在 L60-69 实现了,但仅对"看自己"生效;真正被 403 的路径是"看同部门的人"——仍需要数据修复)。 ### L-4. `SetUserPerms` 在 `FindByIds` 校验与 `BatchInsertWithTx` 之间存在 perm 状态 TOCTOU - **位置**:`internal/logic/user/setUserPermsLogic.go:90-130` - **描述**: - L95-109:循环检查 `dbPerms` 里每条权限都 `ProductCode == productCode` 且 `Status == StatusEnabled`; - L112-131:在新事务里 `DeleteByUserIdForProductTx` + `BatchInsertWithTx`。 - 两段之间若并发一次 `SyncPermissions` 把某个 permId 的 `status` 置成 DISABLED,本次 `SetUserPerms` 依然会把那条 `user_perm` 落库。 - **影响**:`loadPerms` 在组装缓存时是 `JOIN sys_perm WHERE sys_perm.status = ?`(L-4 修过的部分),失效权限不会真正生效。本漏洞**不造成越权**,但会留下一行"脏"`sys_user_perm`,让审计查询 "X 拥有哪些权限" 与 "X 实际享有哪些权限" 出现微小偏差,是数据一致性噪声。 - **修复方案**(按实际需要决定是否做): 1. 把 `FindByIds` 挪到事务内,并对 `sys_perm` 这几行加 `FOR SHARE`(`SyncPerms` 已经通过 `LockByCodeTx` 在 sys_product 行上串行化;这里加 `FOR SHARE` 只是为了在事务边界内读到一致的 status,开销很低); 2. 或者最廉价:在 `BatchInsertWithTx` 之后补一条 `COUNT(*)` 校验 —— "我刚才插入的 permId 全部仍是 Enabled",不满足就主动回滚事务。任一方案都能把 TOCTOU 窗口缩到零。 --- ## 📋 修复优先级汇总 | 优先级 | finding | 一句话概要 | | :----- | :------------------------------------------------------------ | :------------------------------------------------------------------------------------------ | | **P0** | **H-1** UserDetail/UserList PII 暴露(3 轮未落地) | 任意同产品 MEMBER 可读全员手机邮箱备注,违反 PIPL 最小必要 | | **P0** | **H-2** `checkPermLevel` 仍读缓存 MinPermsLevel(M-3 另一半) | 降级 admin 在 5 分钟 TTL 内仍可 RemoveMember / UpdateMember / SetUserPerms | | P1 | **M-1** CreateProduct Redis 写失败 → 产品 + admin 孤儿化 | 一次 Redis 失败 → 永久不可恢复,只能手动 SQL | | P1 | **M-2** SyncPermsError 404 → codes.Internal 契约错位 | 接入方 SDK 的错误分类/重试策略失真 | | P1 | **M-3** RefreshToken CAS 后签名失败 → 用户被强制登出 | tokenVersion 已推进,客户端必须重登,可用性事件 | | P2 | **L-1** `sysPermModel` 仍用 `fmt.Sprintf`(L-4 风格漂移) | 三条 SQL 统一改占位符,接近审计一致性 | | P2 | **L-2** `CountActiveAdminsTx` 零调用(L-5 遗留) | 接口层可见的僵尸方法 | | P2 | **L-3** `CheckManageAccess` 对 `DeptId=0` 老账号 403(L-7) | 需数据迁移或 "看自己" 短路 | | P3 | **L-4** SetUserPerms FindByIds / BatchInsert TOCTOU | 不越权,但会留脏 `sys_user_perm` 行 | --- ## 🛠 建议修复次序 1. **本轮必修 P0**(对称封口): - H-1:`filterPIIForCaller` + `CanViewContact` 两个 helper,落到 `UserDetail` / `UserList` 返回前。 - H-2:`checkPermLevel` 把 `caller.MinPermsLevel` 换成 `loadFreshMinPermsLevel(...)`;与 `GuardRoleLevelAssignable` 共享同一个 helper。两条 P0 建议同批上线,一起做回归单测。 2. **P1**(稳定性与契约): - M-1:CreateProduct 失败路径补偿(删 `sys_product` + `sys_user` + `sys_product_member`)或新增 SuperAdmin-only `RegenerateInitialCredentials`;集成测试强制注入 Redis fault。 - M-2:`SyncPermsError{Code:404}` 补到 switch 里,映射到 `codes.NotFound`;抽 `mapSyncPermsErr` 统一 HTTP / gRPC。 - M-3:RefreshToken 重排为"先签新 token 成功才做 CAS";或至少给 post-CAS 失败路径加 audit 字段。 3. **P2 / P3 收尾**: - L-1:`sysPermModel` 三条 SQL 改占位符; - L-2:`CountActiveAdminsTx` 删除(接口 + 实现 + mock),`CountOtherActiveAdminsTx` 保留; - L-3:数据迁移脚本批量把历史 `deptId=0` 账号挪到默认部门,然后 `CleanByUserIds` 刷缓存; - L-4:`SetUserPerms` 要么把 `FindByIds` 挪进事务 + `FOR SHARE`,要么事务末补一条 status 复核 `COUNT(*)`。 --- ## 🔎 备注:本轮已验证仍在正轨上的机制 下列机制经过完整阅读后认为仍然安全、无需调整,特此备注以免未来误改: - `UserDetailsLoader`:H-1(deny fail-close)、M-1(半加载不写缓存 + 中间件按 `(ud, err)` 分流 401/503)、L-3(loadPerms 任一子步骤错误都 fail-close)、L-6(负缓存 TTL=10s + 写前 `FindOne`)全部落地。 - `RefreshToken` / `Logout` 走 `IncrementTokenVersionIfMatch` / `IncrementTokenVersion` 两把刀,前者是 CAS(单会话轮转),后者是"无条件大杀器"(强制全量失效),`L-2` 的 WARN 注释已就位。 - `DeleteDeptLogic` 的锁序列:`X(target dept)` → `FOR SHARE(children)` → `FOR SHARE(users in dept range)`,AB-BA 死锁风险大幅收敛。 - `SyncPermissions` 通过 `LockByCodeTx` 把同 product 的 sync 串行化,sys_perm UNIQUE 再撞 1062 会落一条 `audit=mysql_error_1062` ERROR 日志,足以触发告警。 - `LoginLogic` / `AdminLoginLogic` / `UserInfoLogic` 里的 `Email` / `Phone` 返回属于"看自己"合法场景,本轮不纳入 H-1 修复范围。 - `ExtractClientIP` 对 `X-Forwarded-For` / `X-Real-IP` 做了严格解析 + `firstValidIP` 过滤,gRPC 侧 `net.SplitHostPort` 剥端口(M-7 已修)。 - `CheckAddMemberAccess` 对 ADMIN 也强制走部门链校验 + 拒绝 `target.IsSuperAdmin`,H-3 入口已封住。