# 权限管理系统 (perms-system-server) — 全路径覆盖测试设计 > 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader > 测试报告与代码审计详见 [test-report.md](./test-report.md) --- ## 一、系统架构与逻辑链路 ### 1.1 整体调用链路 ```text HTTP Client gRPC Client │ │ ▼ ▼ rest.Server (go-zero) zrpc.Server (go-zero) │ (全部 POST 路由) │ ▼ ▼ Handler 层 (JSON Body 解析) PermServer (permserver.go) │ (status.Error + codes.Xxx) ▼ │ JwtAuth Middleware (鉴权/上下文注入) │ │ │ ▼ ▼ Logic 层 (业务逻辑) ◄────── 共享 ────► authHelper (jwt.go / perms.go) │ │ ▼ ▼ util 层 (NormalizePage / IsValidEmail / IsValidPhone) │ ▼ Model 层 (go-zero sqlc + cache + TransactCtx + 批量查询) │ ├── _gen.go (自定义模板生成: CRUD/Batch/WithTx/缓存管理) │ └── 自定义方法 (分页/按条件查询/级联删除等) ▼ MySQL (InnoDB) + Redis Cache ``` ### 1.2 Model 层接口全景 共 9 个 Model,每个包含: | 层级 | 方法类别 | 数量/模型 | 来源 | | :--- | :--- | :--- | :--- | | _gen.go 基础 CRUD | Insert, InsertWithTx, FindOne, FindOneWithTx, Update, UpdateWithTx, Delete, DeleteWithTx | 8 | 自定义模板 | | _gen.go 批量操作 | BatchInsert, BatchInsertWithTx, BatchUpdate, BatchUpdateWithTx, BatchDelete, BatchDeleteWithTx | 6 | 自定义模板 | | _gen.go 唯一索引查询 | FindOneBy{UniqueField}, FindOneBy{UniqueField}WithTx (因表而异) | 0~2 组 | 自定义模板 | | _gen.go 内部辅助 | TransactCtx, TableName, findListByPrimaryKeys, getPrimaryKeyValue, buildBatchUpdateQuery, formatPrimary, queryPrimary | 7 | 自定义模板 | | 自定义方法 | 分页查询/按条件查询/级联删除/批量ID查询等 | 3~7 | 手写 | ### 1.3 权限计算逻辑链路 ```text 输入: userId + deptId + productCode + isSuperAdmin(bool) │ ├─ isSuperAdmin=true → 产品全部启用权限 + "SUPER_ADMIN" ├─ 非产品成员 → nil + "" ├─ DEVELOPER/ADMIN → 产品全部启用权限 + memberType ├─ MEMBER + deptId>0 + dept.DeptType="DEV" → 产品全部启用权限 + "MEMBER" ├─ MEMBER + deptId>0 + dept查询失败/DeptType≠"DEV" → 继续走角色权限流程 └─ MEMBER → (角色权限 ∪ ALLOW) - DENY → 过滤 status=1 ``` --- ## 二、REST API 测试用例 > **注意**: 所有路由统一为 POST 方法,请求参数均通过 JSON Body 传递。 ### 2.1 产品端登录 `POST /api/auth/login` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0001 | POST /api/auth/login | 正常登录(普通用户+productCode) | `{"username":"user1","password":"123456","productCode":"test"}` | code=0, accessToken/refreshToken/userInfo | 正常路径 | P0 | loginLogic全路径 | | TC-0002 | POST /api/auth/login | 正常登录-带productCode+ADMIN成员 | `{"username":"user1","password":"123456","productCode":"test"}` | code=0, perms含用户可用权限, memberType="ADMIN" | 正常路径 | P0 | GetUserPerms(false) MEMBER分支 | | TC-0003 | POST /api/auth/login | 超管通过产品端登录被拒绝 | `{"username":"super","password":"x","productCode":"p1"}` | code=403, "超级管理员不允许通过产品端登录,请使用管理后台" | 安全 | P0 | IsSuperAdmin==1 → ErrForbidden | | TC-0004 | POST /api/auth/login | 超管无productCode被拒绝 | `{"username":"super","password":"x"}` | code=403, "超级管理员不允许通过产品端登录,请使用管理后台" | 安全 | P0 | IsSuperAdmin==1 → ErrForbidden | | TC-0005 | POST /api/auth/login | 用户不存在 | `{"username":"notexist","password":"x"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | ErrNotFound分支 | | TC-0006 | POST /api/auth/login | DB异常(非ErrNotFound) | FindOneByUsername连接失败 | code=500, "服务器内部错误" | 异常路径 | P1 | 透传err→Setup兜底 | | TC-0007 | POST /api/auth/login | 密码错误 | `{"username":"admin","password":"wrong"}` | code=401 | 异常路径 | P0 | bcrypt比对失败 | | TC-0008 | POST /api/auth/login | 账号冻结 | status=2用户 | code=403, "账号已被冻结" | 分支覆盖 | P0 | u.Status!=1 | | TC-0009 | POST /api/auth/login | 非产品成员 | productCode指向用户不属于的产品 | code=403, "您不是该产品的成员" | 安全 | P0 | 非成员禁止登录 | | TC-0010 | POST /api/auth/login | DEVELOPER成员 | DEVELOPER类型成员 | perms全量, memberType="DEVELOPER" | 分支覆盖 | P1 | perms.go DEVELOPER分支 | | TC-0011 | POST /api/auth/login | SQL注入 | `{"username":"' OR 1=1 --","password":"x"}` | code=401 | 安全 | P0 | 参数化查询 | | TC-0012 | POST /api/auth/login | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验(productCode现为必填) | | TC-0013 | POST /api/auth/login | 产品成员被禁用时拒绝登录 | member.status=Disabled | 403 "您的产品成员资格已被禁用" | 安全 | P0 | loginService | | TC-0014 | POST /api/auth/login | 产品被禁用时拒绝登录 | product.status=Disabled | 403 "该产品已被禁用" | 安全 | P0 | loginService | | TC-0751 | POST /api/auth/login | 用户名不存在 + 任意密码 | username="\_\_no\_such\_user\_\_" | 返回与"存在用户但密码错"**完全一致**的错误文案("用户名或密码错误"),仍会走 dummy bcrypt 耗时 | 安全/枚举 | P0 | 用 dummy hash 比对,防止通过响应差异枚举用户名 | | TC-0752 | POST /api/auth/login | 用户名存在但密码错 | 存在用户 + 错误密码 | 同 TC-0751 相同 code、相同文案 | 安全/对照 | P0 | 与 TC-0751 联动,断言两条分支对外表现一致 | | TC-0753 | POST /api/auth/login | 登录限流 key 必须基于 `ip:username` | 同 IP 攻击 userA 耗尽配额 | userA 被限流 429,但同 IP 登录 userB 仍放行 | 安全/限流 | P0 | `UsernameLoginLimit` key = `ip:username`,避免单 IP 单用户暴力破解拖垮同一 IP 其他用户登录 | | TC-0838 | POST /api/auth/login | 用户冻结 + **错误密码** | status=2 的用户,密码错误 | 401 "用户名或密码错误"(**不能**是 403 "账号已被冻结") | 安全/侧信道 | P0 | 账号存在性 + 冻结状态必须在密码正确之前完全不可观察 | | TC-0839 | POST /api/auth/login | 用户冻结 + **正确密码** | status=2 的用户,密码正确 | 403 "账号已被冻结"(奖励性披露) | 正常 | P0 | 只有拿到密码后才披露状态,仍保留业务可见性 | | TC-0840 | POST /api/auth/login | 超管走产品端登录 + 错误密码 | IsSuperAdmin=1,密码错误 | 401 "用户名或密码错误"(不得提前暴露"超管"身份) | 安全/侧信道 | P0 | 超管状态同样延迟披露 | | TC-0841 | POST /api/auth/login | 超管走产品端登录 + 正确密码 | IsSuperAdmin=1,密码正确 | 403 "超级管理员不允许通过产品端登录,请使用管理后台" | 正常 | P0 | 披露顺序反转后的正面路径仍保持原有业务语义 | | TC-0842 | POST /api/auth/login | 用户名不存在 | 不存在用户名 + 任意密码 | 401 "用户名或密码错误",走 dummy bcrypt 恒时对齐 | 安全/枚举 | P0 | 沿用 dummy hash 路径,不被 H-2 新顺序破坏 | ### 2.1b 管理后台登录 `POST /api/auth/adminLogin` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0015 | POST /api/auth/adminLogin | 超管正常登录 | `{"username":"super","password":"x","managementKey":"valid"}` | code=0, accessToken/refreshToken/userInfo, isSuperAdmin=1, memberType="SUPER_ADMIN", perms为空 | 正常路径 | P0 | adminLoginLogic全路径 | | TC-0016 | POST /api/auth/adminLogin | 普通用户被拒绝 | `{"username":"user1","password":"x","managementKey":"valid"}` | code=401, "用户名或密码错误" | 安全 | P0 | 统一错误消息防用户枚举 | | TC-0017 | POST /api/auth/adminLogin | managementKey无效 | `{"username":"user1","password":"x","managementKey":"wrong"}` | code=401, "managementKey无效" | 安全 | P0 | 第一个校验点 | | TC-0018 | POST /api/auth/adminLogin | managementKey为空 | `{"username":"user1","password":"x","managementKey":""}` | code=401, "managementKey无效" | 安全 | P0 | 空字符串≠config值 | | TC-0019 | POST /api/auth/adminLogin | 用户不存在 | `{"username":"notexist","password":"x","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | ErrNotFound分支 | | TC-0020 | POST /api/auth/adminLogin | 密码错误 | `{"username":"user1","password":"wrong","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | bcrypt比对失败 | | TC-0021 | POST /api/auth/adminLogin | 账号冻结 | status=2用户 | code=401, "用户名或密码错误" | 安全 | P0 | 冻结/非超管统一返回同一错误防枚举 | | TC-0022 | POST /api/auth/adminLogin | 不带productCode时perms为空 | 管理后台登录超管 | userInfo.perms为空, memberType="SUPER_ADMIN"(超管标记由Loader自动填充) | 功能验证 | P0 | Load(ctx, uid, "") | | TC-0023 | POST /api/auth/adminLogin | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验 | | TC-0024 | POST /api/auth/adminLogin | SQL注入username | `{"username":"' OR 1=1 --","password":"x","managementKey":"valid"}` | code=401 | 安全 | P0 | 参数化查询 | | TC-0025 | POST /api/auth/adminLogin | adminLogin 用户名限流 | 对同一用户名连续多次失败登录 | 触发后返回 429 "请求过于频繁" | 安全 | P0 | 防用户名枚举爆破 | | TC-0710 | POST /api/auth/adminLogin | 同 IP,产品登录用尽配额后管后登录仍放行 | productLoginRL 配额=1 打满,再打 adminLoginRL | adminLoginRL 正常放行 1 次 | 安全 | P0 | keyPrefix 区分 product/admin | | TC-0781 | POST /api/auth/adminLogin | 不存在用户登录管理后台响应时间恒定 | 不存在的用户名 | code=401, "用户名或密码错误",执行 dummy bcrypt | 安全 | P0 | dummy bcrypt 恒时对齐 | | TC-0834 | POST /api/auth/adminLogin | 相同 IP + 相同 username 连续打满 quota | clientIP=`1.2.3.4`, username=superA,连打 >quota 次错误密码 | 超限后返回 429 "登录尝试过于频繁,请5分钟后再试",同 IP 下同 username 不再放行 | 安全/限流 | P0 | 修复后 key=`admin::`;按 quota 阈值打入,需命中 429 | | TC-0835 | POST /api/auth/adminLogin | 同 username 但换远端 IP | clientIP=`1.2.3.4` 打满后,换 clientIP=`5.6.7.8` 继续 | 不同 IP 分别计数,换 IP 仍应触达 bcrypt → 进入下游业务断言(此处为密码错误 401),而非继承上一桶 429 | 安全/限流 | P0 | 确认 key 含 IP 维度,远端"任何 IP"都能永久锁死的攻击路径被阻断 | | TC-0836 | POST /api/auth/adminLogin | clientIP 缺失(未挂 RateLimit 中间件) | clientIP 未注入 ctx | key 退化为 `admin:unknown:`,仍能正常限流到共享桶,不得直接 panic 或跳过限流 | 安全/鲁棒 | P0 | fail-closed 兜底;未来删除中间件只会退化 key 不会绕过 | | TC-0837 | POST /api/auth/adminLogin | managementKey 无效路径不得消耗 username 配额 | 任意密码,managementKey 错误 | 401 "managementKey无效";同 username 立刻换 managementKey 正确再来仍有完整 quota | 安全 | P1 | managementKey 校验在限流 `Take` 之前,防匿名攻击者只靠错 key 就把配额打满 | | TC-1008 | POST /api/auth/adminLogin | 非超管+错密码 vs 用户不存在 | 错密码 / 不存在用户名 | code + body 完全一致 | 安全/Oracle | P0 | 响应不得区分两条分支 | | TC-1009 | POST /api/auth/adminLogin | 非超管+正确密码 | 真实普通用户正确密码 | 仍 401 "用户名或密码错误" | 安全 | P0 | 不得以 200 暴露账号存在性 | | TC-1010 | POST /api/auth/adminLogin | 两条 dummy bcrypt 分支时序 | 连续 3 次平均耗时 | `非超管+错密` 与 `不存在` 耗时比 <3× | 时序/性能 | P0 | 若比例 >3× 说明 L-N3 被回退(非超管分支跳过了 dummy bcrypt) | ### 2.2 刷新Token `POST /api/auth/refreshToken` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0026 | POST /api/auth/refreshToken | 正常刷新 | Header `Authorization: Bearer ` | code=0, 新accessToken, 新refreshToken(token轮转,保留原始过期时间) | 正常路径 | P0 | refreshTokenLogic全路径 | | TC-0027 | POST /api/auth/refreshToken | 不带productCode(回退) | Header Authorization, 无productCode | 使用claims.ProductCode | 分支覆盖 | P1 | productCode=""回退 | | TC-0028 | POST /api/auth/refreshToken | token无效 | Header `Authorization: Bearer invalid` | code=401 | 异常路径 | P0 | ParseRefreshToken失败 | | TC-0029 | POST /api/auth/refreshToken | 用户已删除 | token中userId不存在 | code=403, "账号已被冻结" | 异常路径 | P1 | UserDetailsLoader返回Status=0 | | TC-0030 | POST /api/auth/refreshToken | 账号冻结 | 冻结用户 | code=403 | 分支覆盖 | P0 | Status!=1 | | TC-0031 | POST /api/auth/refreshToken | 超管+productCode(token中已含相同pc) | isSuperAdmin=1, token中productCode=pc, req.ProductCode=pc | refreshToken原样返回, SUPER_ADMIN权限 | 分支覆盖 | P1 | isSuperAdmin分支+productCode不变 | | TC-0032 | POST /api/auth/refreshToken | 尝试切换产品被拒绝 | token中productCode="p1", req.ProductCode="p2" | code=400, "刷新令牌不允许切换产品" | 安全 | P0 | 禁止跨产品切换 | | TC-0033 | POST /api/auth/refreshToken | TokenVersion不匹配时拒绝刷新 | refreshToken含tokenVersion=999, DB中tokenVersion=0 | 401 "登录状态已失效,请重新登录" | 安全 | P0 | claims.TokenVersion != ud.TokenVersion | | TC-0034 | POST /api/auth/refreshToken | 使用accessToken作为refreshToken被拒绝 | 用accessSecret签发的accessToken作为refreshToken传入 | 401 "refreshToken无效或已过期" | 安全 | P0 | ParseRefreshToken校验TokenType!=refresh | | TC-0035 | POST /api/auth/refreshToken | 产品成员已移除时拒绝刷新 | refreshToken含productCode, 但用户已从该产品移除 | 403 "您已不是该产品的成员" | 安全 | P0 | ud.MemberType=="" && !ud.IsSuperAdmin | | TC-0720 | POST /api/auth/refreshToken 或 logout | 正常登出 | 已登录用户 ctx + WithUserDetails | 用户 tokenVersion=0 → 1;重新 Load 时 TokenVersion 已递增 | 正常路径 | P0 | IncrementTokenVersion + UserDetailsLoader.Del | | TC-0721 | POST /api/auth/refreshToken 或 logout | 未登录调用 /auth/logout | ctx 中无 userDetails | 返回 401 "未登录" | 错误路径 | P0 | 未登录兜底 | | TC-0722 | POST /api/auth/refreshToken 或 logout | 同一用户连续两次 logout | 登出两次 | tokenVersion 累加至 2 | 幂等/累加 | P1 | 每次自增,不被覆盖 | | TC-0832 | POST /api/auth/refreshToken 或 logout | 静态 wiring 检查:routes.go 中 `/auth/refreshToken` 所在 `rest.WithMiddlewares(...)` 块必须包含 `serverCtx.RefreshTokenRateLimit` | 正则匹配 | 命中 | 架构/wiring | P0 | 防有人把中间件从路由剥离而忘了通知 QA | | TC-0833 | POST /api/auth/refreshToken 或 logout | 行为验证:构造等价中间件链(quota=1),同 IP 连打 2 次;再换 IP 打 1 次 | RemoteAddr 三个样本 | 首次放行(业务层 401);同 IP 第 2 次 `Code=429` "过于频繁";不同 IP 不受影响 | 安全/限流 | P0 | 与 wiring 正交交叉验证,限流真实生效且按 IP 隔离 | | TC-0983 | POST /api/auth/refreshToken 或 logout | 成功路径:DB tokenVersion +1 且新 access/refresh claims.TokenVersion 严格等于 DB 新值 | 合法旧 refresh | 三者相等 | 正向/契约 | P0 | 预签版本 ↔ CAS 版本一致 | | TC-0984 | POST /api/auth/refreshToken 或 logout | CAS 不命中("其他并发赢家已把 DB 推到 version=1"后再来一次 claims=0) | 抢先 IncrementTokenVersionIfMatch | 返回 401 "登录状态已失效",DB tokenVersion 不得再 +1 | 对抗/一致性 | P0 | 失败分支绝不推进 DB | | TC-0985 | POST /api/auth/refreshToken 或 logout | 多轮链式刷新 + 旧 token 重放 | 连刷两次后重放第 1 次的新 refreshToken | 第三次 401,DB tokenVersion 不得再 +1 | 对抗 | P0 | 新 token 版本号必须匹配 DB,重放必拦 | | TC-1066 | POST /api/auth/refreshToken 或 logout | 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 | 核心正向 | | TC-1067 | POST /api/auth/refreshToken 或 logout | helper:`claims.TokenVersion` 与 DB 不一致 → `ErrTokenVersionMismatch` | claims.TokenVersion=0,DB=5 | `errors.Is(err, userModel.ErrTokenVersionMismatch)`;DB tokenVersion 不变;`tokens` 零值 | 安全 | P0 | CAS mismatch 不升版 | | TC-1068 | POST /api/auth/refreshToken 或 logout | helper:用户已删除 → `ErrTokenVersionMismatch` | 先 Insert 再 Delete;claims 携带该 id | 同上 error;不 panic | 边界 | P0 | 删后 CAS | | TC-1069 | POST /api/auth/refreshToken 或 logout | 跨协议互认:HTTP 签出的 refreshToken 能被 gRPC RefreshToken 无缝续签 | HTTP 首刷成功(v0→v1,拿到 newRt1);把 newRt1 直接灌到 gRPC | gRPC 返 `NoError`;新 tokens 非空;DB tokenVersion 再 +1(v1→v2) | 契约/集成 | P0 | 核心反漂移 | | TC-1070 | POST /api/auth/refreshToken 或 logout | 跨协议互认:gRPC 签出的 refreshToken 能被 HTTP RefreshToken 无缝续签 | gRPC 先刷(v0→v1);把新 rt 灌到 HTTP | HTTP `err==nil`,DB 前进(v1→v2) | 契约/集成 | P0 | 对称镜像 | | TC-1071 | POST /api/auth/refreshToken 或 logout | gRPC 重放:旧 rtV0 已被用过一次,再发给 gRPC 必须 Unauthenticated(而非 Internal) | gRPC 首刷成功;再用**同一个** rtV0 调 gRPC | `status.Code()==codes.Unauthenticated`;Msg 含 "登录状态已失效" | 安全/错误映射 | P0 | `ErrTokenVersionMismatch` 的协议映射 | | TC-1117 | POST /api/auth/refreshToken | RotateRefreshToken post-commit UD 缓存清理与请求 ctx 解耦 | 构造 `parent = WithCancel(bg)`;插入 user + 预热 UD cache;`RotateRefreshToken(parent, ...)` 成功返回后 `cancel(parent)` | RotateRefreshToken `err==nil`;DB tokenVersion 前进 1;再次 `UserDetailsLoader.Load(userId, "")` 读到的 `ud.TokenVersion` 必须是 **DB 新值**(说明 Clean 已在 detached ctx 上触发),Redis 里原 `cacheKey` 不再命中 | 安全/生命周期 | P0 | M-R14-1:`UserDetailsLoader.Clean` 必须跑在 `DetachCacheCleanCtx` 返回的新 ctx 上,否则请求 ctx 在 HTTP deadline / client 断连场景里会把 Clean 连带 cancel,留 5 分钟 TTL 的"旧 tokenVersion UD 缓存" 让旧 access token 复活 | ### 2.3 同步权限 `POST /api/perm/sync` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0036 | POST /api/perm/sync | 全部新增 | `{"appKey":"ak","appSecret":"as","perms":[{"code":"x","name":"y"}]}` | code=0, added=1, updated=0, disabled=0 | 正常路径 | P0 | toInsert→BatchInsert | | TC-0037 | POST /api/perm/sync | 更新已有(名称变更) | 已存在code但name不同 | updated=1 | 正常路径 | P0 | toUpdate→BatchUpdate | | TC-0038 | POST /api/perm/sync | 无变化 | 已存在且name/remark/status均相同 | added=0, updated=0 | 分支覆盖 | P1 | 跳过更新 | | TC-0039 | POST /api/perm/sync | 禁用权限重启 | 已status=2的权限在列表中 | updated=1, status恢复1 | 分支覆盖 | P1 | Status!=1条件 | | TC-0040 | POST /api/perm/sync | 移除不在列表的权限 | DB有多余权限 | disabled>0 | 正常路径 | P0 | DisableNotInCodes | | TC-0041 | POST /api/perm/sync | 空perms数组被拒绝 | `{"...","perms":[]}` | code=400, "权限列表不能为空" | 输入校验 | P0 | 空列表校验,防止意外批量禁用 | | TC-0042 | POST /api/perm/sync | 验证disabled返回值 | 已知DB有5条,perms仅含2条 | disabled=3 | 功能验证 | P0 | RowsAffected() | | TC-0043 | POST /api/perm/sync | appKey无效 | `{"appKey":"invalid"}` | code=401 | 异常路径 | P0 | FindOneByAppKey失败 | | TC-0044 | POST /api/perm/sync | appSecret错误 | secret不匹配 | code=401 | 异常路径 | P0 | AppSecret比对 | | TC-0045 | POST /api/perm/sync | 产品已禁用 | product.Status!=1 | code=403 | 分支覆盖 | P0 | Status!=1 | | TC-0046 | POST /api/perm/sync | 大批量(1000条) | 1000条perms | added=1000 | 性能 | P2 | BatchInsert性能 | | TC-0047 | POST /api/perm/sync | 重复code去重 | perms中包含两个相同code | 仅处理一次, added=1(而非2) | 分支覆盖 | P0 | seen去重 | | TC-0048 | POST /api/perm/sync | 事务保护-中途失败回滚 | 模拟BatchUpdate失败 | 全部操作回滚, 返回SyncPermsError(500,"同步权限事务失败"), 不透传DB错误 | 事务验证 | P0 | LockByCodeTx→FindMapByProductCodeWithTx→BatchInsert→BatchUpdate 任一失败统一 500 | | TC-0807 | POST /api/perm/sync | `FindMapByProductCodeWithTx` 与非事务版返回等价 | Tx + 产品有启/禁用权限 | 两次调用数据一致;仅 `status=1` 被返回 | 一致性 | P0 | 基础设施:事务内读 perm map | | TC-0808 | POST /api/perm/sync | 产品无权限时返回 | 空集 | 非 nil 空 map(防 upstream NPE) | 健壮性 | P1 | 空产品语义固定 | | TC-0809 | POST /api/perm/sync | `LockByCodeTx` 对存在 code 返回完整行 | Tx 内 `SELECT ... FOR UPDATE` | 行数据完整 | 正常路径 | P0 | 基础设施:事务内锁产品行 | | TC-0810 | POST /api/perm/sync | 对不存在 code | Tx | `sqlx.ErrNotFound` | 分支 | P0 | 让 logic 层分辨"产品不存在" vs "DB 错误" | | TC-0811 | POST /api/perm/sync | 两个事务同时锁同一行 | 并发 `LockByCodeTx` | 后者被阻塞,前者 commit 后才继续 | 并发/锁 | P0 | 实证 FOR UPDATE 的行级锁语义 | | TC-0826 | POST /api/perm/sync | 同一 perm code 在 req 中重复 | `perms = [A, A]` | 落盘仅 1 条(入参内部去重) | 防自伤 | P0 | 入参级去重,避免 tx 内自撞 UNIQUE | | TC-0843 | POST /api/perm/sync | mock 断言事务内必调用 `LockByCodeTx` | 1 次正常 sync | `LockByCodeTx` 在 tx 内被调用过且先于 `FindMapByProductCodeWithTx` | 架构 | P0 | 锁必须落在 tx 内,顺序固定 | | TC-0844 | POST /api/perm/sync | mock `LockByCodeTx` 返回 `sqlx.ErrNotFound` | 在 tx 内返回 NotFound | `SyncPermsError.Code == 404`,文案 "产品不存在" | 分支 | P0 | tx 内识别产品被删 → 404 | | TC-0845 | POST /api/perm/sync | mock `LockByCodeTx` 返回通用 error | boom | SyncPermsError/500 包裹;不泄露原始 driver 错误 | 容错 | P1 | 非 NotFound 错误必须回滚为 500 | | TC-0979 | POST /api/perm/sync | REST:tx 内 `LockByCodeTx → ErrNotFound` | AppKey/AppSecret 有效但产品被并发删 | 返回 `response.CodeError{Code:404, "产品不存在"}` | 契约 | P0 | REST 侧必映射 404 | | TC-0980 | POST /api/perm/sync | REST 反例:未映射 `se.Code=500` | tx 内非业务 err | 继续走 default 分支原样透传 `SyncPermsError` | 契约/反向 | P1 | 防一刀切把 500 误归 404 | | TC-0981 | POST /api/perm/sync | gRPC:同 LockByCodeTx ErrNotFound | 同 TC-0979 输入 | `status.Code() == codes.NotFound`,文案 "产品不存在" | 契约 | P0 | gRPC 对外契约 | | TC-0982 | POST /api/perm/sync | gRPC 反例:未映射 code | 同 TC-0980 | `codes.Internal`,不得被误分类为 NotFound | 契约/反向 | P1 | 防 SDK 误触发重试 | | TC-1021 | POST /api/perm/sync | 既有 404 路径用例的 `LockByCodeTx` mock 显式携带 `Status=1` | 原场景保持不变 | 行为不变 | 适配 | P0 | 所有既有 audit 路径都必须显式带 `Status=1`,否则命中 403 分支 | | TC-1022 | POST /api/perm/sync | Dedup / mock / TxLock 三路径下事务内 Status 复核全覆盖 | 同上 | 行为不变 | 适配 | P0 | 对事务内 Status 复核全覆盖 | | TC-1023 | POST /api/perm/sync | gRPC `SyncPermissions` 入口同样落入 `Status=1` 契约 | `LockByCodeTx` 必带 `Status=1` | UnmappedCode 仍走 Internal | 适配 | P0 | gRPC 层也落入同一契约 | | TC-1063 | POST /api/perm/sync | 纯新增(added>0 && updated==0 && disabled==0)→ **必须**触发 CleanByProduct | 执行首次 `ExecuteSyncPerms` 打底,之后用 `primeProductIndex` 置 canary;再次触发 `ExecuteSyncPerms(perms=全新 codes)` | canary 被删除;`added>0`;全权用户(SuperAdmin/本产品 ADMIN/DEVELOPER/DEV 启用成员)的 UD 聚合缓存必须立刻刷新,不得在 5min TTL 窗口内继续返回旧 perm 集合 | 契约/安全 | P0 | 全权用户 loadPerms 走 `FindAllCodesByProductCode(productCode)`,新增 perm 若不清 UD 会造成 5min 视图偏差,所有触达 perm 的分支都要失效 | | TC-1064 | POST /api/perm/sync | 至少一条 update(code 存在但 name/Status/Sort 变更)→ **必须**触发 CleanByProduct | 预置 canary + 一条已有 perm;然后 sync 带同 code 但改名 | canary 被删除(CleanByProduct 触达);`updated>0` | 契约 | P0 | update 路径 | | TC-1065 | POST /api/perm/sync | 至少一条 disable(列表里不含的 perm 被置 Disabled)→ **必须**触发 CleanByProduct | 同上但 sync 不传原 code;旧 perm 被禁用 | canary 被删除;`disabled>0` | 契约 | P0 | disable 路径 | | TC-1118 | POST /api/perm/sync | ExecuteSyncPerms post-commit CleanByProduct 与请求 ctx 解耦 | `parent = WithCancel(bg)`;第一次 sync 注入 perm 打底;第二次 sync 改 Name 前用 `primeProductIndex` 预置 canary;传 `parent` 执行第二次 sync 后 **立即** `cancel(parent)`,再观察 Redis | 第二次 sync 返回 `err==nil`、`updated==1`;`productIndexKey` canary 已被删除 —— 说明 `CleanByProduct` 跑在 `DetachCacheCleanCtx` 上、与 parent cancel 解耦;若回退为"直接用 parent ctx",Redis DEL 会被 cancel 打断 → canary 残留 | 安全/生命周期 | P0 | M-R14-1:与 TC-1117 对偶,堵住"事务已提交但 UD 仍在 5 分钟 TTL 内挂着被禁用 perm" 的窗口 | ### 2.4 获取用户信息 `POST /api/auth/userInfo` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0049 | POST /api/auth/userInfo | 正常获取-含productCode | Bearer token (含productCode) | code=0, 完整UserInfo+实时perms | 正常路径 | P0 | userInfoLogic全路径 | | TC-0050 | POST /api/auth/userInfo | 不含productCode | Bearer token (无productCode) | perms为空 | 分支覆盖 | P1 | productCode="" | | TC-0051 | POST /api/auth/userInfo | 未登录 | 无Authorization头 | code=401, "未登录" | 异常路径 | P0 | middleware拦截 | | TC-0052 | POST /api/auth/userInfo | token过期 | 过期token | code=401 | 异常路径 | P0 | middleware | | TC-0053 | POST /api/auth/userInfo | userId=0 | 伪造claims | code=401, "未登录" | 分支覆盖 | P1 | userId==0 | ### 2.5 修改密码 `POST /api/auth/changePassword` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0054 | POST /api/auth/changePassword | 正常修改 | `{"oldPassword":"123456","newPassword":"654321"}` | code=0 | 正常路径 | P0 | changePasswordLogic全路径 | | TC-0055 | POST /api/auth/changePassword | mustChangePassword重置 | 正常修改后 | DB中mustChangePassword=2 | 功能验证 | P0 | user.MustChangePassword=2 | | TC-0056 | POST /api/auth/changePassword | 原密码错误 | `{"oldPassword":"wrong","newPassword":"newpwd"}` | code=400, "原密码错误" | 异常路径 | P0 | bcrypt失败 | | TC-0057 | POST /api/auth/changePassword | 新密码少于8字符 | `{"oldPassword":"old","newPassword":"Pas1234"}` | code=400, "密码长度不能少于8个字符" | 输入校验 | P0 | len<8 | | TC-0058 | POST /api/auth/changePassword | 新密码恰好8字符(含大小写+数字) | `{"oldPassword":"old","newPassword":"Abcdef1x"}` | code=0 | 边界 | P1 | len==8,含大小写+数字 | | TC-0059 | POST /api/auth/changePassword | 新密码空字符串 | `{"oldPassword":"old","newPassword":""}` | code=400 | 边界 | P0 | len("")=0<8 | | TC-0060 | POST /api/auth/changePassword | 新密码超过72字符 | `{"oldPassword":"old","newPassword":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | len>72 | | TC-0061 | POST /api/auth/changePassword | 新密码恰好72字符 | `{"oldPassword":"old","newPassword":"a*72"}` | code=0 | 边界 | P1 | len==72 | | TC-0062 | POST /api/auth/changePassword | 新旧密码相同 | `{"oldPassword":"123456","newPassword":"123456"}` | code=400, "新密码不能与原密码相同" | 输入校验 | P0 | OldPassword==NewPassword | | TC-0063 | POST /api/auth/changePassword | 用户不存在 | token中userId已删除 | code=404 | 异常路径 | P1 | FindOne失败 | | TC-0769 | POST /api/auth/changePassword | ChangePassword 超过 TokenOpLimiter 配额 | 同一 userId 连续调用超限 | 429 "操作过于频繁,请稍后再试" | 安全/限流 | P0 | `TokenOpLimiter.Take("chpwd:%d")` | | TC-0770 | POST /api/auth/changePassword | 冻结用户调用 ChangePassword | user.Status=Disabled | 403 "账号已被冻结" | 安全 | P0 | bcrypt 前检查 `user.Status` | | TC-0771 | POST /api/auth/changePassword | 原密码错误时记录日志 | 错误密码 | 400 "原密码错误" + 日志含 `change-password old-password mismatch` | 可观测 | P1 | 失败日志可审计 | | TC-1015 | POST /api/auth/changePassword | `UpdatePassword` 返回 `ErrUpdateConflict` 时必须回 409 | mock `UpdatePassword → ErrUpdateConflict` | `CodeError.Code()==409`;文案含 "密码已被其他会话修改" | 契约 | P0 | 主路径断言 | | TC-1016 | POST /api/auth/changePassword | 非 `ErrUpdateConflict` 的 raw error 仍需透传(由 rest 兜 500) | mock `UpdatePassword → errors.New("driver: bad connection")` | `errors.Is(err, genericErr)==true`;**不是** CodeError(不得被误吞为 409) | 反向契约 | P0 | 防止修复把所有错误都误包为 409 | | TC-1039 | POST /api/auth/changePassword | Model 层正向:`expectedUpdateTime` 与 DB 一致 → 成功 + tokenVersion+1 + updateTime 前进 | 直接调 `UpdatePassword(id, username, newHash, MustChangePasswordNo, existing.UpdateTime)` | `err==nil`;再 FindOne → password/updateTime/tokenVersion 全部按预期前进 | 契约 | P0 | happy path 钉死新签名语义 | | TC-1040 | POST /api/auth/changePassword | 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 | 核心反回归——若回退到"内部自 FindOne",这里会误成功 | | TC-1041 | POST /api/auth/changePassword | Model 层并发:同一 expectedUpdateTime 两 goroutine 并行 CAS | 2 个 goroutine 共享 T0,并发 `UpdatePassword` | 恰好 1 个成功、1 个 `ErrUpdateConflict`;DB 最终密码 = 赢者的密码;tokenVersion 只 +1 不是 +2 | 并发/契约 | P0 | 并发单胜者;`tokenVersion` 被累计两次会立即暴露退化 | | TC-1042 | POST /api/auth/changePassword | Logic 层 E2E:同一 user 连续两次用 "同一旧密码 P0" 发起 ChangePassword,第二次必须 400 "旧密码错误" | 第一次 P0→P1(200),第二次仍送 oldPass=P0 | 第二次 `CodeError.Code()==400`,msg 含 "旧密码错误";**不得**成 409(否则 400/409 语义混淆) | 边界 | P0 | 400/409 分桶契约 | | TC-1043 | POST /api/auth/changePassword | Logic 层 mock:ChangePassword 必须把"外层 FindOne 拿到的 user.UpdateTime" 原封不动透传给 Model 层 | mock `UpdatePassword(id, username, _, MustChangePasswordNo, snapshotUpdateTime)`,断言第 5 个实参 | mock EXPECT 命中;若回退为"Model 内部再读 updateTime",这里会拿到零值触发失败 | 契约 | P0 | CAS 快照来源契约 | | TC-1179 | POST /api/auth/changePassword | "新旧密码相同"校验必须排在 `bcrypt.CompareHashAndPassword` 之前 | 用假冒 OldPassword(bcrypt 比对会失败),但 NewPassword 与 OldPassword 字面相等;请求落 `ChangePassword` | `CodeError.Code()==400`;文案精确等于 "新密码不能与原密码相同"(若文案是"原密码错误"说明顺序被误回滚);`UpdatePassword` 绝不被调用 | 性能/契约 | P1 | 廉价字符串判等要跑在昂贵 bcrypt 之前,防止攻击者借 OldPassword 长度/并发把 bcrypt 放大成 CPU DoS;也把"用户输入了同一个新密码"早期吐回,避免无谓的 hash | ### 2.6 创建产品 `POST /api/product/create` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0064 | POST /api/product/create | 正常创建 | `{"code":"new","name":"新产品"}` | code=0, id/appKey/appSecret/adminUser/adminPassword | 正常路径 | P0 | TransactCtx全路径 | | TC-0065 | POST /api/product/create | 事务回滚-用户创建失败 | 模拟InsertWithTx User失败 | 返回错误, DB无新产品 | 事务验证 | P0 | TransactCtx回滚 | | TC-0066 | POST /api/product/create | 事务回滚-成员创建失败 | 模拟InsertWithTx Member失败 | 产品和用户均回滚 | 事务验证 | P0 | TransactCtx回滚 | | TC-0067 | POST /api/product/create | 编码已存在 | `{"code":"existing","name":"x"}` | code=409 | 异常路径 | P0 | FindOneByCode成功 | | TC-0068 | POST /api/product/create | 并发创建同编码 | 两请求同时 | 一成功一冲突 | 并发 | P1 | uk_code | | TC-0069 | POST /api/product/create | createProduct 含空格被拒绝 | code="abc def" | 400 "产品编码格式不合法" | 输入校验 | P0 | productCodeRegexp | | TC-0070 | POST /api/product/create | createProduct 含特殊字符被拒绝 | code="abc@def" | 400 | 输入校验 | P0 | productCodeRegexp | | TC-0071 | POST /api/product/create | createProduct 全中文被拒绝 | code="产品一" | 400 | 输入校验 | P0 | productCodeRegexp | | TC-0072 | POST /api/product/create | createProduct 纯数字开头被拒绝 | code="1abc" | 400 | 输入校验 | P0 | productCodeRegexp 首字符限定 | | TC-0073 | POST /api/product/create | createProduct 空字符串被拒绝 | code="" | 400 | 边界 | P0 | | | TC-0074 | POST /api/product/create | createProduct 长度>64 被拒绝 | code="a"*65 | 400 "产品编码长度不能超过64个字符" | 边界 | P0 | len>64 | | TC-0075 | POST /api/product/create | createProduct 合法编码(含下划线/中划线/数字) | code="pc_01-test" | 创建成功 | 正常路径 | P0 | Regexp 正向匹配 | | TC-0774 | POST /api/product/create | appKey 长度=32 hex字符 (16字节) | CreateProduct | appKey 长度 = 32 | 功能 | P0 | `generateRandomHex(16)` → 32 hex chars | | TC-0775 | POST /api/product/create | appSecret 长度=64 hex字符 (32字节) | CreateProduct | appSecret 长度 = 64 | 功能 | P0 | `generateRandomHex(32)` → 64 hex chars | | TC-0776 | POST /api/product/create | 初始管理员密码长度=24 hex字符 (12字节) | CreateProduct | adminPassword 长度 = 24 | 功能 | P0 | `generateRandomHex(12)` → 24 hex chars,96 bit 熵 | | TC-0827 | POST /api/product/create | mock `InsertWithTx` 返回 mysql error 1062,message 不含 "uk_code" | `&mysql.MySQLError{Number:1062,Message:"generic"}` | 返回 `response.ErrConflict`(409) | 错误映射 | P0 | 去掉脆弱 `strings.Contains` 依赖,靠 `mysql.MySQLError.Number` 判定 | | TC-0901 | POST /api/product/create | happy path:超管用有效 ticket 取回初始凭据 | superAdmin ctx + 合法 ticket | 返回 AppKey/AppSecret/AdminUser/AdminPassword;AppSecret 与 DB 中 bcrypt 匹配 | 正常路径 | P0 | 凭据落地 Redis 后端,响应体不再明文外泄 | | TC-0902 | POST /api/product/create | 相同 ticket 二次消费 | 同上 ticket 第二次取 | 第二次 404 "凭据票据不存在或已被消费" | 一次性 | P0 | `Redis.GetDelCtx` 原子 GET+DEL | | TC-0903 | POST /api/product/create | ticket 为空 | ticket="" | 400 "ticket 不能为空" | 异常路径 | P0 | 入参校验 | | TC-0904 | POST /api/product/create | ticket 未知 | 随机 64 字符 ticket | 404 "凭据票据不存在或已被消费" | 异常路径 | P0 | 无存在性差异化,避免枚举 oracle | | TC-0905 | POST /api/product/create | 非超管调用 | ADMIN ctx | 403 | 权限 | P0 | RequireSuperAdmin 生效 | | TC-0906 | POST /api/product/create | 未登录调用 | context.Background() | 401/403 | 权限 | P0 | 无 UserDetails 时拒绝 | | TC-0907 | POST /api/product/create | Redis 中 payload 被人为破坏 | 手工写入非 JSON 字符串 | 500 "凭据数据异常" 并删除该 key | 健壮性 | P1 | 防腐败数据长留 | | TC-0908 | POST /api/product/create | Redis key 结构正确 | 观察实际 key | `pm:initcred:{ticket}`;TTL ≤ 300s | 契约 | P1 | 运维可定位 | | TC-0909 | POST /api/product/create | TTL 与响应 expiresAt 一致 | 观察返回的 credentialsExpiresAt | Redis TTL == Unix(expiresAt)-now (±5s) | 契约 | P1 | 客户端过期提示与后端一致 | | TC-0910 | POST /api/product/create | 并发消费同一 ticket | 32 goroutine 同时 Fetch | 仅 1 个成功,其余 31 个返回 404 | 并发 | P0 | GetDelCtx 原子性抗竞态 | | TC-0911 | POST /api/product/create | CreateProductResp JSON 不含 appSecret/adminPassword | marshal resp → json | 字段 `appSecret`/`adminPassword` 不出现 | 契约 | P0 | 响应体永不明文 | | TC-0912 | POST /api/product/create | CreateProductResp 必含 credentialsTicket + credentialsExpiresAt | marshal resp | 两字段均非空/正数 | 契约 | P0 | 新的获取链路必备字段 | | TC-0961 | POST /api/product/create | body 非法 JSON | `{not-valid-json` + superAdmin ctx | 400,且文案不含 sql/redis/ticket 关键字 | 契约/健壮性 | P0 | `httpx.Parse` 错误透传;不泄字段与实现细节 | | TC-0962 | POST /api/product/create | 无登录上下文 | 不注入 UserDetails | 401 "未登录" | 权限 | P0 | handler 自身也必须 fail-close,不依赖 JwtAuth 中间件 | | TC-0963 | POST /api/product/create | 非超管 ctx | MemberType=ADMIN | 403 "仅超级管理员可执行此操作",文案不含 "ticket" | 权限/信息泄漏 | P0 | RequireSuperAdmin 透传;防 ticket 存在性 oracle | | TC-0964 | POST /api/product/create | 超管 + 空 ticket | `{"ticket":""}` | 400,文案含 "ticket" | 契约 | P0 | 入参必填校验 | | TC-0965 | POST /api/product/create | 超管 + 未知 ticket | 随机字符串 | 400 "凭证票据无效或已过期",与"过期"共用文案 | 安全 | P0 | 防枚举 oracle,与 logic TC-0904 同契约 | | TC-0966 | POST /api/product/create | 超管 + 已落地 ticket | 手工 SETEX 合法 JSON payload | HTTP 200 + 4 字段完整映射;Redis key 被 `GetDel` 消费 | 正常路径/一致性 | P0 | 字段映射正确 + 一次性消费 | | TC-0967 | POST /api/product/create | 静态 wiring:JwtAuth 绑定 | 读取 `routes.go` 源码 | `/fetchInitialCredentials` 所在 `rest.WithMiddlewares` 列表含 `serverCtx.JwtAuth`;prefix=`/api/product` | 回归/静态 | P0 | 防未来 goctl 覆写丢失中间件 | | TC-0968 | POST /api/product/create | 静态反证:不得挂到限流组 | 读取 `routes.go` 源码 | `/fetchInitialCredentials` 绝不出现在 `AdminLoginRateLimit` / `ProductLoginRateLimit` / `RefreshTokenRateLimit` / `SyncRateLimit` 的中间件块内 | 回归/静态 | P0 | 防被错迁到无鉴权组 | | TC-0976 | POST /api/product/create | Redis 整个不可达(`SetexCtx` 永久失败) | 正常 CreateProduct 请求 + broken Redis | 返回 5xx 错误;`sys_product`/`sys_user`/`sys_product_member` 行数各 0 | 对抗/一致性 | P0 | 失败链路必须把三张新建行全部补偿掉 | | TC-0977 | POST /api/product/create | Redis 失败 + 补偿成功后以同 Code 重建 | 正常 Redis + 相同 productCode | 第二次创建成功,不被 UNIQUE 约束阻塞 | 正向/幂等 | P0 | 补偿把位点清空,同 Code 不卡住 | | TC-0978 | POST /api/product/create | 补偿顺序显式校验(child → parent) | 观察三表最终行数 | `sys_product_member`、`sys_user`、`sys_product` 均为 0 | 契约 | P0 | 删除顺序与外键契约一致 | | TC-1029 | POST /api/product/create | `seedAdminDept(t, ctx, svcCtx)` 集中化 | 单次调用插一条启用部门 + `t.Cleanup` | 返回 deptId;测试结束自动清理 | 基础 | — | `internal/logic/product/helper_test.go` | | TC-1030 | POST /api/product/create | `CreateProduct` 所有正向用例携带 `AdminDeptId` 后行为不变 | 入参带 `AdminDeptId=seedAdminDept(...)` | 行为不变 | 适配 | P0 | 契约变更全量回归 | | TC-1031 | POST /api/product/create | `FetchInitialCredentials` 票据消费路径携带 `AdminDeptId` 后行为不变 | 同上 | 行为不变 | 适配 | P0 | 票据消费路径不破坏 | | TC-1032 | POST /api/product/create | Redis 降级补偿链路径携带 `AdminDeptId` 后行为不变 | 同上,补偿路径 | 行为不变 | 适配 | P0 | Redis 降级后的补偿链保留 | | TC-1033 | POST /api/product/create | 冲突路径下 `SysDeptModel.FindOne` 返 `Status=1` 且 `AdminDeptId` 透传 | mock `SysDeptModel.FindOne` 返 `Status=1` | 行为不变;`AdminDeptId` 透传 | 适配 | P0 | mock 侧补齐 | | TC-1034 | POST /api/product/create | mock 侧两处 `CreateProductReq` 补齐 `AdminDeptId` 字段 | 同上,两处 `CreateProductReq` | 行为不变 | 适配 | P0 | mock 侧补齐 | | TC-1035 | POST /api/product/create | 一次性票据 `AdminPassword` 长度=16(不再是旧的 24) | 读取一次性票据中的 `AdminPassword` | `len(cred.AdminPassword)==16`(不是旧的 24) | 契约 | P1 | 长度断言回归 | | TC-1202 | POST /api/product/create | 落盘的 `admin_` 账号 `sys_user.Avatar` 必须是 SQL NULL | 真实 CreateProduct 完整落盘后查 `CASE WHEN avatar IS NULL` | 字段值 1(NULL 而非空串) | 契约/数据映射 | P1 | 与 CreateUser 同步契约;ORM 层 `sql.NullString{Valid:false}` 必须序列化成 SQL NULL 而非 `""`,否则未来 `WHERE avatar IS NULL` 判空分支会失准 | | TC-1203 | POST /api/product/create | tx 内必须在 `InsertWithTx` 之前对 `adminDeptId` 取 S 锁;并发 DeleteDept 已提交 → 立即 400 | mock:`FindOne` 预检通过(status=1);`TransactCtx` 执行闭包;闭包内 `FindOneForShareTx` 返 `sqlx.ErrNotFound` | 返 `CodeError.Code()==400`,文案含 "管理员部门不存在或已删除";`SysProductModel.InsertWithTx` / `SysUserModel.InsertWithTx` / `SysProductMemberModel.InsertWithTx` 一次都未被调用(若被调,gomock 在未声明 EXPECT 上立刻失败) | 并发/事务闭包 | P0 | 锁序:先 pre-check 再 tx 内 FOR SHARE,闭合"pre-check 通过→并发 DeleteDept 提交→Insert 写入幽灵 deptId"的窗口;orphan admin 一旦落盘会挂在已删除部门下,后续 DeptPath 校验全部失效 | ### 2.7 产品更新/列表/详情 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0076 | POST /api/product/update | 正常更新 | `{"id":1,"name":"新名","status":1}` | code=0 | 正常路径 | P0 | updateProductLogic | | TC-0077 | POST /api/product/update | 不存在 | `{"id":9999,"name":"x"}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0078 | POST /api/product/update | 不传status | `{"id":1,"name":"x"}` | status不变 | 分支覆盖 | P1 | Status>0 | | ~~TC-0079~0083~~ | ~~POST /api/product/list 分页边界~~ | ~~正常/默认/超限/0/负值~~ | — | — | — | — | **已删除**:分页边界由 `util.TestNormalizePage` 单元测试覆盖;列表语义被 M-2 拆分为"超管走 FindList / 非超管只看自己"两条独立契约(TC-0850、TC-0871) | | ~~TC-0084~0085~~ | ~~POST /api/product/detail 正常/不存在~~ | — | — | — | — | — | **已删除**:被 M-2 契约合并改写为 TC-0852(他人产品 → 404)、TC-0853(自己产品 AppKey 脱敏)、TC-0872(FindOne 错误 → 404 无差别响应) | | ~~TC-0086 / TC-0088~~ | ~~非超管 AppKey 隐藏~~ | — | — | — | — | — | **已删除**:由 TC-0850(list)+ TC-0853(detail)覆盖 | | ~~TC-0087 / TC-0089~~ | ~~超管可见 AppKey~~ | — | — | — | — | — | **已删除**:由 TC-0854(detail)+ TC-0871(list)覆盖 | | TC-0090 | POST /api/product/update | updateProduct 非法状态值被拒绝 | status=99 | 400 "产品状态值无效" | 输入校验 | P0 | 仅允许 1/2 | | TC-1138 | POST /api/product/update | Enabled→Disabled:产品下所有启用成员 sys_user.tokenVersion 批量 +1 | 产品含 3 个 Status=1 成员 + 1 个 Status=2 成员,`req.Status=2` | 3 个启用成员的 tokenVersion 严格 +1;禁用成员 tokenVersion 不变;产品 `status=2` 同事务落盘 | 安全/会话吊销 | P0 | `BatchIncrementTokenVersionWithTx` + `FindActiveMemberUserIdsByProductCodeTx` 原子语义 | | TC-1139 | POST /api/product/update | Disabled→Enabled:tokenVersion 全部不变 | 产品原 status=2 含成员,`req.Status=1` | 所有成员 tokenVersion 不变;产品 `status=1` | 正向回归 | P0 | 重启用不让任何用户获得未曾持有的权限,无需吊销 | | TC-1140 | POST /api/product/update | 禁用产品无 active 成员:事务不崩,批量 UPDATE 跳过 | 产品下 0 启用成员 | `err==nil`;产品 `status=2`;`BatchIncrementTokenVersionWithTx` 的空分支被走过 | 边界 | P0 | `len(ids)==0` 早退;不得在空集合上构造非法 SQL | | TC-1141 | POST /api/product/update | 跨产品隔离:禁用产品 A 不递增产品 B 成员的 tokenVersion | 用户 U 同时是产品 A/B 的启用成员;禁用 A | U 的 tokenVersion +1(因 A 的成员身份);产品 B 成员仅 U 的 tokenVersion 被打高一次,其他 B 独占成员的 tokenVersion 严格不变 | 安全/跨产品最小化 | P0 | `FindActiveMemberUserIdsByProductCodeTx` 必须按 `productCode` 过滤;不得误伤其他产品专属成员 | | TC-1142 | POST /api/product/update | post-commit 失效 sysProduct id/appKey/code 三把 key | 预热三把缓存后正常更新 | 三把 key 均被 DEL | 缓存一致性 | P0 | `InvalidateProductCache` 必须被 Logic 在事务 commit 后显式调用 | | TC-0850 | POST /api/product/update 或 list/detail | MEMBER 调 ProductList | `caller.ProductCode=pA` | 仅返回 pA 一条(即使 DB 内有 pB、pC) | 安全/访问控制 | P0 | 非超管只见自己产品 | | TC-0851 | POST /api/product/update 或 list/detail | MEMBER 调 ProductList 且 `ProductCode==""` | 游离 MEMBER | 返回空列表 `Total=0, List=[]` | 边界 | P0 | 无 productCode 时降级为 0 条 | | TC-0852 | POST /api/product/update 或 list/detail | MEMBER 调 ProductDetail 查他产品 | 目标 id 属于 pB | 404 "产品不存在"(不暴露存在性) | 安全/枚举 | P0 | 区分开"存在但无权"会被当 oracle | | TC-0853 | POST /api/product/update 或 list/detail | MEMBER 调 ProductDetail 查自己产品 | 目标 id 属于 pA | 200 OK,`AppKey` 字段为空(保持原 AppKey-hidden 语义) | 正常路径 | P0 | 字段级脱敏不被取消 | | TC-0854 | POST /api/product/update 或 list/detail | 超管调 ProductDetail | 任意 id | 200 OK + AppKey 可见 | 正常路径 | P1 | 超管路径不受访问控制影响 | | TC-0855 | POST /api/product/update 或 list/detail | MEMBER 调 DeptTree | `DeptPath="/1/2/"` | 返回树中 Path 前缀匹配的子树;父部门/兄弟部门不可见 | 安全 | P0 | 按 DeptPath 剪枝 | | TC-0856 | POST /api/product/update 或 list/detail | MEMBER 调 DeptTree 且 DeptPath="" | 游离成员 | 返回空切片 `[]` | 边界 | P0 | 无 DeptPath 降级空树 | | TC-0857 | POST /api/product/update 或 list/detail | 产品 ADMIN 调 DeptTree | `AdminCtx + DeptPath="/100/1/"` | 仅返回 `/100/1/` 子树(与 MEMBER 同路径);父部门 `/100/` / 平行分支 `/200/` 不可见 | 安全/跨产品信息最小化 | P0 | `fullAccess = caller.IsSuperAdmin`;原 "ADMIN 保留组织视图" 契约已被收回,避免小产品 ADMIN 侦察大产品的 DEV/HR 部门命名 | | TC-1128 | POST /api/product/update 或 list/detail | SuperAdmin 调 DeptTree | `SuperAdminCtx` | 返回完整树(`/100/` + `/200/` 两个根) | 正常路径 | P0 | fullAccess 仅对 SuperAdmin;正向回归 | | TC-1129 | POST /api/product/update 或 list/detail | 产品 DEVELOPER 调 DeptTree | `DeveloperCtx + DeptPath="/100/1/"` | 仅返回 `/100/1/` 子树(父部门 / 平行分支不可见) | 安全 | P0 | 与 MEMBER 同剪枝路径;避免 DEVELOPER 枚举全组织结构 | ### 2.8 创建部门 `POST /api/dept/create` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0091 | POST /api/dept/create | 创建顶级部门 | `{"parentId":0,"name":"总部"}` | code=0, path="/{id}/" | 正常路径 | P0 | TransactCtx, parentPath="/" | | TC-0092 | POST /api/dept/create | 创建子部门 | `{"parentId":1,"name":"技术部"}` | code=0, path=parent.path+id+"/" | 正常路径 | P0 | parentId>0分支 | | TC-0093 | POST /api/dept/create | 父部门不存在 | `{"parentId":9999,"name":"x"}` | code=404, "父部门不存在" | 异常路径 | P0 | FindOneWithTx失败 | | TC-0094 | POST /api/dept/create | 不传DeptType默认NORMAL | `{"parentId":0,"name":"x"}` | DB deptType="NORMAL" | 分支覆盖 | P0 | deptType=""→DeptTypeNormal | | TC-0095 | POST /api/dept/create | 传DeptType=DEV | `{"parentId":0,"name":"x","deptType":"DEV"}` | DB deptType="DEV" | 正常路径 | P0 | req.DeptType赋值 | | TC-0096 | POST /api/dept/create | 事务内FindOneWithTx可见性 | TransactCtx内InsertWithTx后FindOneWithTx | 事务内可读到未提交数据 | 事务验证 | P0 | FindOneWithTx(session) | | TC-0097 | POST /api/dept/create | 事务回滚-Insert失败 | 模拟InsertWithTx失败 | DB无新记录 | 事务验证 | P0 | TransactCtx回滚 | | TC-0098 | POST /api/dept/create | 事务回滚-UpdateWithTx失败 | 模拟UpdateWithTx失败 | Insert也回滚 | 事务验证 | P1 | TransactCtx回滚 | | TC-0099 | POST /api/dept/create | 多层嵌套(5层) | 递归创建5层 | path正确拼接 | 深度测试 | P2 | path逻辑 | | TC-0100 | POST /api/dept/create | 通过Logic创建+验证Path | CreateDeptLogic.CreateDept→FindOne | path包含/{id}/ | 集成验证 | P0 | FindOneWithTx修复后端到端 | | TC-1084 | POST /api/dept/create | 父部门已 Disabled → 事务内 FindOneForShareTx 复核拒绝 | - | ParentStatus=2 | 400 "父部门已被禁用,无法创建子部门";DB 无子行 | P0 | 修复前只 SELECT id 会放行 | | TC-1085 | POST /api/dept/create | 父部门 Enabled 正向路径 + parentPath 来自事务内 snapshot | - | ParentStatus=1 | 子部门创建成功;`child.Path = parent.Path + childId + "/"` | P0 | 保证 parentPath 源自锁视图 | | TC-1086 | POST /api/dept/create | CreateDept × UpdateDept(禁用父) 并发 6 轮 | - | 每轮起 CreateDept + 裸 UPDATE `status=2` | 合法终态二选一:(a) CreateDept 先成功(父当时仍 Enabled),(b) UpdateDept 先成功 → CreateDept 收 400 "父部门已被禁用";**禁止出现 DB 残留子行 + 父 Disabled 但子 Enabled 的 write skew** | P0 | 事务内 S 锁与裸 UPDATE 的锁链闭合 | | TC-1202 | POST /api/dept/create | Sort 超出范围 [-100000, 100000] 被拒绝 | `Sort = -100001` / `Sort = 100001` / `Sort = math.MaxInt64` | 400 "排序值必须在 -100000 到 100000 之间";DB 无新行 | 边界/输入校验 | P0 | 防极端 Sort 值透传 DB 破坏同级排序稳定性;与 UpdateDept 同口径校验 | ### 2.9 部门更新/删除/树 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0101 | POST /api/dept/update | 正常更新 | `{"id":1,"name":"新名","sort":5}` | code=0 | 正常路径 | P0 | updateDeptLogic | | TC-0102 | POST /api/dept/update | 不存在 | `{"id":9999,"name":"x"}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0103 | POST /api/dept/update | DeptType NORMAL→DEV | `{"id":1,"deptType":"DEV"}` | DB deptType="DEV" | 正常路径 | P0 | DeptType合法值更新 | | TC-0104 | POST /api/dept/update | DeptType无效值返回错误 | `{"id":1,"deptType":"INVALID"}` | code=400, "部门类型无效", DB deptType不变 | 输入校验 | P0 | DeptType校验,仅NORMAL/DEV | | TC-0105 | POST /api/dept/update | DeptType变更时级联清除子部门用户缓存 | 部门从NORMAL改为DEV,有子部门含用户 | code=0, 子部门下用户缓存被清除 | 缓存验证 | P0 | 级联缓存失效 | | TC-0106 | POST /api/dept/delete | 正常删除(无子部门) | `{"id":5}` | code=0 | 正常路径 | P0 | deleteDeptLogic | | TC-0107 | POST /api/dept/delete | 有子部门 | `{"id":1}` | code=400, "存在子部门" | 业务约束 | P0 | len(children)>0 | | TC-0108 | POST /api/dept/delete | 不存在的部门 | `{"id":9999}` | code=0(Delete对不存在行不报错) | 边界 | P1 | FindByParentId空+Delete | | TC-0109 | POST /api/dept/delete | 部门下有关联用户 | 部门id指向含用户的部门 | code=400, "该部门下仍有关联用户,无法删除" | 业务约束 | P0 | 检查关联用户 | | ~~TC-0110~0112~~ | ~~POST /api/dept/tree 正常/空/孤儿~~ | — | — | — | — | — | **已删除**:M-2 后 DeptTree 按 caller 身份剪枝,旧测试假定任何身份可拿全树已不成立;新契约由 TC-0855/0856/0857(`deptTreeAccessControl_audit_test.go`)覆盖,"孤儿→根"行为隐含在 fullAccess 路径中 | | TC-0714 | POST /api/dept/update 或 delete/tree | DeptType/Status 未变更时不清缓存 | 只改 name | 无 Clean 调用 | 分支覆盖 | P1 | unchanged 分支 | | TC-0715 | POST /api/dept/update 或 delete/tree | 乐观锁冲突返回 ErrConflict | UpdateWithOptLock 返回 0 行 | 返回 409/Conflict | 并发 | P0 | 版本号冲突 | | TC-0759 | POST /api/dept/update 或 delete/tree | 10 goroutine 同时 UpdateWithOptLock 同一行 | 共享 expectedUpdateTime=t0 | 恰好 1 成功 + 9 `ErrUpdateConflict`; DB 里 UpdateTime 被推进, Remark 非初值 | 并发/竞态 | P0 | `WHERE updateTime=?` + RowsAffected 判定, 挡"无声覆盖"退化 | | TC-0766 | POST /api/dept/update 或 delete/tree | 删除有子部门的部门(FOR UPDATE 锁定读) | parentId 指向目标部门的子部门存在 | 400 "该部门下存在子部门,无法删除" | 业务约束 | P0 | `SELECT id ... FOR UPDATE` 子部门存在性锁定读 | | TC-0767 | POST /api/dept/update 或 delete/tree | 删除有关联用户的部门(FOR UPDATE 锁定读) | deptId 指向目标部门的用户存在 | 400 "该部门下仍有关联用户,无法删除" | 业务约束 | P0 | `SELECT id ... FOR UPDATE` 用户存在性锁定读 | | TC-0768 | POST /api/dept/update 或 delete/tree | CreateDept 父部门 FOR SHARE 锁生效 | parentId > 0 | 事务内对父部门 `SELECT FOR SHARE`;父不存在则 404 | 安全 | P0 | `CreateDept` 防并发删除父部门 | | TC-0846 | POST /api/dept/update 或 delete/tree | `CleanByUserIds` 批量清理多用户缓存 | 预埋 3 用户各 2 产品缓存 | Redis 中 6 条 `ud:userId:productCode` + 3 条 `ud:idx:u:*` 均被删除 | 正确性 | P0 | M-1 基础设施:SUNION + 批 DEL 必须覆盖所有索引 | | TC-0847 | POST /api/dept/update 或 delete/tree | `CleanByUserIds` 空 ids 切片 | `[]` | 立即返回,不 panic,不调用 Redis | 边界 | P1 | 防未来调用方传空列表打空 RTT | | TC-0848 | POST /api/dept/update 或 delete/tree | `UpdateDept` 改 deptType 时调 CleanByUserIds | mock: FindIdsByDeptId → [100,101],断言 CleanByUserIds 路径(通过 mock 的 FindIdsByDeptId 期望 +真实 loader 执行) | 无错误返回;FindIdsByDeptId 被调用恰好 1 次 | 行为 | P0 | UpdateDept 在变更时才触达用户列表 | | TC-0849 | POST /api/dept/update 或 delete/tree | `UpdateDept` 的 `FindIdsByDeptId` 失败 | mock 返回 err | 返回 `nil`(不是 500);旧权限缓存 TTL 兜底 | 容错 | P0 | 修复后的 degraded 成功语义 | | TC-1174 | POST /api/dept/update | DeptType DEV→NORMAL 必须批量递增部门下所有成员 tokenVersion | seed Dept(DeptType=DEV, Status=1) + 3 个成员;`{DeptType:"NORMAL"}` | 3 个成员 `sys_user.tokenVersion` 严格 +1;部门 `deptType="NORMAL"` 同事务落盘 | 安全/会话吊销 | P0 | DEV→NORMAL = 全权分支失效,必须把还挂在 DEV 身份上的 access token 打掉;`BatchIncrementTokenVersionWithTx` + `FindIdsByDeptIdForShareTx` 原子语义 | | TC-1175 | POST /api/dept/update | DEV 部门 Status Enabled→Disabled 批量递增 tokenVersion | seed Dept(DeptType=DEV, Status=1) + 2 个成员;`{Status:2}` | 2 个成员 tokenVersion +1;部门 `status=2` | 安全/会话吊销 | P0 | 禁用 DEV 部门 = 全权分支熄灯,对偶 TC-1174,同样走收窄分支 | | TC-1176 | POST /api/dept/update | NORMAL 部门 Status Enabled→Disabled 批量递增 tokenVersion | seed Dept(DeptType=NORMAL, Status=1) + 2 个成员;`{Status:2}` | 2 个成员 tokenVersion +1;部门 `status=2` | 安全/会话吊销 | P0 | 冻结部门必须冻结其成员会话,避免 DB 里 Disabled 但旧 JWT 仍通 | | TC-1177 | POST /api/dept/update | NORMAL→DEV(升权方向)tokenVersion 保持不变 | seed Dept(DeptType=NORMAL, Status=1) + 1 个成员;`{DeptType:"DEV"}` | 成员 tokenVersion 与初值严格相等;部门 `deptType="DEV"` 落盘 | 正向回归 | P0 | 升权不构成收窄,不得把合法用户无故踢下线 | | TC-1178 | POST /api/dept/update | Status Disabled→Enabled(恢复启用)tokenVersion 保持不变 | seed Dept(DeptType=NORMAL, Status=2) + 1 个成员;`{Status:1}` | 成员 tokenVersion 与初值严格相等;部门 `status=1` | 正向回归 | P0 | 解冻方向镜像 TC-1177;仅"收窄"分支递增 tokenVersion | | TC-1200 | POST /api/dept/delete | 成功删除后必须在 post-commit 上显式失效 `sysDept:id` 缓存 | 插部门 → 手工 SET 一份 ghost 快照到 `:cache:sysDept:id:`(模拟 commit 前的并发回填)→ `DeleteDept` | Redis key 被 DEL(`Exists==false`);若残留则说明 post-commit 失效兜底被误撤,旧 dept 快照最长 5min TTL 内仍可被 FindOne 读到,叠加 orphan user 会放大为跨部门授权泄漏 | 缓存一致性/安全 | P0 | `sqlc.CachedConn.ExecCtx` 在 tx commit 之前已 DelCache,commit 前任何并发读都可能把旧行回填;必须用 detached ctx 在 commit 之后再显式 `InvalidateDeptCache` | | TC-1203 | POST /api/dept/update | Sort 超出范围 [-100000, 100000] 被拒绝 | `Sort = 100001` / `Sort = -100001` | 400 "排序值必须在 -100000 到 100000 之间";DB 记录不变 | 边界/输入校验 | P0 | 与 CreateDept 同口径校验,防极端 Sort 值破坏部门树排序稳定性 | ### 2.10 权限列表 `POST /api/perm/list` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0113 | POST /api/perm/list | 正常查询 | `{"productCode":"p1","page":1,"pageSize":10}` | code=0, total/list | 正常路径 | P0 | permListLogic | | TC-0114 | POST /api/perm/list | 默认分页 | `{"productCode":"p1"}` | page=1, pageSize=20 | 分支覆盖 | P1 | NormalizePage | | TC-0115 | POST /api/perm/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap | | TC-0116 | POST /api/perm/list | 不存在的productCode | `{"productCode":"notexist"}` | total=0, list=[] | 边界 | P1 | 空结果 | ### 2.11 角色管理 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0117 | POST /api/role/create | 正常创建 | `{"productCode":"p1","name":"管理员","permsLevel":1}` | code=0, id>0 | 正常路径 | P0 | createRoleLogic | | TC-0118 | POST /api/role/create | 重复角色名 | 同产品同名 | code=409, "该产品下角色名已存在" | 业务约束 | P0 | Duplicate entry→ErrConflict | | TC-0119 | POST /api/role/create | 并发同名创建 | 两请求同时 | 一成功一冲突409 | 并发 | P1 | 唯一索引+1062捕获 | | TC-0120 | POST /api/role/update | 正常更新 | `{"id":1,"name":"新名","permsLevel":2}` | code=0 | 正常路径 | P0 | updateRoleLogic | | TC-0121 | POST /api/role/update | 不存在 | `{"id":9999,...}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0122 | POST /api/role/list | 正常查询 | `{"productCode":"p1","page":1,"pageSize":10}` | code=0 | 正常路径 | P0 | roleListLogic | | TC-0123 | POST /api/role/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap | | TC-0124 | POST /api/role/detail | 正常查询 | `{"id":1}` | code=0, 含permIds | 正常路径 | P0 | roleDetailLogic | | TC-0125 | POST /api/role/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0730 | POST /api/role/* | 非超管 admin 把 roleA.PermsLevel 从 100 调到 10(数字变小 = 提升权级) | AdminCtx,PermsLevel 100→10 | 403 "非超管不能提升角色的权限级别",DB 保持 100 | 安全 | P0 | caller.IsSuperAdmin=false && newLevel=old 放行 | | TC-0732 | POST /api/role/* | 超管降低 PermsLevel | SuperAdminCtx,500→10 | 成功 | 正常路径 | P0 | IsSuperAdmin 绕开 | | TC-0733 | POST /api/role/* | PermsLevel 越界(0/-1/1000/10000) | 任意非法 PermsLevel | 400 "权限级别必须在 1-999 之间" | 边界 | P0 | L-3 前置校验 | | TC-0777 | POST /api/role/* | UpdateRole post-commit 缓存清理失败时仍返回成功 | `FindUserIdsByRoleId` 返回 err | handler 返回 nil(200)——缓存失效尽力而为,不得因缓存失败把已提交事务映射为 500 让客户端误重试 | 容错 | P0 | 对齐 `UpdateRole` 注释语义;与 TC-0859 联合覆盖 | | TC-0778 | POST /api/role/* | UpdateRole 乐观锁冲突 | 并发修改同一角色 | 409 "数据已被其他操作修改,请刷新后重试" | 并发安全 | P0 | `UpdateWithOptLock` WHERE updateTime=? | | TC-0779 | POST /api/role/* | UpdateProduct 乐观锁冲突 | 并发修改同一产品 | 409 同上 | 并发安全 | P0 | `UpdateWithOptLock` WHERE updateTime=? | | TC-0780 | POST /api/role/* | UpdateMember 基于事务内 locked 数据更新 | 正常更新 | 成功,使用 `locked` 行数据组装 UPDATE | 数据一致 | P0 | 事务内 `FindOneForUpdateTx` 结果作为更新基础 | | TC-0858 | POST /api/role/* | `BindRolePerms`:事务 OK,`FindUserIdsByRoleId` 返回 err | mock | 返回 nil(200)——不再映射 500 | 错误映射 | P0 | degraded 成功;客户端不应重试 | | TC-0859 | POST /api/role/* | `UpdateRole`:事务 OK,`FindUserIdsByRoleId` 返回 err | mock | 返回 nil(200) | 错误映射 | P0 | 同上 | | TC-1000 | POST /api/role/* | 非超管访问别的产品的 role | Admin in `test_product`;目标 role 在 `mn3_other_xxx` | 404 "角色不存在" | 安全/Oracle | P0 | 跨产品必须 404 而非 403 | | TC-1001 | POST /api/role/* | "id 不存在" vs "跨产品" 响应对比 | 两条路径对照 | code + body 完全一致 | 安全/Oracle | P0 | 彻底消除枚举 oracle | | TC-1002 | POST /api/role/* | 超管跨产品访问 | 超管 → 跨产品 role + permIds | 正常返回完整 RoleItem | 正向 | P0 | 审计/运维路径不得被误伤 | | TC-1119 | POST /api/role/update | L-R14-1 非超管访问别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `UpdateRole(id)`,该 roleId 实际归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未写入 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 收敛 404 vs 403 枚举 oracle,与 RoleDetail 的 M-N3 口径一致 | | TC-1197 | POST /api/role/create | 非超管 product ADMIN 禁止创建 `permsLevel=1` 顶格角色 | `AdminCtx(pc)` + `{productCode:pc, name:...,permsLevel:1}` | `CodeError.Code()==403`;文案含 "权限级别为 1 的顶格角色";DB 无新角色 | 安全/纵向越权 | P0 | 顶格角色只能由 SuperAdmin 创建;若放行 ADMIN,其可"建 R_super + BindRoles 给下属" 绕开 `GuardRoleLevelAssignable` 的同级拦截,形成等价横向提权链路 | | TC-1198 | POST /api/role/create | product ADMIN 创建 `permsLevel>=2` 次级角色放行 | `AdminCtx(pc)` + `{productCode:pc, name:..., permsLevel:2}` | 成功;DB `sys_role.permsLevel=2` | 正向回归 | P0 | 防 `GuardCreateRolePermsLevel` 过度收紧把合法业务路径也打死 | | TC-1199 | POST /api/role/create | SuperAdmin 不受 `permsLevel=1` 约束 | `SuperAdminCtx()` + `{..., permsLevel:1}` | 成功;DB `sys_role.permsLevel=1` | 正向回归 | P0 | SuperAdmin 是顶格角色的唯一合法来源;若回滚把超管也拦住,系统将没有任何路径能初始化 permsLevel=1 的角色 | | TC-1204 | POST /api/role/update | UpdateRole 重命名后旧 name 索引缓存必须失效 | 超管创建角色 name=A;第一次 Load 让 `sysRole:productCode:name::A` 写入缓存;UpdateRole 把 name 改为 B;再次 `FindOneByProductCodeName(pc, "A")` | `FindOneByProductCodeName` 返回 `sqlx.ErrNotFound`(不得返回旧行数据);说明 post-commit `InvalidateRoleCache(oldName)` 已把 Redis 里的 `:A` 索引键清掉 | 缓存一致性/安全 | P0 | `UpdateWithOptLock` 内部只失效新 name 键;rename 路径的旧 name 键必须在 post-commit 由 `InvalidateRoleCache(prevName)` 显式清除,否则 Redis TTL 窗口内同名并发创建会命中幽灵快照 | ### 2.12 删除角色 `POST /api/role/delete` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0126 | POST /api/role/delete | 正常删除+级联 | `{"id":5}` (含权限/用户绑定) | code=0, role_perm/user_role同步清理 | 正常+事务 | P0 | TransactCtx全路径 | | TC-0127 | POST /api/role/delete | 事务回滚 | 模拟DeleteWithTx失败 | 级联删除回滚 | 事务验证 | P0 | TransactCtx | | TC-0128 | POST /api/role/delete | 无关联数据 | 新角色无绑定 | code=0 | 分支覆盖 | P1 | 删0条 | | TC-1120 | POST /api/role/delete | L-R14-1 非超管 DeleteRole 别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `DeleteRole(id)`,该 roleId 归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";DB 未删 | 安全/枚举 | P0 | `authHelper.ResolveOwnRoleOr404` 统一收敛,与 UpdateRole / RoleDetail 对齐 | | TC-1201 | POST /api/role/delete | 成功删除后 post-commit 必须同时失效 `sysRole:id` 与 `sysRole:productCode:name` 两把键 | 插角色 → 手工 SET ghost 到 `:cache:sysRole:id:` 与 `:cache:sysRole:productCode:name::` → `DeleteRole` | 两把 Redis key 均被 DEL;若 id key 残留则 `FindOne(id)` 命中 ghost → CAS 撞不到行回 409;若 name key 残留则同名 role 在删除后反复被"已存在"误拒(uniqueness oracle) | 缓存一致性/契约 | P0 | `sqlc.CachedConn.ExecCtx` 的 "exec→DelCache" 在 commit 之前执行;commit 前并发 `FindOne` / `FindOneByProductCodeName` 会回填旧行;detached ctx 上补一次 `InvalidateRoleCache` 是闭环关键 | ### 2.13 绑定角色权限 `POST /api/role/bindPerms` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0129 | POST /api/role/bindPerms | 正常绑定 | `{"roleId":1,"permIds":[1,2,3]}` | code=0 | 正常路径 | P0 | TransactCtx | | TC-0130 | POST /api/role/bindPerms | 角色不存在 | `{"roleId":9999,"permIds":[1]}` | code=404, "角色不存在" | 存在性校验 | P0 | FindOne预检 | | TC-0131 | POST /api/role/bindPerms | 清空权限 | `{"roleId":1,"permIds":[]}` | code=0, 全清空 | 分支覆盖 | P1 | len==0→return | | TC-0132 | POST /api/role/bindPerms | 重复permId | `{"roleId":1,"permIds":[1,1]}` | DB唯一索引→事务回滚 | 边界 | P1 | uk_role_perm | | TC-0133 | POST /api/role/bindPerms | 事务回滚 | 模拟BatchInsertWithTx失败 | 旧数据回滚还原 | 事务验证 | P0 | TransactCtx | | TC-1024 | POST /api/role/bindPerms | `BindRolePerms` 事务首步调用 `LockByIdTx` 锁 `sys_role` 行,再走 `FindPermIdsByRoleIdTx` 读 diff 基准 | gomock 记录调用顺序 | `TransactCtx → LockByIdTx → FindPermIdsByRoleIdTx → DeleteByRolePermTx → BatchInsertWithTx` | 并发/契约 | P0 | `bindRolePermsLogic_mock_test.go` 已更新 | | TC-1025 | POST /api/role/bindPerms | `BindRolePerms` post-commit cache 清理失败仍 Success | `cache Clean` 返 error | 响应 Success;事务内 mock 顺序保持 M-R10-2 | 并发/契约 | P0 | `postCommitCacheDegraded_audit_test.go` 已按 M-R10-2 全量重写 mock | | TC-1026 | POST /api/role/bindPerms | `BindRoles` 事务首步 `FindOneForUpdateTx(memberId)` 锁 `sys_product_member` 行,再走 `FindRoleIdsByUserIdForProductTx` | gomock 记录调用顺序 | `TransactCtx → FindOneForUpdateTx → FindRoleIdsByUserIdForProductTx → DeleteByUserIdAndRoleIdsTx → BatchInsertWithTx` | 并发/契约 | P0 | `bindRolesLogic_mock_test.go` 已更新 | | TC-1121 | POST /api/role/bindPerms | L-R14-1 非超管 BindRolePerms 别产品 roleId 必须 404(不是 403) | `AdminCtx("test_product")` 调 `BindRolePerms(id, [...])`,该 roleId 归属 `other_*` | `CodeError.Code()==404`,文案 "角色不存在";`sys_role_perm` 无变更 | 安全/枚举 | P0 | 与 UpdateRole / DeleteRole 口径一致,避免已认证用户借 404 vs 403 枚举跨产品 roleId | ### 2.14 创建用户 `POST /api/user/create` | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0134 | POST /api/user/create | 正常创建 | `{"username":"new","password":"123456"}` | code=0, id>0 | 正常路径 | P0 | createUserLogic | | TC-0135 | POST /api/user/create | 用户名已存在(预检) | `{"username":"existing","password":"x"}` | code=409, "用户名已存在" | 异常路径 | P0 | FindOneByUsername成功 | | TC-0136 | POST /api/user/create | 带完整可选字段 | 含nickname/email/phone/remark/deptId | code=0 | 正常路径 | P1 | 各字段赋值 | | TC-0137 | POST /api/user/create | 非法email格式 | `{"...","email":"not-an-email"}` | code=400, "邮箱格式不正确" | 输入校验 | P0 | util.IsValidEmail | | TC-0138 | POST /api/user/create | 合法email | `{"...","email":"user@example.com"}` | code=0 | 正常路径 | P1 | IsValidEmail通过 | | TC-0139 | POST /api/user/create | email为空(可选) | `{"...","email":""}` | code=0, 跳过校验 | 分支覆盖 | P1 | email!=""判断 | | TC-0140 | POST /api/user/create | 非法phone格式 | `{"...","phone":"abc"}` | code=400, "手机号格式不正确" | 输入校验 | P0 | util.IsValidPhone | | TC-0141 | POST /api/user/create | 合法phone(国际) | `{"...","phone":"+8613800138000"}` | code=0 | 正常路径 | P1 | IsValidPhone通过 | | TC-0142 | POST /api/user/create | phone为空(可选) | `{"...","phone":""}` | code=0, 跳过校验 | 分支覆盖 | P1 | phone!=""判断 | | TC-0143 | POST /api/user/create | 并发同username(TOCTOU) | 两请求同时 | 一成功一冲突(1062) | 并发 | P0 | Duplicate entry→ErrConflict | | TC-0144 | POST /api/user/create | 唯一索引冲突消息 | 预检通过后DB冲突 | code=409, "用户名已存在" | 异常路径 | P0 | strings.Contains "1062" | | TC-0145 | POST /api/user/create | 密码少于8字符 | `{"username":"x","password":"Pas1234"}` | code=400, "密码长度不能少于8个字符" | 输入校验 | P0 | 密码强度校验(8+字符,含大小写+数字) | | TC-0146 | POST /api/user/create | 密码缺少大写字母 | `{"username":"x","password":"pass123456"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无大写 | | TC-0147 | POST /api/user/create | 密码缺少小写字母 | `{"username":"x","password":"PASS123456"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无小写 | | TC-0148 | POST /api/user/create | 密码缺少数字 | `{"username":"x","password":"Passpasspass"}` | code=400, "密码必须包含大写字母、小写字母和数字" | 输入校验 | P0 | 密码复杂性: 无数字 | | TC-0149 | POST /api/user/create | 密码超过72字符 | `{"username":"x","password":"a*73"}` | code=400, "密码长度不能超过72个字符" | 输入校验 | P0 | 密码强度校验 | | TC-0150 | POST /api/user/create | 用户名含特殊字符被拒绝 | `{"username":"user@name!","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 输入校验 | P0 | usernameRegexp不匹配 | | TC-0151 | POST /api/user/create | 用户名太短(1字符)被拒绝 | `{"username":"a","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 边界值 | P0 | 最小长度2 | | TC-0152 | POST /api/user/create | 用户名太长(65字符)被拒绝 | `{"username":"a*65","password":"pass123456"}` | 400 "用户名只能包含字母、数字和下划线,长度2-64个字符" | 边界值 | P0 | 最大长度64 | | TC-0153 | POST /api/user/create | 部门不存在被拒绝 | `{"username":"x","password":"pass123456","deptId":999999999}` | 400 "部门不存在" | 异常路径 | P1 | DeptId>0时校验FindOne | | TC-0154 | POST /api/user/create | 昵称超过64字符被拒绝 | `{"username":"x","password":"pass123456","nickname":"n*65"}` | 400 "昵称长度不能超过64个字符" | 边界值 | P1 | len(Nickname)>64 | | TC-0155 | POST /api/user/create | 备注超过255字符被拒绝 | `{"username":"x","password":"pass123456","remark":"r*256"}` | 400 "备注长度不能超过255个字符" | 边界值 | P1 | len(Remark)>255 | | TC-0818 | POST /api/user/create | SuperAdmin 创建用户未显式指定 `mustChangePassword` | req 缺字段 | DB 落盘 `MustChangePassword=1` | 默认值/安全 | P1 | 默认 Yes 才能保证账号发出后立刻被改密 | | TC-0994 | POST /api/user/create | 产品 ADMIN 为非自己管辖部门创建用户 | caller `DeptPath=/100/` 目标部门 `/999/` | 403 "无权在非自己管辖的部门下创建用户" | 对抗/越权 | P0 | 防产品 ADMIN 预埋 admin_*/ops_* 等关键用户名合谋 AddMember | | TC-0995 | POST /api/user/create | 产品 ADMIN 在自己子树下创建用户 | caller `/200/` → 目标 `/200/1/` | 正常创建,DB 落盘 | 正向 | P0 | 正向回归,不得误伤合法路径 | | TC-0996 | POST /api/user/create | SuperAdmin 在任意部门 / DeptId=0 均可创建 | 超管两条路径 | 两条均成功,支撑跨组织系统账号语义 | 正向 | P0 | 超管豁免分层 | | TC-0997 | POST /api/user/create | caller.DeptPath=="" 的 legacy 产品 ADMIN | DeptId 指向真实部门 | 403 "您未归属任何部门,无权创建用户" | 对抗 | P1 | legacy 账号 fail-close | | TC-0998 | POST /api/user/create | 非超管 caller 传 DeptId=0 | 任意合法用户名 | 400 "必须指定部门" | 契约 | P1 | 阻断非超管在部门树外开口 | | TC-0999 | POST /api/user/create | 目标部门 status=Disabled | 超管 → 已禁用部门 | 400 "目标部门已停用" | 契约 | P1 | 与 UpdateDept 闭环 | | TC-1100 | POST /api/user/create | 拒绝 deptId<0(避免负数穿透) | 超管 + `DeptId=-1` | 400 "部门ID必须为非负整数";sys_user 无新增行 | 输入校验 | P0 | 防 sys_user.deptId=-1 僵尸账号(FindOne(-1) → 5xx degrade) | | TC-1122 | POST /api/user/create | H-R14-1 非超管 caller 把新用户建到 DEV 部门必须 403 | `callerAdminCtx`(ADMIN / DeptPath 包含目标)+ `DeptId=` | `CodeError.Code()==403`,文案含 "仅超级管理员可将用户调入研发部门";`sys_user` 无新增行 | 安全/跨产品升权 | P0 | 镜像 `updateUserLogic.go` 的 H-R14-1 护栏,封死"加入 DEV 即全权"跨产品信任穿透 | | TC-1123 | POST /api/user/create | H-R14-1 SuperAdmin 仍可把新用户建到 DEV 部门(正向回归) | 超管 + `DeptId=` | 创建成功,DB 落盘 `deptId` 与 `deptType=DEV` 对应;caller.IsSuperAdmin 被豁免 | 正常路径 | P0 | 防护栏误伤合法运维路径 | | TC-1192 | POST /api/user/create | 非超管不得以保留前缀(`admin_` / `svc_` / `root_` / `sys_`)占名 | `AdminCtx` + `{username:"admin_x", password:"Aa123456"}` 等 4 组 | 每组 `CodeError.Code()==400`;文案含保留前缀提示;`sys_user` 均无新增行 | 安全/命名空间抢注 | P1 | 防产品 ADMIN 预置 `admin_` 等"像系统账号"的用户名再通过 AddMember 等路径拔高成 ADMIN,形成命名空间阴谋 | | TC-1193 | POST /api/user/create | SuperAdmin 允许使用保留前缀 | `SuperAdminCtx()` + `{username:"admin_abc", password:"Aa123456"}` | 创建成功;DB 正常落盘 | 正向回归 | P1 | 保留前缀仅限 SuperAdmin;CreateProduct 的 `admin_` 初始化流程、运维脚本必须继续工作 | | TC-1194 | POST /api/user/create | 未传 `avatar` 时 `sys_user.avatar` 必须写入 SQL NULL(而非空串) | 正常创建,入参不带 `avatar` | 创建成功;SELECT `CASE WHEN avatar IS NULL THEN 1 ELSE 0 END` 返回 1 | 契约/数据映射 | P1 | `sql.NullString{Valid:false}` 必须被序列化成 SQL NULL,否则将来 `WHERE avatar IS NULL` 判空分支与"空串 vs NULL"在 ORM 层混淆会触发静默偏差 | | TC-1195 | POST /api/user/create | mock:`InsertWithTx` 必须在 `TransactCtx` 闭包内、并且发生在 `FindOneForShareTx(sys_dept)` 之后 | gomock 记录 Insert 顺序;`TransactCtx` 执行闭包;闭包内 `FindOneForShareTx` 通过 | `SysUserModel.TransactCtx` 恰好 1 次;`SysDeptModel.FindOneForShareTx` 在 `SysUserModel.InsertWithTx` 之前;插入的 `*SysUser.Avatar.Valid==false` | 并发/事务闭包 | P0 | 锁序:对 `sys_dept[deptId]` 取 S 锁后再 Insert `sys_user`,与 DeleteDept 的 X 锁串行;闭环"pre-check 通过→DeleteDept 提交→本 tx Insert 写入幽灵 deptId"的竞态 | | TC-1196 | POST /api/user/create | mock:闭包内 `FindOneForShareTx` 返 `sqlx.ErrNotFound`(并发 DeleteDept 已提交) | 同上 mock,仅把 FOR SHARE 读改成 ErrNotFound | `CodeError.Code()==400`,文案含 "部门不存在或已删除";`SysUserModel.InsertWithTx` 绝不被调用;DB 无新 user | 并发/事务闭包 | P0 | DeleteDept 胜出时必须立即终止;若回退成"忽略 S 锁读的错"继续 Insert,就会产生挂在已删 deptId 上的 orphan user,其 DeptPath 校验永久失效 | ### 2.15 用户更新 `POST /api/user/update` (指针类型+DeptId可清零) | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0156 | POST /api/user/update | 正常更新 | `{"id":1,"nickname":"n","email":"a@b.com"}` | code=0 | 正常路径 | P0 | updateUserLogic | | TC-0157 | POST /api/user/update | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0158 | POST /api/user/update | 仅传id | `{"id":1}` | 仅updateTime变 | 分支覆盖 | P1 | 所有指针nil | | TC-0159 | POST /api/user/update | 清空nickname | `{"id":1,"nickname":""}` | DB nickname→空字符串 | 功能 | P0 | *string非nil+空值 | | TC-0160 | POST /api/user/update | 清空email | `{"id":1,"email":""}` | DB email→空字符串(跳过校验) | 功能 | P0 | *email!=""判断 | | TC-0161 | POST /api/user/update | 清空remark | `{"id":1,"remark":""}` | DB remark清空 | 功能 | P1 | *string→"" | | TC-0162 | POST /api/user/update | 非法email格式 | `{"id":1,"email":"bad-email"}` | code=400, "邮箱格式不正确" | 输入校验 | P0 | util.IsValidEmail | | TC-0163 | POST /api/user/update | 非法phone格式 | `{"id":1,"phone":"12345"}` | code=400, "手机号格式不正确" | 输入校验 | P0 | util.IsValidPhone | | TC-0164 | POST /api/user/update | 合法phone | `{"id":1,"phone":"+8613800138000"}` | code=0 | 正常路径 | P1 | IsValidPhone通过 | | TC-0165 | POST /api/user/update | 不传email(nil) | `{"id":1,"nickname":"x"}` | email不变 | 分支覆盖 | P1 | req.Email==nil | | TC-0166 | POST /api/user/update | DeptId设为0(取消部门) | `{"id":1,"deptId":0}` | DB deptId→0 | 功能 | P0 | \*int64, \*req.DeptId=0 | | TC-0167 | POST /api/user/update | DeptId设为正值 | `{"id":1,"deptId":5}` | DB deptId→5 | 正常路径 | P0 | *int64指针 | | TC-0168 | POST /api/user/update | DeptId不传(nil) | `{"id":1,"nickname":"x"}` | deptId不变 | 分支覆盖 | P1 | req.DeptId==nil | | TC-0169 | POST /api/user/update | 超管不能冻结另一超管 | caller=超管A, target=超管B, status=2 | 403 "不能通过此接口修改超级管理员的状态" | 安全 | P0 | IsSuperAdmin==Yes 保护 | | TC-0170 | POST /api/user/update | updateUser-产品管理员可管理范围内用户 | ctx=ADMIN, target在管理范围内 | 更新成功 | 正常路径 | P0 | CheckManageAccess允许产品管理员 | | TC-0171 | POST /api/user/update | updateUser-昵称超长拒绝 | nickname=65字符 | 400 "昵称长度不能超过64个字符" | 边界 | P1 | 输入校验 | | TC-0172 | POST /api/user/update | updateUser-部门不存在 | deptId=999999 | 400 "部门不存在" | 异常路径 | P1 | 关联对象不存在校验 | | TC-0173 | POST /api/user/update | updateUser 修改状态时递增 tokenVersion | req.Status=Disabled, 原Status=Enabled | 更新成功, tokenVersion+1 | 正常路径 | P0 | 状态变更强制下线 | | TC-0174 | POST /api/user/update | updateUser 仅改 profile 不递增 tokenVersion | req.Nickname+Email | 更新成功, tokenVersion不变 | 正常路径 | P0 | 非状态字段不影响会话 | | TC-0175 | POST /api/user/update | updateUser 乐观锁冲突 -> 409 | 基于过期 updateTime 更新 | 返回 CodeError(409, "数据已被其他操作修改...") | 并发/异常 | P0 | ErrUpdateConflict 透传 | | TC-0746 | POST /api/user/update | DEVELOPER 把成员挪出自己子树 | Caller.DeptPath=/100/,targetNewDept.DeptPath=/999/ | 403 "无权将用户转移到该部门",DB 保持原 deptId | 安全 | P0 | `strings.HasPrefix(newDept.DeptPath, caller.DeptPath)` 前缀校验 | | TC-0747 | POST /api/user/update | DEVELOPER 在自己子树内移动成员 | Caller=/100/,newDept=/100/200/ | 200 OK,DB 更新 deptId | 正常路径 | P0 | 子树内放行 | | TC-0748 | POST /api/user/update | 产品 ADMIN 把 target 挪到子树外必须 403 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、DeptPath="/500/"`> | 403 "无权将用户转移到该部门";DB `sys_user.deptId` 保持原值 | 安全 | P0 | ADMIN 必须与 DEVELOPER 同口径过 `strings.HasPrefix(newDept.DeptPath, caller.DeptPath)`;不得借 ADMIN 身份绕过子树约束 | | TC-0814 | POST /api/user/update | DEVELOPER 将他人 deptId 置 0 | caller=DEVELOPER | 403;目标 deptId 不变 | 安全/越权 | P0 | 防"把用户挪出部门树以逃出管理视野" | | TC-0815 | POST /api/user/update | MEMBER 将他人 deptId 置 0 | caller=MEMBER | 403;目标 deptId 不变 | 安全/越权 | P0 | 同上 | | TC-0816 | POST /api/user/update | 产品 ADMIN 将他人 deptId 置 0 | caller=ADMIN | 403 "仅超级管理员可将用户移出部门";目标 deptId 不变 | 安全/跨产品结构破坏 | P0 | 原"产品 ADMIN 可移出"契约被收回:`sys_user.deptId` 是全局字段,P1 ADMIN 原先可让 P2 视角下共有成员变"孤儿"(P2 的 MEMBER/DEVELOPER/子 ADMIN 均通不过 `checkDeptHierarchy`)。收敛给 SuperAdmin | | TC-0817 | POST /api/user/update | SuperAdmin 将他人 deptId 置 0 | caller=SuperAdmin | 200;目标 deptId=0 | 正常路径 | P1 | 顶级权限链路通畅 | | TC-1049 | POST /api/user/update | 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 | 核心反回归 | | TC-1050 | POST /api/user/update | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | 构造"只改 nickname、deptId 不变" 的更新 | 事务只走 `UpdateProfileWithTx`;`SysDeptModel.FindOneForShareTx` 未被打到(观察事务 SQL / mock 无 expect) | 契约/性能 | P1 | 避免"无切换时也打 S 锁" 导致退化 | | TC-1101 | POST /api/user/update | 拒绝 `*req.DeptId < 0` 透传成脏 deptId | 超管 + `DeptId=Int64Ptr(-1)` | 400 "部门ID必须为非负整数";DB `sys_user.deptId` 不变 | 输入校验 | P0 | 与 CreateUser 对齐;防 FindOne(-1) ErrNotFound → 5xx / 僵尸账号 | | TC-1102 | POST /api/user/bindRoles | 非超管且 `caller.MemberType==""`("游离" JWT)不得通过 404 枚举 userId 存在性 | 自定义 caller:`IsSuperAdmin=false, MemberType=""`;userId 取**不存在**的值 | 403 "缺少产品成员上下文"(不是 404 "用户不存在") | 安全/枚举 | P0 | 修复前:MEMBER 空上下文会先 `FindOne(userId)` 返 404,暴露 userId 空间 | | TC-1103 | POST /api/user/bindRoles | 超管即便 `MemberType==""` 也必须继续走 `FindOne`(不能被 L-R13-1 误伤) | 超管 ctx (MemberType=SuperAdmin) + 不存在 userId | 404 "用户不存在"(超管应继续原路径) | 正向回归 | P0 | 防 L-R13-1 闸门把超管正常链路误拦 | | TC-1124 | POST /api/user/update | H-R14-1 ADMIN 把目标调入 DEV 部门必须 403 | `AdminCtx + DeptPath="/"`(豁免子树校验)+ `req.DeptId=` | 403 "仅超级管理员可将用户调入研发部门";DB `sys_user.deptId` 不变 | 安全/跨产品升权 | P0 | 堵死 ADMIN 借 DeptPath 子树豁免 + `DeptType=DEV` 全权分支对他产品共有成员升权的攻击链 | | TC-1125 | POST /api/user/update | H-R14-1 SuperAdmin 仍可把目标调入 DEV 部门(正向回归) | SuperAdmin + `req.DeptId=` | 200 OK;DB `sys_user.deptId` 落到 DEV 部门 id | 正常路径 | P0 | 保留 SuperAdmin 跨产品调度语义 | | TC-1126 | POST /api/user/update | 产品 ADMIN 在自己子树内挪动 target(非 DEV)放行 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、DeptPath="/300/400/"`> | 200 OK;DB 落盘新 deptId | 正常路径 | P0 | ADMIN 快速通道仅限"同子树内 + 非 DEV";保证合法子树内调动不被误伤 | | TC-1127 | POST /api/user/bindRoles | L-R14-2 三路径(跨产品 / 已禁用 / 不存在)统一文案对比 | 同一 `AdminCtx`:分别构造 (A) 跨产品 roleId、(B) 本产品禁用 roleId、(C) 不存在 roleId | 三条 `CodeError.Code()==400` 全等;`Error()` 均为 "包含无效的角色ID";不依赖顺序 | 安全/Oracle | P0 | 阻断已认证调用方借文案差异枚举他产品 roleId 分布 / 启停状态 | | TC-1170 | POST /api/user/update | 跨产品 P1 ADMIN 不得把共有 target 挪到 P1 子树之外的 NORMAL | `AdminCtx(P1, DeptPath="/100/")`;target 是 P1+P2 共有成员;`req.DeptId=` | `CodeError.Code()==403`,文案含 "无权将用户转移到该部门";DB `sys_user.deptId` 不变;`sys_user.tokenVersion` 不变 | 安全/跨产品升权 | P0 | 反回归:P1 ADMIN 原先被"豁免子树"时可把 P2 视角下的共有成员挪出 P2 ADMIN 管辖范围,形成 P2 侧不可管的孤儿 | | TC-1171 | POST /api/user/update | target 从"DEV+Enabled 部门" → NORMAL 部门:sys_user.tokenVersion +1 | target seed 在 `DeptType=DEV, Status=1` 部门;`req.DeptId=` | DB `sys_user.tokenVersion` 严格 +1;`sys_user.deptId` 落盘为新 deptId;两者同事务 | 安全/会话吊销 | P0 | 单人移出 DEV = 该用户 DEV 全权分支作废,必须把旧 access token 踢下线 | | TC-1172 | POST /api/user/update | target 从"DEV+Enabled 部门" → deptId=0(移出部门树):sys_user.tokenVersion +1 | SuperAdmin + target 在 `DEV+Status=1` 部门;`req.DeptId=Int64Ptr(0)` | `sys_user.tokenVersion +1`;`sys_user.deptId=0` | 安全/会话吊销 | P0 | SuperAdmin 的"移出部门树"动作对 DEV 成员同样构成收窄;覆盖 `newDeptId==0` 分支 | | TC-1173 | POST /api/user/update | NORMAL→NORMAL 不递增 tokenVersion | target 在 `NORMAL 部门 A`;`req.DeptId=` | DB `sys_user.tokenVersion` 与初值严格相等;`deptId` 落盘为 B | 正向回归 | P0 | 非收窄方向不得把合法用户误踢下线;与 TC-1171 对偶 | ### 2.16 用户列表/详情/状态 及其他用户操作 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0176 | POST /api/user/list | 含productCode | `{"productCode":"p1","page":1,"pageSize":10}` | 每用户含memberType(批量查) | 正常路径 | P0 | FindMapByProductCodeUserIds | | TC-0177 | POST /api/user/list | 不含productCode | `{"page":1}` | memberType全空,不调批量查 | 分支覆盖 | P1 | productCode="" | | TC-0178 | POST /api/user/list | pageSize超过上限 | `{"pageSize":500}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap | | TC-0179 | POST /api/user/list | 用户不在产品中 | productCode指定,部分用户不是成员 | memberType为空 | 分支覆盖 | P1 | memberMap无对应key | | TC-0180 | POST /api/user/list | 批量查询DB异常 | FindMapByProductCodeUserIds失败 | code=500 | 异常路径 | P1 | err→透传 | | TC-0181 | POST /api/user/detail | 正常查询 | `{"id":1}` | 含roleIds | 正常路径 | P0 | userDetailLogic | | TC-0182 | POST /api/user/detail | 正常查询-含Avatar | 有Avatar用户 | avatar字段非空 | 分支覆盖 | P1 | Avatar.Valid=true | | TC-0183 | POST /api/user/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0184 | POST /api/user/bindRoles | 正常绑定 | `{"userId":1,"roleIds":[1,2]}` | code=0 | 正常路径 | P0 | TransactCtx | | TC-0185 | POST /api/user/bindRoles | 用户不存在 | `{"userId":9999,"roleIds":[1]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 | | TC-0186 | POST /api/user/bindRoles | 清空角色 | `{"userId":1,"roleIds":[]}` | code=0 | 分支覆盖 | P1 | len==0 | | TC-0187 | POST /api/user/bindRoles | 事务回滚 | 模拟失败 | 旧数据还原 | 事务验证 | P0 | TransactCtx | | TC-0188 | POST /api/user/bindRoles | 角色不属于当前产品 | roleId属于其他产品 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"跨产品"分支 | | TC-0189 | POST /api/user/bindRoles | 角色已禁用 | roleId状态为禁用 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"已禁用"分支 | | TC-0190 | POST /api/user/bindRoles | 角色不存在 | roleId不存在 | code=400, "包含无效的角色ID" | 安全 | P0 | L-R14-2:与跨产品/禁用路径文案一致 | | TC-0191 | POST /api/user/bindRoles | 非产品成员绑定角色被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | BindRoles | | TC-0192 | POST /api/user/setPerms | 正常ALLOW | `{"userId":1,"perms":[{"permId":1,"effect":"ALLOW"}]}` | code=0 | 正常路径 | P0 | TransactCtx | | TC-0193 | POST /api/user/setPerms | 用户不存在 | `{"userId":9999,"perms":[...]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 | | TC-0194 | POST /api/user/setPerms | DENY权限 | effect="DENY" | code=0 | 正常路径 | P0 | effect="DENY" | | TC-0195 | POST /api/user/setPerms | 清空权限 | `{"userId":1,"perms":[]}` | code=0 | 分支覆盖 | P1 | len==0 | | TC-0196 | POST /api/user/setPerms | 无效Effect值 | effect="INVALID" | code=400, "无效的权限效果" | 安全 | P0 | Effect白名单 | | TC-0197 | POST /api/user/setPerms | PermId不存在 | permId=99999 | code=400, "权限不存在" | 安全 | P0 | 校验PermId | | TC-0198 | POST /api/user/setPerms | 权限不属于当前产品 | permId属于其他产品 | code=400, "权限不属于当前产品" | 安全 | P0 | 校验权限归属 | | TC-0199 | POST /api/user/setPerms | 非产品成员设置权限被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | SetUserPerms | | TC-0200 | POST /api/user/updateStatus | 正常冻结 | `{"id":普通用户,"status":2}` | code=0;DB `sys_user.status=2`;DB `sys_user.tokenVersion = before+1`(冻结方向吊销) | 正常路径/会话吊销 | P0 | updateUserStatusLogic;`UpdateStatus` 底层 `SET tokenVersion = tokenVersion + 1` 与 `jwtauthMiddleware` 的 tokenVersion 比对契约对齐,冻结瞬间立刻使已签发 access/refresh token 失效 | | TC-0201 | POST /api/user/updateStatus | 正常解冻(Disabled→Enabled) | `{"id":普通用户,"status":1}` | code=0;DB `sys_user.status=1`;DB `sys_user.tokenVersion = before+1`(解冻方向同样 +1) | 正常路径/会话吊销 | P0 | `UpdateStatus` 的 SQL 无条件 `+1`,不论方向;解冻也 +1 是刻意设计,堵住"冻结→UD 缓存残留→解冻瞬间旧 access token 复活"的极端路径。严禁回滚成"条件 +1 / 仅冻结 +1" | | TC-0202 | POST /api/user/updateStatus | 非法status(0) | `{"id":1,"status":0}` | code=400, "状态值无效" | 输入校验 | P0 | status!=1&&!=2 | | TC-0203 | POST /api/user/updateStatus | 冻结自己 | id=当前登录userId | code=400, "不能修改自己的状态" | 自我保护 | P0 | callerId==req.Id | | TC-0204 | POST /api/user/updateStatus | 冻结超管 | id=超管 | code=403, "不能修改超级管理员的状态" | 超管保护 | P0 | IsSuperAdmin==1 | | TC-0205 | POST /api/user/list | userList-非超管仅可见产品成员 | ctx=ADMIN(非超管), productCode指定 | 仅返回该产品成员, 不返回非成员 | 安全 | P0 | FindListByProductMembers数据隔离 | | TC-0206 | POST /api/user/list | userList-非超管未指定productCode被拒绝 | ctx=ADMIN(非超管), productCode="" | 403 "非超管用户必须指定产品编码" | 安全 | P0 | 强制productCode | | TC-0207 | POST /api/user/list | userList-非超管使用错误productCode被拒绝 | ctx=ADMIN, productCode!=ctx.ProductCode | 403 | 安全 | P0 | productCode一致性校验 | | TC-0208 | POST /api/user/bindRoles | bindRoles-permsLevel越权拒绝 | ctx=ADMIN(MinPermsLevel=50), role.permsLevel=1 | 403 "不能分配权限级别高于自身的角色" | 安全 | P0 | 角色权限级别越权防护 | | TC-0209 | POST /api/user/bindRoles | bindRoles-超管可分配任意级别角色 | ctx=SuperAdmin, role.permsLevel=1 | 绑定成功 | 正常路径 | P0 | 超管无permsLevel限制 | | TC-0210 | POST /api/user/setPerms | 同一权限ID冲突Effect被拒绝 | perms含[{permId:1,effect:"ALLOW"},{permId:1,effect:"DENY"}] | 400 "同一权限ID不能同时为 ALLOW 和 DENY" | 业务约束 | P0 | seen[permId]冲突检测 | | TC-0211 | POST /api/user/setPerms | 重复权限ID相同Effect去重 | perms含[{permId:1,effect:"ALLOW"},{permId:1,effect:"ALLOW"}] | 成功, DB仅1条记录 | 数据鲁棒性 | P1 | seen去重,uniquePerms | | TC-0212 | POST /api/user/setPerms | 已禁用权限不能被设置 | perm.Status=2(Disabled) | 400 "权限 xxx 已被禁用,无法设置" | 业务约束 | P0 | p.Status != StatusEnabled | | TC-0711 | POST /api/user/* | ADMIN 调用者豁免 permsLevel 校验 | caller=ADMIN, MinPermsLevel=math.MaxInt64, 目标角色任意 permsLevel | 成功绑定 | 正常路径 | P0 | ADMIN/DEVELOPER 不再受 permsLevel 约束 | | TC-0712 | POST /api/user/* | DEVELOPER 调用者豁免 permsLevel 校验 | caller=DEVELOPER, 目标角色任意 permsLevel | 成功绑定 | 正常路径 | P0 | DEVELOPER 豁免 | | TC-0713 | POST /api/user/* | MinPermsLevel=MaxInt64 的 MEMBER 不被误阻断 | caller=MEMBER, MinPermsLevel=math.MaxInt64(未持角色) | 不触发 "不能分配权限级别高于自身" 错误 | 分支覆盖 | P0 | sentinel 值语义 | | TC-0734 | POST /api/user/* | 产品已禁用 | product.status=2 | 400 "产品已被禁用,无法设置权限" | 安全 | P0 | 新增 product.Status 校验 | | TC-0735 | POST /api/user/* | 产品不存在 | 虚构 productCode | 404 "产品不存在" | 错误路径 | P0 | FindOneByCode ErrNotFound | | TC-0743 | POST /api/user/* | 普通 MEMBER 给自己授权 | MemberCtx, targetUserId=self | 403 "需要产品管理员权限",DB 中 userperm 无任何写入 | 安全 | P0 | `RequireProductAdminFor` 前置拦截 self-escalation | | TC-0744 | POST /api/user/* | DEVELOPER 调用者(非 ADMIN)操作他人 | DeveloperCtx, targetUserId=other | 403 "需要产品管理员权限",DB 无写入 | 安全 | P0 | DEVELOPER 也必须被拦截(非仅 self 场景) | | TC-0745 | POST /api/user/* | 同产品 ADMIN 操作合法 MEMBER 目标 | AdminCtx, targetUserId=member | 200 OK,userperm 插入成功 | 正常路径 | P0 | 修复后 admin 正向通路仍通畅 | | TC-0772 | POST /api/user/* | 状态无实际变化(已启用→再启用) | user.Status=1, req.Status=1 | 返回成功,tokenVersion 不变,用户不被踢下线 | 功能 | P0 | `user.Status == req.Status` 时跳过写操作 | | TC-0773 | POST /api/user/* | 状态实际变化(启用→冻结) | user.Status=1, req.Status=2 | 成功,tokenVersion+1,用户被踢下线 | 正常路径 | P0 | 真实变更时正常递增 | | TC-0786 | POST /api/user/* | SetUserPerms 调用后 req.Perms 不变 | 带重复 permId 的请求 | 调用后 req.Perms 长度与调用前一致 | 代码质量 | P1 | 使用局部变量去重 | | TC-0787 | POST /api/user/* | BindRoles 调用后 req.RoleIds 不变 | 带重复 roleId 的请求 | 调用后 req.RoleIds 长度与调用前一致 | 代码质量 | P1 | 使用局部变量去重 | | TC-0788 | POST /api/user/* | BindRolePerms 调用后 req.PermIds 不变 | 带重复 permId 的请求 | 调用后 req.PermIds 长度与调用前一致 | 代码质量 | P1 | 使用局部变量去重 | | TC-0813 | POST /api/user/* | MEMBER 调用者给他人赋予 "与自己 permsLevel 相同" 的角色 | caller level=50, role level=50 | 403(等级不允许);DB 的 sys_user_role 关系无变化 | 安全/越权 | P0 | `GuardRoleLevelAssignable` 的 `>=` 防自等升权 | | TC-0988 | POST /api/user/* | FindByIds 前置校验通过(装饰器撒谎 status=1)但 DB 实际 status=2 | 正常请求 | 409 "部分权限在提交时已被禁用",`sys_user_perm` 必须 0 行脏数据 | 对抗/一致性 | P0 | COUNT 复核失效 → 立即可见 | | TC-0989 | POST /api/user/* | 全部真实 Enabled 的正向基线 | 两条 perm + ALLOW/DENY 各一 | 2 行落盘 | 正向 | P0 | 防止误杀 | | TC-0991 | POST /api/user/* | 自看 | caller.UserId == target.Id | 原样返回 Email/Phone/Remark | 正向/回归 | P0 | 业务契约已固定为"全员原值";守护未来误加脱敏不伤 self-view | | TC-0992 | POST /api/user/* | SuperAdmin 看任何人 | caller.IsSuperAdmin | 原样返回 Email/Phone/Remark | 正向/回归 | P0 | 同上契约;守护未来误加脱敏不伤 SuperAdmin 分支 | | TC-1011 | POST /api/user/* | 他人先冻结后本轮解冻 | 先跑 Update → UpdateTime 推进,本轮仍持旧 updateTime 直冲 model | model 层 `ErrUpdateConflict`;Logic happy path 解冻成功且 `updateTime` 推进 | 并发/CAS | P0 | CAS 失败路径 + 正向回归 | | TC-1012 | POST /api/user/* | Logic 层错误映射 | model 层强制 `ErrUpdateConflict` | 映射为 `response.ErrConflict(409, "数据已被其他操作修改,请刷新后重试")` | 契约 | P1 | 文案与 code 对齐 | | TC-1027 | POST /api/user/* | 登录时用户在 `productCode` 下非成员 | 用户在 `productCode` 下非成员 | `CodeError.Code()==403`;文案 "您不是该产品的有效成员" | 安全/Oracle | P0 | 与"禁用成员"同文案 | | TC-1028 | POST /api/user/* | 登录时用户成员资格 `Status=Disabled` | 用户成员资格 `Status=Disabled` | 同上 | 安全/Oracle | P0 | 两条分支合并成一条路径 | | TC-1078 | POST /api/user/* | BindRoles 与 DeleteRole 并发 6 轮 | - | 每轮新建 user+member+role,两 goroutine 同起 | 终态二选一:(a) 两端都成功(BindRoles 先 → DeleteRole 级联把 UserRole 一并清掉),(b) DeleteRole 先成功 + BindRoles 400 "包含无效的角色ID"(事务外 FindByIds 抑或事务内 LockRolesForShareTx 的 sqlx.ErrNotFound 都折叠到同一文案);**任何一轮都不得出现 "sys_role 已删、sys_user_role 仍有 (userId, roleId)" 的 orphan** | P0 | 事务内 S 锁 vs DeleteRole 末尾的 sys_role[X] 锁之间的锁链;兼测 L-R14-2 统一文案 | | TC-1104 | POST /api/user/setPerms | 非 ADMIN caller + **不存在**的 userId 必须 403(而不是 404)以消除 userId 枚举 oracle | `MemberCtx` + `UserId=999999999` | `CodeError.Code()==403`,文案含 "仅超级管理员或该产品的管理员";DB `sys_user_perm` 无写入 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysUserModel.FindOne(userId)` | | TC-1105 | POST /api/user/setPerms | DENY TOCTOU:预检读 member=MEMBER 通过,事务内 S 锁快照返回 ADMIN → 400 并回滚 | `FindOneByProductCodeUserId → MEMBER`;装饰 `FindOneForShareTx → ADMIN` 返回 | `CodeError.Code()==400`,文案含 "产品管理员或开发者";`sys_user_perm` 无脏 DENY 行 | 对抗/一致性 | P0 | 若 L-R13-2 事务内复核被拆除,脏 DENY 行会落盘("能写永不生效") | | TC-1106 | POST /api/user/setPerms | ALLOW-only 请求 **不得** 走 `FindOneForShareTx` S 锁路径(避免把热路径退化到锁链) | `Perms=[{PermId, ALLOW}]`;装饰 member model 断言 `FindOneForShareTx` 调用数=0 | 正常落盘 1 行 ALLOW;mock 上 `FindOneForShareTx` 未被调用 | 契约/性能 | P1 | 防把 S 锁挂到全量路径导致并发降级 | | TC-1164 | POST /api/user/detail | 产品 ADMIN 看同产品他人:返回完整 Email/Phone/Remark | `AdminCtx(P1)`;target 是 P1 MEMBER,带完整 Email/Phone/Remark | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | ADMIN 是 PII 最小授权白名单之一;守护默认脱敏上线后不伤 ADMIN 视角 | | TC-1165 | POST /api/user/detail | 产品 DEVELOPER 看同产品他人:返回完整 Email/Phone/Remark | `DeveloperCtx(P1)`;target 是 P1 MEMBER | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | DEVELOPER 被纳入 PII 白名单;与 ADMIN 同口径 | | TC-1166 | POST /api/user/detail | 产品 MEMBER 看同产品他人:Email/Phone/Remark 必须为空字符串 | `MemberCtx(P1)`;target 是 P1 的其他成员 | `Email==""`、`Phone==""`、`Remark==""`;nickname/deptId 等非 PII 字段仍然返回;DB 原值未被改动 | 安全/最小授权 | P0 | 普通 MEMBER 视角不得窥视他人 PII;避免 PII 明文外泄 | | TC-1167 | POST /api/user/list | 产品 ADMIN 列表视角:全部成员 PII 原值返回 | `AdminCtx(P1)`;两个带 PII 的目标成员 | 列表响应里每条 `Email/Phone/Remark` 与 DB 严格相等 | 正向回归 | P0 | 列表入口与 detail 口径必须一致,否则 ADMIN 真实排障能力塌方 | | TC-1168 | POST /api/user/list | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | `DeveloperCtx(P1)` | 同上 | 正向回归 | P0 | 与 detail TC-1165 对偶 | | TC-1169 | POST /api/user/list | 产品 MEMBER 列表视角:其他成员 PII 必须被置空 | `MemberCtx(P1)`;列表包含 2 个他人 PII | 他人 Email/Phone/Remark 均为空字符串;其他字段(id / nickname / deptId / memberType)保持完整 | 安全/最小授权 | P0 | 列表是 PII 最大暴露面;必须与 TC-1166 共同守护 | ### 2.17 成员管理 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0213 | POST /api/member/add | 正常添加 | `{"productCode":"p1","userId":1,"memberType":"MEMBER"}` | code=0, id>0 | 正常路径 | P0 | addMemberLogic | | TC-0214 | POST /api/member/add | 产品不存在 | `{"productCode":"notexist",...}` | code=404, "产品不存在" | 存在性校验 | P0 | FindOneByCode预检 | | TC-0215 | POST /api/member/add | 用户不存在 | `{"userId":9999,...}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 | | TC-0216 | POST /api/member/add | 已是成员 | 重复添加 | code=409, "已是成员" | 异常路径 | P0 | FindOneByProductCodeUserId成功 | | TC-0217 | POST /api/member/add | 并发添加 | 两请求同时 | 一成功一冲突 | 并发 | P1 | uk_product_user | | TC-0218 | POST /api/member/add | 无效MemberType | `{"memberType":"INVALID"}` | code=400, "无效的成员类型" | 输入校验 | P0 | MemberType白名单 | | TC-0219 | POST /api/member/update | 正常更新 | `{"id":1,"memberType":"ADMIN"}` | code=0 | 正常路径 | P0 | updateMemberLogic | | TC-0220 | POST /api/member/update | 不存在 | `{"id":9999,...}` | code=404 | 异常路径 | P0 | FindOne失败 | | TC-0221 | POST /api/member/update | 无效MemberType | `{"id":1,"memberType":"INVALID"}` | code=400, "无效的成员类型" | 输入校验 | P0 | MemberType白名单 | | TC-0222 | POST /api/member/list | 正常查询(批量查用户) | `{"productCode":"p1","page":1,"pageSize":10}` | 含username/nickname | 正常路径 | P0 | FindByIds批量 | | TC-0223 | POST /api/member/list | 成员用户已删除 | userId不存在于FindByIds结果 | username/nickname为空 | 分支覆盖 | P1 | userMap无对应key | | TC-0224 | POST /api/member/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap | | TC-0225 | POST /api/member/list | 空成员列表 | productCode下无成员 | total=0, list=[], 不调FindByIds | 分支覆盖 | P1 | userIds空 | | TC-0226 | POST /api/member/remove | 正常移除+级联(事务内) | `{"id":1}` (含角色/权限) | code=0, user_role+user_perm同步清理 | 正常+事务 | P0 | TransactCtx全路径 | | TC-0227 | POST /api/member/remove | 跨产品隔离 | 用户在多产品有角色 | 仅清理该产品的 | 深度业务 | P0 | ForProductTx子查询 | | TC-0228 | POST /api/member/remove | 成员不存在 | `{"id":9999}` | code=404, "成员不存在" | 异常路径 | P0 | FindOne失败 | | TC-0229 | POST /api/member/remove | 事务回滚 | 模拟DeleteWithTx失败 | 级联删除全部回滚 | 事务验证 | P0 | TransactCtx | | TC-0723 | POST /api/member/* | 移除产品唯一 ADMIN | 1 个启用 ADMIN | 400 "不能移除该产品的最后一个管理员",ADMIN 仍存在 | 安全 | P0 | CountActiveAdmins<=1 拒绝 | | TC-0724 | POST /api/member/* | 有 2 个 ADMIN 时移除其一 | 2 个 ADMIN | 成功删除 1 个,另一个保留 | 正常路径 | P0 | 非 last-admin 场景放行 | | TC-0725 | POST /api/member/* | 降级产品唯一 ADMIN 为 MEMBER | 1 个启用 ADMIN | 400 "不能降级该产品的最后一个管理员",MemberType 不变 | 安全 | P0 | updateMember 同逻辑 | | TC-0726 | POST /api/member/* | 有 2 个 ADMIN 时降级其一 | 2 个 ADMIN | 成功降级为 MEMBER | 正常路径 | P0 | 非 last-admin 允许 | | TC-0727 | POST /api/member/* | 2 个 ADMIN 但只有 1 个启用,降级该启用 ADMIN | ADMIN(status=1)+ADMIN(status=2) | 400 "不能降级该产品的最后一个管理员" | 安全/边界 | P0 | CountActiveAdmins 只计 status=1 | | TC-0728 | POST /api/member/* | 移除非 ADMIN(MEMBER) | 1 个 MEMBER | 成功删除,不受 last-admin 保护 | 正常路径 | P1 | 仅 ADMIN 触发校验 | | TC-0729 | POST /api/member/* | 对禁用产品 addMember | product.status=2 | 400 "产品已被禁用,无法添加成员" | 安全 | P0 | addMemberLogic 新增 product.Status 校验 | | TC-0760 | POST /api/member/* | 保持 memberType=ADMIN 但 status 改为 Disabled | 产品唯一 ADMIN,`{"memberType":"ADMIN","status":2}` | 400 "不能降级或禁用该产品的最后一个管理员" | 安全 | P0 | `wasActiveAdmin && !willBeActiveAdmin` 覆盖 status 变化 | | TC-0761 | POST /api/member/* | 同时降级 + 禁用唯一 ADMIN | `{"memberType":"MEMBER","status":2}` | 400 同上 | 安全 | P0 | memberType + status 同时变化 | | TC-0762 | POST /api/member/* | 有 2 个 ADMIN 时禁用其一 | 2 个启用 ADMIN | 成功,目标 status=2 | 正常路径 | P0 | 非 last-admin 场景放行 | | TC-0763 | POST /api/member/* | 移除活跃 ADMIN(事务内用 locked 数据判断) | 唯一 ADMIN | 400 "不能移除该产品的最后一个管理员" | 安全 | P0 | `locked.MemberType` 替代事务外 `member.MemberType` | | TC-0764 | POST /api/member/* | 移除非 ADMIN 不触发 last-admin 校验 | MEMBER 身份 | 成功移除 | 正常路径 | P0 | `locked.MemberType != ADMIN` 跳过检查 | | TC-0789 | POST /api/member/* | caller.MemberType="" 调用 CheckMemberTypeAssignment | 空 memberType 的 caller | 403 "缺少产品成员上下文" | 安全 | P0 | 显式分支替代 sentinel 值 | | TC-0940 | POST /api/member/* | Product ADMIN 拉跨部门树的人 | caller path=/100/,target path=/200/201/ | 403,错误含 "其他部门" | 权限 | P0 | ADMIN 不再绕过部门树 | | TC-0941 | POST /api/member/* | Product ADMIN 在自己部门树内 | target 在 /100/101/ | 放行 | 正常路径 | P0 | 正值域 | | TC-0942 | POST /api/member/* | SuperAdmin 跨一切部门 | caller superAdmin | 放行,不查 dept | 正常路径 | P0 | 短路 | | TC-0943 | POST /api/member/* | 自己加自己 | target.Id == caller.UserId | 放行 | 正常路径 | P0 | self-bypass | | TC-0944 | POST /api/member/* | caller 没有部门(DeptId=0 或 DeptPath="") | 非超管 | 403 | 异常路径 | P0 | 无部门 caller 必须拒绝 | | TC-0945 | POST /api/member/* | target 无部门(DeptId=0) | 非自己 | 403 | 异常路径 | P0 | 目标无部门视为不可纳管 | | TC-0946 | POST /api/member/* | ctx 无 caller | context.Background() | 401 | 权限 | P0 | H-3 | | TC-0947 | POST /api/member/* | dept.FindOne 报错 | mock 返回 err | 403(fail-close,文案不泄细节) | 容错 | P0 | H-3 | | TC-0948 | POST /api/member/* | target 为 nil | pass nil | 400 BadRequest | 契约 | P0 | H-3 | | TC-0949 | POST /api/member/* | AddMember 集成:跨部门被拒 | 真实 DB,Product ADMIN 拉树外 | 403 + 不写 sys_product_member | 集成 | P0 | H-3 端到端 | | TC-0950 | POST /api/member/* | AddMember 集成:target=SuperAdmin | superAdmin 被作为 MEMBER 加入 | 403 "超级管理员" + 不写 sys_product_member | 安全 | P0 | 超管防混入 | | TC-1055 | POST /api/member/* | `MemberType==nil && Status==nil` → 400 "请至少提供一个要更新的字段" | `UpdateMemberReq{productCode, userId}` 两字段都不传 | `CodeError.Code()==400`,msg 含 "至少提供一个要更新的字段" | 契约 | P0 | nil 判定入口 | | TC-1056 | POST /api/member/* | 只传 `Status`,`MemberType` 保持不变 | `{Status: Int64Ptr(2)}`,原 member.MemberType="ADMIN" | DB:`memberType` 仍是 "ADMIN",`status=2` | 契约 | P0 | 部分更新语义 | | TC-1057 | POST /api/member/* | 只传 `MemberType`,`Status` 保持不变 | `{MemberType: StrPtr("DEVELOPER")}`,原 member.Status=1 | DB:`memberType` 变为 "DEVELOPER",`status` 仍是 1 | 契约 | P0 | 镜像对称 | | TC-1058 | POST /api/member/* | DEVELOPER → 只改 Status 时跳过"分配校验" | 只传 `Status=1`,member.MemberType="DEVELOPER" | 不走分配校验分支;`memberType` 保持 DEVELOPER;状态落盘为 1 | 契约/性能 | P1 | DEVELOPER 分支被误挂会立即红 | | TC-1059 | POST /api/member/* | 非法 Status 值(例如 7)→ 400 | `{Status: Int64Ptr(7)}` | `CodeError.Code()==400` | 边界 | P0 | Status 枚举防御 | | TC-1060 | POST /api/member/* | 完全 no-op(传进来的值与 DB 现值相同)→ 返 nil 且 updateTime 不前进 | 传 `{Status: Int64Ptr(member.Status)}` | err==nil;DB updateTime 保持原值 | 契约/幂等 | P1 | MySQL 行为——值未变 RowsAffected=0,不被误升格为冲突 | | TC-1130 | POST /api/member/update | 降级 ADMIN→MEMBER:sys_user.tokenVersion +1 | seed ADMIN(产品内有其他启用 ADMIN 以绕过 last-admin),`{MemberType:"MEMBER"}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member.memberType=="MEMBER"`;两者原子同事务落盘 | 安全/会话吊销 | P0 | 哪怕 UserDetailsLoader.Del 失败,中间件 `claims.TokenVersion != ud.TokenVersion` 也会 401 旧 token | | TC-1131 | POST /api/member/update | 禁用启用成员:sys_user.tokenVersion +1 | seed MEMBER(Status=1),`{Status:2}` | `tokenVersion +1`;`sys_product_member.status==2` | 安全/会话吊销 | P0 | 冻结的成员不应继续持有生效 token | | TC-1132 | POST /api/member/update | 降级 DEVELOPER→MEMBER:sys_user.tokenVersion +1 | seed DEVELOPER,`{MemberType:"MEMBER"}` | `tokenVersion +1`;`memberType=="MEMBER"` | 安全/会话吊销 | P0 | DEVELOPER 同样算"特权身份",降为 MEMBER 必须视作"权限收窄" | | TC-1133 | POST /api/member/update | 升权 MEMBER→ADMIN:sys_user.tokenVersion 不变 | seed MEMBER,`{MemberType:"ADMIN"}` | `tokenVersion` 与之前完全相等;`memberType=="ADMIN"` | 正向回归 | P0 | 升权不构成对被管理方的实际损害,不应把目标用户误踢下线 | | TC-1134 | POST /api/member/update | 重启用 Disabled→Enabled:sys_user.tokenVersion 不变 | seed MEMBER(Status=2),`{Status:1}` | `tokenVersion` 不变;`status==1` | 正向回归 | P0 | 解冻不需要递增,维持既有会话有效 | | TC-1135 | POST /api/member/update | 降级事务失败(last-admin 400):sys_user.tokenVersion 不变 | 唯一启用 ADMIN,`{MemberType:"MEMBER"}` | 返回 400 "最后一个管理员";DB `sys_user.tokenVersion` 严格等于初值;`sys_product_member` 行内容也保持原状 | 事务回滚 | P0 | 关键:tokenVersion 增量必须与 member 更新在同一事务里;业务失败不得污染 tokenVersion | | TC-1136 | POST /api/member/update | no-op 更新不递增 tokenVersion | 传进来的 memberType/status 与 DB 现值相同 | `tokenVersion` 不变;早退分支不进事务 | 正向/幂等 | P0 | `locked.MemberType==nextType && locked.Status==nextStatus` 早退 | | TC-1137 | POST /api/member/update | 降级成功后 post-commit 失效 sysUser id-key / username-key 两把缓存 | seed ADMIN→降级 MEMBER,先预热 `FindOne(id)` + `FindOneByUsername(name)` 把缓存灌入 Redis | 事务成功返回后两把 cache key 均被 DEL;下一次 FindOne 取到 DB 中递增后的 tokenVersion | 缓存一致性 | P0 | UD loader 下次 cache-miss 重建时不得从旧 sysUser 缓存把 tokenVersion 抹回 | | TC-1107 | POST /api/member/add | 非 ADMIN caller + **不存在的 productCode**:必须 403(不是 404)以消除 productCode 枚举 oracle | `MemberCtx("other_product")` + `ProductCode="does_not_exist"` | `CodeError.Code()==403`(不是 404 "产品不存在");DB 无 `sys_product_member` 新增 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysProductModel.FindOneByCode` | | TC-1108 | POST /api/member/add | 非 ADMIN caller + 非法 `MemberType`:返回 403 而不是 400(权限优先于字面校验) | `MemberCtx` + `MemberType="INVALID"` | `CodeError.Code()==403`(不是 400 "无效的成员类型") | 安全/枚举 | P0 | 防通过 400/404 差分探测产品/用户存在性 | | TC-1109 | POST /api/member/add | 超管 + 非法 `MemberType`:正常 400 | `SuperAdminCtx` + `MemberType="INVALID"`(产品存在) | `CodeError.Code()==400`,文案含 "无效的成员类型" | 正向回归 | P0 | 确认权限通过后仍走字面 400 检查,不误伤合法路径 | | TC-1162 | POST /api/member/remove | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | seed 2 个 ADMIN 绕过 last-admin,`{id: targetMemberId}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member` 行被删;post-commit 产品成员缓存失效 | 安全/会话吊销 | P0 | 镜像 updateMember 的 tokenVersion 契约,避免被踢出产品后旧 access token 仍能访问该产品 | | TC-1163 | POST /api/member/remove | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | 唯一启用 ADMIN,`{id: adminMemberId}` | 返回 400 "不能移除该产品的最后一个管理员";DB `sys_user.tokenVersion` 与初值严格相等;`sys_product_member` 行仍在 | 事务回滚 | P0 | tokenVersion 增量必须与 member 删除同事务;失败路径不得污染 tokenVersion 让合法会话被无故踢下线 | --- ## 三、gRPC 接口测试用例 ### 3.1 gRPC SyncPermissions | TC编号 | 接口/方法 | 测试场景 | 输入 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0230 | SyncPermissions | 正常同步 | valid req | added/updated/disabled计数正确 | 正常路径 | P0 | permserver.go SyncPermissions | | TC-0231 | SyncPermissions | appKey无效 | invalid appKey | codes.Unauthenticated | 异常路径 | P0 | status.Error | | TC-0232 | SyncPermissions | appSecret错误 | wrong secret | codes.Unauthenticated | 异常路径 | P0 | status.Error | | TC-0233 | SyncPermissions | 产品已禁用 | disabled product | codes.PermissionDenied | 分支覆盖 | P0 | status.Error | | TC-0234 | SyncPermissions | 验证disabled计数 | DB有5条,perms含2条 | disabled=3 | 功能验证 | P0 | RowsAffected | ### 3.2 gRPC Login / RefreshToken / VerifyToken / GetUserPerms | TC编号 | 接口/方法 | 测试场景 | 输入 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0235 | Login | 正常登录(普通用户+productCode) | valid credentials + productCode | token对+userInfo(含nickname) | 正常路径 | P0 | permserver.go Login; resp.Nickname应返回用户昵称 | | TC-0236 | Login | 用户不存在 | wrong username | codes.Unauthenticated | 异常路径 | P0 | status.Error | | TC-0237 | Login | 密码错误 | wrong password | codes.Unauthenticated | 异常路径 | P0 | status.Error | | TC-0238 | Login | 账号冻结 | frozen user | codes.PermissionDenied | 分支覆盖 | P0 | status.Error | | TC-0239 | Login | 超管被拒绝 | isSuperAdmin=1+productCode | codes.PermissionDenied, "超级管理员不允许通过产品端登录" | 安全 | P0 | IsSuperAdmin==1 → 拒绝 | | TC-0240 | Login | 普通用户+productCode | 普通MEMBER+productCode | perms含角色权限, memberType="MEMBER" | 分支覆盖 | P0 | !isSuperAdmin && productCode!="" | | TC-0241 | Login | 产品成员被禁用时拒绝登录 | member.status=Disabled | PermissionDenied | 安全 | P0 | permserver Login | | TC-0242 | Login | productCode为空 | productCode="" | codes.InvalidArgument, "productCode不能为空" | 输入校验 | P0 | 第一个校验点 | | TC-0243 | RefreshToken | 正常刷新 | valid token | 新token对 | 正常路径 | P0 | RefreshToken | | TC-0244 | RefreshToken | token无效 | invalid token | codes.Unauthenticated | 异常路径 | P0 | status.Error | | TC-0245 | RefreshToken | 账号冻结 | frozen | codes.PermissionDenied | 分支覆盖 | P0 | status.Error | | TC-0246 | RefreshToken | productCode回退到claims | req.ProductCode="", claims含productCode | 使用claims.ProductCode | 分支覆盖 | P0 | productCode==""回退 | | TC-0247 | RefreshToken | 超管+productCode | isSuperAdmin=1+productCode | memberType="SUPER_ADMIN", perms全量 | 分支覆盖 | P0 | isSuperAdmin && productCode!="" | | TC-0248 | RefreshToken | 普通用户+productCode | 普通MEMBER+productCode | perms含角色权限 | 分支覆盖 | P0 | !isSuperAdmin && productCode!="" | | TC-0249 | VerifyToken | 有效token | valid | valid=true, userId/perms/productCode正确 | 正常路径 | P0 | VerifyToken; resp.ProductCode应返回产品编码 | | TC-0250 | VerifyToken | 无效token | invalid | valid=false | 异常路径 | P0 | err或!Valid | | TC-0251 | VerifyToken | 缺少userId | 伪造claims | valid=false | 安全 | P0 | !ok断言保护 | | TC-0252 | VerifyToken | 冻结用户token返回Invalid | user.status=Disabled | Valid=false | 安全 | P0 | 实时查DB | | TC-0253 | VerifyToken | 非成员token返回Invalid | user非产品成员 | Valid=false | 安全 | P0 | 实时查成员状态 | | TC-0254 | VerifyToken | 返回实时MemberType和Perms | DB中ADMIN+自定义权限 | 返回实时数据而非token中旧数据 | 安全 | P0 | 实时数据 | | TC-0255 | GetUserPerms | 用户不存在(需先通过AppKey/Secret认证) | userId=9999, 合法AppKey/AppSecret | codes.NotFound | 异常路径 | P0 | status.Error; 先认证再查用户 | | TC-0256 | GetUserPerms | 超管(需先通过AppKey/Secret认证) | isSuperAdmin, 合法AppKey/AppSecret | perms全量, "SUPER_ADMIN" | 正常路径 | P0 | GetUserPerms(true); AppKey认证前置 | | TC-0257 | GetUserPerms | MEMBER-DENY覆盖(需先通过AppKey/Secret认证) | 角色有permA, DENY permA, 合法AppKey/AppSecret | perms不含permA | 深度业务 | P0 | denySet过滤; AppKey认证前置 | | TC-0700 | gRPC PermService | 冻结用户 (Status=Disabled) | GetUserPerms 请求冻结账号 | gRPC PermissionDenied,msg 含"冻结" | 安全 | P0 | 对齐 VerifyToken 的 StatusEnabled 判定 | | TC-0701 | gRPC PermService | 非产品成员 | 启用用户但非目标产品成员 | gRPC PermissionDenied,msg 含"成员" | 安全 | P0 | MemberType=="" 拒绝 | | TC-0702 | gRPC PermService | DEV 部门但产品成员被禁用 | dept.DeptType=DEV & member.Status=Disabled | gRPC PermissionDenied | 安全 | P0 | DEV 部门不再旁路已禁用成员校验 | | TC-0703 | gRPC PermService | 启用 ADMIN 成员(正向回归) | 正常启用成员,产品存在权限 | 成功,返回 MemberType=ADMIN 且 Perms 含已配置项 | 正常路径 | P0 | H-2 修复后正常路径未被误伤 | | TC-0704 | gRPC PermService | Loader 层:DEV 部门 + 产品成员禁用 | DEV 启用,member.Status=Disabled | UserDetails.MemberType="",Perms=[] | 安全 | P0 | 禁用成员走入 MemberType 清空分支后不再命中全量权限 | | TC-0782 | gRPC PermService | VerifyToken 各失败分支记录日志 | invalid token / disabled user / version mismatch 等 | 日志含 `verifyToken fail reason=` | 可观测 | P1 | 每个失败分支有 logx.Infof | | TC-0783 | gRPC PermService | peer.FromContext 失败时仍限流 | 无 peer 的 ctx | 限流 key 使用 `"unknown"`,超限仍返回 ResourceExhausted | 安全 | P0 | fail-closed 不跳过限流 | | TC-0794 | gRPC PermService | VerifyToken 对任意畸形 AccessToken | 13 条种子(空串/alg=none/长串/Unicode/控制符)+ 运行期可扩 `-fuzz=...` | never-panic, `(resp,nil)`, `resp.Valid=false` | 协议健壮性 | P0 | gRPC 契约:畸形令牌不得使服务端返回 `err != nil` 或崩溃 | | TC-0795 | gRPC PermService | GetUserPerms 任意 (appKey,appSecret,productCode,userId) | 6 条种子(空/不存在/SQL 注入样本/Unicode) | 错误码只能在 Unauthenticated/PermissionDenied/InvalidArgument/NotFound/Internal 中 | 协议健壮性 | P0 | 错误码 taxonomy 冻结, 产品侧权限网关依赖此集合 | | TC-0828 | gRPC PermService | 同 IP 两次 gRPC RefreshToken,quota=1 | peer `10.1.2.3:11111` → `10.1.2.3:22222` | 第 1 次 Unauthenticated(业务放行);第 2 次 ResourceExhausted + "过于频繁" | 安全/限流 | P0 | 端口变化不得绕过限流 | | TC-0829 | gRPC PermService | 同 IP 两次 gRPC VerifyToken,quota=1 | peer `10.9.8.7:30001` → `10.9.8.7:30002` | 第 1 次 `Valid=false` + nil err;第 2 次 ResourceExhausted | 安全/限流 | P0 | VerifyToken 作为 token oracle 必须受限流保护 | | TC-0830 | gRPC PermService | `extractClientIP` 对 "host:port" 剥离 | `192.168.0.1:54321` | 返回 `192.168.0.1`;无 peer 时 error | 契约 | P0 | 剥端口契约不得回退 | | TC-0831 | gRPC PermService | gRPC refresh 成功后重放旧 rt(换端口) | quota 宽松 | 第 2 次 Unauthenticated + "登录状态已失效" | 安全/并发 | P0 | H-1 + M-7 纵深交叉 | | TC-1036 | gRPC PermService | `GetUserPerms` 查询 userId 全局不存在 | userId 全局不存在 | `status.Code()==NotFound` + "用户不是该产品的有效成员" | 安全/Oracle | P0 | 与"非成员"同响应 | | TC-1037 | gRPC PermService | `GetUserPerms` 查询 userId 存在但非成员 | userId 存在但非成员 | 同上(status 码由 `PermissionDenied` 改为 `NotFound`) | 安全/Oracle | P0 | 与"userId 不存在"同响应 | | TC-1038 | gRPC PermService | `GetUserPerms` 查询成员 `Status=Disabled` | 成员 `Status=Disabled` | 同上 | 安全/Oracle | P0 | 禁用成员走 `loadMembership` 清空 MemberType → 同路径 | | TC-1051 | gRPC PermService | `SyncPermissions` 同 appKey 连打 `quota+1` 次触发 `ResourceExhausted` | `GrpcSyncLimiter = NewPeriodLimit(60, 1, rds, uniqPrefix)`;同一 appKey 连续 2 次调用 | 第 1 次走业务层(Unauthenticated,因 appKey 不真实);第 2 次 `codes.ResourceExhausted` | 安全/限流 | P0 | appKey 桶命中上限 | | TC-1052 | gRPC PermService | `GetUserPerms` 同 appKey 连打 `quota+1` 次触发 `ResourceExhausted` | 同上 limiter 套给 `GrpcGetUserPermsLimiter`;同一 appKey 连续 2 次 | 第 2 次 `codes.ResourceExhausted` | 安全/限流 | P0 | `GetUserPerms` 双桶中的 appKey 桶 | | TC-1053 | gRPC PermService | 空 `AppKey` 不消耗 limiter 配额 | `AppKey=""` 连打 3 次;然后真实 `realKey` 首次请求 | 3 次空串请求都 `codes.Unauthenticated`(走 FindOneByAppKey("") → ErrNotFound),`realKey` 首次仍命中业务层 `codes.Unauthenticated`(**不是** `ResourceExhausted`) | 安全/防污染 | P0 | 空串前置分支缺失会把 limiter 计数器打到上限 | | TC-1054 | gRPC PermService | `GetUserPerms` 同一 IP、多个不同 appKey → 命中 IP 桶 | `GrpcGetUserPermsLimiter=NewPeriodLimit(60, 2, ...)`;同 IP 依次用 appKey `"a"/"b"/"c"` 发请求 | 第 3 次命中 IP 桶上限 `codes.ResourceExhausted`(尽管每个 appKey 桶都还有额度) | 安全/限流 | P0 | `appKey + IP` 双桶叠加 | --- ## 四、JWT中间件 / 统一响应测试用例 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0258 | 正常Bearer token | `Authorization: Bearer {valid}` | 通过, ctx注入5个值 | 正常路径 | P0 | middleware全路径 | | TC-0259 | 无Authorization头 | 无Header | code=401, "未登录" | 异常 | P0 | authHeader=="" | | TC-0260 | 无Bearer前缀 | `Authorization: xxx` | code=401, "token格式错误" | 异常 | P0 | TrimPrefix相等 | | TC-0261 | token签名错误 | 错误secret | code=401, "token无效或已过期" | 异常 | P0 | !Valid | | TC-0262 | token过期 | expired | code=401 | 异常 | P0 | jwt过期 | | TC-0263 | claims类型断言失败 | 非标准claims | code=401, "token无效或类型错误" | 异常 | P1 | !ok 防御性分支,jwt.ParseWithClaims(&Claims{}) 下不可达;TokenType 检查由 TC-0264 覆盖 | | TC-0264 | refresh token被拒绝 | 用refresh token访问API | code=401, "token无效或类型错误" | 安全 | P0 | TokenType="refresh"时拒绝 | | TC-0265 | 业务错误(CodeError) | 触发404等 | `{code:业务码, msg:业务消息}` | 正常 | P0 | errors.As成功 | | TC-0266 | 内部错误 | DB异常 | `{code:500, msg:"服务器内部错误"}` | 安全 | P0 | logx.Errorf+兜底 | | TC-0267 | 成功(有data) | 正常请求 | `{code:0, msg:"ok", data:{...}}` | 正常 | P0 | v!=nil | | TC-0268 | 成功(无data) | 返回nil | `{code:0, msg:"ok"}` | 正常 | P0 | v==nil | | TC-0716 | access token payload 中不得含 perms | 生成 access token 后 base64 解码 payload | JSON 中不存在 "perms" key | 安全 | P0 | Dead field 清理 | | TC-0749 | token.tokenVersion != DB & 产品被禁用 | 同时具备两个失败条件 | 返回 401 "令牌已失效"(而非 403 "产品已禁用") | 安全/顺序 | P0 | TokenVersion 比 ProductStatus 先判,避免客户端看到"用户被踢出"后误以为是产品被禁用 | | TC-0750 | token.tokenVersion == DB,产品被禁用 | TokenVersion 对齐,product.status=2 | 403 "该产品已被禁用" | 安全 | P0 | TokenVersion 通过后才看 ProductStatus | | TC-0754 | 用户已被删除 + TokenVersion 失配 | Username="" + tokenVersion mismatch | 401 "用户不存在或已被删除" | 安全/顺序 | P0 | L-B 矩阵:Username empty 必须先于 TokenVersion 裁决, 否则软删除语义泄漏成"登录已失效" | | TC-0755 | 账号冻结 + TokenVersion 失配 + 产品禁用 | 三重 failing 同时命中 | 403 "账号已被冻结"(而非 401/ProductDisabled) | 安全/顺序 | P0 | L-B 矩阵:账号级 > 会话级 > 产品级 的优先级契约 | | TC-0756 | TokenVersion OK + 产品启用 + 非超管 + MemberType="" | 曾是成员后被移除 | 403 "您已不是该产品的有效成员" | 安全 | P0 | L-B 矩阵:成员移除后 old token 必须被识别 | | TC-0757 | SuperAdmin + ProductCode + MemberType="" | 超管 claim 携带 productCode | 200 放行到下游 handler | 正常路径 | P0 | L-B 矩阵:超管 bypass 成员校验不可被移除 | | TC-0758 | Frozen 用户 + TokenVersion 失配(无 ProductCode) | 冻结账号 + stale token | 403 "账号已被冻结" | 安全 | P0 | L-B 矩阵:不走产品分支时 Status 仍先于 TokenVersion | | TC-0951 | 合法 HS256 + 正确 secret | 正常 token | 解析成功,claims 可读 | 正常路径 | P0 | good path | | TC-0952 | alg=none 伪造 | 手工拼无签名段 | 拒绝,错误含 "unexpected signing method" | 安全 | P0 | alg=none 零信任 | | TC-0953 | alg=RS256 但用 HS secret 签名 | RSA→HMAC 混淆攻击 | 拒绝 | 安全 | P0 | 算法混淆防御 | | TC-0954 | alg=ES256 header | 非 HMAC 族 | 拒绝 | 安全 | P0 | 非 HMAC 一律拒 | | TC-0955 | HS256 但 secret 错 | 同算法错密钥 | 拒绝(签名校验失败) | 安全 | P0 | 基础用例 | | TC-0956 | ParseRefreshToken 复用 ParseWithHMAC | 伪造 alg=none | 拒绝 | 安全 | P0 | 上层也防 | | TC-0957 | 乱码 token | "abc.def" | 拒绝(malformed) | 容错 | P1 | H-4 | | TC-0958 | ParseRefreshToken 的 tokenType 校验 | AccessToken 类型 | 拒绝 TokenTypeMismatch | 契约 | P0 | 不被 H-4 修复破坏 | | TC-0959 | 合法 HS256 + 非标 `typ` header | typ=JWT-X | 解析成功(只严格校验 alg) | 兼容 | P1 | 不过度收紧 | | TC-0960 | 回放过期 token | exp 已过 | 拒绝(解析器底层校验) | 安全 | P0 | H-4 | | TC-1003 | 合法 HS256 token | 正常 `Claims` | 解析通过,`token.Valid == true` | 正向 | P0 | 中央入口正向路径 | | TC-1004 | alg=RS256(公钥→HMAC 混淆)伪造 | 攻击者用 secret 当 HS256 密钥签署 | `unexpected signing method` 错误 | 安全 | P0 | CVE-2016-10555 同类防御 | | TC-1005 | alg=none 伪造 | 空签名段 | 错误返回 | 安全 | P0 | 深度防御 | | TC-1006 | HS256 但错误 secret | 合法结构 + 猜测 secret | 签名校验失败 | 安全 | P0 | 签名错误必拦 | | TC-1007 | 任意 `jwt.Claims` 结构体 | 自定义 `customClaims` | 解析通过,字段可转型回取 | 契约 | P1 | 与具体 claims 类型解耦,所有调用方可复用 | --- ## 五、util 层测试用例 ### 5.1 NormalizePage | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0269 | 正常值 | page=2, pageSize=10 | (2, 10) | 正常路径 | P0 | 无修正 | | TC-0270 | page<=0 | page=0, pageSize=10 | (1, 10) | 边界 | P0 | page<=0→1 | | TC-0271 | page=-1 | page=-1, pageSize=10 | (1, 10) | 边界 | P0 | page<=0→1 | | TC-0272 | pageSize<=0 | page=1, pageSize=0 | (1, 20) | 边界 | P0 | pageSize<=0→20 | | TC-0273 | pageSize>100 | page=1, pageSize=500 | (1, 100) | 边界-上限 | P0 | pageSize>100→100 | | TC-0274 | pageSize=100 | page=1, pageSize=100 | (1, 100) | 边界 | P1 | 恰好不触发 | | TC-0275 | pageSize=101 | page=1, pageSize=101 | (1, 100) | 边界 | P1 | 恰好触发 | | TC-0276 | 双零 | page=0, pageSize=0 | (1, 20) | 边界 | P1 | 两条件同时 | ### 5.2 IsValidEmail | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0277 | 正常邮箱 | `user@example.com` | true | 正常路径 | P0 | 标准格式 | | TC-0278 | 含点号 | `user.name@example.com` | true | 正常路径 | P1 | 允许点号 | | TC-0279 | 含加号 | `user+tag@example.com` | true | 正常路径 | P1 | 允许加号 | | TC-0280 | 缺少@ | `userexample.com` | false | 异常路径 | P0 | 无@ | | TC-0281 | 缺少域名 | `user@` | false | 异常路径 | P0 | 无域名 | | TC-0282 | 缺少TLD | `user@example` | false | 异常路径 | P0 | TLD<2字符 | | TC-0283 | 空字符串 | `""` | false | 边界 | P1 | 空 | ### 5.3 IsValidPhone | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0284 | 国内手机号 | `13800138000` | true | 正常路径 | P0 | 11位数字 | | TC-0285 | 带+国际码 | `+8613800138000` | true | 正常路径 | P0 | +前缀 | | TC-0286 | 太短(6位) | `123456` | false | 边界 | P0 | <7位 | | TC-0287 | 恰好7位 | `1234567` | true | 边界 | P1 | 最小长度 | | TC-0288 | 最长15位 | `+123456789012345` | true | 边界 | P1 | 最大长度 | | TC-0289 | 超长16位 | `1234567890123456` | false | 边界 | P1 | 超限 | | TC-0290 | 包含字母 | `1380013abc` | false | 异常路径 | P0 | 非数字 | | TC-0291 | 空字符串 | `""` | false | 边界 | P1 | 空 | --- ## 六、Logic 层单元测试用例 > 以下针对 Logic 层中的核心共享函数,使用 mock Model 接口进行纯单元测试。 ### 6.1 auth/jwt.go — GenerateAccessToken | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0292 | 正常生成 | secret="s", expire=3600, userId=1, username="u", productCode="p", memberType="M", perms=["a"] | 返回非空token, err=nil | 正常路径 | P0 | jwt.NewWithClaims(HS256) | | TC-0293 | 解析token验证claims | 上述token | ParseWithClaims可解析出正确userId/username/productCode/memberType/perms | 功能验证 | P0 | claims完整性 | | TC-0294 | 空secret | secret="" | 仍能生成token(空key签名) | 边界 | P2 | HS256 允许空key | | TC-0295 | 空perms | perms=nil | token生成成功, 解析后perms=nil | 边界 | P1 | nil slice | | TC-0296 | 过期时间验证 | expireSeconds=1, sleep 2s | ParseWithClaims返回过期错误 | 功能验证 | P0 | ExpiresAt | ### 6.2 auth/jwt.go — GenerateRefreshToken | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0297 | 正常生成 | secret="s", expire=86400, userId=1, productCode="p" | 返回非空token | 正常路径 | P0 | RefreshClaims | | TC-0298 | 解析验证 | 上述token | ParseRefreshToken解析出userId=1, productCode="p" | 功能验证 | P0 | 往返一致 | | TC-0299 | productCode为空 | productCode="" | 生成成功, 解析后productCode="" | 边界 | P1 | 空字符串 | ### 6.3 auth/jwt.go — ParseRefreshToken | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0300 | 正常解析 | 有效token+正确secret | 返回RefreshClaims, err=nil | 正常路径 | P0 | token.Valid | | TC-0301 | 错误secret | 有效token+错误secret | err!=nil | 异常路径 | P0 | 签名验证失败 | | TC-0302 | 无效token字符串 | "invalid-token" | err!=nil | 异常路径 | P0 | 解析失败 | | TC-0303 | 空token | "" | err!=nil | 边界 | P1 | 空字符串 | | TC-0304 | 过期token | 已过期的token | err!=nil (token expired) | 异常路径 | P0 | ExpiresAt已过 | | TC-0305 | AccessToken误用 | 用AccessToken当RefreshToken解析 | err!=nil (TokenType="access"≠"refresh") | 安全 | P0 | TokenType字段校验 | ### 6.4 middleware — 辅助函数单元测试 > **M-5/M-6重构**:`GetUserPerms` (auth/perms.go) 以及 `GetUsername` / `GetMemberType` / `IsSuperAdmin` 等 context helper 作为死代码被移除;统一使用 `GetUserDetails` 读取完整 `UserDetails` 后访问字段。 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0306 | GetUserId-正常 | ctx含userId=100 | 100 | 正常路径 | P0 | 类型断言成功 | | TC-0307 | GetUserId-空ctx | 空ctx | 0 | 边界 | P0 | 断言失败→零值 | | TC-0308 | GetProductCode-正常 | ctx含productCode="p1" | "p1" | 正常路径 | P0 | 类型断言 | | TC-0309 | GetUserDetails 返回完整字段 | ctx含UserDetails{UserId,Username,ProductCode,MemberType,IsSuperAdmin} | 读出字段全部一致, 空ctx返回nil | 正常路径 | P0 | 替代已移除的 GetUsername/GetMemberType/IsSuperAdmin | --- ## 七、Model 层 _gen.go 模板生成方法测试用例 > 所有 9 个 Model 的 `_gen.go` 均由自定义模板 (`cli/goctl/model/`) 生成,包含非标准方法(批量操作、事务变体、`buildBatchUpdateQuery` 等)。 > 以下以 **通用测试模式** 列出,适用于全部 9 个 Model(注明差异部分)。 ### 7.1 通用 CRUD 方法 (每个 Model 均需测试) > 适用: SysUser, SysProduct, SysPerm, SysDept, SysRole, SysRolePerm, SysUserPerm, SysUserRole, SysProductMember | TC编号 | 方法 | 测试场景 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0310 | **Insert** | 正常插入 | 返回Result+nil, DB有新记录 | 正常路径 | P0 | ExecCtx+缓存key清理 | | TC-0311 | **Insert** | 正常插入含TokenVersion | err=nil, DB中tokenVersion=0(默认) | 正常路径 | P0 | 验证Insert包含tokenVersion | | TC-0312 | **Insert** | 唯一索引冲突 | 返回DB错误(1062) | 异常路径 | P0 | MySQL uk | | TC-0313 | **Insert** | 缓存key生成正确 | 验证清理的缓存key包含主键和唯一索引 | 功能验证 | P0 | cacheSys*Prefix | | TC-0314 | **InsertWithTx** | 事务内插入 | 使用session执行, 返回Result | 正常路径 | P0 | session.ExecCtx | | TC-0315 | **InsertWithTx** | 事务内插入含TokenVersion | err=nil, 事务内可读到正确tokenVersion | 正常路径 | P0 | 验证InsertWithTx包含tokenVersion | | TC-0316 | **InsertWithTx** | 事务回滚后无数据 | 事务内Insert+外部回滚→DB无记录 | 事务验证 | P0 | TransactCtx | | TC-0317 | **FindOne** | 正常查询(缓存未命中) | 返回记录, 缓存已写入 | 正常路径 | P0 | QueryRowCtx→DB | | TC-0318 | **FindOne** | 正常查询(缓存命中) | 不触发DB查询, 返回缓存数据 | 正常路径 | P0 | QueryRowCtx→cache | | TC-0319 | **FindOne** | 记录不存在 | 返回ErrNotFound | 异常路径 | P0 | sqlc.ErrNotFound→ErrNotFound | | TC-0320 | **FindOne** | DB异常(非ErrNotFound) | 返回原始error | 异常路径 | P1 | default分支 | | TC-0321 | **FindOneWithTx** | 事务内正常查询 | 使用session.QueryRowCtx, 返回记录 | 正常路径 | P0 | session直查无缓存 | | TC-0322 | **FindOneWithTx** | 事务内记录不存在 | 返回ErrNotFound | 异常路径 | P0 | sqlx.ErrNotFound | | TC-0323 | **FindOneWithTx** | 事务内可见性 | InsertWithTx后FindOneWithTx可读到 | 事务验证 | P0 | 同session内可见 | | TC-0324 | **Update** | 正常更新 | 旧缓存key+新缓存key均被清理 | 正常路径 | P0 | FindOne→ExecCtx | | TC-0325 | **Update** | 正常更新含TokenVersion | err=nil, DB中tokenVersion正确更新 | 正常路径 | P0 | 验证Update包含tokenVersion | | TC-0326 | **Update** | 记录不存在 | FindOne失败→返回ErrNotFound | 异常路径 | P0 | FindOne err | | TC-0327 | **UpdateWithTx** | 事务内更新 | 使用session, 缓存被清理 | 正常路径 | P0 | session.ExecCtx | | TC-0328 | **Delete** | 正常删除 | 记录被删, 缓存key被清理 | 正常路径 | P0 | FindOne→ExecCtx DELETE | | TC-0329 | **Delete** | 记录不存在 | FindOne失败→返回ErrNotFound | 异常路径 | P0 | FindOne err | | TC-0330 | **DeleteWithTx** | 事务内删除 | 使用session, 缓存被清理 | 正常路径 | P0 | session.ExecCtx | | TC-0331 | **TransactCtx** | 正常事务 | fn执行成功→提交 | 正常路径 | P0 | conn.TransactCtx | | TC-0332 | **TransactCtx** | fn返回错误 | 自动回滚 | 异常路径 | P0 | 回滚 | | TC-0333 | **TableName** | 获取表名 | 返回正确表名(如 `` `sys_user` ``) | 正常路径 | P0 | m.table | ### 7.2 批量插入方法 | TC编号 | 方法 | 测试场景 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0334 | **BatchInsert** | 空列表 | 直接返回nil, 不执行SQL | 边界 | P0 | len==0 early return | | TC-0335 | **BatchInsert** | 单条记录 | 生成1组VALUES, 执行成功 | 正常路径 | P0 | 单条 | | TC-0336 | **BatchInsert** | 多条记录(3条) | 生成3组VALUES, SQL正确, 缓存key全清理 | 正常路径 | P0 | 多条+缓存 | | TC-0337 | **BatchInsert** | 批量插入含TokenVersion | err=nil, 所有记录tokenVersion正确 | 正常路径 | P0 | 验证BatchInsert包含tokenVersion | | TC-0338 | **BatchInsert** | 唯一索引冲突 | 全部失败, 返回DB错误 | 异常路径 | P0 | MySQL uk | | TC-0339 | **BatchInsert** | 大批量(1000条) | SQL长度合理, 执行成功 | 性能 | P2 | 拼接性能 | | TC-0340 | **BatchInsertWithTx** | 空列表 | 直接返回nil | 边界 | P0 | len==0 | | TC-0341 | **BatchInsertWithTx** | 正常多条 | 使用session执行 | 正常路径 | P0 | session.ExecCtx | | TC-0342 | **BatchInsertWithTx** | 事务回滚 | 外部回滚→无新记录 | 事务验证 | P0 | TransactCtx | ### 7.3 批量更新方法 | TC编号 | 方法 | 测试场景 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0343 | **BatchUpdate** | 空列表 | 直接返回nil | 边界 | P0 | len==0 early return | | TC-0344 | **BatchUpdate** | 单条记录 | CASE-WHEN SQL正确, 更新成功 | 正常路径 | P0 | buildBatchUpdateQuery 单条 | | TC-0345 | **BatchUpdate** | 多条记录(3条) | CASE-WHEN生成3个WHEN子句, 旧缓存key全清理 | 正常路径 | P0 | buildBatchUpdateQuery 多条 | | TC-0346 | **BatchUpdate** | 批量更新不污染数据 | err=nil, tokenVersion/createTime/updateTime均正确 | 正常路径 | P0 | 验证buildBatchUpdateQuery值对齐 | | TC-0347 | **BatchUpdate** | 部分id不存在 | findListByPrimaryKeys返回部分→仅清理存在的缓存 | 边界 | P1 | oldList可能少于dataList | | TC-0348 | **BatchUpdateWithTx** | 空列表 | 直接返回nil | 边界 | P0 | len==0 | | TC-0349 | **BatchUpdateWithTx** | 正常多条 | 使用session执行 | 正常路径 | P0 | session.ExecCtx | | TC-0350 | **buildBatchUpdateQuery** | 单条 | SQL: `UPDATE SET field=CASE WHEN id=? THEN ? ELSE field END WHERE id IN (?)` | 功能验证 | P0 | SQL结构 | | TC-0351 | **buildBatchUpdateQuery** | 多条 | 每个字段均有多个WHEN子句, WHERE IN含全部id | 功能验证 | P0 | SQL正确性 | | TC-0352 | **buildBatchUpdateQuery** | vals数量正确 | vals = N*(fields*2) + N (WHERE IN) | 功能验证 | P0 | 参数计数 | ### 7.4 批量删除方法 | TC编号 | 方法 | 测试场景 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0353 | **BatchDelete** | 空ids | 直接返回nil | 边界 | P0 | len==0 early return | | TC-0354 | **BatchDelete** | 单个id | DELETE WHERE id IN (?), 缓存清理 | 正常路径 | P0 | 单条 | | TC-0355 | **BatchDelete** | 多个id(3个) | 3个占位符, 旧数据查询→缓存key全清理 | 正常路径 | P0 | findListByPrimaryKeys | | TC-0356 | **BatchDelete** | 包含不存在id | findListByPrimaryKeys返回部分, 不报错 | 边界 | P1 | 部分存在 | | TC-0357 | **BatchDeleteWithTx** | 空ids | 直接返回nil | 边界 | P0 | len==0 | | TC-0358 | **BatchDeleteWithTx** | 正常多条 | 使用session执行 | 正常路径 | P0 | session.ExecCtx | ### 7.5 唯一索引查询方法 (按 Model 差异) | TC编号 | Model | 方法 | 测试场景 | 预期结果 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0359 | SysUser | FindOneByUsername | 正常查询 | 返回用户, 缓存写入 (索引缓存→主键缓存双层) | P0 | QueryRowIndexCtx | | TC-0360 | SysUser | FindOneByUsername | 不存在 | 返回ErrNotFound | P0 | sqlc.ErrNotFound | | TC-0361 | SysUser | FindOneByUsernameWithTx | 事务内正常查询 | 返回用户, 使用session直查 | P0 | session.QueryRowCtx | | TC-0362 | SysUser | FindOneByUsernameWithTx | 事务内不存在 | 返回ErrNotFound | P0 | sqlx.ErrNotFound | | TC-0363 | SysProduct | FindOneByAppKey | 正常查询 | 返回产品 | P0 | appKey唯一索引 | | TC-0364 | SysProduct | FindOneByAppKey | 不存在 | 返回ErrNotFound | P0 | | | TC-0365 | SysProduct | FindOneByAppKeyWithTx | 事务内正常查询 | 返回产品 | P0 | session直查 | | TC-0366 | SysProduct | FindOneByAppKeyWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0367 | SysProduct | FindOneByCode | 正常查询 | 返回产品 | P0 | code唯一索引 | | TC-0368 | SysProduct | FindOneByCode | 不存在 | 返回ErrNotFound | P0 | | | TC-0369 | SysProduct | FindOneByCodeWithTx | 事务内正常查询 | 返回产品 | P0 | session直查 | | TC-0370 | SysProduct | FindOneByCodeWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0371 | SysPerm | FindOneByProductCodeCode | 正常查询 | 返回权限(复合唯一索引) | P0 | productCode+code | | TC-0372 | SysPerm | FindOneByProductCodeCode | 不存在 | 返回ErrNotFound | P0 | | | TC-0373 | SysPerm | FindOneByProductCodeCodeWithTx | 事务内正常查询 | 返回权限 | P0 | session直查 | | TC-0374 | SysPerm | FindOneByProductCodeCodeWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0375 | SysRole | FindOneByProductCodeName | 正常查询 | 返回角色(复合唯一索引) | P0 | productCode+name | | TC-0376 | SysRole | FindOneByProductCodeName | 不存在 | 返回ErrNotFound | P0 | | | TC-0377 | SysRole | FindOneByProductCodeNameWithTx | 事务内正常查询 | 返回角色 | P0 | session直查 | | TC-0378 | SysRole | FindOneByProductCodeNameWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0379 | SysRolePerm | FindOneByRoleIdPermId | 正常查询 | 返回关联记录 | P0 | roleId+permId | | TC-0380 | SysRolePerm | FindOneByRoleIdPermId | 不存在 | 返回ErrNotFound | P0 | | | TC-0381 | SysRolePerm | FindOneByRoleIdPermIdWithTx | 事务内正常查询 | 返回关联记录 | P0 | session直查 | | TC-0382 | SysRolePerm | FindOneByRoleIdPermIdWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0383 | SysUserPerm | FindOneByUserIdPermId | 正常查询 | 返回关联记录 | P0 | userId+permId | | TC-0384 | SysUserPerm | FindOneByUserIdPermId | 不存在 | 返回ErrNotFound | P0 | | | TC-0385 | SysUserPerm | FindOneByUserIdPermIdWithTx | 事务内正常查询 | 返回关联记录 | P0 | session直查 | | TC-0386 | SysUserPerm | FindOneByUserIdPermIdWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0387 | SysUserRole | FindOneByUserIdRoleId | 正常查询 | 返回关联记录 | P0 | userId+roleId | | TC-0388 | SysUserRole | FindOneByUserIdRoleId | 不存在 | 返回ErrNotFound | P0 | | | TC-0389 | SysUserRole | FindOneByUserIdRoleIdWithTx | 事务内正常查询 | 返回关联记录 | P0 | session直查 | | TC-0390 | SysUserRole | FindOneByUserIdRoleIdWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | | TC-0391 | SysProductMember | FindOneByProductCodeUserId | 正常查询 | 返回成员记录 | P0 | productCode+userId | | TC-0392 | SysProductMember | FindOneByProductCodeUserId | 不存在 | 返回ErrNotFound | P0 | | | TC-0393 | SysProductMember | FindOneByProductCodeUserIdWithTx | 事务内正常查询 | 返回成员记录 | P0 | session直查 | | TC-0394 | SysProductMember | FindOneByProductCodeUserIdWithTx | 事务内不存在 | 返回ErrNotFound | P0 | | ### 7.6 内部辅助方法 | TC编号 | 方法 | 测试场景 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0395 | **findListByPrimaryKeys** | 空ids | 返回空slice, 不执行SQL | 边界 | P0 | len==0 | | TC-0396 | **findListByPrimaryKeys** | 正常ids | 返回匹配记录(无缓存) | 正常路径 | P0 | QueryRowsNoCacheCtx | | TC-0397 | **findListByPrimaryKeys** | 部分不存在 | 仅返回存在的记录 | 边界 | P1 | IN查询 | | TC-0398 | **findListByPrimaryKeys** | DB异常 | 返回nil, err | 异常路径 | P1 | err透传 | | TC-0399 | **getPrimaryKeyValue** | 正常 | 返回data.Id | 功能验证 | P0 | interface{} | | TC-0400 | **formatPrimary** | 正常 | 返回 "cache:sysXxx:id:{id}" | 功能验证 | P0 | 缓存key格式 | | TC-0401 | **queryPrimary** | 正常 | 执行 SELECT WHERE id=? | 功能验证 | P0 | SQL | ### 7.7 缓存key与前缀初始化 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0402 | cachePrefix为空 | cachePrefix="" | 使用默认前缀 (如 "cache:sysUser:id:") | 分支覆盖 | P0 | if cachePrefix!="" 未进入 | | TC-0403 | cachePrefix非空 | cachePrefix="test" | 前缀变为 "test:cache:sysUser:id:" | 分支覆盖 | P0 | if cachePrefix!="" 进入 | | TC-0404 | 多唯一索引前缀(SysProduct) | cachePrefix="test" | 3个缓存前缀均更新: id/appKey/code | 功能验证 | P0 | 3个变量均修改 | --- ## 八、Model 层自定义方法测试用例 ### 8.1 SysUserModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0405 | FindListByPage | 正常分页 | page=1, pageSize=10, DB有20条 | 返回10条+total=20 | 正常路径 | P0 | count+limit offset | | TC-0406 | FindListByPage | 第二页 | page=2, pageSize=10 | offset=10, 返回后10条 | 正常路径 | P0 | (page-1)*pageSize | | TC-0407 | FindListByPage | 空表 | 无数据 | total=0, list为空 | 边界 | P0 | count=0 | | TC-0408 | FindListByPage | count查询失败 | DB异常 | 返回0,0,err | 异常路径 | P1 | 第一个err | | TC-0409 | FindListByPage | list查询失败 | DB异常 | 返回0,total,err | 异常路径 | P1 | 第二个err | | TC-0410 | FindListByProductMembers | 正常查询 | productCode="p1", page=1, pageSize=10 | 返回该产品所有成员用户, total正确 | 正常路径 | P0 | 替代FindListByDeptIds: INNER JOIN sys_product_member | | TC-0411 | FindListByProductMembers | productCode不存在 | productCode="no_such_pc" | total=0, list空 | 边界 | P1 | JOIN 无匹配 | | TC-0412 | FindByIds | 正常批量查询 | ids=[1,2,3] | 返回3条 | 正常路径 | P0 | IN查询 | | TC-0413 | FindByIds | 空ids | ids=[] | 返回nil,nil | 边界 | P0 | len==0 | | TC-0414 | FindByIds | 部分id不存在 | ids=[1,9999] | 仅返回存在的 | 边界 | P1 | IN不报错 | | TC-0415 | FindByIds | DB异常 | 连接失败 | 返回nil,err | 异常路径 | P1 | err透传 | | TC-0416 | FindIdsByDeptId | 有用户的部门 | deptId=1(有用户) | 返回id列表 | 正常路径 | P0 | WHERE deptId=? | | TC-0417 | FindIdsByDeptId | 无用户部门 | deptId=999 | 空slice | 边界 | P1 | | | TC-0418 | UpdateProfile | 状态未变-不递增tokenVersion | statusChanged=false | 成功, tokenVersion不变 | 正常路径 | P0 | 非状态字段更新不影响会话 | | TC-0419 | UpdateProfile | 状态变更-tokenVersion+1 | statusChanged=true | 成功, tokenVersion+1 | 正常路径 | P0 | 状态变更使会话失效 | | TC-0420 | UpdateProfile | 乐观锁冲突 | expectedUpdateTime 与DB不符 | 返回ErrUpdateConflict | 异常路径 | P0 | WHERE updateTime=? | | TC-0421 | UpdateProfile | 并发场景 | 两个 goroutine 基于同一 updateTime 并发更新 | 仅一方成功, 另一方得到 ErrUpdateConflict | 并发 | P0 | 乐观锁仅允许一个成功 | | TC-0422 | UpdateProfile | userId不存在 | id=9999999 | 返回 ErrUpdateConflict (affected=0) | 异常路径 | P1 | WHERE 不匹配 | | TC-0736 | SysUserModel | 单次 Increment 返回值 == DB 持久值 | 初始 tokenVersion=v0 | 返回值 = v0+1 == DB 读取值 | 正常路径 | P0 | `LAST_INSERT_ID(tokenVersion+1)` 原子自增,不依赖缓存旧值 | | TC-0737 | SysUserModel | Increment 后缓存必须被主动清理 | 先 Load 预热缓存,再 Increment | 再次 Load 读到的 TokenVersion 为自增后值(非 stale) | 缓存一致性 | P0 | 事务成功后 `DelCacheCtx` | | TC-0738 | SysUserModel | 10 goroutine 并发自增同一用户 | 起始 v0,并发 Increment×10 | 10 次返回值互不重复,最终 DB = v0+10 | 并发/竞态 | P0 | 原子 UPDATE,不会丢失更新 | | TC-0784 | SysUserModel | MySQL 1062 错误正确识别 | `mysql.MySQLError{Number: 1062}` | `IsDuplicateEntryErr` 返回 true | 功能 | P0 | 类型断言替代字符串匹配 | | TC-0785 | SysUserModel | 非 1062 错误不误判 | 其他 MySQL 错误或普通 error | 返回 false | 功能 | P0 | 只匹配 1062 | | TC-0802 | SysUserModel | `expected == DB.tokenVersion` | expected=5, DB=5 | 返回 6;DB 落盘 6 | 正常路径 | P0 | CAS 成功分支 | | TC-0803 | SysUserModel | `expected != DB.tokenVersion` | expected=9, DB=10 | `ErrTokenVersionMismatch`;DB 零副作用 | 安全/并发 | P0 | 会话劫持窗口拦截 | | TC-0805 | SysUserModel | 8 goroutine 同时 CAS 同 expected | N=8 | 恰好 1 成功 + 7 `ErrTokenVersionMismatch`;DB `tokenVersion` 只递增 1 | 并发/竞态 | P0 | 原子性外部可观察证据 | | TC-0806 | SysUserModel | 成功后 id-key / username-key 缓存一致性 | CAS→1 | 再读两路都看到 1(非 stale 0) | 缓存 | P0 | 防 middleware 读 stale tokenVersion 放行旧 token | | TC-0812 | SysUserModel | logic 层 6 goroutine 并发 RefreshToken 同一旧 rt | N=6 | 1 成功 + 5 × 401 "登录状态已失效";DB 递增 1 | 并发/协议 | P0 | H-1 纵深,覆盖 logic 层分支到 CodeError | | TC-0867 | SysUserModel | 透传 username 与 DB 一致 | CAS 正常 | 成功且 username-key 缓存也失效(沿用 TC-0806 验证) | 契约 | P0 | 签名扩展不破坏既有 CAS 语义 | | TC-0924 | SysUserModel | UpdatePassword:FindOne 填缓存后行被并发删除 | 直接 SQL 删行绕过缓存失效 | `ErrUpdateConflict`,不得静默成功 | 并发/TOCTOU | P0 | RowsAffected=0 必须升格 | | TC-0925 | SysUserModel | UpdatePassword:正常写入 | 存在且未并发 | 持久化 + tokenVersion 递增 | 正常路径 | P0 | hot path 不回归 | | TC-0926 | SysUserModel | UpdatePassword:user 不存在 | id=非法 | `ErrNotFound`(FindOne 先挂) | 异常路径 | P0 | 直接失败 | | TC-0927 | SysUserModel | UpdateStatus:行被并发删除 | 同 TC-0924 手法 | `ErrUpdateConflict` | 并发/TOCTOU | P0 | 对称覆盖 | | TC-0928 | SysUserModel | UpdateStatus:正常禁用 | status=2 | 持久化 + tokenVersion 递增(用户被踢) | 正常路径 | P0 | 禁用副作用 | | TC-0929 | SysUserModel | UpdateStatus:user 不存在 | id=非法 | `ErrNotFound` | 异常路径 | P0 | M-2 | | TC-1044 | SysUserModel | `UpdateStatus` 用"错误 username" 调用 → Model 层仍按错误 key 清理 | 真实 username="u1";调 `UpdateStatus(id, "WRONG", ...)`;预置 `u1` 和 `WRONG` 两个 cache 槽 | 只有 `WRONG` 对应的 cache 槽被删;`u1` 的 cache 槽仍在(证明 Model 层**没有**自己 FindOne 纠正 username) | 契约/反向 | P0 | 内部二次 FindOne 回退一跑即红 | | TC-1045 | SysUserModel | `IncrementTokenVersion` 用"错误 username" 调用 → 同上 | 同上 | 同上;DB tokenVersion 正常前进 | 契约/反向 | P0 | IncrementTokenVersion 分支 | | TC-1046 | SysUserModel | `IncrementTokenVersion` 对"已被删除的行" 仍正常走 RowsAffected=0 → `ErrUpdateConflict` 分支 | 先 Insert 再 Delete,随后调 `IncrementTokenVersion(deletedId, "anyName", deletedUpdateTime)` | `errors.Is(err, ErrUpdateConflict)`;**不**触发 panic / nil user 崩溃(证明没有 FindOne 前置) | 契约/边界 | P0 | 删后 CAS 的边界 | | TC-1047 | SysUserModel | Logic 层 `Logout` 必须把 `ud.Username` 透传到 Model | mock `IncrementTokenVersion(id, "u1", _)`,ud.Username="u1" | mock EXPECT 命中,**不得**出现任何 `FindOne` mock 调用 | 契约 | P0 | Logout 口径 | | TC-1048 | SysUserModel | Logic 层 `Logout` 即使 `ud.Username==""` 也必须透传(空串) | mock `IncrementTokenVersion(id, "", _)` | mock EXPECT 命中,空串仍被透传(Model 层自负其责) | 契约/边界 | P1 | 不在 Logic 层搬运内部补全 | | TC-1079 | SysUserModel | `UpdateProfileWithTx` 成功后 id/username 两把 sysUser 缓存仍持旧值 | - | 预热缓存 → 事务改 nickname/remark 并 commit | Redis 两把 key 仍存在且与预热 payload 等价;DB 已落新值 | P0 | 证明 pre-commit DelCache 已被移除(修复前会在此处失败) | | TC-1080 | SysUserModel | `InvalidateProfileCache` 一次性失效 id / username 两把 key | - | 预热后调 InvalidateProfileCache | 两把 key 均被 DEL 掉 | P0 | post-commit 显式失效入口契约 | | TC-1081 | SysUserModel | 两段式 E2E:UpdateProfileWithTx(不碰缓存) + InvalidateProfileCache(清缓存) | - | 事务 commit 后 (a) 立即 FindOne (b) 调 invalidate 后再 FindOne | (a) 命中缓存返回旧值;(b) 回源 DB 取到新值 | P0 | 锁死"事务提交 → 缓存权威"的正确顺序,防止未来有人漏掉 invalidate | | TC-1082 | SysUserModel | UpdateUser tx 分支(改 deptId)post-commit 失效 sysUser 两级缓存 | - | 超管改 deptId 触发 tx 分支 | sysUser:id / sysUser:username / ud:{userId}:{product} 三把 key 均为空;下一轮 FindOne 返回新 deptId | P0 | Logic 层在 TransactCtx 返回后先 `SysUserModel.InvalidateProfileCache` 再 `UserDetailsLoader.Clean` | | TC-1083 | SysUserModel | (保留编号) | - | — | — | — | — | | TC-1143 | SysUserModel | `IncrementTokenVersionWithTx` 正常路径:事务内 `tokenVersion +1` 返回新值 | seed user(tv=5),TransactCtx 内调用 | 返回 6;事务 commit 后 DB 落盘为 6;`updateTime` 前进 | 正常路径 | P0 | LAST_INSERT_ID(tokenVersion+1) 原子自增契约 | | TC-1144 | SysUserModel | `IncrementTokenVersionWithTx` 目标行不存在 → ErrUpdateConflict | 调用不存在的 id | 返回 `(0, ErrUpdateConflict)`;事务内未生成 LAST_INSERT_ID | 异常路径 | P0 | `affected==0` 必须升格为 ErrUpdateConflict,不得静默返回 0 | | TC-1145 | SysUserModel | `IncrementTokenVersionWithTx` session==nil 报错 | session=nil | 返回错误("requires a non-nil session");不发 SQL | 契约/防御 | P0 | 非事务场景请改走 IncrementTokenVersion;不应被静默降级 | | TC-1146 | SysUserModel | `IncrementTokenVersionWithTx` 事务 rollback 时 DB 不落盘 | TransactCtx 内先 Increment 再 return err | DB 中 tokenVersion 保持初值;`updateTime` 不变 | 事务 | P0 | tokenVersion 增量必须能随业务事务整体回滚 | | TC-1147 | SysUserModel | `BatchIncrementTokenVersionWithTx` 正常批量 | 3 个 user,tv=0/5/10 | 三者 tokenVersion 各 +1(1/6/11) | 正常路径 | P0 | `IN(?,?,?)` 一次性批量递增 | | TC-1148 | SysUserModel | `BatchIncrementTokenVersionWithTx` 空 ids 直接返回 nil 且不发 SQL | ids=[] | `err==nil`;DB 任何行 tokenVersion 不变;事务内没有任何 UPDATE | 边界 | P0 | 空集合直接短路 | | TC-1149 | SysUserModel | `BatchIncrementTokenVersionWithTx` session==nil 报错 | session=nil | 返回错误;不发 SQL | 契约 | P0 | 与单个版本对称 | | TC-1150 | SysUserModel | `BatchIncrementTokenVersionWithTx` 事务 rollback 时全体回滚 | TransactCtx 内先 batch 再 return err | 所有目标 tokenVersion 保持初值 | 事务 | P0 | 原子性跨多行 | | TC-1151 | SysProductModel | `UpdateWithOptLockTx` 正常事务内 CAS | seed product,TransactCtx 内用正确 expectedUpdateTime | err==nil;commit 后 DB 落盘新 name/status;`updateTime` 推进 | 正常路径 | P0 | 事务版本复现 UpdateWithOptLock 的 CAS 语义 | | TC-1152 | SysProductModel | `UpdateWithOptLockTx` expectedUpdateTime 错位 → ErrUpdateConflict | 传入过期的 expectedUpdateTime | 返回 `ErrUpdateConflict`;事务 rollback 后 DB 无变更 | 异常/并发 | P0 | WHERE updateTime=? 打空 → affected=0 | | TC-1153 | SysProductModel | `UpdateWithOptLockTx` session==nil 报错 | session=nil | 返回错误;不发 SQL | 契约 | P0 | 与 sysUserModel 对称 | | TC-1154 | SysProductModel | `InvalidateProductCache` 一次性失效 id/appKey/code 三把 key | 预热三把缓存后调用 | 三把 key 均为空;`Get` 返回空串 | 缓存一致性 | P0 | post-commit 显式失效入口 | | TC-1155 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 返回启用成员 userId | 产品下 3 个 Status=1 + 1 个 Status=2 + 1 个他产品 | 返回 3 个启用成员 userId(disabled 与他产品均不在列) | 正常路径 | P0 | WHERE productCode=? AND status=1;跨产品 / 跨状态隔离 | | TC-1156 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 空产品 / 无启用成员 → 空切片 | 产品下 0 启用成员 | 返回 `len==0`;err==nil | 边界 | P0 | 与 UpdateProductLogic 的 `len(ids)==0` 分支对接 | | TC-1157 | SysProductMemberModel | `FindActiveMemberUserIdsByProductCodeTx` 按 `id` 升序输出 | 乱序插入多个成员 | 返回切片严格按 `id` 升序 | 契约/死锁防护 | P1 | `ORDER BY id`:锁获取顺序稳定,避免与按主键扫描的其它事务互相死锁 | ### 8.2 SysProductModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0423 | FindList | 正常分页 | page=1, pageSize=10 | 返回list+total | 正常路径 | P0 | count+limit | | TC-0424 | FindList | 空表 | 无数据 | total=0, list空 | 边界 | P0 | | | TC-0425 | FindList | count失败 | DB异常 | 返回err | 异常路径 | P1 | 第一个err | ### 8.3 SysPermModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0426 | FindListByProductCode | 正常分页 | productCode="p1", page=1, pageSize=10 | list+total | 正常路径 | P0 | WHERE productCode=? | | TC-0427 | FindListByProductCode | 不存在的productCode | "notexist" | total=0, list空 | 边界 | P1 | | | TC-0428 | FindAllCodesByProductCode | 正常查询 | DB有3条启用权限 | 返回3个code | 正常路径 | P0 | SELECT code WHERE status=1 | | TC-0429 | FindAllCodesByProductCode | 空结果 | 无匹配 | 空slice | 边界 | P1 | | | TC-0430 | FindByIds | 正常 | ids=[1,2] | 返回2条 | 正常路径 | P0 | IN查询 | | TC-0431 | FindByIds | 空ids | [] | 返回nil,nil | 边界 | P0 | len==0 | | TC-0432 | FindMapByProductCode | 正常查询 | productCode="p1" | map[code]*SysPerm, key为code | 正常路径 | P0 | result[p.Code]=p | | TC-0433 | FindMapByProductCode | 空结果 | 无匹配 | 空map | 边界 | P1 | | | TC-0434 | FindMapByProductCode | key唯一性 | 同productCode有3条 | map长度=3 | 功能验证 | P0 | code唯一 | | TC-0435 | DisableNotInCodesWithTx | codes非空-正常 | session+productCode="p1", codes=["a","b"], DB有a/b/c | 禁用c, 返回affected=1 | 正常路径 | P0 | 事务内 NOT IN | | TC-0436 | DisableNotInCodesWithTx | codes为空-全部禁用 | codes=[] | 全部已启用的被禁用 | 分支覆盖 | P0 | len==0分支 | | TC-0437 | DisableNotInCodesWithTx | 无需禁用 | codes包含所有已启用 | affected=0 | 边界 | P1 | 0行更新 | | TC-0438 | DisableNotInCodesWithTx | DB异常 | session.Exec报错 | 返回0,err | 异常路径 | P1 | err | | TC-0439 | FindAllCodesByProductCode | 有权限产品 | productCode="p1" | 返回code列表(仅status=1) | 正常路径 | P0 | WHERE status=1 | | TC-0440 | FindAllCodesByProductCode | 无权限产品 | productCode="notexist" | 空slice | 边界 | P1 | | | TC-0441 | FindAllCodesByProductCode | 全部已禁用 | 所有perm.status=2 | 空slice | 边界 | P1 | WHERE status=1 过滤, 全禁用时返回空 | | TC-0986 | SysPermModel | 产品含 status ∈ {1, 2, 99} 三类行 | FindAllCodesByProductCode | 只返回 status=1 的 code | 契约 | P1 | 严格 `=` 不模糊 | | TC-0987 | SysPermModel | DisableNotInCodesWithTx 只禁用 status=1 且不在白名单的行 | 同上数据 + 白名单含 status=1 的一条 | 仅 1 行被 1→2,99 和既有 2 均不动 | 契约 | P1 | WHERE 严格等值,未来枚举值不被误伤 | ### 8.4 SysDeptModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0442 | FindAll | 正常查询 | DB有5条 | 返回5条, 按sort asc排序 | 正常路径 | P0 | ORDER BY sort, id | | TC-0443 | FindAll | 空表 | 无数据 | 空slice | 边界 | P0 | | | TC-0444 | FindByParentId | 正常查询 | parentId=1 | 返回子部门列表 | 正常路径 | P0 | WHERE parentId=? | | TC-0445 | FindByParentId | 无子部门 | parentId=999 | 空slice | 边界 | P1 | | | TC-0446 | FindByPathPrefix | 正常查询 | pathPrefix="/1/" | 返回路径以/1/开头的部门 | 正常路径 | P0 | LIKE pathPrefix% | | TC-0447 | FindByPathPrefix | LIKE注入已阻止 | pathPrefix含% | 空slice(%和_已转义,不作为通配符) | 安全 | P1 | NewReplacer转义 | | TC-0448 | FindByPathPrefix | 无匹配 | "/999/" | 空slice | 边界 | P1 | | ### 8.5 SysRoleModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0449 | FindListByProductCode | 正常分页 | productCode="p1" | 按permsLevel asc排序 | 正常路径 | P0 | ORDER BY permsLevel, id | | TC-0450 | FindListByProductCode | 空结果 | 无匹配 | total=0 | 边界 | P1 | | | TC-0451 | FindByIds | 正常 | ids=[1,2] | 返回2条 | 正常路径 | P0 | IN查询 | | TC-0452 | FindByIds | 空ids | [] | 返回nil,nil | 边界 | P0 | len==0 | | TC-0453 | FindMinPermsLevelByUserIdAndProductCode | 有角色用户 | userId=1, productCode="p1" | 返回最小permsLevel | 正常路径 | P0 | MIN聚合 | | TC-0454 | FindMinPermsLevelByUserIdAndProductCode | 无角色用户 | userId=无角色用户 | error(ErrNotFound) | 边界 | P0 | IFNULL返回-1→level<0→ErrNotFound | | TC-1072 | SysRoleModel | Happy path:所有 roleId 存在且 Enabled | - | 2 个 Enabled role | 返回 nil;事务成功 | P0 | SELECT ... LOCK IN SHARE MODE 命中率 = len(ids) | | TC-1073 | SysRoleModel | 存在一个被删除的 roleId | - | 1 Enabled + 1 已 DELETE | 返回 `sqlx.ErrNotFound` | P0 | 命中行数 != len(ids) → ErrNotFound | | TC-1074 | SysRoleModel | 存在一个 Disabled 的 role | - | 1 Enabled + 1 Disabled | 返回 `sqlx.ErrNotFound` | P0 | WHERE status=Enabled 过滤掉禁用项 | | TC-1075 | SysRoleModel | 空 / nil 切片 | - | `[]int64{}` / `nil` | 返回 nil,不发 SQL | P1 | 提前 return 短路 | | TC-1076 | SysRoleModel | 入参含重复 id | - | `[r1,r1,r2]` 全 Enabled | 去重后比较 `len(ids)==2`,返回 nil | P1 | 内部 sort+dedup 避免假阳 ErrNotFound | | TC-1077 | SysRoleModel | 不按 productCode 过滤 | - | role 属 productA,lock 调用不传 productCode | 返回 nil(只管 id + status) | P1 | productCode 校验是上游 BindRoles 职责,LockRolesForShareTx 保持通用 | ### 8.6 SysRolePermModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0455 | FindPermIdsByRoleId | 正常查询 | roleId=1, DB有3条 | 返回3个permId | 正常路径 | P0 | SELECT permId WHERE roleId=? | | TC-0456 | FindPermIdsByRoleId | 无绑定 | roleId=999 | 空slice | 边界 | P1 | | | TC-0457 | FindPermIdsByRoleIds | 正常查询 | roleIds=[1,2] | 返回去重后的permId | 正常路径 | P0 | DISTINCT + IN | | TC-0458 | FindPermIdsByRoleIds | 空roleIds | [] | 返回nil,nil | 边界 | P0 | len==0 | | TC-0459 | FindPermIdsByRoleIds | 去重验证 | 两角色有相同permId | 结果中permId不重复 | 功能验证 | P0 | DISTINCT | | TC-0460 | DeleteByRoleIdTx | 正常事务内删除 | session+roleId | 使用session执行 | 正常路径 | P0 | session.ExecCtx | | TC-0461 | DeleteByRoleIdTx | 无绑定 | session+roleId=999 | 删0行, 不报错 | 边界 | P1 | session.ExecCtx, affected=0 不报错 | ### 8.7 SysUserPermModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0462 | FindPermIdsByUserIdAndEffectForProduct | ALLOW-指定产品 | userId=1, effect="ALLOW", productCode="p1" | 返回该用户在该产品下ALLOW的permIds | 正常路径 | P0 | 跨产品权限隔离 | | TC-0463 | FindPermIdsByUserIdAndEffectForProduct | DENY-指定产品 | userId=1, effect="DENY", productCode="p1" | 返回DENY的permIds | 正常路径 | P0 | 跨产品权限隔离 | | TC-0464 | FindPermIdsByUserIdAndEffectForProduct | 无记录/其他产品 | productCode不匹配 | 空slice | 边界 | P1 | WHERE 子句过滤 productCode 后无匹配 | | TC-0465 | DeleteByUserIdForProductTx | 事务内跨产品删除 | session+userId+productCode | 使用session | 正常路径 | P0 | session.ExecCtx, 仅删该产品下配置 | | TC-0466 | DeleteByUserIdForProductTx | 跨产品隔离 | 用户在多产品有配置 | 仅删目标产品的 | 深度业务 | P0 | WHERE userId=? AND productCode=? 隔离其他产品 | ### 8.8 SysUserRoleModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0467 | FindRoleIdsByUserId | 正常查询 | userId=1, DB有3条 | 返回3个roleId | 正常路径 | P0 | SELECT roleId | | TC-0468 | FindRoleIdsByUserId | 无绑定 | userId=999 | 空slice | 边界 | P1 | | | TC-0469 | DeleteByRoleIdTx | 正常删除 | session+roleId | 删除该角色的所有用户绑定 | 正常路径 | P0 | session.ExecCtx | | TC-0470 | DeleteByUserIdForProductTx | 事务内跨产品删除 | session+userId+productCode | 使用session | 正常路径 | P0 | session.ExecCtx | | TC-0471 | DeleteByUserIdForProductTx | 跨产品隔离 | 用户在多产品有角色 | 仅删目标产品的 | 深度业务 | P0 | WHERE userId=? AND productCode=? 子查询过滤 | | TC-0472 | FindUserIdsByRoleId | 有绑定的角色 | roleId=1 | 返回userId列表 | 正常路径 | P0 | WHERE roleId=? | | TC-0473 | FindUserIdsByRoleId | 无绑定角色 | roleId=999 | 空slice | 边界 | P1 | | | TC-0474 | FindRoleIdsByUserIdForProduct | 跨产品过滤 | userId=1, productCode="p1" | 仅返回该产品下绑定的roleId | 深度业务 | P0 | 差量更新依赖此接口 | | TC-0706 | SysUserRoleModel | 同一产品下同时存在启用/禁用角色 | user 绑定启用+禁用 2 个角色 | 仅返回启用角色的 id | 安全/过滤 | P0 | SQL 加入 r.status=1 | | TC-0707 | SysUserRoleModel | DeleteByUserIdAndRoleIdsTx 批量删除 | 插入 3 条 (user, roleX),批量删除其中 2 条 | 仅保留未被删除的 1 条 | 正常路径 | P0 | 循环 DELETE → 批量 IN | | TC-0708 | SysUserRoleModel | 批量删除空列表为 no-op | roleIds=[] | 无任何删除,原记录保留 | 边界 | P0 | 空集合保护 | | TC-0709 | SysUserRoleModel | 批量删除仅作用于指定 userId | 同 roleId 下两个 user,仅删 user1 | user2 的绑定不受影响 | 约束 | P0 | WHERE userId 严格约束 | | TC-1061 | SysUserRoleModel | `DeleteByRoleIdTx` 删除多行后,id 级和组合 key 缓存全部失效 | 预置 3 条 `sys_user_role(roleId=R)`,先 `FindOne` / 组合 key `FindOne` 把所有 cache 槽填热 | 删除后 `FindOne(id)` 均返 `ErrNotFound`;`FindOneByUserIdRoleId(userId, R)` 也均返 `ErrNotFound`;无脏读 | 契约/缓存 | P0 | 两套 key 都得失效 | | TC-1062 | SysUserRoleModel | `DeleteByUserIdAndRoleIdsTx` 只删指定 (userId, roleIds) 集合后同上 | 预置多条;删除部分;剩余部分保留 | 被删的全返 `ErrNotFound`;未被删的 `FindOne` 正常命中;两层 cache key 对齐 | 契约/缓存 | P0 | 组合 key 的定点失效不误伤 | ### 8.9 SysProductMemberModel | TC编号 | 方法 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0475 | FindListByProductCode | 正常分页 | productCode="p1" | list+total | 正常路径 | P0 | WHERE productCode=? | | TC-0476 | FindListByProductCode | 空结果 | 无匹配 | total=0 | 边界 | P1 | | | TC-0477 | FindMapByProductCodeUserIds | 正常批量 | productCode="p1", userIds=[1,2] | map key=userId | 正常路径 | P0 | IN+productCode | | TC-0478 | FindMapByProductCodeUserIds | 空userIds | [] | 返回空map | 边界 | P0 | len==0 | | TC-0479 | FindMapByProductCodeUserIds | 部分不是成员 | userIds含非成员 | map仅含成员 | 边界 | P1 | | | TC-0480 | FindMapByProductCodeUserIds | map key正确 | 查询结果 | key=userId, val=*SysProductMember | 功能验证 | P0 | result[pm.UserId] | | TC-0765 | SysProductMemberModel | CountActiveAdminsTx 返回正确计数 | 产品下 2 个启用 ADMIN + 1 个禁用 ADMIN | 返回 2 | 功能验证 | P0 | `SELECT id ... FOR UPDATE` 仅计活跃行 | | TC-0868 | SysProductMemberModel | 产品内 3 个 active admin,排除其中 1 | excludeId=第二个 admin | 返回 2 | 计数 | P0 | 排除目标后正确计数 | | TC-0869 | SysProductMemberModel | 唯一 active admin,排除他自己 | excludeId=唯一 admin | 返回 0 → 上层识别为"最后一个" | 语义 | P0 | removeMember/updateMember 据此防"降级/移除最后一个 admin" | | TC-0870 | SysProductMemberModel | 存在 1 个 active + 1 个 disabled admin | excludeId=active | 返回 0(disabled 不计入) | 语义 | P0 | 仍需状态=enabled 才算 | | TC-1110 | FindOneForShareTx | 事务内按 id 读到最新行并持有 S 锁 | 先 Insert(member),后在 `TransactCtx` 中调 `FindOneForShareTx(id)` | 返回与插入一致的 `*SysProductMember`,`Id/ProductCode/UserId/MemberType/Status` 全部对齐 | 正常路径 | P0 | L-R13-2 新 API 契约——不走缓存、参数直传事务 session | | TC-1111 | FindOneForShareTx | id 不存在时返回 `sqlx.ErrNotFound` | 事务内调 `FindOneForShareTx(99999999)` | `errors.Is(err, sqlx.ErrNotFound)==true` | 边界 | P0 | 让上层能区分"目标成员被并发删除"与其它 DB 错误,不被误吞为 5xx | ## 九、访问控制 (auth/access.go) ### 9.1 RequireSuperAdmin | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0481 | 超管通过 | ctx含SuperAdmin UserDetails | nil (允许) | 正常路径 | P0 | caller.IsSuperAdmin | | TC-0482 | 非超管拒绝 | ctx含ADMIN UserDetails | 403 "仅超级管理员" | 异常路径 | P0 | !IsSuperAdmin | | TC-0483 | MEMBER拒绝 | ctx含MEMBER UserDetails | 403 "仅超级管理员" | 异常路径 | P0 | | | TC-0484 | 未登录 | ctx无UserDetails | 401 "未登录" | 边界 | P0 | caller==nil | ### 9.2 RequireProductAdminFor(ctx, targetProductCode) | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0485 | 超管通过 | ctx含SuperAdmin, productCode="p1" | nil | 正常路径 | P0 | IsSuperAdmin | | TC-0486 | ADMIN通过(同产品) | ctx含ADMIN(p1), productCode="p1" | nil | 正常路径 | P0 | MemberType==ADMIN且productCode匹配 | | TC-0487 | DEVELOPER拒绝 | ctx含DEVELOPER(p1), productCode="p1" | 403 | 异常路径 | P0 | 非Admin | | TC-0488 | MEMBER拒绝 | ctx含MEMBER(p1), productCode="p1" | 403 | 异常路径 | P0 | 非Admin | | TC-0489 | 未登录 | ctx无UserDetails, productCode="p1" | 401 | 边界 | P0 | caller==nil | | TC-0490 | ADMIN跨产品拒绝 | ctx含ADMIN(p1), productCode="other" | 403 | 安全 | P0 | productCode不匹配→拒绝 | ### 9.3 CheckMemberTypeAssignment | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0491 | 超管可分配任何类型 | caller=SuperAdmin, assigned=ADMIN | nil | 正常路径 | P0 | IsSuperAdmin豁免 | | TC-0492 | ADMIN分配DEVELOPER | caller=ADMIN, assigned=DEVELOPER | nil | 正常路径 | P0 | callerPri(1) < assignPri(2) | | TC-0493 | ADMIN分配ADMIN(同级拒绝) | caller=ADMIN, assigned=ADMIN | 403 | 深度业务 | P0 | callerPri >= assignPri | | TC-0494 | DEVELOPER分配ADMIN(越级拒绝) | caller=DEVELOPER, assigned=ADMIN | 403 | 深度业务 | P0 | callerPri > assignPri | | TC-0495 | MEMBER分配MEMBER(同级拒绝) | caller=MEMBER, assigned=MEMBER | 403 | 深度业务 | P0 | | | TC-0496 | 未登录 | ctx无UserDetails | 401 | 边界 | P0 | | ### 9.4 CheckManageAccess | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0497 | 超管可管理任何人 | caller=SuperAdmin | nil | 正常路径 | P0 | IsSuperAdmin豁免 | | TC-0498 | 操作自己 | caller.UserId==targetUserId | nil | 正常路径 | P0 | self豁免 | | TC-0499 | ADMIN跳过部门检查 | caller=ADMIN | nil (直接比级别) | 深度业务 | P0 | checkDeptHierarchy ADMIN豁免 | | TC-0500 | 非ADMIN无部门拒绝 | caller.DeptId=0 | 403 "未归属部门" | 边界 | P0 | caller.DeptId==0 | | TC-0501 | 目标用户无部门 | target.DeptId=0 | 403 "目标用户未归属部门" | 边界 | P0 | target.DeptId==0 | | TC-0502 | 目标在不同部门 | 目标不在caller子部门 | 403 "无权管理其他部门" | 深度业务 | P0 | !HasPrefix | | TC-0503 | 未登录 | ctx无UserDetails | 401 | 边界 | P0 | | | TC-0504 | caller.DeptPath为空时拒绝 | caller有DeptId但DeptPath="" | 403 且文案含"未归属任何部门"(与 TC-0993 合一) | 安全 | P0 | DeptPath 空串与 DeptId=0 合一文案,防回归 | | TC-0819 | DB 层瞬时错误(mock `errors.New("boom")`) | mock 抛通用 err | `CodeError.Code == 500`,不是 403 | 鲁棒性/安全 | P0 | 禁止把 DB 故障曲解为"没角色 → 403" | | TC-0820 | DB 返回 `sqlx.ErrNotFound` | mock `sqlx.ErrNotFound` | 403(保留原业务语义) | 分支区分 | P0 | 只有真正"无角色"才 403,保证可审计 | | TC-0860 | 传入 prefetched target,不再查 FindOne | caller=MEMBER,prefetched.DeptId 合法 | `SysUserModel.FindOne` 次数 = 0;业务结果同无 option 版本 | 性能/契约 | P1 | 避免重复 FindOne | | TC-0861 | prefetched target.Id 与参数 targetUserId 不一致 | 被 defensive 忽略 | 依然触发一次 FindOne 真实查询(option 失效) | 安全 | P1 | prefetched 自洽校验,不让调用方传错 id 绕过访问控制 | | TC-0930 | caller 的 MinPermsLevel 缓存过期(偏高) | mock 返回 DB=100,caller 缓存=5,role=50 | 403(以 DB=100 判,50 ≤ 100 越级) | TOCTOU | P0 | 不得信任缓存高权值 | | TC-0931 | 同级分配 | DB=50,role=50 | 403 "不能分配权限级别高于自身的角色(含同级)" | 契约 | P0 | 含同级 | | TC-0932 | 严格低一级分配 | DB=50,role=51 | 放行 | 正常路径 | P0 | 正值域 | | TC-0933 | SuperAdmin 绕过 DB | superAdmin caller | 不查 DB,直接放行 | 正常路径 | P0 | 短路 | | TC-0934 | Product ADMIN 绕过 DB | memberType=ADMIN | 不查 DB,直接放行 | 正常路径 | P0 | 短路 | | TC-0935 | DEVELOPER 绕过 DB | memberType=DEVELOPER | 不查 DB,直接放行 | 正常路径 | P0 | 短路 | | TC-0936 | caller 在 DB 没有任何角色 | FindMin... 返回 ErrNotFound | 403 "您没有可分配的角色等级" | 异常路径 | P0 | fail-close | | TC-0937 | caller 查询 DB 遇到一般错误 | 非 ErrNotFound | 500(fail-close,不透传原文) | 容错 | P0 | 不泄细节 | | TC-0938 | caller 为 nil(无 UserDetails) | ctx 未带 | 401(未授权) | 异常路径 | P0 | nil 保护 | | TC-0969 | caller 缓存级别高(10)但 DB 已降级(20),target 级别 15 | 管理高级目标 | 403 "无权管理权限级别高于或等于您的用户" | 对抗/安全 | P0 | 钉死缓存 TOCTOU | | TC-0970 | caller 在 DB 中无任何角色(`ErrNotFound`) | 非超管/非 self 管理路径 | 403(按"无角色 = 最低等级"处理) | 安全 | P0 | ErrNotFound 等同最低 | | TC-0971 | caller DB 级别(5)严格高于 target(15) | 正常管理 | 放行 | 正向 | P0 | H-2 正向不回归 | | TC-0972 | caller 侧 `FindMinPermsLevelByUserIdAndProductCode` 通用 DB 错 | — | 500 "校验权限级别失败"(fail-close) | 容错 | P0 | 一般错误非 ErrNotFound → 500 | | TC-0973 | caller 是 SuperAdmin | CheckManageAccess 链路 | 短路放行,不发生 caller-side FindMin 查询 | 正向/优化 | P1 | SuperAdmin 必短路 | | TC-0974 | caller.UserId == targetUserId(自操作) | 同上 | 短路放行,不发生 caller-side FindMin 查询 | 正向/优化 | P1 | self 必短路 | | TC-0975 | 共享 helper `loadFreshMinPermsLevel` 的契约对齐 | 通用 err / ErrNotFound | 分别返回 `(0, false, err)` 与 `(0, true, nil)` | 契约 | P0 | helper 契约与 `GuardRoleLevelAssignable` 同步 | | TC-0993 | legacy DEVELOPER(DeptId=0、DeptPath="")去管理他人 | 合法 target + productCode | `response.CodeError{Code:403}`,文案含 "未归属任何部门"(与 TC-0504 同路径) | 契约/回归 | P0 | 文案与错误结构已合一,防再次分叉 | | TC-1017 | SuperAdmin / ADMIN / DEVELOPER 走 `HasFullPerms` 短路 | 3 条子用例分别构造不同 caller | `HasFullPerms=true`;`FindMinPermsLevelByUserIdAndProductCode` **不得**被调用(gomock 无 EXPECT 命中即 fail) | 性能/契约 | P0 | 全权调用者零 DB 成本 | | TC-1018 | MEMBER caller 仅打 1 次 DB,循环内对 5 个角色走 `CheckRoleLevelAgainst` 不再打 DB | mock `FindMin` `Times(1)`;本地用 5 个 role level 做比较 | Times(1) 断言命中;同级/更高级角色拒 403;严格低级角色通过 | 性能/安全 | P0 | `Times(1)` 是核心断言,一旦循环内误打 DB 会命中"unexpected call" | | TC-1019 | caller `ErrNotFound` → `NoRole=true`,不翻 500 | mock 返 `sqlx.ErrNotFound` | `snap.NoRole=true`;`CheckRoleLevelAgainst(999)` 仍 403 "没有可分配的角色等级" | 契约 | P1 | 与 `loadFreshMinPermsLevel` 的口径对称,保留 `ErrNotFound → 最低级` 的原契约 | | TC-1020 | caller 其他 DB 错误 → fail-close 500 | mock 返通用 `error` | `CodeError.Code()==500` | 安全 | P0 | DB 抖动不得被同化为"无角色 → 最低级"放行,保持与 L-4 修复一致 | ### 9.5 memberTypePriority | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0505 | 各类型优先级正确 | 全部4种+未知 | SA=0,A=1,D=2,M=3,unknown=MaxInt32 | 白盒 | P0 | switch分支 | ## 十、UserDetailsLoader (loaders/userDetailsLoader.go) ### 10.1 Load / 缓存 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0506 | DB加载(缓存miss) | 有效userId+productCode | 返回完整UserDetails | 正常路径 | P0 | loadFromDB全链路 | | TC-0507 | 缓存命中 | 第二次Load同key | 从Redis返回,不查DB | 正常路径 | P0 | GetCtx hit | | TC-0508 | 用户不存在 | userId=999999 | 返回零值UserDetails(Status=0) | 边界 | P0 | loadUser失败 | | TC-0509 | productCode为空 | productCode="" | 跳过产品/成员/角色/权限加载 | 边界 | P1 | 各方法guard | | TC-0705 | Load 不存在用户 | userId=999999999 | 返回空 Username;第二次 Load 行为与首次一致(无缓存污染) | 边界/缓存 | P1 | !ok 分支不缓存零值 | | TC-0792 | 50 goroutine barrier 同时 Load 同 userId | 缓存已清空 | 每个 goroutine 都拿到完整数据; `FindOne` 调用次数 ≤ workers/5, >0 | 并发/缓存 | P0 | `singleflight.Group.Do` 合并冷启动击穿 | | TC-0793 | 首次 Load 后再 20 次 Load | 缓存已预热 | 首次 DB 命中 1 次, 后续 0 次(全部走 Redis cache) | 缓存命中 | P0 | 写 Redis 成功后应走 fast-path 不再打 DB | | TC-0821 | 同一"不存在 userId" 第 2 次 Load | 首次写入 sentinel 后再 Load | 第 2 次 0 次 DB 调用;sentinel 持有 `negativeCacheTTL` | 防 DoS/缓存 | P0 | 携带已删除用户 token 的请求不再反复击穿 DB | | TC-0822 | 负缓存不登记到 `userIndex`/`productIndex` | 查不存在用户后观察集合 | sentinel 不进 Clean 索引,避免误伤 | 缓存一致性 | P0 | 防 Clean 误删合法 key 或污染统计 | | TC-0823 | 50 并发 Load 同一不存在 userId | singleflight + 负缓存协同 | 最终 Redis key = 负缓存 sentinel;无 panic | 并发/缓存 | P0 | 防并发惊群 + 负缓存收敛 | | TC-0913 | 用户不存在 → `(ud, nil)` 且 `ud.Username == ""` | userId=999999999 | 无 err,Username 空,caller 自行映射 401 | 契约 | P0 | 401 vs 503 区分 | | TC-0914 | 新建用户刚落地 `CreateUser` 后立刻 `Load` | 新 username | 读到真实 user,不命中负缓存哨兵 | 契约 | P0 | CreateUser 反向清除负缓存 | | TC-0915 | partial load 失败(幽灵 deptId) | 人为把 user.deptId 改成不存在 | 返回 ud(含错误子加载),但不写 5 分钟正缓存 | 契约 | P0 | 局部失败不污染主缓存 | | TC-0916 | 全绿 MEMBER 正路径 | 正常 user + product + 无角色 | `loadOk=true`,命中 5 分钟正缓存 | 契约 | P0 | 保证好路径仍能缓存 | | TC-0917 | `ErrLoaderDegraded` 作为 sentinel 可 `errors.Is` 断言 | 直接断言导出符号 | `errors.Is(ErrLoaderDegraded, ErrLoaderDegraded) == true` | 契约 | P2 | 调用方可稳定识别 | ### 10.2 缓存失效 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0510 | Del删除指定缓存 | Del(uid, pc) | 缓存被删除,下次Load查DB | 正常路径 | P0 | DelCtx | | TC-0511 | Clean清除用户所有产品缓存 | Clean(uid) | 该用户所有key被删 | 正常路径 | P0 | KEYS pattern | | TC-0512 | CleanByProduct清除产品所有用户 | CleanByProduct(pc) | 该产品所有key被删 | 正常路径 | P0 | KEYS pattern | | TC-0513 | BatchDel批量删除 | BatchDel([uid1,uid2], pc) | 多个key被删 | 正常路径 | P0 | DelCtx多key | | TC-0514 | BatchDel空数组 | BatchDel([], pc) | 无操作 | 边界 | P1 | len==0 guard | | TC-1013 | N=2 的真实缓存场景 | 2 用户各 Load 预热后 BatchDel | 主 key DEL、userIndex/productIndex 中 2×3 元素全部 SREM | 契约/缓存 | P1 | 同步清理不能被回退 | | TC-1014 | `productCode=""` 分支 | 无效 uid + 空 productCode | 不 panic / 不报错 | 契约/防御 | P2 | pipeline 分支 fail-safe | | TC-1112 | `DetachCacheCleanCtx`:parent 取消后 detached ctx 仍存活 | `ctx, cancel := context.WithCancel(parent); cleanCtx, _ := DetachCacheCleanCtx(ctx); cancel()` | `cleanCtx.Err() == nil`;`<-cleanCtx.Done()` 只在 3s timeout 后触发 | 契约 | P0 | 防 client 断连把 post-commit 缓存失效一并带走 | | TC-1113 | `DetachCacheCleanCtx`:硬 3s 超时兜底 | 观察 `cleanCtx` 的 deadline | `ok==true` 且 `deadline ∈ [now+2.5s, now+3.5s]` | 契约 | P0 | 防后台 goroutine 悬挂;窗口必须被锁定 | | TC-1114 | `DetachCacheCleanCtx`:parent 的 Value 透传 | `parent := context.WithValue(context.Background(), k, "v"); cleanCtx,_ := ...` | `cleanCtx.Value(k) == "v"` | 契约 | P1 | trace id / tenant id 等日志上下文不被剥离 | | TC-1115 | `isCtxCanceledErr` 对 Canceled/DeadlineExceeded 返回 true,其它错误 false | `context.Canceled`、`context.DeadlineExceeded`、`errors.New("redis down")` | `true / true / false` | 契约 | P0 | 审计 L-R13-5 方案 B 分类口径冻结 | | TC-1116 | `logCacheInvalidationErr` 对 nil 错误早退 | `err=nil` | 不触发任何日志写入,函数瞬时返回 | 契约 | P1 | 避免误写 nil 日志干扰排查 | | TC-1117 | `InvalidateProfileCache` 在 ctx canceled 下仍不 panic、不阻断主流程 | 传入已 cancel 的 ctx + id + username | 函数返回 nil(方法签名本就无返回),Redis 键可能未删除,但无 panic/无 error | 容错 | P0 | 修复前的 `_ = DelCacheCtx` 会吞 error,修复后分类记录 | ### 10.3 loadPerms权限计算 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0515 | 超管全量权限 | IsSuperAdmin=true | Perms=全部启用的权限码 | 正常路径 | P0 | 超管分支 | | TC-0516 | ADMIN全量权限 | MemberType=ADMIN | Perms=全量 | 正常路径 | P0 | ADMIN分支 | | TC-0517 | DEVELOPER全量权限 | MemberType=DEVELOPER | Perms=全量 | 正常路径 | P0 | DEVELOPER分支 | | TC-0518 | DEV部门全量权限 | DeptType=DEV | Perms=全量 | 正常路径 | P0 | DeptTypeDev分支 | | TC-0519 | MEMBER角色权限+ALLOW-DENY | 有角色+ALLOW+DENY | 正确计算 | 深度业务 | P0 | denySet过滤 | | TC-0520 | 用户ALLOW权限不跨产品泄漏 | 用户在产品A/B各有ALLOW权限 | 加载产品A时仅含A权限,不含B权限 | 安全 | P0 | FindPermIdsByUserIdAndEffectForProduct | | TC-0521 | 禁用DEV部门成员无全量权限 | dept.type=DEV, dept.status=Disabled | ud.Perms为空 | 安全 | P0 | DeptStatus检查 | | TC-1205 | NORMAL 部门冻结(Status Disabled)后成员 Perms 为空 | DeptType=NORMAL, DeptStatus=Disabled, MemberType=MEMBER | `ud.Perms` 等于 `[]string{}`(非 nil);不得包含任何权限码 | 安全/业务约束 | P0 | `loadPerms` 在 `if !ud.IsSuperAdmin && ud.DeptId>0 && ud.DeptStatus!=Enabled { return nil }` 前置拦截;冻结部门成员重登后应立即无权,而非等 JWT 过期 | | TC-1206 | loadPerms 出口 Perms 恒为非 nil 数组 | 普通成员无角色无附加权限 | `ud.Perms` 为 `[]string{}`(`json.Marshal` 输出 `[]` 而非 `null`) | 接口契约 | P0 | `ud.Perms = []string{}` 初始化保证下游前端/gRPC 客户端无需做 nil vs [] 的双重 defensive check | ### 10.4 loadRoles + MinPermsLevel | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0522 | 多角色取最小permsLevel | 用户有level=10和level=5的角色 | MinPermsLevel=5 | 正常路径 | P0 | min计算 | | TC-0523 | 无角色 | 用户无角色 | MinPermsLevel=MaxInt64 | 边界 | P0 | 默认值 | | TC-0524 | 角色跨产品过滤 | 角色在不同产品 | 仅加载当前产品角色 | 深度业务 | P0 | productCode过滤 | | TC-0525 | 禁用角色不计入 | 角色status=2 | 不在Roles列表中 | 深度业务 | P0 | Status==Enabled | ### 10.5 loadMembership | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0526 | 超管自动设置SUPER_ADMIN | IsSuperAdmin=true | MemberType=SUPER_ADMIN, 不查DB | 正常路径 | P0 | 早期return | | TC-0527 | 非成员MemberType为空 | 用户非该产品成员 | MemberType="" | 边界 | P0 | ErrNotFound | | TC-0528 | 禁用成员MemberType为空 | member.status=Disabled | ud.MemberType="" | 安全 | P0 | loadMembership | | TC-1207 | `errors.Is` 语义稳健性:ErrNotFound 包装后仍被正确识别 | `productmember.ErrNotFound` 来自 `sqlx.ErrNotFound`;若 model 层未来包装为 `fmt.Errorf("%w", err)`,`errors.Is` 仍应成立 | `errors.Is(productmember.ErrNotFound, sqlx.ErrNotFound) == true`;`errors.Is(fmt.Errorf("wrap: %w", productmember.ErrNotFound), sqlx.ErrNotFound) == true`;两层包装均不应把"用户非成员"退化成 503 | 契约/健壮性 | P1 | 防止未来 model 层引入包装错误时 `loadMembership` 的 ErrNotFound 分支悄悄失配、把合法的"不是成员"判定退化为 `ErrLoaderDegraded` | ## 十一、中间件 — 冻结账号拦截 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0529 | 冻结用户被403 | 有效token但Status=2 | code=403 "账号已被冻结" | 安全 | P0 | ud.Status!=Enabled | | TC-0530 | 用户不存在(Status=0) | token中userId不存在 | code=403 "账号已被冻结" | 安全 | P0 | loadUser失败→Status=0 | | TC-0531 | UserDetails注入context | 正常请求 | GetUserDetails(ctx)非nil | 正常路径 | P0 | WithUserDetails | ## 十二、Logic层 — 访问控制负面测试 | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0532 | createDept非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin | | TC-0533 | updateDept非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin | | TC-0534 | deleteDept非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin | | TC-0535 | createProduct非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin | | TC-0536 | updateProduct非超管拒绝 | ctx=ADMIN | 403 "仅超级管理员" | 安全 | P0 | RequireSuperAdmin | | TC-0537 | createUser非产品管理员拒绝 | ctx=MEMBER | 403 "仅超级管理员或产品管理员" | 安全 | P0 | RequireProductAdminFor(ctx, productCode) | | TC-0538 | createRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) | | TC-0539 | updateRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) | | TC-0540 | deleteRole非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) | | TC-0541 | bindRolePerms非产品管理员拒绝 | ctx=MEMBER | 403 | 安全 | P0 | RequireProductAdminFor(ctx, productCode) | | TC-0542 | updateUser-MEMBER不能管理他人 | ctx=MEMBER, id!=self | 403 (CheckManageAccess拒绝) | 安全 | P0 | CheckManageAccess权限校验 | | TC-0543 | updateUser自己修改DeptId被拒绝 | ctx含userId=X, req.Id=X, req.DeptId!=nil | 403 "不允许修改自己的部门和状态" | 安全 | P0 | 自编辑限制DeptId | | TC-0544 | updateUser自己修改Status被拒绝 | ctx含userId=X, req.Id=X, req.Status!=0 | 403 "不允许修改自己的部门和状态" | 安全 | P0 | 自编辑限制Status | | TC-0545 | updateUser未登录被拒绝 | ctx无UserDetails | 401 "未登录" | 安全 | P0 | caller==nil | | TC-0796 | LogoutHandler 无 userDetails ctx | 空 ctx | 401 "未登录" | 契约 | P0 | handler 正确透传 logic 错误, 不吞成 200/5xx | | TC-0797 | LogoutHandler 携带合法 ctx | 注入 UserDetails | 200 + DB 中 tokenVersion=1 | 契约 | P0 | handler 必须真正触达 logic, 不能 stub 式伪装成功 | | TC-0798 | ChangePasswordHandler 非法 JSON body | `{not-json` | 400, 文案不含业务词"原密码" | 契约 | P0 | httpx.Parse 错误 → 400 而非 500; 不泄露业务语义 | | TC-0799 | ChangePasswordHandler 缺必填字段 | `{}` | 400, 文案点名 `oldPassword`/`newPassword` | 契约 | P0 | goctl required/optional 标注防退化 | | TC-0800 | RefreshTokenHandler 缺 Authorization | 无 header | 401 或 400, 文案不含 `sql`/`redis` | 契约 | P0 | handler 错误文案不得泄露实现细节 | | TC-0801 | RefreshTokenHandler 非法 bearer | `Bearer garbage.token.value` | 401(绝不 500/200/panic) | 契约 | P0 | refresh token 畸形时等价于未登录 | --- ## 十三、限流中间件 (middleware/ratelimitMiddleware.go) | TC编号 | 测试场景 | 输入 | 预期结果 | 类型 | 优先级 | 覆盖说明 | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | | TC-0546 | 正常请求(未超限) | 首次请求 | 请求正常通过, next被调用 | 正常路径 | P0 | code!=OverQuota→next | | TC-0547 | 超限请求被拒绝 | 超出配额后的请求 | code=429, "请求过于频繁,请稍后再试" | 异常路径 | P0 | code==OverQuota→ErrTooManyRequests | | TC-0548 | behindProxy=false时XFF被忽略 | behindProxy=false, 不同XFF头+相同RemoteAddr | 仍被限流, nextCount保持为1 | 安全 | P0 | behindProxy=false: 仅用RemoteAddr | | TC-0549 | behindProxy=false时X-Real-IP被忽略 | behindProxy=false, 不同XRI头+相同RemoteAddr | 仍被限流, nextCount保持为1 | 安全 | P0 | behindProxy=false: 仅用RemoteAddr | | TC-0550 | IP从RemoteAddr解析 | 无代理头, RemoteAddr="ip:port" | 使用SplitHostPort解析host作为限流key | 分支覆盖 | P0 | SplitHostPort解析host | | TC-0551 | 不同IP独立限流 | 两个不同IP | 各自独立计数, 互不影响 | 功能验证 | P0 | key隔离 | | TC-0552 | behindProxy=true时信任X-Real-IP | behindProxy=true, 不同X-Real-IP头 | 按X-Real-IP独立限流 | 正常路径 | P0 | behindProxy=true: X-Real-IP优先 | | TC-0553 | behindProxy=true时无X-Real-IP回退RemoteAddr | behindProxy=true, 无X-Real-IP头 | 使用RemoteAddr作为限流key | 分支覆盖 | P0 | X-Real-IP为空→fallback RemoteAddr | | ~~TC-0554~~ | ~~behindProxy=true 时 XFF 仍被忽略~~ | — | — | — | — | **已删除**:M-6 显式反转契约 —— behindProxy=true 时 XFF 首段合法应优先;新契约由 TC-0862~0866(`ratelimitMiddlewareXff_audit_test.go`)覆盖 | | TC-0555 | RemoteAddr无端口格式 | RemoteAddr="1.2.3.4"(无端口) | 返回原始RemoteAddr "1.2.3.4" | 边界 | P1 | SplitHostPort失败→r.RemoteAddr | | TC-0739 | quota=2 的 limiter,第 3 次 logout | 同一 userId 连续 logout 3 次 | 前 2 次成功 + tokenVersion+2;第 3 次 429 "请求过于频繁",**tokenVersion 不再递增** | 安全/限流 | P0 | 超限请求不得进入业务层,否则攻击者可反复"自增搅乱缓存" | | TC-0740 | 限流按 userId 隔离 | userA 打满配额后 userB 登出 | userB 正常 logout,不受 userA 限流影响 | 安全/隔离 | P0 | 限流 key 必须按 userId 分桶 | | TC-0741 | quota=1 limiter,burst 第 2 次 | 同一 userId 刷新 2 次 | 第 1 次 200;第 2 次 429,**tokenVersion 不得递增**(避免攻击者持续废除 refresh token) | 安全/限流 | P0 | refresh 同样走 TokenOpLimiter | | TC-0742 | 限流按 userId 隔离(productCode 无关) | userA(p1) 满额 → userB(p1) 刷新 | userB 正常 200 | 安全/隔离 | P0 | key 不含 productCode,不会跨用户误伤 | | TC-0790 | period=1s quota=1, 打满后 sleep 1.2s 再调 | 同一 userId 连续 logout | 第 1 次 200; 第 2 次 429; sleep 后第 3 次 200 且 tokenVersion=2 | 安全/限流 | P0 | 限流必须是滚动窗口, 不能退化成永久 deny | | TC-0791 | Redis 不可达(`127.0.0.1:1` + NonBlock) | limit.Take 返回 err | logout 仍成功(fail-OPEN), tokenVersion=1 | 容错契约 | P0 | `code, _ :=` 的工程取舍被冻结, 未来改 fail-close 需 code review | | TC-0862 | behindProxy=true + `X-Forwarded-For: 1.1.1.1, 2.2.2.2` | 首段合法 | 返回 `1.1.1.1` | 契约 | P0 | XFF 首段优先 | | TC-0863 | behindProxy=true + `X-Forwarded-For` 全非法 + `X-Real-IP: 10.0.0.1` | XFF=`garbage, abc`,XRI 合法 | fallthrough 到 `10.0.0.1` | 契约 | P0 | 非法段跳过后走 XRI | | TC-0864 | behindProxy=true + 两头均空 | 无 XFF / 无 XRI | 回落到 `RemoteAddr` 剥端口后的 host | 容错 | P1 | 降级路径 | | TC-0865 | behindProxy=true + `X-Forwarded-For: " 3.3.3.3 "` (空白) | 首段带空白 | 返回 `3.3.3.3`(trim 后合法) | 边界 | P1 | trim 后再解析 | | TC-0866 | behindProxy=false + XFF=`1.1.1.1` | RemoteAddr=`5.5.5.5:8080` | 忽略 XFF,返回 `5.5.5.5` | 安全/伪造 | P0 | 不信任客户端注入的头部 | ---