# `auth_risk_block` 表设计 日期:`2026-04-21` ## 1. 文档目的 这份文档用于完成 `M2` 的第五条任务:`设计 auth_risk_block`。 目标是把鉴权风控保护表明确为“当前生效态真相表”,并固定: 1. 什么叫活跃封禁 2. 什么叫解除封禁 3. 它与 `auth_audit_log` 的边界 4. `/api/auth/risk-blocks` 与 `/api/auth/risk-blocks/:scopeType/lift` 的读写语义 ## 2. 当前基线 当前 Node 后端已经存在 `auth_risk_blocks` 表,并具备以下能力: 1. 当手机号验证码失败次数达到阈值时,创建或刷新手机号保护 2. 当 IP 验证失败次数达到阈值时,创建或刷新网络保护 3. `GET /api/auth/risk-blocks` 返回当前用户手机号与当前请求 IP 命中的保护 4. `POST /api/auth/risk-blocks/:scopeType/lift` 支持用户主动解除当前手机号或当前网络保护 当前 Node `auth_risk_blocks` 字段基线: 1. `id` 2. `scope_type` 3. `scope_key` 4. `reason` 5. `expires_at` 6. `lifted_at` 7. `created_at` 8. `updated_at` 当前已落地的 scope 基线: 1. `phone` 2. `ip` 当前已落地的 reason 基线: 1. `sms_verify_failures` ## 3. 表职责边界 ### 3.1 `auth_risk_block` 负责 1. 记录某个手机号或某个 IP 当前是否处于保护状态 2. 记录这次保护何时过期 3. 记录这次保护是否已被手动解除 4. 作为 `/api/auth/risk-blocks` 的唯一事实来源 ### 3.2 它不负责 1. 记录所有历史触发次数 2. 记录所有历史解除动作 3. 生成用户可读标题和说明文案 4. 短信频控与失败次数统计 ### 3.3 与其他表的边界 1. `sms_auth_event` 负责失败次数统计源数据 2. `auth_risk_block` 负责统计之后得到的“当前保护状态” 3. `auth_audit_log` 负责记录“何时触发保护 / 何时解除保护”的历史事实 ## 4. 访问级别 `auth_risk_block` 固定为 `private table`。 原因: 1. 原始手机号与原始 IP 都属于敏感安全数据 2. 前端只应该通过已鉴权接口读取当前命中的保护摘要 3. 不能让客户端直接订阅或查询全表 ## 5. 字段设计 | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `risk_block_id` | `String` | 是 | 主键,建议继续沿用 `risk_*` 前缀。 | | `scope_type` | `String` | 是 | 保护作用域,枚举固定为 `phone`、`ip`。 | | `scope_key` | `String` | 是 | 作用域主体键;`phone` 时为 `E.164` 手机号,`ip` 时为原始 IP。 | | `reason_code` | `String` | 是 | 触发原因码,当前固定为 `sms_verify_failures`。 | | `expires_at` | `String` | 是 | 当前保护的失效时间,UTC RFC3339。 | | `lifted_at` | `Option` | 否 | 手动解除时间;为 `null` 表示未手动解除。 | | `created_at` | `String` | 是 | 首次创建该保护记录的时间。 | | `updated_at` | `String` | 是 | 最近一次刷新保护或解除保护的时间。 | 补充约束: 1. 当前阶段不额外存 `user_id`,因为 IP 保护天然不是账号主键作用域。 2. 当前阶段不额外存 `remaining_seconds`,读取时按 `expires_at` 动态计算。 3. 当前阶段不把标题或 detail 文案入库,继续由 Axum 读取时派生。 ## 6. 作用域设计 ### 6.1 `phone` 固定规则: 1. `scope_key` 必须是标准化后的 `E.164` 手机号 2. 主要用于手机号验证码登录、绑定手机号、换绑手机号 3. 查询时通过当前账号的主手机号命中 ### 6.2 `ip` 固定规则: 1. `scope_key` 为请求来源 IP 原文 2. 主要用于防御同一网络环境的异常尝试 3. 查询时通过当前请求上下文中的 IP 命中 ## 7. 活跃态定义 一条 `auth_risk_block` 只有同时满足以下条件,才视为活跃: 1. `lifted_at = null` 2. `expires_at > now` 说明: 1. 超时失效不需要额外更新行状态。 2. 手动解除必须显式写 `lifted_at`。 ## 8. 写入规则 ### 8.1 创建或刷新保护 触发点: 1. 手机号验证码失败次数达到手机号阈值 2. IP 验证失败次数达到 IP 阈值 写入规则: 1. 先按 `(scope_type, scope_key)` 查当前活跃保护 2. 若已有活跃保护,则更新: - `reason_code` - `expires_at` - `updated_at` 3. 若不存在活跃保护,则新建一条记录 关键约束: 1. 风控表是“当前态表”,因此允许刷新同一条活跃记录 2. 触发保护的历史事实不在这里重复保留,由 `auth_audit_log` 承担 ### 8.2 手动解除保护 触发点: 1. `POST /api/auth/risk-blocks/phone/lift` 2. `POST /api/auth/risk-blocks/ip/lift` 写入规则: 1. 只更新当前活跃保护 2. 写入: - `lifted_at = now` - `updated_at = now` 3. 不删除行 ### 8.3 自动超时 触发点: 1. 当前时间超过 `expires_at` 写入规则: 1. 不做同步更新 2. 查询活跃状态时自然排除 ## 9. 唯一约束与索引 ### 9.1 必须具备的唯一约束 1. `risk_block_id` 主键唯一 ### 9.2 必须具备的查询索引 1. `(scope_type, scope_key, expires_at DESC)` 作用:查当前活跃保护 2. `(scope_type, scope_key, lifted_at, expires_at DESC)` 作用:提升当前活跃保护查找效率 3. `(expires_at, lifted_at)` 作用:后续定时清理或后台巡检 说明: 1. 当前阶段不强行加“同 scope 只有一条记录”的数据库唯一约束,因为历史已失效/已解除记录允许共存。 2. 活跃唯一性由“查询时只取未解除且未过期的最新一条”保证。 ## 10. 对外读取规则 ### 10.1 `/api/auth/risk-blocks` 固定读取: 1. 当前账号主手机号命中的活跃 `phone` 保护 2. 当前请求 IP 命中的活跃 `ip` 保护 固定返回: 1. `scopeType` 2. `title` 3. `detail` 4. `expiresAt` 5. `remainingSeconds` 其中: 1. `title` 读取时按 `scope_type` 派生 2. `detail` 读取时按 `scope_type + expires_at` 派生 3. `remainingSeconds` 读取时按 `expires_at - now` 计算 ### 10.2 标题派生规则 1. `phone` -> `手机号保护中` 2. `ip` -> `当前网络保护中` ### 10.3 detail 派生规则 1. `phone` -> `该手机号因异常尝试已被临时保护,请约 N 分钟后再试` 2. `ip` -> `当前网络因异常尝试已被临时保护,请约 N 分钟后再试` ## 11. 与当前 Node `auth_risk_blocks` 的映射关系 | Node 列 | 新字段 | 迁移规则 | | --- | --- | --- | | `id` | `risk_block_id` | 原样迁移。 | | `scope_type` | `scope_type` | 原样迁移。 | | `scope_key` | `scope_key` | 原样迁移。 | | `reason` | `reason_code` | 重命名迁移,值当前原样保留。 | | `expires_at` | `expires_at` | 原样迁移。 | | `lifted_at` | `lifted_at` | 原样迁移。 | | `created_at` | `created_at` | 原样迁移。 | | `updated_at` | `updated_at` | 原样迁移。 | ## 12. reducer / service 落地约束 ### 12.1 `module-auth` reducer 层 必须至少具备: 1. `upsert_auth_risk_block` 2. `lift_auth_risk_block` ### 12.2 Axum 应用层 固定负责: 1. 根据 `sms_auth_event` 统计结果决定是否触发保护 2. 计算新的 `expires_at` 3. 读取保护时派生标题、detail 与剩余秒数 4. 解除保护成功后同步写 `auth_audit_log` ## 13. 与 `auth_audit_log` 的协作规则 ### 13.1 触发保护时 必须: 1. 先写或刷新 `auth_risk_block` 2. 再写 `auth_audit_log` ### 13.2 解除保护时 必须: 1. 先写 `lifted_at` 2. 再写 `risk_unblock_phone` 或 `risk_unblock_ip` 审计 ### 13.3 禁止的反向依赖 禁止: 1. 从 `auth_audit_log` 回推当前是否仍在保护中 ## 14. 不允许的设计漂移 后续实现时禁止出现以下情况: 1. 为了保存历史,把每次刷新保护都新建一条新活跃记录而不复用现有活跃记录 2. 手动解除时直接删行,导致无法保留解除痕迹 3. 把 `remainingSeconds` 这类瞬时值入库 4. 把风控表当作审计表使用 5. 把 `scope_key` 脱敏后再入库,导致无法稳定命中当前作用域 ## 15. 本任务完成定义 当以下条件满足时,`设计 auth_risk_block` 视为完成: 1. 当前态与历史态边界已经和 `auth_audit_log` 切开。 2. scope、活跃态、刷新态、解除态的规则已明确。 3. `/api/auth/risk-blocks` 与 `/lift` 的读取和写入语义已固定。 4. 后续可以直接按这份文档编码 reducer、view 与 Axum 接口。 ## 16. 依据文件 1. `server-node/src/repositories/authRiskBlockRepository.ts` 2. `server-node/src/auth/authService.ts` 3. `server-node/src/routes/authRoutes.ts` 4. `server-node/src/db/migrations.ts` 5. `packages/shared/src/contracts/auth.ts` 6. `docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md` 7. `docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md` 8. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`