# `refresh_session` 表设计 日期:`2026-04-21` ## 1. 文档目的 这份文档用于完成 `M2` 的第三条任务:`设计 refresh_session`。 目标是把以下几件事固定到可编码级别: 1. refresh cookie 与服务端 session 表的边界 2. `refresh`、`logout`、`logout-all`、`sessions/:sessionId/revoke` 的失效语义 3. `refresh_session` 与 `user_account.token_version` 的职责切分 4. 会话列表、当前设备识别、轮换与吊销的数据结构 补充约束: 1. 多端登录识别字段以 [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md) 为准。 2. 本文负责把这些字段正式并入 `refresh_session` 结构、迁移规则与接口读模型。 ## 2. 当前基线 当前 Node 后端已经存在一张 `user_sessions` 表,并且 refresh cookie 主链已经完整可用: 1. 登录成功后创建随机 refresh token,并只把原始 token 放入 HttpOnly cookie 2. 服务端只存 `sha256(refresh_token)` 结果 3. `/api/auth/refresh` 会轮换 refresh token,同时更新过期时间与 `last_seen_at` 4. `/api/auth/logout` 会吊销当前 refresh session,并提升 `token_version` 5. `/api/auth/logout-all` 会吊销当前账号全部 refresh session,并提升 `token_version` 6. `/api/auth/sessions` 依赖会话表列出当前设备与远端设备 7. `/api/auth/sessions/:sessionId/revoke` 只吊销目标设备,不影响当前设备 当前 Node `user_sessions` 字段基线: 1. `id` 2. `user_id` 3. `refresh_token_hash` 4. `client_type` 5. `user_agent` 6. `ip` 7. `expires_at` 8. `revoked_at` 9. `created_at` 10. `updated_at` 11. `last_seen_at` 这说明: 1. refresh session 已经是现有系统的既有真相源。 2. Rust 重写时不需要重新发明另一套“session cache + cookie state”双轨模型。 3. 只需要把当前语义更明确地迁入 SpacetimeDB,并把与 `user_account` 的职责切开。 4. 旧 Node 基线只有最小 `client_type + user_agent + ip` 粒度,本轮需要升级为结构化客户端身份模型。 ## 3. 边界定义 ### 3.1 `refresh_session` 负责 1. 设备级 refresh token hash 真相 2. 设备级过期时间 3. 设备级吊销状态 4. 设备级最后活跃时间 5. 会话列表所需的客户端信息 ### 3.2 它不负责 1. access token 签发 2. access token 全局失效版本号 3. 用户主状态 4. provider 身份绑定 5. 短信验证码与微信 OAuth ### 3.3 与 `user_account` 的职责切分 固定规则: 1. `refresh_session` 负责“哪台设备还能继续 refresh” 2. `user_account.token_version` 负责“旧 access token 是否整体失效” 因此: 1. `logout` 必须同时改两层 2. `logout-all` 必须同时改两层 3. `sessions/:sessionId/revoke` 只改 `refresh_session` 4. `/refresh` 只改 `refresh_session`,不改 `token_version` ## 4. cookie 与表的边界 ### 4.1 cookie 只存原始 token 浏览器侧固定继续存: 1. cookie 名:`genarrative_refresh_session` 2. 值:原始 refresh token 3. `HttpOnly` 4. `Path=/api/auth` 5. 默认 `SameSite=Lax` 6. 生产环境按配置决定 `Secure` ### 4.2 表里只存 hash `refresh_session` 固定只存: 1. `sha256(refresh_token)` 禁止: 1. 把原始 refresh token 落库 2. 把原始 refresh token 写日志 3. 把 cookie 配置字段冗余进表结构 ### 4.3 当前设备识别方式 `/api/auth/sessions` 的 `isCurrent` 固定按以下规则判断: 1. 从 cookie 读出原始 refresh token 2. 计算 hash 3. 与 `refresh_session.refresh_token_hash` 比较 4. 若 refresh cookie 缺失或不可用,再使用 Bearer access token claims 中的 `sid` 与 `refresh_session.session_id` 比较 5. 会话列表按“同设备 + 同 IP”聚合时,组内任一 session 命中当前 hash 或当前 `sid`,整组都视为当前设备组 ## 5. 表访问级别 `refresh_session` 固定为 `private table`。 原因: 1. 包含 refresh token hash 2. 包含客户端 UA 与 IP 3. 包含设备级会话状态 前端不直接查询该表,只能通过 Axum / view 聚合后的 DTO 读取。 ## 6. 字段设计 | 字段名 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `session_id` | `String` | 是 | 主键,建议继续沿用 `usess_*` 前缀。 | | `user_id` | `String` | 是 | 归属账号 ID,外键指向 `user_account.user_id`。 | | `refresh_token_hash` | `String` | 是 | 当前生效 refresh token 的哈希值。 | | `client_type` | `String` | 是 | 终端大类,固定枚举见多端会话身份设计。 | | `client_runtime` | `String` | 是 | 具体运行时,例如 `chrome`、`wechat_mini_program`。 | | `client_platform` | `String` | 是 | 平台类型,例如 `windows`、`ios`、`android`。 | | `client_instance_id` | `Option` | 否 | 客户端实例 ID,用于区分同设备不同浏览器或同浏览器不同安装实例。 | | `device_fingerprint` | `Option` | 否 | 服务端派生的设备聚类指纹,不作为安全凭据。 | | `device_display_name` | `String` | 是 | 用于会话列表展示的统一端侧名称。 | | `mini_program_app_id` | `Option` | 否 | 小程序 appid。 | | `mini_program_env` | `Option` | 否 | 小程序环境,例如 `develop`、`trial`、`release`。 | | `user_agent` | `Option` | 否 | 请求头中的 `User-Agent` 原文。 | | `ip` | `Option` | 否 | 会话创建时采集的客户端 IP。 | | `issued_by_provider` | `String` | 是 | 该会话是由哪种登录链路创建,枚举固定为 `password`、`phone`、`wechat`。 | | `expires_at` | `String` | 是 | 当前 refresh token 过期时间,UTC RFC3339。 | | `revoked_at` | `Option` | 否 | 会话被吊销的时间。 | | `revoked_reason_code` | `Option` | 否 | 吊销原因码,例如 `logout`、`logout_all`、`session_revoke`、`account_disabled`。 | | `created_at` | `String` | 是 | 会话首次创建时间。 | | `updated_at` | `String` | 是 | 最近一次会话状态变更时间。 | | `last_seen_at` | `String` | 是 | 最近一次 refresh 成功或创建时的活跃时间。 | 补充说明: 1. 当前阶段时间字段统一继续使用 UTC RFC3339 字符串。 2. `session_id` 在 refresh 轮换时保持不变,不创建新会话行。 3. `issued_by_provider` 不是为了做 provider 身份表,而是为了后续账号安全页和审计展示保留稳定字段。 4. `device_display_name` 由 Axum 应用层基于结构化字段派生,不直接信任前端自由文本。 5. `client_type`、`client_runtime`、`client_platform` 的具体枚举口径固定受多端会话身份设计文档约束。 ## 7. 唯一约束与索引 ### 7.1 必须具备的唯一约束 1. `session_id` 主键唯一 2. `refresh_token_hash` 全局唯一 ### 7.2 必须具备的查询索引 1. `(user_id, revoked_at, expires_at, last_seen_at DESC)` 作用:列当前账号活跃会话 2. `(user_id, session_id)` 作用:按用户吊销指定会话 3. `(expires_at, revoked_at)` 作用:后续清理过期/已吊销会话 4. `refresh_token_hash` 作用:refresh、logout、current session 判断 ## 8. 生命周期设计 ### 8.1 创建 触发点: 1. 密码登录成功 2. 手机号登录成功 3. 微信登录成功 4. 微信绑定手机号成功后签发正式会话 写入规则: 1. 生成原始 refresh token 2. 计算 `refresh_token_hash` 3. 创建一条新 `refresh_session` 4. `last_seen_at = created_at` ### 8.2 刷新 触发点: 1. `POST /api/auth/refresh` 写入规则: 1. 先按 `refresh_token_hash` 找当前 session 2. 校验 `revoked_at == null` 3. 校验 `expires_at > now` 4. 生成新的 refresh token 5. 更新同一条 session 的 `refresh_token_hash` 6. 更新 `expires_at` 7. 更新 `last_seen_at` 8. 更新 `updated_at` 关键约束: 1. refresh 是“同一会话轮换”,不是“新建第二条会话”。 2. `session_id` 在轮换前后必须稳定,保证会话列表中的设备 ID 不跳变。 ### 8.3 吊销当前会话 触发点: 1. `POST /api/auth/logout` 写入规则: 1. 按当前 cookie 找 session 2. 如果 refresh cookie 缺失,则回退用 Bearer access token claims 中的 `sid` 找当前 session 3. 写 `revoked_at = now` 4. 写 `revoked_reason_code = logout` 5. 同时提升 `user_account.token_version` ### 8.4 吊销全部会话 触发点: 1. `POST /api/auth/logout-all` 写入规则: 1. 按 `user_id` 批量吊销全部未吊销 session 2. `revoked_reason_code = logout_all` 3. 同时提升 `user_account.token_version` ### 8.5 吊销指定远端设备 触发点: 1. `POST /api/auth/sessions/{sessionId}/revoke` 写入规则: 1. 只允许吊销同一 `user_id` 下的目标 session 2. 当前设备不允许通过该接口吊销自己 3. 只改目标 `refresh_session` 4. `revoked_reason_code = session_revoke` 5. 不提升 `token_version` 6. 撤销后必须同步 auth store 到 SpacetimeDB 读取约束: 1. Bearer JWT 中的 `sid` 必须对应 active `refresh_session` 2. 被该接口撤销的设备即使 access token 未过期,后续请求也必须立刻返回未授权 3. 该接口不承担当前设备退出语义;当前设备退出固定走 `/api/auth/logout` ### 8.6 账号被禁用或并入 触发点: 1. `user_account.account_status = disabled` 写入规则: 1. 该账号下所有未吊销 session 都必须被批量吊销 2. `revoked_reason_code = account_disabled` ## 9. 活跃态判断规则 一条 `refresh_session` 只有同时满足以下条件,才视为活跃: 1. `revoked_at = null` 2. `expires_at > now` 3. 所属 `user_account.account_status = active` 或允许 refresh 的待绑定状态 补充约束: 1. 当前阶段 `pending_bind_phone` 的微信壳账号允许 refresh,但只允许继续走绑定手机号相关接口。 2. `disabled` 账号无论 session 本身是否过期,都不能继续 refresh。 ## 10. 与现有接口的映射 ### 10.1 `POST /api/auth/refresh` 依赖: 1. `refresh_session.refresh_token_hash` 2. `refresh_session.expires_at` 3. `refresh_session.revoked_at` 4. `user_account.account_status` 5. `user_account.token_version` ### 10.2 `GET /api/auth/sessions` 直接读取: 1. `session_id` 2. `client_type` 3. `client_runtime` 4. `client_platform` 5. `device_display_name` 6. `mini_program_app_id` 7. `mini_program_env` 8. `user_agent` 9. `ip` 10. `created_at` 11. `last_seen_at` 12. `expires_at` 前端 DTO 侧: 1. `clientLabel` 当前阶段继续兼容保留,但固定与 `deviceDisplayName` 对齐。 2. `ipMasked`、`isCurrent` 继续在 Axum 侧派生。 3. 同设备同 IP 的 active sessions 由 Axum 聚合后返回一条记录。 4. `sessionId` 是代表 ID;当前组代表 ID 使用当前 `sid` 对应 session。 5. `sessionIds` 返回组内全部 active session ID,`sessionCount` 返回组内数量。 6. 聚合组时间语义:`createdAt` 取最早创建时间,`lastSeenAt` 与 `expiresAt` 取最新值。 ### 10.3 `POST /api/auth/logout` 依赖: 1. 当前 cookie 命中的 `refresh_session` 2. cookie 缺失时 Bearer `sid` 命中的 `refresh_session` 3. `user_account.token_version` ### 10.4 `POST /api/auth/logout-all` 依赖: 1. 当前 `user_id` 下全部活跃 `refresh_session` 2. `user_account.token_version` ### 10.5 `POST /api/auth/sessions/{sessionId}/revoke` 依赖: 1. 当前 Bearer JWT 的 `user_id` 2. 当前 Bearer JWT 的 `sid` 3. 目标 `refresh_session.session_id` 4. `refresh_session.revoked_at` 5. `refresh_session.expires_at` 固定行为: 1. 目标 session 必须属于当前用户 2. 目标 session 不能是当前 `sid` 3. 成功只撤销目标 session,不递增 `token_version` ## 11. 与当前 Node `user_sessions` 的映射关系 | Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 | | --- | --- | --- | | `id` | `session_id` | 原样迁移。 | | `user_id` | `user_id` | 原样迁移。 | | `refresh_token_hash` | `refresh_token_hash` | 原样迁移。 | | `client_type` | `client_type` | `browser` 在迁移后统一归一为 `web_browser`。 | | `user_agent` | `user_agent` | 原样迁移。 | | `ip` | `ip` | 原样迁移。 | | `expires_at` | `expires_at` | 原样迁移。 | | `revoked_at` | `revoked_at` | 原样迁移。 | | `created_at` | `created_at` | 原样迁移。 | | `updated_at` | `updated_at` | 原样迁移。 | | `last_seen_at` | `last_seen_at` | 原样迁移。 | 新增字段回填规则: 1. `issued_by_provider` 初次迁移统一回填为 `password` 说明:这是保守回填值,后续只影响展示,不影响鉴权正确性 2. `revoked_reason_code` 初次迁移统一回填为 `null` 3. `client_runtime` 初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown` 4. `client_platform` 初次迁移按 `User-Agent` 粗判,无法识别时回填 `unknown` 5. `client_instance_id` 初次迁移统一回填为 `null` 6. `device_fingerprint` 初次迁移按 `client_type + client_runtime + client_platform + normalized_user_agent` 派生 7. `device_display_name` 初次迁移由后端按多端会话身份规则派生 8. `mini_program_app_id` 初次迁移统一回填为 `null` 9. `mini_program_env` 初次迁移统一回填为 `null` ## 12. reducer / service 落地约束 ### 12.1 `module-auth` reducer 层 必须至少具备这些命令入口: 1. `create_refresh_session` 2. `rotate_refresh_session` 3. `revoke_refresh_session` 4. `revoke_refresh_sessions_by_user` 5. `revoke_refresh_session_by_user_and_session` 6. `touch_refresh_session_last_seen` ### 12.2 Axum 应用层 固定负责: 1. 生成原始 refresh token 2. 计算 hash 3. 读写 HttpOnly cookie 4. 决定当前调用是创建、轮换还是吊销 5. 把 `revoked_reason_code` 映射到对应业务语义 ## 13. 不允许的设计漂移 后续实现时禁止出现以下情况: 1. refresh 轮换时新建第二条 session,而不是更新原 session 2. `sessions/:sessionId/revoke` 顺手提升 `token_version`,导致当前 access token 一起失效 3. `logout-all` 只提升 `token_version`,却不吊销 refresh session 4. 原始 refresh token 直接入库 5. 会话表开始承担 `user_account` 状态职责 ## 14. 本任务完成定义 当以下条件满足时,`设计 refresh_session` 视为完成: 1. refresh cookie 与服务端 session hash 的边界已经明确。 2. 轮换、当前设备吊销、全部设备吊销三种语义已经切开。 3. `refresh_session` 与 `user_account.token_version` 的职责已明确。 4. 字段、唯一约束、索引与迁移规则已具体到可直接编码。 ## 15. 依据文件 1. `server-node/src/routes/authRoutes.ts` 2. `server-node/src/auth/authService.ts` 3. `server-node/src/auth/refreshSessionCookie.ts` 4. `server-node/src/repositories/userSessionRepository.ts` 5. `server-node/src/config.ts` 6. `server-node/src/db/migrations.ts` 7. `server-node/src/app.test.ts` 8. `docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md` 9. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md` 10. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md` 11. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`