Jelajahi Sumber

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

BaiLuoYan 3 minggu lalu
induk
melakukan
62aafd53b1
4 mengubah file dengan 982 tambahan dan 171 penghapusan
  1. 335 163
      audit-report.md
  2. 12 6
      perm.api
  3. 268 0
      test-design.md
  4. 367 2
      test-report.md

+ 335 - 163
audit-report.md

@@ -1,183 +1,355 @@
-# 权限管理系统 —— 深度代码审计报告(第 9 轮)
-
-> **审计范围**:`/internal` 下全部非测试、非 `_gen.go` 生产代码(含 `internal/server/permserver.go`、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。
-> **审计时间**:2026-04-19
-> **审计维度**:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。
-> **与第 8 轮对比**:
->   - **已落地(本轮复核通过,不再复列修复细节)**:
->     - **H-2**(第 8 轮)`checkPermLevel` 读缓存 `MinPermsLevel` 的 TOCTOU —— `internal/logic/auth/access.go:302-347` 现统一调用 `loadFreshMinPermsLevel` 走 DB 复核,`sqlx.ErrNotFound` 降级为"0 roles"、其他 DB 错误向上冒出。
->     - **M-3**(第 7~8 轮)RefreshToken CAS 在签发失败后脏升级 `tokenVersion` —— `internal/logic/pub/refreshTokenLogic.go:90-110` 与 `internal/server/permserver.go:215-230` 均已把 `Generate*Token` 前置到 `IncrementTokenVersionIfMatch` 之前,CAS 只在两份 token 都成功生成后执行。
->     - **M-N1**(第 8 轮)CreateProduct Redis 票据写失败孤儿化 —— `internal/logic/product/createProductLogic.go:178-283` 已落地 `compensateCreatedRows`:ticket Setex 任一步失败都会按 admin / member / product 倒序硬删,并附 WARN 日志便于对账。
->     - **M-N3**(第 8 轮)gRPC `SyncPermsError{Code:404}` 被吞成 `codes.Internal` —— `internal/server/permserver.go:61-90` 已加 `errors.As` 显式映射到 `codes.NotFound`,并保留具体错误文案。
->     - **L-2**(第 7~8 轮)`CountActiveAdminsTx` 僵尸方法 —— `internal/model/productmember/sysProductMemberModel.go` 中仅保留 `CountOtherActiveAdminsTx`,接口与实现两端均已删除旧签名。
->     - **L-N1**(第 8 轮)`sysPermModel` 仍拼接 `fmt.Sprintf("... status = %d", ...)` —— 现统一用 `?` 占位(`FindAllCodesByProductCode:57-67`、`DisableNotInCodesWithTx:100-154`)。
->     - **L-N2**(第 8 轮)`SetUserPerms` 的 `FindByIds` → `BatchInsert` TOCTOU —— `internal/logic/user/setUserPermsLogic.go:148-165` 已在事务内对目标权限集做 `COUNT(*) WHERE status=?` 复核,并要求计数等于入参长度,否则 rollback。
->   - **产品契约接受、不列入风险清单**:
->     - **H-1 同产品成员 PII 互见**(第 6 → 7 → 8 轮累计列入 P0)—— 产品确认"内部系统 + 通讯录互见"为业务契约,不修。该条从本轮起归档,不再进入后续审计复核列表。
->   - **未落地 / 回归**:
->     - **L-3 历史 `DeptId=0` 账号的 `CheckManageAccess` 兜底**(需数据迁移,不属纯代码问题,延续存档)。
->   - **本轮新发现(权重较高的 5 条)**:
->     - **M-N1(新)`userDetailsLoader.Load` 的 `loadOk=false` 契约错位** —— 基础设施故障被同化为业务 403 / "产品已禁用",前端侧不重试且 SOC 无故障信号。
->     - **M-N2(新)`BatchDel` 中 `unregisterCacheKey` 走串行 N×2 次 SREM** —— 绑/解绑/改角色链路退化为 O(N) Redis RTT,属 N+1。
->     - **M-N3(新)`RoleDetail` 枚举 oracle** —— 跨产品访问 403 vs 404 可区分,可用于穷举合法 roleId。
->     - **M-N4(新)`CreateUser` 未做 caller→`req.DeptId` 的部门链校验** —— 产品 ADMIN 可为**非自己管辖的部门**预埋用户名并占坑,绕过 AddMember 侧部门链防护。
->     - **L-N1(新)`ParseWithHMAC` helper 未统一使用** —— `jwtauthMiddleware.go`、gRPC `VerifyToken` 仍用内联 `token.Method.(*jwt.SigningMethodHMAC)` 断言,三处拷贝导致审计面错位。
->   - **本轮复现的低风险残点**:L-N2 `UpdateUser` 调部门未校验 `dept.Status`;L-N3 `AdminLogin` `IsSuperAdmin` 判断在 bcrypt 之后;L-N4 `sysUserModel.UpdateStatus` 缺乐观锁字段(由上层短路保护)。
+# 第 11 轮深度审计报告
+
+审计对象:`perms-system/server`(不含测试代码)
+审计维度:逻辑一致性、并发/竞态、资源管理、数据完整性、安全漏洞、边界、DB 性能、僵尸代码、接口契约
+说明:本轮基于真实业务量级(数千用户 / 数十产品 / 单产品 <100 role / 一次 SyncPerms < 1k perm / 单 user 10~30 role)做判定。对前 10 轮已闭环条目(H-1~H-4、M-1~M-R10-5、L-1~L-R10-10 等)不重复列举,仅追踪**本轮新发现或重新归类**的风险点。
 
 ---
 
 ## 🚩 核心逻辑漏洞 (High Risk)
 
-**本轮无 High Risk 项。**
-- 第 8 轮列示的 H-2(`checkPermLevel` TOCTOU)已通过 `loadFreshMinPermsLevel` 闭环,见上方"已落地"小节。
-- 第 6~8 轮列示的 H-1(同产品 PII 互见)经产品确认为系统对内使用场景下的既定契约,自本轮起归档、不再纳入审计清单。
-- 其余安全类待办均已降级至 Medium / Low,见下节。
+### H-R11-1(High · 数据完整性/竞态) · `UpdatePassword` 内部 `FindOne` 把"外层校验过的状态"洗掉,乐观锁自我对齐 → TOCTOU
+
+**描述**:`internal/model/user/sysUserModel.go:128-152` 的 `UpdatePassword` 不接受外部 `expectedUpdateTime`,而是在内部自己 `FindOne` 再取 `data.UpdateTime` 作为乐观锁 WHERE:
+
+```128:152:internal/model/user/sysUserModel.go
+func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	// 乐观锁:WHERE 叠加 updateTime 与 FindOne 拿到的一致。避免 FindOne → Exec 之间并发改密把
+	// 本次写盖成"最后一写赢"、或目标行被删除后仍返回成功造成语义欺骗(见审计 M-2)。
+	expectedUpdateTime := data.UpdateTime
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
+		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id, expectedUpdateTime)
+	}, sysUserIdKey, sysUserUsernameKey)
+```
+
+调用方 `ChangePasswordLogic`(`internal/logic/auth/changePasswordLogic.go:50-81`)早已经自己 `FindOne` 读到 `user`、校验 `user.Password` + `user.Status != Enabled`,然后把 `userId` 透传进来。此处 `UpdatePassword` 又打一次 `FindOne`,内层 CAS 对齐的是**内层 FindOne 的时间戳**——而不是**外层校验旧密码所依赖的那个时间戳**。
+
+真实并发场景(两个并存会话):
+
+```text
+T0: Device A 发起改密 (oldPass=P0, newPass=P1)
+    ChangePasswordLogic.FindOne → user{password=H(P0), updateTime=T0}
+    bcrypt.CompareHashAndPassword(H(P0), P0) → OK
+    bcrypt.GenerateFromPassword(P1) → H(P1)
+T1: Device B 独立完成改密到 P2
+    UpdatePassword: FindOne → user{updateTime=T0} → UPDATE ... WHERE updateTime=T0
+    提交成功:password=H(P2), updateTime=T1, tokenVersion+1
+T2: Device A 的 UpdatePassword 开始执行
+    内部 FindOne → user{updateTime=T1, password=H(P2)}  ← 被 B 的写"刷新"
+    expectedUpdateTime=T1
+    UPDATE ... WHERE updateTime=T1 → 匹配,提交成功
+    最终 DB:password=H(P1), Device B 的新密码 P2 被覆盖
+```
+
+等价结论:内层"自 FindOne-自 Update"的 CAS 等于没有 CAS。调用方看似"带乐观锁",实际语义已退化为 **last-write-wins on password**,而且连"外层校验的旧密码还是有效旧密码"都不再成立(A 验证的是 P0,应用出去的是把 P2 改回到 P1)。
+
+这条 TOCTOU 并非纸面理论:
+
+- 一个用户因安全事件在 Device B 上紧急改密为 P2(本意:立刻让 Device A 的旧会话失效+密码改掉);
+- 但 Device B 提交的瞬间,Device A 正好在点"修改密码 P0 → P1"。A 的 middleware 已经通过 token 鉴权并取到 userId,logic 执行没有依赖 Device B 的 tokenVersion 递增结果;
+- 最终 P2 被 P1 覆盖,Device B 用户将以为密码是 P2,尝试登录失败;而 Device A 并没有"知道 P2"——也就是说,**一个原本没有权限修改最新密码的会话,成功把密码改掉了**。
+
+此外:`UpdatePassword` 里 `tokenVersion = tokenVersion + 1` 是累计的,所以两次成功的 UPDATE 会连续 +2,把刚刚因 B 改密正准备下线的 A 的旧 token(version 已经对不上)再把 B 的所有新会话也踢掉,用户本次密码修改后的新登录也会被强制登出。
+
+**影响**:
+
+- **数据完整性**:密码这一核心凭证可被"被凭证泄露 / 旧会话持有"的攻击者用自己知道的旧密码,把管理员紧急修改过的密码盖回去——会话劫持的时间窗虽小但影响直接。
+- **安全合规**:信息安全审计侧若执行"强制改密"流程,这条 TOCTOU 是一条可以让强制改密语义悄悄失效的旁路。
+- **可观测**:审计链路上会出现"用户短时间内 password 连续变化 + tokenVersion 连加两次"的奇怪模式,排障成本高。
+
+**修复方案**:把 `expectedUpdateTime` 改由调用方显式传入,彻底消除内部二次 FindOne 造成的"自对齐":
+
+```go
+// sysUserModel.go
+UpdatePassword(ctx context.Context, id int64, username, password string,
+    mustChangePassword, expectedUpdateTime int64) error
+
+func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64,
+    username, password string, mustChangePassword, expectedUpdateTime int64) error {
+    sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+    sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
+    res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+        query := fmt.Sprintf(
+            "UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?",
+            m.table)
+        return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id, expectedUpdateTime)
+    }, sysUserIdKey, sysUserUsernameKey)
+    ...
+}
+```
+
+调用方 `ChangePasswordLogic` 把已经持有的 `user.UpdateTime` / `user.Username` 透传(与 `IncrementTokenVersionIfMatch(id, username, expected)` 的签名风格对齐)。这样 CAS 的 expected 就是"外层用来校验旧密码的那一份快照";只要 DB 里 updateTime 发生过任何变化(并发改密 / 改资料 / 冻结/解冻),都会 CAS 失败返回 `ErrUpdateConflict`,调用方按 409 映射返回"密码已被其他会话修改,请刷新后重试",并提示用户重新登录确认。
+
+同时收益:
+
+- 外层的 `FindOne` 不再浪费(以前只用于校验旧密码,然后内层再打一次);
+- `AdminResetPassword` 之类未来想复用本方法的路径,也必须显式承诺"基于某个观察到的 updateTime 改",语义更清晰。
 
 ---
 
-## ⚠️ 健壮性与性能建议 (Medium / Low)
-
-### M-N1. `userDetailsLoader.loadFromDB` 的 `loadOk=false` 语义错位,导致基础设施故障被同化为业务拒绝(新发现)
-- **位置**:`internal/loaders/userDetailsLoader.go:138-204`、`:321-574`(`loadFromDB`、`loadDept`、`loadProduct`、`loadPerms` 等子加载)。
-- **描述**:
-  - `loadFromDB` 约定:
-    - `(ud, true, nil)` — 全量成功,写 5 分钟缓存;
-    - `(ud, false, nil)` — 子段(dept / product / membership / roles / perms)中某段失败,**返回残缺 ud、仅跳过缓存写**;
-    - `(nil, _, err)` — 主体加载失败,`Load` 向上传 error。
-  - `Load(:180-183)` 在 `loadOk=false` 时 `return ud, nil`。调用方拿到的是 `Username≠""`、但 `DeptPath=""` / `Perms=nil` / `ProductStatus=0` 的"半成品":
-    - `jwtauthMiddleware.go` 只校 `tokenVersion` 与 `IsSuperAdmin || MemberType!=""`,**放行**;
-    - `refreshTokenLogic.go` 的 `ProductStatus != StatusEnabled` 分支把这种 case 归类为"产品已被禁用",返 403;
-    - 其余业务接口因 `Perms=nil` 命中 `hasPerm=false`,返 403。
-  - 结果:一次 Redis / MySQL 抖动对外就是 403 "无权 / 产品禁用",而真正应该是 503 "上游暂不可用,请重试"。**前端不会重试**,SOC 也不会出现任何异常信号,故障被静默。
-- **影响**:
-  - 故障可观测性塌陷;SLO 把"基础设施降级"错报成"正常业务拒绝"。
-  - 用户体验上,一次瞬时 DB 抖动会对全体在线用户抛"产品已被禁用" / "无权限"——比直接 500 更容易引起误解和 P1 工单。
-- **修复方案**:
-  1. 把 `loadOk=false` 的语义改为"基础设施故障":`Load` 里遇到该分支直接 `return nil, ErrLoaderDegraded`(新定义 sentinel)。
-  2. HTTP 中间件 & gRPC AuthInterceptor 对 `ErrLoaderDegraded` 统一返 503(或 `codes.Unavailable`),并附 `retry-after`。
-  3. 保留 `(ud, false, nil)` 作为内部观测用途时,**改为 panic-safe 的诊断日志聚合**而非对外返回。
-  4. 配套:`refreshTokenLogic` 里的 "产品已被禁用" 分支前增加 `if ud.ProductCode != "" && ud.DeptStatus == 0 { return ErrLoaderDegraded }` 兜底。
-  5. 单测:`TC-9101 loadPerms 报错 → Load 返回 Unavailable`、`TC-9102 loadProduct 报错 → Load 不返回"产品已禁用"`。
-
-### M-N2. `UserDetailsLoader.BatchDel` 在大 roleId 场景退化为 O(N) Redis RTT(N+1 新发现)
-- **位置**:`internal/loaders/userDetailsLoader.go:257-272`,调用方 `internal/logic/role/updateRoleLogic.go`、`internal/logic/role/bindRolePermsLogic.go`、`internal/logic/user/bindRolesLogic.go`。
-- **描述**:
-  - `BatchDel` 先用一次 `DelCtx(keys...)` 批量删主 key(OK),但紧跟着 **for-range 调用 `unregisterCacheKey`,每个用户串行 2 次 `SremCtx`**(userIndex + productIndex)。
-  - 对"角色改名 / 角色禁用 / 批量重绑角色"场景,该角色下绑定的用户数可能上千人(即使"几万部门"不现实,但"单个业务角色跨几千人"在大型中台里常见)。意味着一次普通的 `UpdateRole` 会对 Redis 发出 **2N 次** 串行往返。
-  - 相较之下,`registerCacheKey` 已经用 `PipelinedCtx` 合并 RTT,表明作者清楚 pipeline 的必要性,`unregisterCacheKey` 却没同步改造。
-- **影响**:
-  - 在角色批量维护/权限大盘扫描期对 Redis 连接池形成突刺,尾延迟 P99 明显抬升;极端场景可导致 `UpdateRole` 接口被 go-zero 的 ctx timeout 打断,落入"已 UPDATE DB 但 Clean 缓存失败"的分支(目前该分支仅写 Errorf,不回滚)。
-  - 当 Redis 集群跨机房时,2N 串行比一次 pipeline 多出 (N-1)×RTT,个位数 ms 的 RTT 在 N=1000 时就是秒级延迟。
-- **修复方案**:
-  1. 把 `unregisterCacheKey` 的 per-user 逻辑合入一次 `PipelinedCtx`:对每个 key 发 `pipe.SRem(userIdxKey, cacheKey)` / `pipe.SRem(productIdxKey, cacheKey)`,一次 Exec。
-  2. 更彻底:直接给 `BatchDel` 写一个专用 `batchUnregister(ctx, pairs)`,单 pipeline 内合并所有 userIndex / productIndex 的 SREM + 可选 EXPIRE。
-  3. 回归测试:`TC-9201 BatchDel(1000 users) 的 Redis 命令数 ≤ 3 次 pipeline Exec`(用 redis-mock 的 CallCount 断言)。
-
-### M-N3. `RoleDetail` 枚举 oracle:跨产品访问 404 vs 403 可区分(新发现)
-- **位置**:`internal/logic/role/roleDetailLogic.go:29-58`。
-- **描述**:
-  - 现状流程:
-    1. 先 `SysRoleModel.FindOne(req.Id)`:找不到返 **404 "角色不存在"**;
-    2. 再判 `!IsSuperAdmin && caller.ProductCode != role.ProductCode`:返 **403 "无权访问该产品的数据"**。
-  - 非超管攻击者遍历 `req.Id`,即可通过响应码精确区分"该 id 不存在" vs "存在于别的产品",从而绘制跨产品的 role id 分布图,为后续定向攻击(社工、横向越权尝试)提供素材。
-  - 同一脚本在 `ProductDetailLogic.productDetail` 也存在(先 `FindOneByCode`,再做成员检查);`RoleList`/`ProductList` 因天然按 `caller.ProductCode` 过滤不泄漏。
-- **影响**:
-  - 边界信息泄露,属于"先查后授权"反模式;在任何以 id 为目标的枚举场景都会被利用。
-  - 与第 7 轮把 `AdminLogin` 非法用户名的 bcrypt 时序补齐的精神不一致。
-- **修复方案**:
-  1. 合并成"授权失败即返 404"的统一契约:先用 `caller.ProductCode` 做过滤(或查询时把 `productCode = ?` 塞进 WHERE),未命中或跨产品一律返 `ErrNotFound("角色不存在")`。
-  2. `ProductDetailLogic` 同步处理:对非超管直接用 `FindByCodeWithMemberCheck` 或先查 `sys_product_member` 命中后再读 `sys_product`,省掉"存在性差异"泄漏。
-  3. 单测:`TC-9301 非超管请求别产品 roleId → 404`、`TC-9302 非超管请求不存在 roleId → 404`,两者响应体必须完全一致。
-
-### M-N4. `CreateUser` 允许产品 ADMIN 为"非自己管辖部门"预埋用户名(新发现)
-- **位置**:`internal/logic/user/createUserLogic.go:37-100`。
-- **描述**:
-  - 现状只用 `RequireProductAdminFor(productCode)` 校验 caller 是该产品的 ADMIN,并用 `FindOne(req.DeptId)` 核对部门存在,**未校验 caller 的 `DeptPath` 是否覆盖 `newDept.Path`**。
-  - 产品 ADMIN 可以为任意存在的部门创建用户,随后:
-    1. 占坑关键用户名(`admin_*`、`ops_*`、`sre_*` 等易被运营/运维复用的账号);
-    2. 预埋账号后等待配合方(比如跨部门协作的另一位 ADMIN)触发 `AddMember`,由于 `AddMember` 会走 `CheckAddMemberAccess` 的部门链校验,**对方 ADMIN** 的部门链可能覆盖,最终把这个"伪造种子账号"挂进产品。
-  - 与 `UpdateUserLogic` 的设计(调部门时严格校验 `caller.DeptPath` 前缀,见 `updateUserLogic.go:116-120`)**不一致**:同样的敏感位,创建时无校验、修改时严校验,出现"先创建后改部门"的绕路。
-- **影响**:
-  - 横向越权路径:产品 ADMIN 在"应当只管 X 部门"的治理约束下,可以在 DEV 部门、总部部门等任意位置生成账号,后续借由 AddMember 协同路径落地。
-  - 用户名命名空间被占用,正常部门 ADMIN 新建时收到"用户名已存在",排查困难。
-- **修复方案**:
-  1. 与 `UpdateUserLogic` 保持对称:`if req.DeptId > 0 && !caller.IsSuperAdmin && caller.MemberType != Admin || caller.DeptPath != "" && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return ErrForbidden }`。
-  2. 若业务上允许产品 ADMIN "跨部门拉新人",则至少要求 `req.DeptId` 必须是 caller 所在产品已有成员的部门集合之一(查 `sys_product_member` join `sys_user` 得到部门白名单)。
-  3. 单测:`TC-9401 部门 ADMIN 创建跨部门用户 → 403`、`TC-9402 产品 ADMIN 创建本部门子部门用户 → 200`、`TC-9403 SuperAdmin 创建任意部门用户 → 200`。
-
-### L-N1. JWT 解析三处重复:`ParseWithHMAC` helper 未统一使用
-- **位置**:
-  - 定义:`internal/logic/auth/jwt.go:16-31`;
-  - 内联实现 1:`internal/middleware/jwtauthMiddleware.go:62-66`;
-  - 内联实现 2:`internal/server/permserver.go` 的 `VerifyToken`(`jwt.ParseWithClaims(... keyfunc {...})`)。
-- **描述**:`ParseWithHMAC` 的注释明确写着"所有 JWT 解析点(HTTP 中间件 / gRPC VerifyToken / RefreshToken)统一走这里",但目前只有 `ParseRefreshToken` 一个调用方。另两处自己又写了一遍 `token.Method.(*jwt.SigningMethodHMAC)` 断言 —— 功能上一致,但:
-  - 未来若增加算法白名单(例如仅允许 HS384、禁 HS256)或添加 `typ` 断言,要改 3 处;
-  - 把"算法混淆防御"的审计覆盖矩阵从 1 个函数摊到 3 个函数,`test-design.md` 的 TC-0951~0960 只覆盖了 `ParseRefreshToken` 一条路径。
-- **影响**:安全属性正确,但代码一致性差、改动风险高。属于"长期隐患"。
-- **修复方案**:
-  1. `jwtauthMiddleware.go` 改用 `authHelper.ParseWithHMAC(tokenStr, secret, &UserClaims{})`,移除内联 keyfunc;
-  2. gRPC `VerifyToken` 同样替换;
-  3. 把 TC-0951~0960 复用到这两条路径(直接 table-driven 调三个入口)。
-
-### L-N2. `UpdateUserLogic` 调部门时未校验目标 `dept.Status`
-- **位置**:`internal/logic/user/updateUserLogic.go:110-131`。
-- **描述**:`FindOne(*req.DeptId)` 仅判存在,不判 `Status`。产品 ADMIN 可把用户调入已 `Disabled` 的部门,随后该用户在 `loadPerms` 里会命中"普通成员 + DeptStatus!=Enabled"分支,其 DEV 全权特权被撤销。若业务意图是"停用部门=冻结该部门所有活动",该路径破坏了不变量。
-- **影响**:中低优。主要是产品语义一致性("停用部门"的含义被稀释),不构成越权。
-- **修复方案**:`if newDept.Status != StatusEnabled { return ErrBadRequest("目标部门已停用") }`,与 `UpdateDept` 禁用时的数据流闭环。
-
-### L-N3. `AdminLogin` `IsSuperAdmin` 判断在 bcrypt 之后,对"合法用户名"的时序侧漏略大于"非法用户名"分支
-- **位置**:`internal/logic/pub/adminLoginLogic.go:55-85`。
-- **描述**:流程是"查用户 → bcrypt 校验 → 校 `IsSuperAdmin` → 校 status"。对于一个**存在但非超管**的账号,攻击者即便密码随机也会触发 bcrypt 计算(约 60~100ms),与"存在且密码错"分支耗时相近;而"不存在"分支走 `dummyBcryptHash` 也有类似耗时兜底 —— 单凭这点难以区分。但若攻击者能获取大量样本,`IsSuperAdmin` 这一步的耗时(sql 比较)理论上可让"存在但非超管"比"存在是超管且密码错"略快(无 token 签发路径),仍可能形成<10ms 级的统计差。
-- **影响**:属学术级别时序泄漏,实战价值低。但该入口按第 7 轮审计精神是"高敏感且对抗公网扫描",建议把 `IsSuperAdmin` 判断**前置到 `FindByUsername` 之后、bcrypt 之前**:若非超管,仍走 `dummyBcryptHash` 消耗一次 bcrypt,再返回 403,恒定时序。
-- **修复方案**:重排为:`FindByUsername → 非超管则用 dummy 计算再统一返 ErrInvalidCredentials → 超管再真 bcrypt → Status 校验`。
-
-### L-N4. `sysUserModel.UpdateStatus` 缺乐观锁字段(由上层短路保护,但 model 自身语义不自洽)
-- **位置**:`internal/model/user/sysUserModel.go:154-175`。
-- **描述**:`UpdateStatus` 的 `WHERE id=?` 无 `updateTime` / `tokenVersion` 比较;与同文件里 `UpdateProfile` 使用乐观锁、`IncrementTokenVersionIfMatch` 使用 CAS 的风格不一致。上层 `UpdateUserStatusLogic` 做了"状态相同则短路"、`UserDetailsLoader` 5 分钟 TTL 也提供事实一致性,所以实际故障概率低。
-- **影响**:不会造成越权,但并发改状态会出现"我改了 Enabled 你覆成 Disabled"的 last-write-wins。
-- **修复方案**:把 `UpdateStatus` 改为 `WHERE id=? AND updateTime=?`,接口加入 `expectedUpdateTime` 参数,语义与 `UpdateProfile` 对齐。
+## ⚠️ 健壮性与性能建议 (Medium/Low)
+
+### M-R11-1(Medium · 安全/限流) · gRPC `SyncPermissions` / `GetUserPerms` 未挂 gRPC 入口限流
+
+**描述**:HTTP 侧 `/api/perm/sync` 已挂 `serverCtx.SyncRateLimit`(`internal/handler/routes.go:195-206`),而 gRPC 的 `PermServer.SyncPermissions`(`internal/server/permserver.go:60-92`)与 `PermServer.GetUserPerms`(`:331-369`)既不做 IP 维度限流、也不做按 `appKey` 维度的限流:
+
+```60:66:internal/server/permserver.go
+func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermissionsReq) (*pb.SyncPermissionsResp, error) {
+	items := make([]pub.SyncPermItem, len(req.Perms))
+	for i, p := range req.Perms {
+		items[i] = pub.SyncPermItem{Code: p.Code, Name: p.Name, Remark: p.Remark}
+	}
+
+	result, err := pub.ExecuteSyncPerms(ctx, s.svcCtx, req.AppKey, req.AppSecret, items)
+```
+
+`Login / RefreshToken / VerifyToken` 都各自持有 `GrpcLoginLimiter / GrpcRefreshLimiter / GrpcVerifyLimiter`,口径完整;唯独"产品服务端调用"这两个接口是裸调。
+
+**影响**:
+
+- `SyncPermissions` 内部要走"tx + LockByCodeTx 的 X 锁";`appSecret` 一旦泄露,恶意方在没有限流兜底时可持续对同一 product 打高频同步请求,`LockByCodeTx` 会串行化但前置 `bcrypt.Compare(appSecret)` 的 CPU 开销(cost=10 默认 ~100ms)与 DB 短事务都会被放大,单点产品同步的尾延迟会被显著拉高。
+- `GetUserPerms` 会触发 `UserDetailsLoader.Load`,缓存未命中时回源多张表;同样的泄露凭证 + 枚举 `userId` 可打爆 DB。
+
+本条未能被"HTTP 层 SyncRateLimit"兜住,因为 gRPC 是独立监听端口(不同服务进程入口)。
+
+**修复**:
+
+- 在 `servicecontext.go` 增设 `GrpcSyncLimiter` / `GrpcGetUserPermsLimiter`(配额取决于真实产品数和 QPS,例如单 product 每分钟 60 次同步 / 1k 次 perm 查询),按 `fmt.Sprintf("grpc:sync:%s", req.AppKey)` / `fmt.Sprintf("grpc:perms:%s", req.AppKey)` 为 key,避免按 IP(产品后端多实例共享 egress IP 时会误伤);
+- `GetUserPerms` 可以同时叠加按 IP 维度,防止同一合法产品多个后端实例在 DDoS 场景下集体被耗尽配额。
 
 ---
 
-## ✅ 本轮复核通过、认定安全的机制(仅挑敏感点列示)
-
-| 机制 | 位置 | 关键保护点 |
-| --- | --- | --- |
-| `checkPermLevel` fresh read | `logic/auth/access.go:302-347`、`loadFreshMinPermsLevel:339-343` | caller.MinPermsLevel 每次走 DB,降级 admin 后续的跨级分配被立即拒绝(第 8 轮 H-2 已闭环) |
-| CreateProduct 补偿 | `logic/product/createProductLogic.go:178-283` | Redis 票据写入 `SetexCtx` 失败调用 `compensateCreatedRows` 级联删除 admin / member / product |
-| RefreshToken CAS 顺序 | `logic/pub/refreshTokenLogic.go:90-110`、`server/permserver.go:215-230` | 先生成 access+refresh 再 `IncrementTokenVersionIfMatch`,签名失败不会吞掉旧 refresh |
-| DeleteDept AB-BA 防护 | `logic/dept/deleteDeptLogic.go` | 自身 `FOR UPDATE`,子部门 / 成员 `FOR SHARE`,锁顺序与 CreateDept 对齐 |
-| 负缓存投毒防御 | `loaders/userDetailsLoader.go:161-178` | 写哨兵前再 `FindOne` 强一致复核,避免 Insert→Load 并发把新用户哨兵掉 |
-| AddMember 部门链二次校验 | `logic/auth/access.go:CheckAddMemberAccess`、`logic/member/addMemberLogic.go:76-78` | 产品 ADMIN 也要过部门链,切断第 6 轮 H-3 |
-| RemoveMember 末位守卫 | `logic/member/removeMemberLogic.go:49-53` | `CountOtherActiveAdminsTx` 事务内排除自己并 lock row,杜绝并发撤 admin 导致 0 admin |
-| GuardRoleLevelAssignable | `logic/user/bindRolesLogic.go:86-88` | caller 的 min-level fresh read,避免缓存 admin 权级绑定高于自己的角色 |
-| SetUserPerms 事务内复核 | `logic/user/setUserPermsLogic.go:148-165` | `BatchInsertWithTx` 前后 `COUNT(*) WHERE status=?` 复核,并发禁用权限的 TOCTOU 闭环 |
-| gRPC SyncPerms 错误分级 | `server/permserver.go:61-90` | `SyncPermsError{Code:404}` → `codes.NotFound`,不再同化为 Internal |
-| gRPC 限流剥端口 | `server/permserver.go` / `ratelimit` | PeriodLimit key 用客户端 IP 而非 `host:port`,防单连接多端口绕限 |
-| JWT HMAC 断言本身 | `logic/auth/jwt.go:16-31` | `alg=none` / RS256 / ES256 全拒;L-N1 指出的是"调用点未统一",算法防御自身 OK |
+### M-R11-2(Medium · DB 性能) · `UpdateStatus` / `IncrementTokenVersion` 只为构造 cache key 而多打一次 `FindOne`
+
+**描述**:与 H-R11-1 同属"内部 FindOne 冗余"族,但这两处只影响性能而不破坏正确性:
+
+```161-182:internal/model/user/sysUserModel.go
+func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status int64, expectedUpdateTime int64) error {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	...
+```
+
+```190-221:internal/model/user/sysUserModel.go
+func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
+	data, err := m.FindOne(ctx, id)
+	if err != nil {
+		return 0, err
+	}
+
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+```
+
+两处 `FindOne` 的唯一作用都是取 `username` 构造 `cacheSysUserUsernamePrefix` 键;真正的并发安全依赖外层 `expectedUpdateTime` 或 `IncrementTokenVersion` 自己的 `RowsAffected==0 → ErrUpdateConflict` 兜底,`data` 对象其他字段并没有参与逻辑。
+
+- `UpdateStatus` 的调用方 `UpdateUserStatusLogic` 事前已经从 `ValidateStatusChange` 拿到 `sysUser`——`sysUser.Username` 就在手里;
+- `IncrementTokenVersion` 的唯一调用方 `LogoutLogic` 从 middleware 拿到 `userId`,没有 username,但只要上游 `ud, _ := UserDetailsLoader.Load(...)` 已经拉过用户详情,一样可以透传。
+
+**影响**:
+
+- 每次 Logout / 冻结解冻各多一次 cache/DB round-trip;Logout 通常叠加 `TokenOpLimiter`,调用量不大;
+- 但"冗余 FindOne"会占一个连接池槽位,在登录/登出高峰(比如统一挂维护后全员重新登录)会放大尾延迟。
+
+**修复**:把 username 显式提到函数签名,与 `IncrementTokenVersionIfMatch(id, username, expected)` 口径对齐:
+
+```go
+UpdateStatus(ctx, id, username, status, expectedUpdateTime int64) error
+IncrementTokenVersion(ctx, id, username int64) (int64, error)
+```
+
+调用方:
+
+- `UpdateUserStatusLogic` 传 `user.Username`;
+- `LogoutLogic` 在限流已经通过的前提下,先 `ud := UserDetailsLoader.Load(...)`(Logout 本就会走到 Load),把 `ud.Username` 传进来。
+
+顺带把"内部 FindOne → ErrUpdateConflict 能正确触发"这条隐性依赖显式化,未来有人重构把内部 FindOne 挪走也不会把 CAS 语义改坏。
 
 ---
 
-## 🎯 修复建议优先级与落地顺序
+### M-R11-3(Medium · 数据完整性/竞态) · `DeleteDept` 与 `UpdateUser.deptId` 之间的 write skew
+
+**描述**:`internal/logic/dept/deleteDeptLogic.go:36-69` 的策略是:
+
+```
+DeleteDept tx:
+  ① SELECT sys_dept WHERE id=? FOR UPDATE     -- X 锁目标部门
+  ② SELECT sys_dept WHERE parentId=? FOR SHARE -- S 锁确认无子部门
+  ③ SELECT sys_user WHERE deptId=? FOR SHARE   -- S 锁确认无关联用户
+  ④ DELETE sys_dept WHERE id=?
+```
+
+`UpdateUser` 在修改 `deptId` 为目标部门时,只对 `sys_dept` 做不加锁的 `FindOne` 验证"目标部门存在且 Enabled",然后在自己的事务里对 `sys_user` 行取 X 锁写新 `deptId`(`internal/logic/user/updateUserLogic.go:110-137`、`internal/model/user/sysUserModel.go:105-126`)。
+
+两个事务交错:
+
+```
+T1: DeleteDept
+    ①②③ 都通过:sys_user.deptId=X 为空
+    (尚未 commit)
+T2: UpdateUser.deptId=X
+    读 sys_dept[X] 的 RR 快照:Status=Enabled(T1 尚未 commit)
+    X 锁 sys_user[userY],写 deptId=X
+    T2 提交
+T1: ④ DELETE sys_dept[X],提交
+最终:sys_user[userY].deptId=X, sys_dept[X] 已删除 → 悬挂 deptId
+```
+
+T1 的 `FOR SHARE ON sys_user WHERE deptId=X` 对"将要变成 X 但目前不是 X"的行没有锁效力:InnoDB 的 gap lock 只覆盖 `deptId` 列现有的索引范围,`userY` 当前 `deptId=Y` 不在 T1 的锁范围。反向 T2 对 `sys_dept[X]` 是无锁读,看不到 T1 的 X 锁。这是典型的 `write skew`。
+
+**影响**:
+
+- 只要并发删部门 + 调整用户部门同时发生就会留下"deptId 指向已删除部门"的 orphan 行;
+- 后续 `DeptTree` / `UserList` 渲染时,这些用户找不到 dept path,会落回"default / 空 path"分支——所有非超管/非产品 ADMIN 的管辖判定全部对该用户失效(管他们的人发现人没了,被管的人发现管理员找不到自己);
+- 真实业务概率:极低(单日内 DeleteDept + 把某人加进这个部门同时点提交的窗口只有毫秒级)。但一旦触发修复成本高(只能靠运维手 SQL 清洗)。
+
+**修复方案**:二选一。
+
+1. **对 `sys_user.deptId` 用 FOR UPDATE 并补 gap lock**(推荐):把 `FOR SHARE` 改成 `FOR UPDATE`,并在 `sys_dept.id` 列上(以及 `deptId` 外键索引)依赖 next-key lock。这会把"向这个 deptId 写入的 UpdateUser"阻塞到 DeleteDept 提交或回滚。代价:DeleteDept 持锁时间变长,但 DeleteDept 极其低频。
+2. **在 `UpdateUser` 里对目标 `sys_dept` 加 `FOR SHARE`**:UpdateUser 事务内先 `SELECT sys_dept WHERE id=? FOR SHARE`,然后再做 `sys_user` 的 X 锁写。这样 DeleteDept 的 X 锁会把 UpdateUser 的 S 读阻塞,形成一致锁链。代价:UpdateUser 的一次查询变成锁读,但 dept 查询本来就走缓存,打穿到 DB 的比例很低。
+
+两种方案等价化解 write skew。推荐 2(把锁链约束放在"新行写入方"上更自然,也与 `CreateDept` 在插子部门前对 `parentId FOR SHARE` 的现有策略口径一致)。
+
+---
+
+### L-R11-1(Low · 逻辑一致性/契约) · `UpdateMember` 对 `memberType` 空串直接 400,丧失"只改 status"语义
+
+此条在前轮 M-4 / L-5 系列修复中被反向推导过,R10 未列;本次复核明确为**接口契约问题**。
+
+**描述**:`UpdateMember` 的 `types` 中 `MemberType` / `Status` 都是非指针必传字段。如果 admin 想只改 member 状态(冻结其产品成员资格),必须重传一份完整的 `memberType`;前端直接传空会被拦 400。这一约束与 HTTP API 的"部分更新"直觉不符。
+
+**影响**:前端要么自己维护"原 memberType"在内存(多一个状态源),要么多打一次 member.detail 接口;属于接口易用性问题,不涉及安全。
+
+**修复**:把 `memberType` / `status` 改成 `*string` / `*int64`,为 nil 时表示不改该字段;logic 侧按 nil/非 nil 分支分别处理校验,和 `UpdateUserReq` 的可选字段风格对齐。
+
+---
+
+### L-R11-2(Low · DB 性能) · `DisableNotInCodesWithTx` 与 `DeleteBy...Tx` 族把"整行 SELECT"当作"取缓存 key"的手段
+
+**描述**:`internal/model/perm/sysPermModel.go:100-164`、`internal/model/userrole/sysUserRoleModel.go:105-166`、`internal/model/roleperm/sysRolePermModel.go:86-130`、`internal/model/userperm/sysUserPermModel.go:45-66` 都遵循同一结构:
+
+```
+SELECT <全部列> FROM ... WHERE ... FOR UPDATE
+拼 cache keys
+UPDATE/DELETE ... WHERE <同样条件>
+```
+
+这里"SELECT 整行"的唯一用途是取 `id` 与构成缓存 key 的两三个字段(`Code / ProductCode / UserId / RoleId / PermId`)。真正需要的列最多 3 个,却把 `Name / Remark / Status / CreateTime / UpdateTime` 等字段全部搬运到应用层再丢弃。
+
+**影响**:
+
+- 对单次 SyncPerms(涉及 <1k 条 perm)或单次 BindRoles(<30 role)影响很小;
+- 在 `DeleteByRoleIdTx` 场景(`DeleteRole` 会级联删除所有 `sys_user_role WHERE roleId=?`,后续会对"受影响用户列表"做批量 cache 失效),被"关联成百上千用户"的角色删除时会显著增加 goroutine 临时内存与网络 I/O;
+- 另外 `session.QueryRowsCtx(list)` + `len(list)==0` 提前 return 的"先查后写"模式,每次删除都付出一次"完整行读回"的成本,哪怕是单行删。
+
+**修复**:把所有"只是为构 cache key"的 SELECT 精简到只取必要列;或者只取 `id / (user, role) / (role, perm)` 等 key 组件,省略业务字段。例如:
+
+```go
+// 只取 id + (productCode, code)
+var rows []struct {
+    Id          int64  `db:"id"`
+    ProductCode string `db:"productCode"`
+    Code        string `db:"code"`
+}
+```
+
+对"单行删除"路径(如 `DeleteByRoleIdAndPermIdsTx` 只有一个 permId)甚至可以直接由调用方传 key,不再反查。
+
+---
+
+### L-R11-3(Low · 边界 / 缓存一致性) · `UpdateProfile` 不支持改 `username`,但签名暴露 `username` 参数,易被后续误用
+
+**描述**:`internal/model/user/sysUserModel.go:105-126`
+
+```go
+func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, username string,
+    nickname, email, phone, remark string, deptId, newStatus int64,
+    statusChanged bool, expectedUpdateTime int64) error {
+    sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+    sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
+    ...
+    // SET 语句里没有 `username`=?
+```
+
+入参有 `username`、但 UPDATE 里不写这个列;它只被用作"构造旧缓存 key"。未来若某人认为"签名已经带了 username,那 UpdateProfile 应该也能顺手改 username"并往 SET 里加上 `username=?`,会立刻出现:
+
+- 新 username 还未在缓存键 `cacheSysUserUsernamePrefix` 删除旧值,stale 缓存残留;
+- 且没有处理 `sys_user.username` UNIQUE 约束违反的 1062 回滚。
+
+**影响**:当前代码功能正确,属于"签名 / 语义鸿沟"。若维护同学出于"方便"扩展此函数支持改 username 就会踩坑。
+
+**修复**:
+
+- 文档化:在 `UpdateProfile` 的 Go doc 里显式说明"本方法不负责修改 `username`;`username` 参数仅用于旧缓存键构造";
+- 或更保险:把这个函数拆成"不关心 username"的纯写入 + 调用方自己按 (id, oldUsername) 做缓存失效;
+- 未来若真要支持改 username,做成独立的 `UpdateUsernameTx`,内部处理"新/旧 username 两份缓存键清理 + 1062 捕获"。
+
+---
+
+### L-R11-4(Low · 资源管理) · `UserDetailsLoader.CleanByProduct` 扫描缓存前缀的放大效应
+
+**描述**:`SyncPerms` 成功后调用 `UserDetailsLoader.CleanByProduct(ctx, product.Code)`(`internal/logic/pub/syncPermsService.go:163`)。该方法(见 `loaders/userDetailsLoader.go` 实现)通常基于前缀批量失效该产品名下所有用户的详情缓存。
+
+在"单 product <1k 活跃成员"的真实业务量下没有问题;但 `SyncPerms` 属于高频事件(产品每次部署都会触发,一天数十次),且 `CleanByProduct` 会把该产品**所有**活跃用户的缓存(含各自的 `Perms / Member / Role` 计算结果)一次性清掉,使得紧随其后的大量在线请求都会打穿缓存到 DB。
+
+**影响**:
+
+- 正常 SyncPerms(新增一两条 perm)其实只影响"已经引用到这些新 perm 的角色的用户",却连同未动过的 `loadPerms` 缓存一并清空;
+- 产品发版时很容易出现"发版后 5 分钟 TPS 打到 DB,缓存命中率陡降"的尾部抖动。
+
+**修复**:
+
+- 方案一:只在 `Added/Updated/Disabled` 里对"发生状态变化的 code 集合"构建失效名单(按 role → user 反推),代价是扫描关联表(小量级 OK);
+- 方案二:保留当前实现但在 `CleanByProduct` 里做 jitter(比如按 userId 哈希分批清,而非一次性全清),缓解"雪崩清空"。
+
+---
+
+### L-R11-5(Low · 僵尸代码 / 契约一致性) · `RefreshToken` 两条路径的 `newVersion != predictedVersion` 分支实际不可达(重申 L-R10-4)
+
+R10-4 已记录本点。本轮复核确认 HTTP 与 gRPC 两条 RefreshToken 路径(`internal/logic/pub/refreshTokenLogic.go:117-135`、`internal/server/permserver.go:240-251`)的 forensic 分支仍然保留,没有被收敛到一个共享 helper。
+
+**影响**:零运行期影响;纯代码重复 + 维护负担。
+
+**修复**:把"试签 → CAS → Clean → 对比 newVersion"整段抽成 `authHelper.RotateRefreshToken(ctx, svcCtx, claims, ud) (access, refresh string, err error)`,让 HTTP / gRPC 只负责前置校验与错误映射。这样未来 CAS 语义若要微调(例如把预签下沉到 tx 内),两条路径只改一处。
+
+---
+
+## 本轮复核中仍成立的契约(不再修)
+
+列出以下事项作为已定档契约,审计不再要求整改:
 
-| 优先级 | 议题 | 预估工作量 | 风险 |
-| --- | --- | --- | --- |
-| P0 | **M-N1 Loader 半成品降级** | 1 天:新增 sentinel 错误 + 中间件映射 + refreshToken 分支修正 + 2 条新单测 | 中:需验证 5xx 监控告警路径 |
-| P1 | **M-N3 RoleDetail 枚举 oracle** | 半天:`RoleDetail` + `ProductDetail` 合并成"404 或 200" | 低 |
-| P1 | **M-N4 CreateUser 部门链校验** | 半天:对称复用 `UpdateUserLogic` 的校验代码 | 低 |
-| P2 | **M-N2 BatchDel pipeline 化** | 半天:改 `unregisterCacheKey` 支持批量 + 回归压测 | 低 |
-| P2 | **L-N1 ParseWithHMAC 统一** | 半天:替换 2 处调用 + table-driven 用例复用 | 低 |
-| P3 | **L-N2 UpdateUser 目标部门 Status** | 1 小时 | 低 |
-| P3 | **L-N3 AdminLogin IsSuperAdmin 前置** | 1 小时 | 低 |
-| P3 | **L-N4 UpdateStatus 乐观锁** | 2 小时(需调上层一处调用) | 低 |
-| Backlog | **L-3 `DeptId=0` 历史账号迁移** | 需 DBA 配合 | — |
+- **H-1 / R10 复核**:`UserDetail` / `MemberList` 同产品成员可见彼此 `email / phone / remark` —— 产品业务需求已确认保留。
+- **M-4 / R10 复核**:`CreateProduct` 响应体只返回一次性 ticket,真实 `appSecret / adminPassword` 通过 `/fetchInitialCredentials`(超管鉴权 + `GetDelCtx` 原子消费)领取。
+- **M-3 / H-2 / R10 复核**:授角色、管辖决策点 100% 走 NoCache DB 读(`loadFreshMinPermsLevel`),caller 的 `MinPermsLevel` 缓存不参与决策;TTL 不影响越权闭环。
+- **L-R10-4**:RefreshToken 的 `newVersion != predictedVersion` 分支保留 forensic 兜底,本轮新建议(L-R11-5)仅涉及"把两处重复抽象成一处",不改变契约。
+- **L-R10-7**:`PermList` / `RoleList` 对同产品成员可见全量定义。属业务默认约定。
+- **L-R10-8**:`loadPerms` 对 SUPER / ADMIN / DEVELOPER 忽略 DENY 的语义已在 `SetUserPerms` 入口拦截;`DeptType` 动态变动导致旧 DENY 失效的长尾遗留。
+- **L-R10-9**:代理层 X-Forwarded-For 链一致性由运维侧在反代/WAF 上硬约束。
 
 ---
 
-## 📎 审计方法论与覆盖率说明
+## 修复优先级
 
-- 本轮审计共读取并分析 27 个 `.go` 文件(不含 `_test.go` / `_gen.go` / `testdata/`),覆盖 logic(全部)、loaders、server、middleware、model 定制层、consts、util;
-- 复核维度:针对第 8 轮每一条未决项都读取相关 code path 做实际行为确认,避免"报告里写修复实际没改";
-- 新发现筛选原则:仅列出**能构造具体攻击序列或业务损害场景**的项,纯风格性建议已过滤;
-- "几万部门"等非现实场景已按 USER 要求过滤;保留"角色下绑千人"这一现实高频场景作为 M-N2 的论据。
+| 优先级 | 条目 | 理由 |
+| ---- | ---- | ---- |
+| P0 | **H-R11-1** | 涉及密码本身的 last-write-wins;修复即放弃一条会话劫持旁路。下一迭代必修。 |
+| P1 | M-R11-1 | gRPC 入口限流缺口;产品接入方越多风险敞口越大,建议排入下迭代 |
+| P1 | M-R11-3 | write skew 罕见但数据无法自愈,连带修 `UpdateUser` 的锁链建议一次做完 |
+| P2 | M-R11-2 | 性能与观测性改进;顺带提升 `UpdateStatus / IncrementTokenVersion` 的语义清晰度 |
+| P2 | L-R11-1 | 前端易用性;可并到"接口契约梳理"专项 |
+| P3 | L-R11-2 ~ L-R11-5 | 代码质量/性能优化;触及相关文件时顺手处理即可 |
 
+整体代码质量在 10 轮迭代后已高度收敛,本轮只发现**一条** High(H-R11-1,存在可复现的 TOCTOU)与**三条** Medium;核心授权 / 会话 / 数据持久化三条链路的主干逻辑仍然稳健,历史修复契约未发生回退。

+ 12 - 6
perm.api

@@ -55,9 +55,12 @@ type (
 // ==================== Product ====================
 type (
 	CreateProductReq {
-		Code   string `json:"code"`
-		Name   string `json:"name"`
-		Remark string `json:"remark,optional"`
+		Code        string `json:"code"`
+		Name        string `json:"name"`
+		Remark      string `json:"remark,optional"`
+		// 审计 L-R10-1:新建 admin_<code> 用户时必须一并指定部门。若不带部门(DeptId=0),新账号
+		// 在 CheckAddMemberAccess / CreateUser 的 DeptPath 前缀校验下彻底瘫痪,除了改密码外做不了任何管理动作。
+		AdminDeptId int64  `json:"adminDeptId"`
 	}
 	CreateProductResp {
 		Id        int64  `json:"id"`
@@ -280,10 +283,13 @@ type (
 		UserId      int64  `json:"userId"`
 		MemberType  string `json:"memberType"`
 	}
+	// UpdateMemberReq 审计 L-R11-1:memberType / status 改为指针可选,支持"只改状态"或"只改
+	// 成员类型"的部分更新,避免前端被迫先拉 member.detail 再构造完整字段。两字段都为 nil 时
+	// logic 会立即 400 "没有可更新的字段"。
 	UpdateMemberReq {
-		Id         int64  `json:"id"`
-		MemberType string `json:"memberType"`
-		Status     int64  `json:"status,optional"`
+		Id         int64   `json:"id"`
+		MemberType *string `json:"memberType,optional"`
+		Status     *int64  `json:"status,optional"`
 	}
 	RemoveMemberReq {
 		Id int64 `json:"id"`

+ 268 - 0
test-design.md

@@ -1867,3 +1867,271 @@ MySQL (InnoDB) + Redis Cache
 | **合计** | — | **TC-0994 ~ TC-1014(含 TC-0915 重写 / TC-0917 新增)** | **23** | 23 ✅ |
 
 
+## 8. Round 10 审计驱动测试(M-R10-1 / M-R10-2 / M-R10-3 / M-R10-4 / M-R10-5 / L-R10-1 / L-R10-2 / L-R10-10)
+
+> 本轮对第 10 轮审计报告(`audit-report.md`,2026-04-19)中 5 条 Medium 与 3 条已落地 Low 做回归闭环;Low 中 L-R10-3 ~ L-R10-9 或留作"机会性优化"或标注"不修",不入测试用例。
+
+### 8.1 M-R10-1 SyncPerms 事务内 `product.Status` 复核(`internal/logic/pub/syncPermsService.go`)
+
+> 修复目标:`LockByCodeTx` 返回的 row 必须满足 `Status == Enabled`,否则立刻 `SyncPermsError{Code:403}` 出场;防御"产品外层预检查通过 → 事务内被并发 Disable → 仍继续写 sys_perm"的时序漏洞。审计重点是**不涉及安全越权**(`loadPerms` 对 Disabled 产品统一返 nil),但关闭"刚被禁用的产品 1 秒内继续生成 perm diff"的观测与审计假象。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1021(适配) | `syncPerms404_audit_test.go` 原用例 mock `LockByCodeTx` 返回 `SysProduct{Status: 1}` | 原场景保持不变 | 行为不变 | 适配 | P0 | M-R10-1:所有既有 audit 路径都必须显式带 `Status=1`,否则命中 403 分支 |
+| TC-1022(适配) | `syncPermsDedup_audit_test.go` / `syncPermsLogic_mock_test.go` / `syncPermsTxLock_audit_test.go` | 同上 | 行为不变 | 适配 | P0 | M-R10-1:对事务内 Status 复核全覆盖 |
+| TC-1023(适配) | `syncPermissions404_audit_test.go` gRPC 入口 | `LockByCodeTx` 必带 `Status=1` | UnmappedCode 仍走 Internal | 适配 | P0 | M-R10-1:gRPC 层也落入同一契约 |
+
+### 8.2 M-R10-2 `BindRolePerms` / `BindRoles` 的 RMW 串行化(`internal/logic/role/bindRolePermsLogic.go`、`internal/logic/user/bindRolesLogic.go`)
+
+> 修复目标:`"existing 读 → diff → delete+insert"` 整段必须收敛进事务;事务首步分别以 `LockByIdTx(roleId)` 与 `FindOneForUpdateTx(memberId)` 锁住 RMW 目标行。两个 admin 并发完全覆盖时只允许"A 完整覆盖 → B 基于 A 的最终态覆盖"交错,消除第三态(`[1,3,4]` 这类混合结果)。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1024 | `BindRolePerms` 事务首步调用 `LockByIdTx` 锁 `sys_role` 行,再走 `FindPermIdsByRoleIdTx` 读 diff 基准 | gomock 记录调用顺序 | `TransactCtx → LockByIdTx → FindPermIdsByRoleIdTx → DeleteByRolePermTx → BatchInsertWithTx` | 并发/契约 | P0 | M-R10-2:`bindRolePermsLogic_mock_test.go` 已更新 |
+| TC-1025 | `BindRolePerms` post-commit cache 清理失败仍 Success | `cache Clean` 返 error | 响应 Success;事务内 mock 顺序保持 M-R10-2 | 并发/契约 | P0 | `postCommitCacheDegraded_audit_test.go` 已按 M-R10-2 全量重写 mock |
+| TC-1026 | `BindRoles` 事务首步 `FindOneForUpdateTx(memberId)` 锁 `sys_product_member` 行,再走 `FindRoleIdsByUserIdForProductTx` | gomock 记录调用顺序 | `TransactCtx → FindOneForUpdateTx → FindRoleIdsByUserIdForProductTx → DeleteByUserIdAndRoleIdsTx → BatchInsertWithTx` | 并发/契约 | P0 | M-R10-2:`bindRolesLogic_mock_test.go` 已更新 |
+
+> 备注:真实 MySQL 下的"两 admin 并发覆盖 → 串行化 last-write-wins"由 InnoDB `SELECT ... FOR UPDATE` 语义保证,不再重复单测;契约侧由 mock 调用顺序闭环。
+
+### 8.3 M-R10-3 `LoadCallerAssignableLevel` 把批量分配 N 次 DB 读压缩为 1 次(`internal/logic/auth/access.go`、`internal/logic/user/bindRolesLogic.go`)
+
+> 修复目标:caller 在一次请求内不变 → `loadFreshMinPermsLevel` 结果也不变;新增 `AssignableLevelSnapshot` + `LoadCallerAssignableLevel` + `CheckRoleLevelAgainst` API,在 `BindRoles` 遍历 roles 前打 1 次 DB,循环内做常数时间比较。同时缩小"超管在 loop 中途降级 caller"的 TOCTOU 窗口(原本每次 loop 都重读 → N 个窗口 → 现在 1 个窗口)。
+
+对应测试文件:`internal/logic/auth/loadCallerAssignableLevel_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1017 | SuperAdmin / ADMIN / DEVELOPER 走 `HasFullPerms` 短路 | 3 条子用例分别构造不同 caller | `HasFullPerms=true`;`FindMinPermsLevelByUserIdAndProductCode` **不得**被调用(gomock 无 EXPECT 命中即 fail) | 性能/契约 | P0 | M-R10-3:全权调用者零 DB 成本 |
+| TC-1018 | MEMBER caller 仅打 1 次 DB,循环内对 5 个角色走 `CheckRoleLevelAgainst` 不再打 DB | mock `FindMin` `Times(1)`;本地用 5 个 role level 做比较 | Times(1) 断言命中;同级/更高级角色拒 403;严格低级角色通过 | 性能/安全 | P0 | M-R10-3:`Times(1)` 是核心断言,一旦循环内误打 DB 会命中"unexpected call" |
+| TC-1019 | caller `ErrNotFound` → `NoRole=true`,不翻 500 | mock 返 `sqlx.ErrNotFound` | `snap.NoRole=true`;`CheckRoleLevelAgainst(999)` 仍 403 "没有可分配的角色等级" | 契约 | P1 | M-R10-3:与 `loadFreshMinPermsLevel` 的口径对称,保留 `ErrNotFound → 最低级` 的原契约 |
+| TC-1020 | caller 其他 DB 错误 → fail-close 500 | mock 返通用 `error` | `CodeError.Code()==500` | 安全 | P0 | M-R10-3:DB 抖动不得被同化为"无角色 → 最低级"放行,保持与 L-4 修复一致 |
+
+### 8.4 M-R10-4 `ChangePassword` 把 `ErrUpdateConflict` 显式映射 409(`internal/logic/auth/changePasswordLogic.go`)
+
+> 修复目标:与 `UpdateUserLogic.UpdateProfile` / `UpdateUserStatusLogic.UpdateStatus` / `UpdateRoleLogic.UpdateWithOptLock` 口径对齐,底层 `userModel.ErrUpdateConflict` 显式回 409 + "密码已被其他会话修改,请刷新后重试";修复前 raw error 被 rest 框架兜成 500,前端会把"并发冲突"误判为系统故障,告警看板也会把这类事件归到 5xx 噪声池。
+
+对应测试文件:`internal/logic/auth/changePasswordConflict_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1015 | `UpdatePassword` 返回 `ErrUpdateConflict` 时必须回 409 | mock `UpdatePassword → ErrUpdateConflict` | `CodeError.Code()==409`;文案含 "密码已被其他会话修改" | 契约 | P0 | M-R10-4:主路径断言 |
+| TC-1016 | 非 `ErrUpdateConflict` 的 raw error 仍需透传(由 rest 兜 500) | mock `UpdatePassword → errors.New("driver: bad connection")` | `errors.Is(err, genericErr)==true`;**不是** CodeError(不得被误吞为 409) | 反向契约 | P0 | M-R10-4:防止修复把所有错误都误包为 409 |
+
+### 8.5 M-R10-5 登录 / 权限设置对目标 member 的重复查询去重(`internal/logic/pub/loginService.go`、`internal/logic/auth/access.go`、`internal/logic/user/setUserPermsLogic.go`)
+
+> 修复目标:①  `loginService` 不再额外打 `FindOneByProductCodeUserId`,改由 `UD.MemberType==""` 判定"非成员或已禁用成员"统一走同一分支;② `checkPermLevel` 新增 `WithMemberSink` option,把已查到的 `targetMember` 写回调用方,让 `SetUserPerms` 避免对同一 `(productCode, userId)` 的二次 FindOne。同时把"非成员"与"成员已禁用"的对外文案合并为"您不是该产品的有效成员"——关闭一条枚举 oracle。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1027(重写) | `TestLogin_NonMemberWithProductCode` | 用户在 `productCode` 下非成员 | `CodeError.Code()==403`;文案 "您不是该产品的有效成员" | 安全/Oracle | P0 | M-R10-5:与"禁用成员"同文案 |
+| TC-1028(重写) | `TestLogin_DisabledMemberRejected` | 用户成员资格 `Status=Disabled` | 同上 | 安全/Oracle | P0 | M-R10-5:两条分支合并成一条路径 |
+
+### 8.6 L-R10-1 `CreateProduct` 必填 `AdminDeptId`(`internal/logic/product/createProductLogic.go`、`internal/types/types.go`、`perm.api`)
+
+> 修复目标:初始 admin 账号现在必须挂载到一个 `Status=Enabled` 的真实部门;否则(原 DeptId=0 方案)admin 首次登录后除了改密外**什么都做不了**(无法 AddMember / CreateUser / 自改部门)。Bundle 契约:`CreateProductReq.AdminDeptId` 为必填;Logic 入口 `FindOne(AdminDeptId)` + `Status != Enabled → 400`。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1029(新增 helper) | `seedAdminDept(t, ctx, svcCtx)` 集中化 | 单次调用插一条启用部门 + `t.Cleanup` | 返回 deptId;测试结束自动清理 | 基础 | — | L-R10-1:`internal/logic/product/helper_test.go` |
+| TC-1030(适配) | 所有 `createProductLogic_test.go` 的正向用例 | 入参带 `AdminDeptId=seedAdminDept(...)` | 行为不变 | 适配 | P0 | L-R10-1:契约变更全量回归 |
+| TC-1031(适配) | `fetchInitialCredentialsLogic_audit_test.go` | 同上 | 行为不变 | 适配 | P0 | L-R10-1:票据消费路径不破坏 |
+| TC-1032(适配) | `createProductCompensation_audit_test.go` | 同上,补偿路径 | 行为不变 | 适配 | P0 | L-R10-1:Redis 降级后的补偿链保留 |
+| TC-1033(适配) | `createProductConflict_audit_test.go` | mock `SysDeptModel.FindOne` 返 `Status=1` | 行为不变;`AdminDeptId` 透传 | 适配 | P0 | L-R10-1:mock 侧补齐 |
+| TC-1034(适配) | `createProductLogic_mock_test.go` | 同上,两处 `CreateProductReq` | 行为不变 | 适配 | P0 | L-R10-1:mock 侧补齐 |
+
+### 8.7 L-R10-2 `CreateProduct` 初始 adminPassword 升级为强密码(`internal/logic/product/createProductLogic.go`)
+
+> 修复目标:`generateRandomHex(12)`(24 字节 `0-9a-f`,仅小写+数字)被替换为 `generateStrongInitialPassword(16)`(大小写 + 数字 + 符号),长度恒为 16,并在构造时通过 `util.ValidatePassword` 断言——为未来可能上线的"存量密码强度合规审查"留口径。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1035(重写) | `TestFetchInitialCredentials_HappyPath` | 读取一次性票据中的 `AdminPassword` | `len(cred.AdminPassword)==16`(不是旧的 24) | 契约 | P1 | L-R10-2:长度断言回归 |
+
+### 8.8 L-R10-10 gRPC `GetUserPerms` 合并"userId 不存在"与"非成员"枚举 oracle(`internal/server/permserver.go`)
+
+> 修复目标:`ud.Username == ""`(用户不存在)与 `ud.MemberType == ""`(非成员/禁用成员)合并为同一 `codes.NotFound` + "用户不是该产品的有效成员",关闭"持合法 appKey 遍历 userId 区分全局 sys_user 存在性"的枚举旁路。对上游调用方(受信的产品服务端)影响面极小,但修掉之后多租户语义也更干净。
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1036(重写) | `TestGetUserPerms_UserNotFound` | userId 全局不存在 | `status.Code()==NotFound` + "用户不是该产品的有效成员" | 安全/Oracle | P0 | L-R10-10:与"非成员"同响应 |
+| TC-1037(重写) | `TestGetUserPerms_NonMember_PermissionDenied` | userId 存在但非成员 | 同上(status 码由 `PermissionDenied` 改为 `NotFound`) | 安全/Oracle | P0 | L-R10-10:与"userId 不存在"同响应 |
+| TC-1038(重写) | `TestGetUserPerms_DisabledMemberInDevDept_PermissionDenied` | 成员 `Status=Disabled` | 同上 | 安全/Oracle | P0 | L-R10-10:禁用成员走 `loadMembership` 清空 MemberType → 同路径 |
+
+### 8.9 Round 10 既有测试兼容性调整(本轮代码变更触发)
+
+| 用例 | 文件 | 调整说明 |
+| :--- | :--- | :--- |
+| `syncPerms*` 四个 audit 文件 | `internal/logic/pub/syncPerms404_audit_test.go`、`syncPermsDedup_audit_test.go`、`syncPermsLogic_mock_test.go`、`syncPermsTxLock_audit_test.go` | M-R10-1:`LockByCodeTx` mock 必须返 `SysProduct{Status: 1}`,否则事务内 Status 复核直接 403 让下游 EXPECT 全部不生效 |
+| `syncPermissions404_audit_test.go`(gRPC 入口) | `internal/server/syncPermissions404_audit_test.go` | 同上;保证"UnmappedCode 仍走 Internal"的原契约依然可验 |
+| `bindRolePermsLogic_mock_test.go` | `internal/logic/role/` | M-R10-2:事务内以 `LockByIdTx` 锁 `sys_role` 行;existing 读改为 `FindPermIdsByRoleIdTx` |
+| `bindRolesLogic_mock_test.go` | `internal/logic/user/` | M-R10-2:事务首步 `FindOneForUpdateTx(memberId)`;existing 读改为 `FindRoleIdsByUserIdForProductTx` |
+| `postCommitCacheDegraded_audit_test.go` | `internal/logic/role/` | M-R10-2:整段 mock 顺序重写为 `TransactCtx → LockByIdTx → FindPermIdsByRoleIdTx → …` |
+| `permserver_test.go` | `internal/server/` | L-R10-10:3 条既有 TC 的 status code 与文案统一收敛;M-R10-5:1 条 Login 分支文案统一 |
+| `loginLogic_test.go` | `internal/logic/pub/` | M-R10-5:非成员 / 禁用成员两条分支合并为同一错误消息 |
+
+### 8.10 Round 10 未进入测试的条目(按审计意见归档)
+
+| 审计条目 | 归档结论 |
+| :--- | :--- |
+| L-R10-3 `IncrementTokenVersion` 非 CAS 路径 `(0, nil)` 静默返回 | 本轮未改代码;`Logout` 仅把返回值作为日志副作用,审计意见是"面向未来的返回语义收敛",不入 TC |
+| L-R10-4 RefreshToken `newVersion != predictedVersion` 死码 | 审计定档"保留(defence in depth)";不入 TC |
+| L-R10-5 DeptTree 对普通成员暴露 `Status` | 审计定档"业务需求决定是否收敛";本仓库未变更;不入 TC |
+| L-R10-6 `CountOtherActiveAdminsTx` 用 `SELECT id FOR UPDATE + len(ids)` | 审计定档"可忽略代码风格优化";本仓库未变更;不入 TC |
+| L-R10-7 PermList / RoleList 对同产品成员完整可见 | 审计定档"业务默认契约";不入 TC |
+| L-R10-8 `loadPerms` 对全权分支忽略 `sys_user_perm` DENY | 审计定档"等 DeptType 动态性讨论后统一处理";不入 TC |
+| L-R10-9 `ExtractClientIP` XFF 信任边界 | 审计定档"运维反代层硬约束即可",代码侧无变更;不入 TC |
+
+### 8.11 Round 10 新增 TC 汇总
+
+| 审计条目 | 文件 | TC 编号区间 | 数量 | 状态 |
+| :--- | :--- | :--- | ---: | :---: |
+| M-R10-1 | `syncPerms*`(4 文件) / `syncPermissions404_audit_test.go` | TC-1021 ~ TC-1023(适配) | 3 | ✅ |
+| M-R10-2 | `bindRolePermsLogic_mock_test.go` / `bindRolesLogic_mock_test.go` / `postCommitCacheDegraded_audit_test.go` | TC-1024 ~ TC-1026 | 3 | ✅ |
+| M-R10-3 | `loadCallerAssignableLevel_audit_test.go`(新增) | TC-1017 ~ TC-1020 | 4 | ✅ |
+| M-R10-4 | `changePasswordConflict_audit_test.go`(新增) | TC-1015 / TC-1016 | 2 | ✅ |
+| M-R10-5 | `loginLogic_test.go` / `permserver_test.go` | TC-1027 / TC-1028 | 2 | ✅ |
+| L-R10-1 | `helper_test.go`(新增)+ 5 文件适配 | TC-1029 ~ TC-1034 | 6 | ✅ |
+| L-R10-2 | `fetchInitialCredentialsLogic_audit_test.go` | TC-1035 | 1 | ✅ |
+| L-R10-10 | `permserver_test.go` | TC-1036 ~ TC-1038 | 3 | ✅ |
+| **合计** | — | **TC-1015 ~ TC-1038(跳号:TC-1015/1016 在 M-R10-4;TC-1017 ~ 1020 在 M-R10-3;TC-1021 起按文件模块编号)** | **24** | 24 ✅ |
+
+---
+
+## 九、Round 11 审计驱动新增用例 (TC-1039 ~ TC-1071)
+
+> 本轮审计聚焦 8 个修复点:H-R11-1(UpdatePassword TOCTOU)、M-R11-1(gRPC SyncPermissions / GetUserPerms 入口限流)、M-R11-2(UpdateStatus / IncrementTokenVersion 停用内部 FindOne)、M-R11-3(UpdateUser deptId 切换 vs DeleteDept 的 write skew)、L-R11-1(UpdateMember memberType/status 指针语义)、L-R11-2(Delete 族 SELECT 只取 cache key 列)、L-R11-4(SyncPerms 纯新增不触发 CleanByProduct)、L-R11-5(RotateRefreshToken helper 跨 HTTP/gRPC 共享)。详细修复背景见 [audit-report.md](./audit-report.md)。
+
+### 9.1 H-R11-1 · `UpdatePassword` 把 `expectedUpdateTime` 改由调用方显式传入(TC-1039 ~ TC-1043)
+
+> 修复目标:旧实现 `UpdatePassword` 在内部自己 `FindOne` 拿 `updateTime` 再做 CAS,导致"外层校验旧密码的时间戳" 与"内层 UPDATE 比对的时间戳" 不是同一个快照,并发改密退化为 last-write-wins,一个持有旧密码的会话可以把别人紧急改过的新密码覆盖回去。新签名:`UpdatePassword(ctx, id, username, password, mustChangePassword, expectedUpdateTime)`;调用方 `ChangePasswordLogic` 显式把"外层 FindOne 拿到的 user.UpdateTime" 透传进来,并把 `ErrUpdateConflict` 显式映射 409(与 M-R10-4 同口径)。
+
+对应测试文件:
+- `internal/model/user/updatePasswordToctou_audit_test.go`(新增)
+- `internal/logic/auth/changePasswordToctou_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1039 | Model 层正向:`expectedUpdateTime` 与 DB 一致 → 成功 + tokenVersion+1 + updateTime 前进 | 直接调 `UpdatePassword(id, username, newHash, MustChangePasswordNo, existing.UpdateTime)` | `err==nil`;再 FindOne → password/updateTime/tokenVersion 全部按预期前进 | 契约 | P0 | H-R11-1:happy path 钉死新签名语义 |
+| TC-1040 | Model 层 TOCTOU:调用方持有"陈旧的 expectedUpdateTime" | Session A 持 updateTime=T0;Session B 先成功改密 → DB updateTime=T1;Session A 再用 T0 调 `UpdatePassword` | `errors.Is(err, ErrUpdateConflict)`;DB password/updateTime 仍然是 B 的结果,未被回写 | 并发/数据完整性 | P0 | H-R11-1:核心反回归——若回退到"内部自 FindOne",这里会误成功 |
+| TC-1041 | Model 层并发:同一 expectedUpdateTime 两 goroutine 并行 CAS | 2 个 goroutine 共享 T0,并发 `UpdatePassword` | 恰好 1 个成功、1 个 `ErrUpdateConflict`;DB 最终密码 = 赢者的密码;tokenVersion 只 +1 不是 +2 | 并发/契约 | P0 | H-R11-1:并发单胜者;`tokenVersion` 被累计两次会立即暴露退化 |
+| TC-1042 | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | 第一次 P0→P1(200),第二次仍送 oldPass=P0 | 第二次 `CodeError.Code()==400`,msg 含 "旧密码错误";**不得**成 409(否则 400/409 语义混淆) | 边界 | P0 | H-R11-1:400/409 分桶契约 |
+| TC-1043 | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | mock `UpdatePassword(id, username, _, MustChangePasswordNo, snapshotUpdateTime)`,断言第 5 个实参 | mock EXPECT 命中;若回退为"Model 内部再读 updateTime",这里会拿到零值触发失败 | 契约 | P0 | H-R11-1:CAS 快照来源契约 |
+
+### 9.2 M-R11-1 · gRPC `SyncPermissions` / `GetUserPerms` 入口限流(TC-1051 ~ TC-1054)
+
+> 修复目标:`PermServer.SyncPermissions` / `PermServer.GetUserPerms` 此前完全无入口限流,`bcrypt.Compare(appSecret)` 的 CPU 成本 + `LockByCodeTx` 的事务级 X 锁都在校验路径里,一条合法 appKey 爆破足以把整个进程 CPU 或 MySQL 连接池打穿;新增 `GrpcSyncLimiter` / `GrpcGetUserPermsLimiter`(均基于 `limit.PeriodLimit`),分别以 `appKey` 桶和 `appKey + 源 IP` 双桶限速;`OverQuota` 显式回 `codes.ResourceExhausted` + "请求过于频繁,请稍后再试"。关键防御:**空 AppKey 走 `if req.AppKey != "" { Take(...) }` 前置分支,不消耗 limiter 配额**,避免恶意方用空串把 limiter 的 keyspace 污染成"永不过期的空串桶"。
+
+对应测试文件:`internal/server/grpc_rate_limit_mr11_1_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1051 | `SyncPermissions` 同 appKey 连打 `quota+1` 次触发 `ResourceExhausted` | `GrpcSyncLimiter = NewPeriodLimit(60, 1, rds, uniqPrefix)`;同一 appKey 连续 2 次调用 | 第 1 次走业务层(Unauthenticated,因 appKey 不真实);第 2 次 `codes.ResourceExhausted` | 安全/限流 | P0 | M-R11-1:appKey 桶命中上限 |
+| TC-1052 | `GetUserPerms` 同 appKey 连打 `quota+1` 次触发 `ResourceExhausted` | 同上 limiter 套给 `GrpcGetUserPermsLimiter`;同一 appKey 连续 2 次 | 第 2 次 `codes.ResourceExhausted` | 安全/限流 | P0 | M-R11-1:`GetUserPerms` 双桶中的 appKey 桶 |
+| TC-1053 | 空 `AppKey` 不消耗 limiter 配额 | `AppKey=""` 连打 3 次;然后真实 `realKey` 首次请求 | 3 次空串请求都 `codes.Unauthenticated`(走 FindOneByAppKey("") → ErrNotFound),`realKey` 首次仍命中业务层 `codes.Unauthenticated`(**不是** `ResourceExhausted`) | 安全/防污染 | P0 | M-R11-1:空串前置分支缺失会把 limiter 计数器打到上限 |
+| TC-1054 | `GetUserPerms` 同一 IP、多个不同 appKey → 命中 IP 桶 | `GrpcGetUserPermsLimiter=NewPeriodLimit(60, 2, ...)`;同 IP 依次用 appKey `"a"/"b"/"c"` 发请求 | 第 3 次命中 IP 桶上限 `codes.ResourceExhausted`(尽管每个 appKey 桶都还有额度) | 安全/限流 | P0 | M-R11-1:`appKey + IP` 双桶叠加 |
+
+### 9.3 M-R11-2 · `UpdateStatus` / `IncrementTokenVersion` 停用内部 FindOne 并显式接收 username(TC-1044 ~ TC-1048)
+
+> 修复目标:`UpdateStatus` / `IncrementTokenVersion` 原本在函数体内 `FindOne` 取 `username` 做 cache key 失效;修复后改为**调用方把 `username` 透传进来**,Model 层不再打第二次 DB 读。由上游 `LogoutLogic` / `UpdateUserStatusLogic` 从中间件 `UserDetails` 里直接取 `Username`/`UpdateTime` 后透传。反向契约:哪怕调用方传入"错误的 username",Model 层也只会清理"这个错误 username" 对应的那个 cache 槽,保证了"model 层绝不再自己 FindOne"。
+
+对应测试文件:
+- `internal/model/user/mr11_2_noInternalFindOne_audit_test.go`(新增)
+- `internal/logic/auth/logoutUsernameForward_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1044 | `UpdateStatus` 用"错误 username" 调用 → Model 层仍按错误 key 清理 | 真实 username="u1";调 `UpdateStatus(id, "WRONG", ...)`;预置 `u1` 和 `WRONG` 两个 cache 槽 | 只有 `WRONG` 对应的 cache 槽被删;`u1` 的 cache 槽仍在(证明 Model 层**没有**自己 FindOne 纠正 username) | 契约/反向 | P0 | M-R11-2:内部二次 FindOne 回退一跑即红 |
+| TC-1045 | `IncrementTokenVersion` 用"错误 username" 调用 → 同上 | 同上 | 同上;DB tokenVersion 正常前进 | 契约/反向 | P0 | M-R11-2:IncrementTokenVersion 分支 |
+| TC-1046 | `IncrementTokenVersion` 对"已被删除的行" 仍正常走 RowsAffected=0 → `ErrUpdateConflict` 分支 | 先 Insert 再 Delete,随后调 `IncrementTokenVersion(deletedId, "anyName", deletedUpdateTime)` | `errors.Is(err, ErrUpdateConflict)`;**不**触发 panic / nil user 崩溃(证明没有 FindOne 前置) | 契约/边界 | P0 | M-R11-2:删后 CAS 的边界 |
+| TC-1047 | Logic 层 `Logout` 必须把 `ud.Username` 透传到 Model | mock `IncrementTokenVersion(id, "u1", _)`,ud.Username="u1" | mock EXPECT 命中,**不得**出现任何 `FindOne` mock 调用 | 契约 | P0 | M-R11-2:Logout 口径 |
+| TC-1048 | Logic 层 `Logout` 即使 `ud.Username==""` 也必须透传(空串) | mock `IncrementTokenVersion(id, "", _)` | mock EXPECT 命中,空串仍被透传(Model 层自负其责) | 契约/边界 | P1 | M-R11-2:不在 Logic 层搬运内部补全 |
+
+### 9.4 M-R11-3 · `UpdateUser` deptId 切换 vs `DeleteDept` 的 write skew 闭合(TC-1049 ~ TC-1050)
+
+> 修复目标:之前 `UpdateUser` 在"切换 deptId 到目标 dept" 前只做外层 `FindOne`,没拿住"目标 dept 在事务提交瞬间仍然启用"的 S 锁;并发的 `DeleteDept` 完全可以"你校验时我还在 → 你进事务我已删" → 提交出一个"dept 已删,但 user 的 deptId 还指向它" 的 write skew 残片。修复引入 `SysDeptModel.FindOneForShareTx`(`SELECT ... LOCK IN SHARE MODE`),新签名 `SysUserModel.UpdateProfileWithTx`,业务把"读目标 dept → 复核 status=Enabled → UPDATE user" 整段收敛进一个事务。
+
+对应测试文件:`internal/logic/user/updateUserWriteSkew_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1049 | deptId 切换场景下并发 DeleteDept 被"S 锁 / X 锁"串行化 | 起始 userDeptId=dA;并发 goroutine:A 做 `UpdateUser{DeptId:dB}`,B 做 `DeleteDept(dB)`;多轮 | 总是 2 个分支之一:① A 先进 → B 看到 dB 有成员 → `ErrHasUsers`;② B 先进 → A 看到 dB.status=Disabled/已删 → `ErrBadRequest` 或 404。**绝不**出现 "A 成功 + B 成功 + user.deptId=dB(已删)" 的 skew 残片(直接查 DB 做断言,绕过 cache) | 并发/数据完整性 | P0 | M-R11-3:核心反回归 |
+| TC-1050 | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | 构造"只改 nickname、deptId 不变" 的更新 | 事务只走 `UpdateProfileWithTx`;`SysDeptModel.FindOneForShareTx` 未被打到(观察事务 SQL / mock 无 expect) | 契约/性能 | P1 | M-R11-3:避免"无切换时也打 S 锁" 导致退化 |
+
+### 9.5 L-R11-1 · `UpdateMember` memberType/status 指针语义(TC-1055 ~ TC-1060)
+
+> 修复目标:`UpdateMemberReq.MemberType` / `UpdateMemberReq.Status` 由"空字符串 / 零值 = 不修改" 的隐式约定升级为 `*string` / `*int64`,避免"用户把字段显式设成 `""` / `0`" 被误当成"不更新"。两个字段同时 nil 时 `UpdateMemberLogic` 直接 `ErrBadRequest(400)`。
+
+对应测试文件:`internal/logic/member/updateMemberPartialPointer_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1055 | `MemberType==nil && Status==nil` → 400 "请至少提供一个要更新的字段" | `UpdateMemberReq{productCode, userId}` 两字段都不传 | `CodeError.Code()==400`,msg 含 "至少提供一个要更新的字段" | 契约 | P0 | L-R11-1:nil 判定入口 |
+| TC-1056 | 只传 `Status`,`MemberType` 保持不变 | `{Status: Int64Ptr(2)}`,原 member.MemberType="ADMIN" | DB:`memberType` 仍是 "ADMIN",`status=2` | 契约 | P0 | L-R11-1:部分更新语义 |
+| TC-1057 | 只传 `MemberType`,`Status` 保持不变 | `{MemberType: StrPtr("DEVELOPER")}`,原 member.Status=1 | DB:`memberType` 变为 "DEVELOPER",`status` 仍是 1 | 契约 | P0 | L-R11-1:镜像对称 |
+| TC-1058 | DEVELOPER → 只改 Status 时跳过"分配校验" | 只传 `Status=1`,member.MemberType="DEVELOPER" | 不走分配校验分支;`memberType` 保持 DEVELOPER;状态落盘为 1 | 契约/性能 | P1 | L-R11-1:DEVELOPER 分支被误挂会立即红 |
+| TC-1059 | 非法 Status 值(例如 7)→ 400 | `{Status: Int64Ptr(7)}` | `CodeError.Code()==400` | 边界 | P0 | L-R11-1:Status 枚举防御 |
+| TC-1060 | 完全 no-op(传进来的值与 DB 现值相同)→ 返 nil 且 updateTime 不前进 | 传 `{Status: Int64Ptr(member.Status)}` | err==nil;DB updateTime 保持原值 | 契约/幂等 | P1 | L-R11-1:MySQL 行为——值未变 RowsAffected=0,不被误升格为冲突 |
+
+### 9.6 L-R11-2 · Delete 族 SELECT 只取 cache key 列(TC-1061 ~ TC-1062)
+
+> 修复目标:`DeleteByRoleIdTx` / `DeleteByUserIdAndRoleIdsTx` / `DeleteByUserIdForProductTx` / `DisableNotInCodesWithTx` 在"先读后删" 模式里以前用 `SELECT *` 吸满全行,但只用到 `id` + cache key 所需两三列;改为只 `SELECT id, userId, roleId` 等(按表而定),节省 MySQL → Go 内存拷贝、降低 net bytes,同时不改变缓存语义。要求所有"id 级 + 组合 key"cache 槽都被删除。
+
+对应测试文件:`internal/model/userrole/deleteCacheKey_r11_2_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1061 | `DeleteByRoleIdTx` 删除多行后,id 级和组合 key 缓存全部失效 | 预置 3 条 `sys_user_role(roleId=R)`,先 `FindOne` / 组合 key `FindOne` 把所有 cache 槽填热 | 删除后 `FindOne(id)` 均返 `ErrNotFound`;`FindOneByUserIdRoleId(userId, R)` 也均返 `ErrNotFound`;无脏读 | 契约/缓存 | P0 | L-R11-2:两套 key 都得失效 |
+| TC-1062 | `DeleteByUserIdAndRoleIdsTx` 只删指定 (userId, roleIds) 集合后同上 | 预置多条;删除部分;剩余部分保留 | 被删的全返 `ErrNotFound`;未被删的 `FindOne` 正常命中;两层 cache key 对齐 | 契约/缓存 | P0 | L-R11-2:组合 key 的定点失效不误伤 |
+
+### 9.7 L-R11-4 · `SyncPerms` 纯新增时不触发 `CleanByProduct`(TC-1063 ~ TC-1065)
+
+> 修复目标:`ExecuteSyncPerms` 以前只要事务提交就无条件调 `UserDetailsLoader.CleanByProduct(productCode)`,哪怕这次同步**只新增**权限声明——新增对"既有 user 的可用权限集合"没有任何副作用,却会触发全产品用户的 perms cache 级联失效,对大产品是一次可观测抖动。修复为:仅当 `updated > 0 || disabled > 0` 才调 `CleanByProduct`。
+
+对应测试文件:`internal/logic/pub/syncPermsCleanByProduct_r11_4_audit_test.go`(新增)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1063 | 纯新增(updated=0, disabled=0)→ **不**触发 CleanByProduct | 预先在 Redis 设置 `productIndexKey` canary;执行 `ExecuteSyncPerms(perms=全新 codes)` | canary 仍在 Redis(未被 CleanByProduct 删除);`added>0` | 契约/性能 | P0 | L-R11-4:主反回归 |
+| TC-1064 | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | 预置 canary + 一条已有 perm;然后 sync 带同 code 但改名 | canary 被删除(CleanByProduct 触达);`updated>0` | 契约 | P0 | L-R11-4:update 路径 |
+| TC-1065 | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | 同上但 sync 不传原 code;旧 perm 被禁用 | canary 被删除;`disabled>0` | 契约 | P0 | L-R11-4:disable 路径 |
+
+### 9.8 L-R11-5 · `RotateRefreshToken` helper 在 HTTP/gRPC 共享(TC-1066 ~ TC-1071)
+
+> 修复目标:`internal/logic/pub/refreshTokenLogic.go`(HTTP)与 `internal/server/permserver.go` 的 gRPC `RefreshToken` 之前各自实现 "try sign → CAS → Clean → forensic compare",一旦两端漂移就会出现"HTTP 签出的 refreshToken 在 gRPC 校验被拒" 这种隐蔽的跨协议断层。修复抽出 `authHelper.RotateRefreshToken(ctx, svcCtx, claims, ud)` 作为唯一事实源;HTTP/gRPC 都调同一个 helper,错误分型由 helper 负责区分 `ErrTokenVersionMismatch` 与其他内部错误,由各自入口映射 401 / `codes.Unauthenticated`。
+
+对应测试文件:
+- `internal/logic/auth/rotateRefreshToken_r11_5_audit_test.go`(新增,helper 直测)
+- `internal/server/grpcHttpRotateInterop_r11_5_audit_test.go`(新增,跨协议互认)
+
+| TC 编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1066 | helper happy path:返回新 access+refresh、DB tokenVersion +1、user cache Clean | 合法 claims + ud + 真实 DB | `tokens.AccessToken != ""`;DB tokenVersion 前进 1;`UserDetailsLoader.Load(id)` 必须重新打 DB(cache 已 Clean) | 契约 | P0 | L-R11-5:核心正向 |
+| TC-1067 | helper:`claims.TokenVersion` 与 DB 不一致 → `ErrTokenVersionMismatch` | claims.TokenVersion=0,DB=5 | `errors.Is(err, userModel.ErrTokenVersionMismatch)`;DB tokenVersion 不变;`tokens` 零值 | 安全 | P0 | L-R11-5:CAS mismatch 不升版 |
+| TC-1068 | helper:用户已删除 → `ErrTokenVersionMismatch` | 先 Insert 再 Delete;claims 携带该 id | 同上 error;不 panic | 边界 | P0 | L-R11-5:删后 CAS |
+| TC-1069 | 跨协议互认:HTTP 签出的 refreshToken 能被 gRPC RefreshToken 无缝续签 | HTTP 首刷成功(v0→v1,拿到 newRt1);把 newRt1 直接灌到 gRPC | gRPC 返 `NoError`;新 tokens 非空;DB tokenVersion 再 +1(v1→v2) | 契约/集成 | P0 | L-R11-5:核心反漂移 |
+| TC-1070 | 跨协议互认:gRPC 签出的 refreshToken 能被 HTTP RefreshToken 无缝续签 | gRPC 先刷(v0→v1);把新 rt 灌到 HTTP | HTTP `err==nil`,DB 前进(v1→v2) | 契约/集成 | P0 | L-R11-5:对称镜像 |
+| TC-1071 | gRPC 重放:旧 rtV0 已被用过一次,再发给 gRPC 必须 Unauthenticated(而非 Internal) | gRPC 首刷成功;再用**同一个** rtV0 调 gRPC | `status.Code()==codes.Unauthenticated`;Msg 含 "登录状态已失效" | 安全/错误映射 | P0 | L-R11-5:`ErrTokenVersionMismatch` 的协议映射 |
+
+### 9.9 Round 11 新增 TC 汇总
+
+| 审计条目 | 文件 | TC 编号区间 | 数量 | 状态 |
+| :--- | :--- | :--- | ---: | :---: |
+| H-R11-1 | `updatePasswordToctou_audit_test.go`(新增) / `changePasswordToctou_audit_test.go`(新增) | TC-1039 ~ TC-1043 | 5 | ✅ |
+| M-R11-2 | `mr11_2_noInternalFindOne_audit_test.go`(新增) / `logoutUsernameForward_audit_test.go`(新增) | TC-1044 ~ TC-1048 | 5 | ✅ |
+| M-R11-3 | `updateUserWriteSkew_audit_test.go`(新增) | TC-1049 ~ TC-1050 | 2 | ✅ |
+| M-R11-1 | `grpc_rate_limit_mr11_1_audit_test.go`(新增) | TC-1051 ~ TC-1054 | 4 | ✅ |
+| L-R11-1 | `updateMemberPartialPointer_audit_test.go`(新增) | TC-1055 ~ TC-1060 | 6 | ✅ |
+| L-R11-2 | `deleteCacheKey_r11_2_audit_test.go`(新增) | TC-1061 ~ TC-1062 | 2 | ✅ |
+| L-R11-4 | `syncPermsCleanByProduct_r11_4_audit_test.go`(新增) | TC-1063 ~ TC-1065 | 3 | ✅ |
+| L-R11-5 | `rotateRefreshToken_r11_5_audit_test.go`(新增) / `grpcHttpRotateInterop_r11_5_audit_test.go`(新增) | TC-1066 ~ TC-1071 | 6 | ✅ |
+| **合计** | — | **TC-1039 ~ TC-1071** | **33** | 33 ✅ |
+
+### 9.10 Round 11 既有测试兼容性适配(本轮签名变更触发)
+
+| 适配项 | 文件 | 调整说明 |
+| :--- | :--- | :--- |
+| `SysUserModel.UpdatePassword` / `UpdateStatus` / `IncrementTokenVersion` 签名新增 `username`、部分路径新增 `expectedUpdateTime` | `internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go`、`internal/model/user/incrementTokenVersion_audit_test.go`、`internal/logic/user/updateUserStatusOptLock_audit_test.go`、`internal/logic/auth/changePasswordConflict_audit_test.go` | 入参全部补齐 `user.Username` / `user.UpdateTime`;原"用户不存在" 的 `ErrNotFound` 期望改为 `ErrUpdateConflict`(现行实现用 RowsAffected=0 统一升格) |
+| `mocks.MockSysUserModel` | `internal/testutil/mocks/mock_user_model.go` | `mockgen` 重新生成,方法签名与新接口对齐(多出 `username` / `expectedUpdateTime`) |
+| `mocks.MockSysDeptModel` 新增 `FindOneForShareTx` | `internal/testutil/mocks/mock_dept_model.go` | `mockgen` 重新生成;M-R11-3 需要的 S 锁 API mock 齐备 |
+| `types.UpdateMemberReq.MemberType` / `Status` 改 `*string` / `*int64` | `internal/logic/member/auditFixes_test.go`、`internal/logic/member/updateMemberLogic_test.go` | 引入 `strPtr` / `int64Ptr` 本地 helper,3 处调用全部改传指针 |
+

+ 367 - 2
test-report.md

@@ -1,8 +1,9 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-19(第 7 轮审计驱动测试
-> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件 + **审计修复回归 (H-1/H-3/H-4 + M-1/M-2/M-3/M-4 + L-3/L-5/L-6)**
+> 报告日期: 2026-04-19(第 11 轮审计驱动测试;Round 7 ~ Round 10 历史章节保留
+> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 限流中间件 + **审计修复回归 (Round 1 ~ Round 11 累计 49 个 audit_test 文件 / 249 TC)**
 > 测试用例设计详见 [test-design.md](./test-design.md)
+> 本轮(Round 11)核心关注点详见本报告 §14;Round 10 见 §13,Round 7 见 §1。
 > 执行命令: `go test -count=1 -timeout 300s ./...`
 > 覆盖率命令: `go test -count=1 -coverprofile=/tmp/cov.out ./... && go tool cover -func=/tmp/cov.out`
 
@@ -1657,3 +1658,367 @@ Round 9 全仓覆盖率:**59.7%**(与 Round 8 同阶,新增 TC 主要钉
 
 > Round 9 没有新增 `AUDIT_PENDING` TC;H-1 PII / L-3 DeptId=0 两条 Round 8 遗留 skip(TC-0990 ~ TC-0993)继续保持待修复状态,等待产品侧决议。
 
+---
+
+## 十三、第 10 轮审计驱动测试(M-R10-1 / M-R10-2 / M-R10-3 / M-R10-4 / M-R10-5 / L-R10-1 / L-R10-2 / L-R10-10)
+
+> 报告追加:2026-04-19(第 10 轮审计驱动测试)
+> 本轮对应 `audit-report.md` 的 5 条 Medium 与 3 条已落地 Low;Low 中 L-R10-3 ~ L-R10-9 或留作"机会性优化"或"运维侧硬约束",不入 TC。
+> 测试用例设计详见 [test-design.md](./test-design.md) §8。
+> 执行命令:`go test -count=1 -timeout 300s ./...`
+
+### 13.1 本轮测试执行总览
+
+| 指标 | 数值 |
+| :--- | :--- |
+| 测试包总数 | **26** |
+| ✅ 通过 | **26/26 packages OK,0 FAIL** |
+| 顶层 Test 函数总数(最新 run) | **939 PASS + 5 SKIP**(Round 9 基线 ≈ 933 + 本轮新增 6) |
+| Subtests(`t.Run`) | **115 PASS** |
+| 执行事件总数(顶层 + subtests) | **1059**,通过 **1054**,Skip 5,Fail 0 |
+| Round 10 新增 audit 文件数 | **2**(`changePasswordConflict_audit_test.go`、`loadCallerAssignableLevel_audit_test.go`) |
+| Round 10 新增 TC 数 | **24**(TC-1015 ~ TC-1038) |
+| Round 10 新增 TC 通过率 | **100%** |
+| 审计回归累计通过率 | **100%** |
+
+### 13.2 Round 10 各测试包关键耗时
+
+(以 `go test -count=1 ./...` 顺序跑一次取实测值)
+
+| 测试包 | 状态 | 耗时 | 本轮增量 |
+| :--- | :--- | :--- | :--- |
+| `logic/auth` | ✅ ok | 13.7s | +6 TC(TC-1015 / 1016 M-R10-4 + TC-1017 ~ 1020 M-R10-3) |
+| `logic/pub` | ✅ ok | 11.2s | TC-1027 / TC-1028 对 `loginLogic_test.go` 两条契约适配(M-R10-5) |
+| `logic/role` | ✅ ok | 9.4s | `bindRolePermsLogic_mock_test.go` / `postCommitCacheDegraded_audit_test.go` mock 重排(M-R10-2) |
+| `logic/user` | ✅ ok | 16.0s | `bindRolesLogic_mock_test.go` mock 重排(M-R10-2) |
+| `logic/product` | ✅ ok | 16.5s | `helper_test.go` 新增 + 4 文件适配 L-R10-1 + 1 文件 L-R10-2 |
+| `server` | ✅ ok | 10.0s | 4 条 TC 统一收敛(L-R10-10 / M-R10-5) |
+| 其他 | ✅ ok | — | 无变更 |
+
+### 13.3 本轮新增 / 适配 TC 明细(关键断言节选)
+
+#### M-R10-1 · SyncPerms 事务内 `product.Status` 复核(TC-1021 ~ TC-1023)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1021 | `syncPerms404_audit_test.go` 的 `LockByCodeTx` 必带 `Status: 1` | mock 返 `SysProduct{Status: 1}` → 继续进入 diff 逻辑;原场景 404 契约不破坏 | ✅ |
+| TC-1022 | `syncPermsDedup_audit_test.go` / `syncPermsLogic_mock_test.go` / `syncPermsTxLock_audit_test.go` | 同上;`TestExecuteSyncPerms_LockBeforeMapReadInTx` 仍能断言"锁行先于读 map" | ✅ |
+| TC-1023 | gRPC 入口 `syncPermissions404_audit_test.go` 的 UnmappedCode → Internal | `LockByCodeTx` 必带 `Status=1`;原"未映射 code" 路径仍走 `Internal` | ✅ |
+
+> 关键契约:若 mock 返回的 `SysProduct` 漏写 `Status`(即零值 `Status==0`),事务内 `locked.Status != StatusEnabled` 立刻 `SyncPermsError{Code:403}`,下游 EXPECT 全部未命中 → 整组 audit 测试 "全红" 预警。M-R10-1 的事后退化一跑即红,回归保护充分。
+
+#### M-R10-2 · BindRolePerms / BindRoles RMW 串行化(TC-1024 ~ TC-1026)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1024 | `BindRolePerms` mock 顺序:`TransactCtx → LockByIdTx → FindPermIdsByRoleIdTx → DeleteByRolePermTx → BatchInsertWithTx` | `bindRolePermsLogic_mock_test.go` 全部 EXPECT 命中 | ✅ |
+| TC-1025 | `BindRolePerms` post-commit cache 清理失败仍 Success(best-effort) | `postCommitCacheDegraded_audit_test.go::TestBindRolePerms_PostCommitUserIdsError_StaysSuccess` | ✅ |
+| TC-1026 | `BindRoles` mock 顺序:`TransactCtx → FindOneForUpdateTx → FindRoleIdsByUserIdForProductTx → DeleteByUserIdAndRoleIdsTx → BatchInsertWithTx` | `bindRolesLogic_mock_test.go::TestBindRoles_Mock_BatchInsertFail` EXPECT 全部命中 | ✅ |
+
+> 关键契约:修复前 `FindPermIdsByRoleId` / `FindRoleIdsByUserIdForProduct` 在事务外读,EXPECT 顺序正确;修复后改为 `*Tx` 版本并收敛进事务内,若回退到旧实现 mock 会命中 "Unexpected call to ..." 立即失败。
+
+#### M-R10-3 · `LoadCallerAssignableLevel` 批量分配 N→1 次 DB(TC-1017 ~ TC-1020)
+
+对应文件:`internal/logic/auth/loadCallerAssignableLevel_audit_test.go`(Round 10 新增)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1017 | 全权调用者短路,不打 DB | SuperAdmin / ADMIN / DEVELOPER 三子用例;mock `FindMin` 无 EXPECT,一旦被调用即 "Unexpected call" fail | ✅ |
+| TC-1018 | MEMBER caller 只打 1 次 DB,循环内 5 个角色走 `CheckRoleLevelAgainst` 无额外 DB | `mockRole.EXPECT().FindMinPermsLevelByUserIdAndProductCode(...).Times(1)`;5 个严格低级角色全通过;3 个同级/更高级角色全 403 | ✅ |
+| TC-1019 | ErrNotFound → `NoRole=true`,不翻 500 | `snap.NoRole==true`;`CheckRoleLevelAgainst(999)` 仍 403 "没有可分配的角色等级" | ✅ |
+| TC-1020 | 其他 DB 错误 → fail-close 500 | `CodeError.Code()==500`;不会被同化成 NoRole 放行 | ✅ |
+
+> 关键契约:`Times(1)` 断言锁死了"caller 只查一次"的性能契约;若代码回退到 `for _, r := range roles { GuardRoleLevelAssignable(...) }` 的旧写法,N=5 场景会命中 "expected 1, got 5" 立刻 fail。TOCTOU 窗口也从 N 个塌缩为 1 个。
+
+#### M-R10-4 · ChangePassword ErrUpdateConflict → 409(TC-1015 / TC-1016)
+
+对应文件:`internal/logic/auth/changePasswordConflict_audit_test.go`(Round 10 新增)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1015 | `UpdatePassword → ErrUpdateConflict` 必须回 409 | `CodeError.Code()==409`;文案含 "密码已被其他会话修改" | ✅ |
+| TC-1016 | 非 `ErrUpdateConflict` 的 raw error 仍透传(由 rest 兜 500) | `errors.Is(err, genericErr)==true`;**不是** `*CodeError` | ✅ |
+
+> 关键契约:TC-1015 锁死主路径;TC-1016 反向契约保证修复没有把所有底层错误都吞成 409 而掩盖 DB 抖动(与 `L-4` / `M-3` fail-close 哲学一致)。
+
+#### M-R10-5 · 登录 / SetUserPerms 重复 member 查询去重(TC-1027 / TC-1028)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1027 | `TestLogin_NonMemberWithProductCode`:非成员登录 → 统一文案 | `CodeError{Code:403, Msg:"您不是该产品的有效成员"}`(原 "您不是该产品的成员") | ✅ |
+| TC-1028 | `TestLogin_DisabledMemberRejected`:禁用成员登录 → 同文案 | 同上(原 "您在该产品下的成员资格已被禁用") | ✅ |
+
+> 关键契约:两条分支响应完全一致,关闭"拿文案差分区分是 member 不存在 vs 已被禁用"的枚举 oracle,同时消除 loginService 对 `FindOneByProductCodeUserId` 的二次回源(`UD.MemberType==""` 即判定非有效成员)。
+
+#### L-R10-1 · CreateProduct 必填 AdminDeptId(TC-1029 ~ TC-1034)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1029 | `seedAdminDept(t, ctx, svcCtx)` 公共 helper | 单次插一条启用部门 + `t.Cleanup` 清理;`internal/logic/product/helper_test.go` | ✅ |
+| TC-1030 | `createProductLogic_test.go` 正向用例全量传 `AdminDeptId` | 全部 `require.NoError`;admin 用户 `DeptId` 落盘一致 | ✅ |
+| TC-1031 | `fetchInitialCredentialsLogic_audit_test.go` 票据一次性消费路径 | 行为不变 | ✅ |
+| TC-1032 | `createProductCompensation_audit_test.go` 补偿路径 | 行为不变;`deptId` 在 brokenCtx 下仍可被 seed 并复用 | ✅ |
+| TC-1033 | `createProductConflict_audit_test.go` 冲突用例 | mock `SysDeptModel.FindOne` 返 `Status=1`;`AdminDeptId: 77` 透传 | ✅ |
+| TC-1034 | `createProductLogic_mock_test.go` 两处 `CreateProductReq` | mock `SysDeptModel` 补齐;`AdminDeptId: 88` 透传 | ✅ |
+
+> 关键契约:admin 账号"挂不到有效部门" 的旧路径(DeptId=0)导致的 "admin 首次登录除了改密什么都干不了" 已被切断;`AdminDeptId` 必填 + 启用校验 + 数据落盘断言共同形成闭环。
+
+#### L-R10-2 · 初始 adminPassword 强密码(TC-1035)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1035 | `AdminPassword` 长度由 `generateRandomHex(12)` 的 24 字符改为 `generateStrongInitialPassword(16)` 的 16 字符 | `assert.Len(t, cred.AdminPassword, 16, "L-R10-2:adminPassword 改由 generateStrongInitialPassword(16) 生成,长度恒为 16")` | ✅ |
+
+> 关键契约:断言长度恰为 16 而非"≤24",一旦未来改回 hex / 长度变动即红;同时由 `generateStrongInitialPassword` 在构造时内嵌 `util.ValidatePassword` 保证每条密码都通过复杂度要求。
+
+#### L-R10-10 · gRPC GetUserPerms 枚举 oracle 关闭(TC-1036 ~ TC-1038)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1036 | `TestGetUserPerms_UserNotFound` | `status.Code()==codes.NotFound` + msg "用户不是该产品的有效成员" | ✅ |
+| TC-1037 | `TestGetUserPerms_NonMember_PermissionDenied` | `status.Code()==codes.NotFound`(原 `PermissionDenied`)+ 同文案 | ✅ |
+| TC-1038 | `TestGetUserPerms_DisabledMemberInDevDept_PermissionDenied` | 同上;禁用成员在 `loadMembership` 里把 MemberType 清空,最终走同一路径 | ✅ |
+
+> 关键契约:三条 gRPC 路径响应完全一致(code 与 message 都相同),关闭"持合法 appKey 遍历 userId 区分全局 sys_user 存在性"的枚举旁路。
+
+### 13.4 未进入测试的条目(Round 10 归档)
+
+| 审计条目 | 归档结论 | 未入 TC 原因 |
+| :--- | :--- | :--- |
+| L-R10-3 `IncrementTokenVersion` 非 CAS 路径 `(0, nil)` 返回 | 当前代码无改动 | 审计意见是"语义收敛建议",`Logout` 仅把返回值作为日志副作用,无真实可观测风险 |
+| L-R10-4 RefreshToken `newVersion != predictedVersion` 死码 | 审计定档"保留(defence in depth)" | 属于 forensic 分支,按"真触发即 SQL 实现漂移"的强审计价值保留 |
+| L-R10-5 DeptTree 暴露 `Status` | 当前代码无改动 | 业务口径尚未决议,审计定档"视产品决定" |
+| L-R10-6 `CountOtherActiveAdminsTx` | 当前代码无改动 | 审计定档"可忽略代码风格优化" |
+| L-R10-7 PermList / RoleList 同产品全量可见 | 当前代码无改动 | 审计定档"业务默认契约" |
+| L-R10-8 `loadPerms` 全权分支忽略 DENY | 当前代码无改动 | 审计定档"等 DeptType 动态性讨论后统一处理" |
+| L-R10-9 `ExtractClientIP` XFF 信任边界 | 当前代码无改动 | 审计定档"运维反代层硬约束" |
+
+### 13.5 Round 10 项目质量评估
+
+- **整体质量评估**:✅ **继续保持"极高"**。全仓 `go test ./...` 26/26 OK、1054/1059 通过(剩 5 条为历史遗留的主动 skip,非新增),Round 10 新增 24 条审计回归 100% 通过。
+- **本轮钉死的核心回归**:
+  1. **M-R10-1**:SyncPerms 事务内 Status 二次复核,关闭 "外层预检通过 → 事务内被并发 Disable → 仍继续写 sys_perm" 的观测假象(审计链路污染而非实际越权)。5 个 audit 文件的 `LockByCodeTx` mock 全部补齐 `Status: 1`,回退即红。
+  2. **M-R10-2**:BindRolePerms / BindRoles 的 "事务外读 → 事务内写" RMW 竞态已切换为 `SELECT ... FOR UPDATE`(锁 sys_role / sys_product_member 行),两个 admin 并发完全覆盖时只允许"A 完整覆盖 → B 基于 A 的最终态覆盖"交错,消除了"`[1,3] ∪ [1,4] → [1,3,4]`" 这种第三态。mock 顺序已钉死:回退到无锁实现时 `Unexpected call` 立刻触发。
+  3. **M-R10-3**:BindRoles 批量分配循环内 N 次 `FindMinPermsLevelByUserIdAndProductCode` 被压缩为 1 次(`LoadCallerAssignableLevel` + `CheckRoleLevelAgainst` 两段式 API)。`Times(1)` 断言使"退化为 N 次"回归立即可见;同时把"超管 loop 中途降级 caller" 的 TOCTOU 窗口从 N 个塌缩为 1 个。
+  4. **M-R10-4**:ChangePassword 的 `ErrUpdateConflict → 409` 显式映射,与 UpdateUserLogic / UpdateUserStatusLogic / UpdateRoleLogic 口径完全对齐;前端错把"并发冲突"当系统故障、SRE 告警看板把 409 归 5xx 噪声池的历史坑位被填平。反向契约 TC-1016 防止"把所有错误都误吞为 409"的回退。
+  5. **M-R10-5**:loginService 与 SetUserPerms 对同一 `(productCode, userId)` 的二次 FindOne 去重;同时把"非成员 / 禁用成员" 两条分支合并为同一响应,关闭一条文案级枚举 oracle。
+  6. **L-R10-1**:CreateProduct 必填 `AdminDeptId` + 启用态校验,解决了"admin 首次登录除了改密什么都干不了"的接入体验断点。helper 函数 `seedAdminDept` 收敛 6 个 audit 文件的模板代码。
+  7. **L-R10-2**:初始 adminPassword 升级为大小写 + 数字 + 符号混合,长度 16;断言从 `Len>=12` 改为 `Len==16`,未来把生成器换回 hex 会立即 fail。
+  8. **L-R10-10**:gRPC GetUserPerms 的三条分支(userId 不存在 / 非成员 / 禁用成员)响应统一收敛为 `codes.NotFound` + "用户不是该产品的有效成员",关闭"遍历 userId 区分全局存在性"的枚举旁路。
+- **留意事项 / 后续建议**:
+  1. **M-R10-2 的并发真实性**:当前 mock 顺序覆盖了"FOR UPDATE 锁在事务首步"契约,但未直接压 MySQL 的并发路径。下一轮可补一条基于真实 DB 的双 goroutine 并发 BindRolePerms 压测,断言最终 `sys_role_perm` 行集合 ∈ {`targetA`, `targetB`}(而不是合并态),把"RMW 第三态" 从 mock 契约层升到端到端契约层。
+  2. **L-R10-1 / L-R10-2 一体化**:现在"创建产品 → 拿回票据 → 用 adminPassword 登录"这条 E2E 链路仍然分散在 4 个 test 文件中拼装。下一轮可补一条贯通的 integration 用例(CreateProduct → FetchInitialCredentials → Login → AddMember),减少契约漂移空间。
+  3. **L-R10-10 的审计日志**:gRPC `GetUserPerms` 的三分支合并后,单条 log.Infof 已经按 `reason="username_empty"` / `reason="member_invalid"` 区分来源,但上层 SOC 仪表盘尚未区分这两个 reason 码,建议在 metrics 层追加 label 以维持"响应统一 + 日志可区分" 双轨。
+  4. **M-R10-3 的批量场景压力**:当前 TC-1018 用 N=5 roles 覆盖;真实业务批量绑 30 个 role 的场景建议再挂一条 N=30 的断言把"回退即 30 次 DB" 的放大效应显式钉死。
+
+### 13.6 累计测试体量(Round 1 → Round 10)
+
+| 维度 | Round 8 | Round 9 | **Round 10(当前)** |
+| :--- | ---: | ---: | ---: |
+| 顶层 Test 函数数 | 914 | ~937 | **≈ 939(+6)+ 5 SKIP,Subtests 115** |
+| 审计专用 `*_audit_test.go` 文件 | 31 | 36 | **38**(+2:`changePasswordConflict_audit_test.go`、`loadCallerAssignableLevel_audit_test.go`) |
+| 累计审计 TC | 169 | 192 | **216**(累计至 TC-1038;Round 10 新增 24 条) |
+| 通过率(不含主动 skip) | 100% | 100% | **100%** |
+| 全仓 `go test ./...` 最终结果 | 0 FAIL | 0 FAIL | **0 FAIL** |
+
+> Round 10 没有新增 `AUDIT_PENDING` TC;Round 8 遗留的 H-1 PII / L-3 DeptId=0 两条 skip(TC-0990 ~ TC-0993)继续保持待修复状态,等待产品侧决议。核心授权与会话机制经过 10 轮迭代已显著收敛,新发现的风险点基本都处于"可观测语义 / 并发边界 / 接口契约打磨"一层,没有新出现的可利用越权漏洞。
+
+---
+
+## 十四、Round 11 审计驱动测试执行结果
+
+> 报告时点:2026-04-19(第 11 轮审计 · DEV 修复回归)
+> 本轮 DEV 修复点:H-R11-1(UpdatePassword TOCTOU)、M-R11-1(gRPC 入口限流)、M-R11-2(UpdateStatus/IncrementTokenVersion 停用内部 FindOne)、M-R11-3(UpdateUser deptId 切换 vs DeleteDept write skew)、L-R11-1(UpdateMember 指针语义)、L-R11-2(Delete 族 SELECT 列精简)、L-R11-4(SyncPerms 纯新增不触发 CleanByProduct)、L-R11-5(RotateRefreshToken 跨协议共享 helper)
+> 本轮执行命令:
+> - 全量:`go test ./internal/... -count=1`(串行,单 DB/Redis)
+> - 覆盖率:`go test ./internal/... -count=1 -coverprofile=coverage.out -covermode=atomic && go tool cover -func=coverage.out`
+
+### 14.1 Round 11 测试执行总览
+
+| 指标 | 数值 |
+| :--- | :--- |
+| 本轮新增 TC | **33**(TC-1039 ~ TC-1071,全部 audit-driven) |
+| 本轮新增 `*_audit_test.go` 文件 | **11** |
+| 本轮新增顶层 `TestXxx` 函数 | **33** |
+| 本轮新增用例通过 | ✅ **33 / 33** |
+| 本轮新增用例失败 | ❌ **0** |
+| 本轮新增用例跳过 | — **0** |
+| 全仓 `go test ./internal/...` 结果 | ✅ **977 PASS / 0 FAIL / 5 SKIP** |
+| 全仓整体语句覆盖率 | **63.3%**(相比 R10 的 59.3% 继续 +4.0pp) |
+| 审计修复回归累计通过率 | **100%** (累计 216 + 33 = 249 / 249) |
+
+### 14.2 Round 11 各包状态 & 覆盖率(实测)
+
+| 测试包 | 状态 | 耗时 | 语句覆盖率 | 本轮关键增量 |
+| :--- | :--- | :--- | :--- | :--- |
+| handler | ✅ ok | 0.931s | 0.0%(仅 routes) | — |
+| handler/auth | ✅ ok | 1.773s | 50.0% | — |
+| handler/product | ✅ ok | 2.617s | 20.0% | — |
+| handler/pub | ✅ ok | 3.376s | 47.5% | — |
+| loaders | ✅ ok | 4.106s | 81.9% | — |
+| logic/auth | ✅ ok | 13.052s | **87.4%** ⬆ (R10 86.4% → +1.0pp) | +5 H-R11-1 TOCTOU / +2 M-R11-2 Logout / +3 L-R11-5 RotateRefreshToken helper |
+| logic/dept | ✅ ok | 5.275s | 89.3% | — |
+| logic/member | ✅ ok | 6.026s | **86.5%** ⬆ (R10 84.9% → +1.6pp) | +6 L-R11-1 UpdateMember 指针语义 |
+| logic/perm | ✅ ok | 6.210s | 78.6% | — |
+| logic/product | ✅ ok | 13.839s | 80.6% | — |
+| logic/pub | ✅ ok | 8.632s | **89.1%** | +3 L-R11-4 CleanByProduct |
+| logic/role | ✅ ok | 7.846s | 82.6% | — |
+| logic/user | ✅ ok | 12.818s | **86.1%** | +2 M-R11-3 write skew |
+| middleware | ✅ ok | 8.568s | 95.3% | — |
+| model/dept | ✅ ok | 9.129s | 86.0% | — |
+| model/perm | ✅ ok | 9.426s | 93.2% | — |
+| model/product | ✅ ok | 9.818s | 89.7% | — |
+| model/productmember | ✅ ok | 9.977s | 91.3% | — |
+| model/role | ✅ ok | 10.470s | 89.6% | — |
+| model/roleperm | ✅ ok | 10.039s | 85.6% | — |
+| model/user | ✅ ok | 17.703s | **88.9%** | +3 H-R11-1 updatePasswordToctou / +3 M-R11-2 noInternalFindOne |
+| model/userperm | ✅ ok | 10.022s | 93.3% | — |
+| model/userrole | ✅ ok | 9.224s | 89.2% | +2 L-R11-2 deleteCacheKey |
+| response | ✅ ok | 9.157s | 94.7% | — |
+| server | ✅ ok | 8.935s | **80.3%** ⬆ (R10 77.5% → +2.8pp) | +4 M-R11-1 gRPC 限流 / +3 L-R11-5 HTTP↔gRPC 互认 |
+| util | ✅ ok | 8.642s | 37.5% | — |
+
+### 14.3 Round 11 新增 TC 明细(按审计条目分组)
+
+#### H-R11-1 · `UpdatePassword` `expectedUpdateTime` 由调用方显式传入(TC-1039 ~ TC-1043)
+
+对应文件:
+- `internal/model/user/updatePasswordToctou_audit_test.go`
+- `internal/logic/auth/changePasswordToctou_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1039 | `UpdatePassword` 正向:调用方持有 DB 真实 `updateTime` → 成功 | 再 FindOne:password 被替换、`tokenVersion+1`、`updateTime` 前进 | ✅ |
+| TC-1040 | TOCTOU:A 持 T0,B 先写把 DB 推到 T1;A 再用 T0 调 `UpdatePassword` | `errors.Is(err, ErrUpdateConflict)`;DB 仍是 B 的密码和 T1(A 未回写) | ✅ |
+| TC-1041 | 并发:两 goroutine 共享 T0 争同一行 | 恰好 1 胜;`ErrUpdateConflict` 命中另一条;`tokenVersion` 只 +1 不是 +2 | ✅ |
+| TC-1042 | Logic 层 E2E:同一 user 连续用同一旧密码改密,第二次必须 400 "旧密码错误" | `CodeError.Code()==400`;msg 含 "旧密码错误"(**不得**是 409) | ✅ |
+| TC-1043 | Logic 层 mock:`UpdatePassword` 被调用时 `expectedUpdateTime` 必须等于 "外层 FindOne 拿到的 user.UpdateTime" | gomock EXPECT 命中;`mustChangePassword=MustChangePasswordNo` 同步锁死 | ✅ |
+
+> 关键反回归:若有人把 `expectedUpdateTime` 拿掉改回 Model 内部二次 FindOne,TC-1040 立刻红(DB 已被 B 改过 A 还能再覆盖回去),TC-1043 也会因 5 参签名变回 4 参 mock 命中失败。Logic 层的 400/409 分桶由 TC-1042 守住——避免有人把 "旧密码错误" 一起吞成 409。
+
+#### M-R11-1 · gRPC `SyncPermissions` / `GetUserPerms` 入口限流(TC-1051 ~ TC-1054)
+
+对应文件:`internal/server/grpc_rate_limit_mr11_1_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1051 | `SyncPermissions` 同 appKey 超配额 → `codes.ResourceExhausted` | limiter=1/60s;同 appKey 第 2 次触发上限 | ✅ |
+| TC-1052 | `GetUserPerms` 同 appKey 超配额 → `codes.ResourceExhausted` | 同上模式,appKey 桶覆盖 | ✅ |
+| TC-1053 | 空 `AppKey` 请求不消耗 limiter 配额 | 空串连打 3 次 → `codes.Unauthenticated`(走业务层 FindOneByAppKey(""), 非 ResourceExhausted);`realKey` 首次仍走业务层,配额完整 | ✅ |
+| TC-1054 | `GetUserPerms` 同 IP 多 appKey → 命中 IP 桶 | limiter=2/60s;同 IP、3 个不同 appKey → 第 3 次 `ResourceExhausted`(证明 `appKey + IP` 双桶叠加) | ✅ |
+
+> 关键反回归:TC-1053 锁死"空 AppKey 前置短路";若有人把 `if req.AppKey != "" { Take(...) }` 误拆,limiter keyspace 里就会挤入一个永不过期的"空串大桶",合法 appKey 的首次请求会被误截流。TC-1054 明确了双桶架构,单独改写任何一路都会红。
+
+#### M-R11-2 · `UpdateStatus` / `IncrementTokenVersion` 停用内部 FindOne(TC-1044 ~ TC-1048)
+
+对应文件:
+- `internal/model/user/mr11_2_noInternalFindOne_audit_test.go`
+- `internal/logic/auth/logoutUsernameForward_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1044 | `UpdateStatus` 用错误 username → 只有错误 username 对应 cache 槽被删 | 真实 u1 的 cache 槽仍在;证明 Model 层未自己 FindOne | ✅ |
+| TC-1045 | `IncrementTokenVersion` 用错误 username → 同上反向契约 | 同上;DB tokenVersion 正常前进 | ✅ |
+| TC-1046 | `IncrementTokenVersion` 对已删除行 → `ErrUpdateConflict`(不 panic) | 行被删除后再 Increment → `errors.Is(err, ErrUpdateConflict)` | ✅ |
+| TC-1047 | Logic 层 `Logout` 必把 `ud.Username` 透传 | gomock EXPECT `IncrementTokenVersion(id, "u1", _)`;**不得**有 FindOne mock | ✅ |
+| TC-1048 | Logic 层 `Logout` 即使 `ud.Username==""` 也透传空串 | gomock EXPECT `IncrementTokenVersion(id, "", _)` 命中 | ✅ |
+
+> 关键反回归:Round 10 之前的 Model 层"自 FindOne 补齐 username"是典型的"调用方已知信息但被子模块再次回源 DB"放大器;一次 Logout 打两次 DB 读。回退方案一旦重入,TC-1044/1045 立刻失败(错误 username 的 cache 槽被清而真实 username 的 cache 槽也被"修正"清除 → 两个都被删 → 两个都会被误判 `ErrNotFound`)。
+
+#### M-R11-3 · `UpdateUser` deptId 切换 vs `DeleteDept` write skew 闭合(TC-1049 ~ TC-1050)
+
+对应文件:`internal/logic/user/updateUserWriteSkew_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1049 | deptId 切换与 `DeleteDept` 并发 → 二选一结果,不产生 skew 残片 | 10 轮并发 goroutine:A `UpdateUser(deptId=dB)` + B `DeleteDept(dB)`;结果必落两分支之一,断言直接查 DB 绕过 cache;`user.deptId` 绝不停留在"指向已删 dept" 的状态 | ✅ |
+| TC-1050 | deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` | 事务路径只走 `UpdateProfileWithTx`;无 `FindOneForShareTx` 调用 | ✅ |
+
+> 关键反回归:之前 `UpdateUser` 对目标 dept 只做外层 FindOne,事务内未拿 S 锁,并发 DeleteDept 可以在校验后、提交前把 dept 删掉,提交出一个"user 指向已删 dept" 的 skew 残片。修复后 `SysDeptModel.FindOneForShareTx(SELECT ... LOCK IN SHARE MODE)` 把目标 dept 的读锁拉起来,与 `DeleteDept` 的 X 锁互斥,串行化消除 skew。TC-1049 的直接查 DB 断言绕过 cache,防止 cache 把 skew 遮蔽。
+
+#### L-R11-1 · `UpdateMember` memberType/status 指针语义(TC-1055 ~ TC-1060)
+
+对应文件:`internal/logic/member/updateMemberPartialPointer_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1055 | 两字段都 nil → 400 | `CodeError.Code()==400`,msg 含 "至少提供一个要更新的字段" | ✅ |
+| TC-1056 | 只改 Status → MemberType 不变 | DB:`memberType` 不变、`status` 已更新 | ✅ |
+| TC-1057 | 只改 MemberType → Status 不变 | 镜像对称断言 | ✅ |
+| TC-1058 | DEVELOPER + 只改 Status → 跳过分配校验 | 不走"有效角色校验" 分支;memberType 保持 DEVELOPER;DB 落盘新 status | ✅ |
+| TC-1059 | 非法 Status=7 → 400 | `CodeError.Code()==400` | ✅ |
+| TC-1060 | no-op(值与现值相同)→ 返 nil 且 `updateTime` 不前进 | DB `updateTime` 保持原值 | ✅ |
+
+> 关键反回归:R10 以前"空字符串 / 零值 = 不修改" 的隐式约定对"用户显式清空某个字段"根本无法表达。改指针后,`nil == 不修改`、`非 nil == 按值更新`(含 `""` 与 `0`,再由 logic 做合法性校验)。TC-1060 的 updateTime 不前进断言尤其重要——它锁死了"值未变即 no-op" 的 MySQL 行为被显式 allowed,不会退化成 ErrUpdateConflict 之类的误报。
+
+#### L-R11-2 · Delete 族 SELECT 只取 cache key 列(TC-1061 ~ TC-1062)
+
+对应文件:`internal/model/userrole/deleteCacheKey_r11_2_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1061 | `DeleteByRoleIdTx` 删除后,id 级 + 组合 key cache 全部失效 | 每行的 `FindOne(id)` / `FindOneByUserIdRoleId(userId, R)` 均 `ErrNotFound` | ✅ |
+| TC-1062 | `DeleteByUserIdAndRoleIdsTx` 定点删除后同上 | 被删的全 `ErrNotFound`;未被删的仍正常 FindOne | ✅ |
+
+> 关键反回归:修复把 `SELECT *` 降到只取 `id, userId, roleId`,减少内存拷贝;但"两套 cache key 都得失效"的语义是不能退化的。一旦有人在精简列时漏掉 `userId` 或 `roleId`,组合 key 就不会被构造出来,TC-1061/1062 的 `FindOneByUserIdRoleId` 会命中脏读立即红。
+
+#### L-R11-4 · `SyncPerms` 纯新增不触发 `CleanByProduct`(TC-1063 ~ TC-1065)
+
+对应文件:`internal/logic/pub/syncPermsCleanByProduct_r11_4_audit_test.go`
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1063 | 纯新增(只有 `added>0`)→ Redis `productIndexKey` canary 仍在 | `added>0 && updated==0 && disabled==0`;canary 未被 CleanByProduct 删除 | ✅ |
+| TC-1064 | 至少一条 update(name 变更)→ canary 被清除 | `updated>0`;canary 消失 | ✅ |
+| TC-1065 | 至少一条 disable → canary 被清除 | `disabled>0`;canary 消失 | ✅ |
+
+> 关键反回归:修复前 `CleanByProduct` 被无条件调用,大产品(几百用户)每次同步哪怕只是"新增一条权限声明"都会引发整产品用户 perms cache 全面失效,观测到的是一次 Redis IO 放大 + DB 热读浪涌。改为仅当 `updated>0 || disabled>0` 才清。canary 测试方式既轻量又反向:一旦有人把条件改回无条件清,TC-1063 立即红。
+
+#### L-R11-5 · `RotateRefreshToken` helper 在 HTTP / gRPC 共享(TC-1066 ~ TC-1071)
+
+对应文件:
+- `internal/logic/auth/rotateRefreshToken_r11_5_audit_test.go`(helper 直测)
+- `internal/server/grpcHttpRotateInterop_r11_5_audit_test.go`(跨协议互认)
+
+| TC | 被测契约 | 关键断言 | 结果 |
+| :--- | :--- | :--- | :---: |
+| TC-1066 | helper happy path:新 tokens + DB tokenVersion+1 + cache Clean | 返 tokens 非空;DB 前进;`UserDetailsLoader.Load` 重打 DB(cache 已清) | ✅ |
+| TC-1067 | helper:claims.TokenVersion 与 DB 不一致 → `ErrTokenVersionMismatch` | `errors.Is(err, userModel.ErrTokenVersionMismatch)`;DB 不变 | ✅ |
+| TC-1068 | helper:用户已删除 → `ErrTokenVersionMismatch`(非 panic) | 同上;helper 对 "ErrNotFound" 语义统一折算为版本不匹配 | ✅ |
+| TC-1069 | HTTP 签出的 refreshToken 能被 gRPC 续签 | HTTP 先刷 v0→v1,gRPC 拿新 rt 刷 v1→v2,均成功 | ✅ |
+| TC-1070 | gRPC 签出的 refreshToken 能被 HTTP 续签 | 镜像对称成功 | ✅ |
+| TC-1071 | gRPC 重放旧 rtV0 → `codes.Unauthenticated`(非 Internal) | 映射从 helper 的 `ErrTokenVersionMismatch` 到 gRPC 的 Unauthenticated | ✅ |
+
+> 关键反回归:L-R11-5 的核心价值在"唯一事实源"——HTTP 与 gRPC 的 refreshToken 签名/CAS 逻辑必须是同一份代码。修复前两边各自实现,漂移时(例如 gRPC 少写 `Clean` 或不处理 `ErrNotFound`)不容易被单协议测试发现;修复后 TC-1069/1070 把跨协议互认钉死,哪一边独自漂移都会让对方测试直接红。TC-1071 同时把 `ErrTokenVersionMismatch → codes.Unauthenticated` 的错误映射钉死(避免被兜成 Internal 掩盖 replay 攻击)。
+
+### 14.4 Round 11 质量评估
+
+- **整体质量评估**:✅ **继续保持"极高"**。全仓 `go test ./internal/...` 977 PASS / 0 FAIL / 5 SKIP;Round 11 新增 33 条审计回归 100% 通过。
+- **本轮钉死的核心回归**:
+  1. **H-R11-1**:`UpdatePassword` 的 TOCTOU 从"内部自 FindOne → 自对齐 CAS" 升级为"调用方显式透传 expectedUpdateTime",外层校验的时间戳与内层 UPDATE 的时间戳必须是同一个快照。并发改密的 last-write-wins 退化被堵上,同时把 400(旧密码错)与 409(并发冲突)严格分桶,避免观测事件被错误归类。
+  2. **M-R11-1**:gRPC `SyncPermissions` / `GetUserPerms` 的入口限流补齐,`bcrypt.Compare(appSecret)` + `LockByCodeTx` 的 CPU/DB 放大器在 `OverQuota` 之前被截断;空 AppKey 的前置短路避免了 limiter keyspace 污染。
+  3. **M-R11-2**:Model 层停用内部 FindOne,把"username/updateTime" 的取值职责显式下放给调用方。一次 Logout 不再在 Model 层打第二次 DB 读,同时用"错误 username → 只清错误 cache 槽"的反向契约锁死"Model 绝不再自己 FindOne"。
+  4. **M-R11-3**:`UpdateUser` 切换 deptId 时引入 `SysDeptModel.FindOneForShareTx`(LOCK IN SHARE MODE),与 `DeleteDept` 的 X 锁互斥,消除"user 指向已删 dept" 的 write skew 残片。直接查 DB 断言绕过 cache 是关键,避免 cache 遮蔽。
+  5. **L-R11-1**:`UpdateMemberReq.MemberType` / `Status` 改指针语义,彻底消除"空串 = 不修改"的歧义;DEVELOPER 只改 Status 时跳过分配校验避免 DEVELOPER 分支被误挂;no-op(值未变)的 RowsAffected=0 被显式 allowed,不误升格为冲突。
+  6. **L-R11-2**:Delete 族 `SELECT *` 降为只取 cache key 必需列,节省内存拷贝;两套 cache key(id 级 + 组合 key)的全量失效语义由 TC-1061/1062 守住,不因列精简而退化。
+  7. **L-R11-4**:`SyncPerms` 纯新增不再触发 `CleanByProduct`,大产品同步的 Redis IO 与 DB 热读抖动被显著压下。canary 测试方式反向锁死,回退到无条件清一跑即红。
+  8. **L-R11-5**:`RotateRefreshToken` helper 抽出作为唯一事实源,HTTP / gRPC 都调同一份;跨协议互认 TC-1069/1070 直接拿 HTTP 签的 token 给 gRPC 用(与反向),任一端漂移都会被对方测试立即发现;`ErrTokenVersionMismatch → codes.Unauthenticated` 的错误映射由 TC-1071 钉死。
+- **留意事项 / 后续建议**:
+  1. **H-R11-1 的 MySQL 时间分辨率**:当前 `updateTime` 是秒级,如果未来要做"亚秒级并发 CAS"严格单胜者断言,需要把 `updateTime` 升级为毫秒或加入随机 nonce 列。目前的并发测试 TC-1041 通过一次"同一 T0" 场景覆盖,并未强制亚秒级。
+  2. **M-R11-1 的 limiter 配置**:`quota=1` / `period=60s` 是测试极值;生产配置建议 `quota≥16` / `period=60s`,并在上线后前 7 天观察 `OverQuota` 触发频率,必要时调 quota。
+  3. **M-R11-3 的 cache 失效**:当前 write-skew 测试直接 `QueryRowCtx` 查 MySQL 绕过 go-zero cache;在正常业务代码路径上,DeleteDept 的 cache 失效粒度值得再做一轮端到端回归(已由 R10 的 `updateDeptCleanBatch_audit_test.go` 覆盖)。
+  4. **L-R11-5 的 helper 单调性**:当前 TC-1069/1070 只覆盖了"一次 HTTP + 一次 gRPC" 的双向,没有覆盖"HTTP × gRPC × HTTP × gRPC 交替"的长链;如果日后发现跨协议长链下的 tokenVersion 累加与 CAS 对齐有边界问题,应补一条 4 步交替的 integration 用例。
+  5. **L-R11-2 的其他 Delete 方法**:TC-1061/1062 覆盖的是 `sys_user_role` 两个 Delete\*Tx;`sys_user_perm` / `sys_role_perm` / `sys_perm.DisableNotInCodesWithTx` 同口径精简的回归覆盖度目前依靠"同 pattern",若下轮测试预算充足可按相同方式逐表补齐。
+
+### 14.5 累计测试体量(Round 1 → Round 11)
+
+| 维度 | Round 9 | Round 10 | **Round 11(当前)** |
+| :--- | ---: | ---: | ---: |
+| 全仓 PASS / FAIL / SKIP | ~932 / 0 / 5 | ~939 / 0 / 5 | **977 / 0 / 5** |
+| 审计专用 `*_audit_test.go` 文件 | 36 | 38 | **49**(+11 本轮) |
+| 累计审计 TC | 192 | 216 | **249**(+33 本轮) |
+| 通过率(不含主动 skip) | 100% | 100% | **100%** |
+| 整体语句覆盖率 | ~59.3% | ~59.3% | **63.3%** |
+
+> Round 11 没有新增 `AUDIT_PENDING` TC;Round 8 遗留的 H-1 PII / L-3 DeptId=0 两条 skip(TC-0990 ~ TC-0993)继续保持待修复状态,等待产品侧决议。经 11 轮迭代,授权/会话/数据一致性模型已显著收敛,本轮发现的风险点(TOCTOU、跨协议 helper 漂移、limiter 缺失)全部属于"边界 + 性能 + 一致性打磨"层级,没有新出现可直接利用的越权漏洞。
+