| @@ -0,0 +1,670 @@ | |||||
| # 海外已注册用户迁移到国内主站设计文档 | |||||
| **日期**:2026-06-02 | |||||
| **状态**:待评审 | |||||
| **范围**:将一批已注册于海外站点的历史用户迁移为“国内主账号 + 海外同步从账号”模型,保留原登录方式和海外入口,不迁移历史日志,不考虑订阅迁移。 | |||||
| --- | |||||
| ## 背景与目标 | |||||
| 当前项目已经具备基础的 `region_sync` 主从同步能力,核心方向是: | |||||
| - 主站创建用户后向从站推送同步用户 | |||||
| - 从站上的 `synced user` 消费额度后回写主站 | |||||
| - 从站周期性拉取主站额度用于本地 `synced_quota` | |||||
| 本次需求不是新增长期双主能力,而是把一批“历史上注册在海外站的用户”迁移为与现有同步用户一致的模型: | |||||
| - 国内站成为唯一主数据源 | |||||
| - 海外站保留原用户入口,但该用户转为 `synced user` | |||||
| - 保留原密码登录能力 | |||||
| - 保留原 OAuth 登录能力 | |||||
| - 保留海外站入口可登录、可使用 | |||||
| - 迁移用户的可用额度进入国内主账号 | |||||
| - 不迁移调用日志、充值日志、普通操作日志 | |||||
| 本次不考虑活动订阅用户,因为当前确认这批用户不存在订阅数据。 | |||||
| --- | |||||
| ## 非目标 | |||||
| 本次设计明确不做以下事情: | |||||
| - 不实现长期双向双主同步 | |||||
| - 不共享国内和海外数据库 | |||||
| - 不迁移 `topups`、`subscription_orders`、usage logs、quota logs | |||||
| - 不重构现有普通登录链路 | |||||
| - 不删除海外原用户 | |||||
| - 不修改项目受保护标识、品牌、模块路径 | |||||
| --- | |||||
| ## 推荐方案 | |||||
| 推荐方案为:`单独迁移界面 + 后端迁移任务内核`。 | |||||
| ### 方案说明 | |||||
| - 国内站新增一个仅 root 管理员可见的“用户迁移中心” | |||||
| - 前端界面只负责: | |||||
| - 创建迁移批次 | |||||
| - 触发预检查 | |||||
| - 处理冲突 | |||||
| - 触发迁移执行 | |||||
| - 查看结果与验证 | |||||
| - 真正的数据迁移逻辑全部放在后端任务内核中 | |||||
| - 海外站与国内站之间通过受鉴权保护的内部 HTTP 接口通信 | |||||
| - 迁移完成后: | |||||
| - 国内主账号持有最终主数据和额度 | |||||
| - 海外原账号改为 `source=synced` | |||||
| - 海外原账号通过 `remote_user_id` 指向国内主账号 | |||||
| ### 不选择其他方案的原因 | |||||
| - 纯脚本:冲突处理、审计、幂等和回滚边界过弱 | |||||
| - 纯界面前端直写:迁移逻辑分散,重试与恢复困难 | |||||
| - 登录代理/远程鉴权:改动登录、会话、OAuth 主链路,范围过大 | |||||
| - 长期双主:后续冲突处理成本过高,不适合本项目现状 | |||||
| --- | |||||
| ## 核心设计原则 | |||||
| ### 1. 国内唯一主账号 | |||||
| 迁移完成后,国内站是该批用户唯一主账号归属地。之后额度主数据、用户主数据、后续运营动作都以国内站为准。 | |||||
| ### 2. 海外原账号保留 | |||||
| 不删除海外账号,不替换海外用户主键,不强制切登录入口。迁移后只是将其降级为同步从账号。 | |||||
| ### 3. 冲突必须显式决策 | |||||
| 对 `username`、`email`、OAuth 绑定冲突,不做隐式自动合并。允许自动推荐,但最终决策必须可审计。 | |||||
| ### 4. 幂等优先 | |||||
| 整个迁移链路按“可能中断、可能重试、可能部分成功”的现实场景设计,所有关键步骤必须可安全重跑。 | |||||
| ### 5. 不做整批自动回滚 | |||||
| 跨站点、带人工决策的迁移不适合整批一键回滚。系统只提供受限的单用户回退能力。 | |||||
| --- | |||||
| ## 整体架构 | |||||
| 新增一个独立迁移模块,由以下部分组成: | |||||
| - 国内站后端迁移服务 | |||||
| - 海外站内部迁移接口 | |||||
| - 国内站管理端迁移页面 | |||||
| - 迁移批次表和迁移明细表 | |||||
| 职责分工如下: | |||||
| - 国内站迁移服务: | |||||
| - 抓取海外候选用户快照 | |||||
| - 执行国内侧冲突预检查 | |||||
| - 记录批次与条目状态 | |||||
| - 根据策略创建或合并国内主账号 | |||||
| - 调用海外内部接口把原账号转换为 `synced` | |||||
| - 做迁移后验证 | |||||
| - 海外内部接口: | |||||
| - 返回候选用户快照 | |||||
| - 返回单用户最新快照 | |||||
| - 执行“转换为 `synced` 用户” | |||||
| - 管理端页面: | |||||
| - 批次管理 | |||||
| - 冲突处理 | |||||
| - 迁移执行 | |||||
| - 结果查看和验证 | |||||
| --- | |||||
| ## 数据模型 | |||||
| 本次不复用 `quota_sync_logs`,新增两张迁移专用表。 | |||||
| ### `user_migration_batches` | |||||
| 表示一次迁移批次。 | |||||
| 建议字段: | |||||
| - `id` | |||||
| - `name` | |||||
| - `source_region` | |||||
| - `target_region` | |||||
| - `status` | |||||
| - `draft` | |||||
| - `scanned` | |||||
| - `ready` | |||||
| - `running` | |||||
| - `completed` | |||||
| - `failed` | |||||
| - `cancelled` | |||||
| - `total_users` | |||||
| - `conflict_users` | |||||
| - `migrated_users` | |||||
| - `failed_users` | |||||
| - `operator_id` | |||||
| - `created_at` | |||||
| - `updated_at` | |||||
| ### `user_migration_items` | |||||
| 表示一个海外用户在某个批次中的迁移决议和执行结果。 | |||||
| 建议字段: | |||||
| - `id` | |||||
| - `batch_id` | |||||
| - `source_user_id` | |||||
| - `target_user_id` | |||||
| - `source_username` | |||||
| - `source_email` | |||||
| - `source_quota` | |||||
| - `conflict_flags` | |||||
| - JSON 字符串 | |||||
| - 记录 `username/email/oauth/aff_code` 等冲突 | |||||
| - `resolution_strategy` | |||||
| - `create_new` | |||||
| - `merge_into_existing` | |||||
| - `skip` | |||||
| - `status` | |||||
| - `pending_scan` | |||||
| - `conflict` | |||||
| - `ready` | |||||
| - `migrating` | |||||
| - `migrated` | |||||
| - `skipped` | |||||
| - `failed` | |||||
| - `error_message` | |||||
| - `snapshot` | |||||
| - JSON 字符串 | |||||
| - 保存预检查时的海外用户快照 | |||||
| - `migration_token` | |||||
| - 用于额度合并和幂等保护 | |||||
| - `migrated_at` | |||||
| - `created_at` | |||||
| - `updated_at` | |||||
| ### 唯一性建议 | |||||
| - `user_migration_items(batch_id, source_user_id)` 唯一 | |||||
| - 迁移执行时按 `migration_token` 保证不会重复做额度叠加 | |||||
| --- | |||||
| ## 状态机 | |||||
| ### 批次状态 | |||||
| - `draft`:已创建,未扫描 | |||||
| - `scanned`:预检查完成,但仍可能存在冲突项 | |||||
| - `ready`:所有项都已变成 `ready` 或 `skipped` | |||||
| - `running`:正在执行迁移 | |||||
| - `completed`:全部可迁移项执行成功 | |||||
| - `failed`:存在失败项,需要人工修正后重试 | |||||
| - `cancelled`:人工取消 | |||||
| ### 条目状态 | |||||
| - `pending_scan`:尚未预检查 | |||||
| - `conflict`:发现冲突,等待人工决策 | |||||
| - `ready`:策略已明确,可以执行迁移 | |||||
| - `migrating`:迁移进行中 | |||||
| - `migrated`:迁移成功 | |||||
| - `skipped`:人工跳过 | |||||
| - `failed`:执行失败,允许单项重试 | |||||
| --- | |||||
| ## 迁移范围 | |||||
| ### 必迁数据 | |||||
| 从海外站迁移以下主数据: | |||||
| - `users` 主字段 | |||||
| - 密码哈希 | |||||
| - `display_name` | |||||
| - `email` | |||||
| - `status` | |||||
| - `role` | |||||
| - `group` | |||||
| - `created_at` | |||||
| - `setting` | |||||
| - `aff_code` | |||||
| - 内置 OAuth 字段 | |||||
| - `github_id` | |||||
| - `discord_id` | |||||
| - `oidc_id` | |||||
| - `wechat_id` | |||||
| - `telegram_id` | |||||
| - `linux_do_id` | |||||
| - `user_oauth_bindings` | |||||
| - 可用额度 | |||||
| ### 不迁数据 | |||||
| - `topups` | |||||
| - `subscription_orders` | |||||
| - `quota_sync_logs` | |||||
| - usage logs | |||||
| - 普通系统日志 | |||||
| --- | |||||
| ## 预检查设计 | |||||
| ### 海外候选用户快照 | |||||
| 海外站为国内站提供用户快照接口,返回: | |||||
| - 用户主字段 | |||||
| - 内置 OAuth 字段 | |||||
| - 自定义 OAuth 绑定 | |||||
| - `quota` | |||||
| - `group` | |||||
| - `status` | |||||
| - `aff_code` | |||||
| - `created_at` | |||||
| - 可选 `setting` | |||||
| - 可选 `display_name` | |||||
| ### 国内预检查规则 | |||||
| 对每个海外用户,在国内检查: | |||||
| - `username` 是否已存在 | |||||
| - `email` 是否已存在 | |||||
| - 每个内置 OAuth ID 是否已被其他用户占用 | |||||
| - 每个自定义 OAuth 绑定是否已被其他用户占用 | |||||
| - `aff_code` 是否已存在 | |||||
| ### 自定义 OAuth 绑定匹配规则 | |||||
| 不能直接按 `provider_id` 比较,必须先通过 `custom_oauth_providers.slug` 建立海外与国内的 provider 映射,再按: | |||||
| - `provider_slug` | |||||
| - `provider_user_id` | |||||
| 进行冲突判断。 | |||||
| ### 冲突分级 | |||||
| #### 硬冲突 | |||||
| 必须人工处理,不能自动迁移: | |||||
| - 用户名冲突且无法确认同一人 | |||||
| - 邮箱冲突且无法确认同一人 | |||||
| - OAuth 身份已绑定到另一个明确不同的国内用户 | |||||
| - 自定义 OAuth 绑定指向多个不同国内候选目标 | |||||
| #### 可合并冲突 | |||||
| 可以由界面推荐合并,但仍需要人工确认: | |||||
| - 同邮箱,且无其他矛盾 | |||||
| - 唯一 OAuth 身份指向同一个国内用户,且无其他矛盾 | |||||
| #### 软冲突 | |||||
| 不阻塞迁移,可自动处理: | |||||
| - `aff_code` 冲突 | |||||
| - `display_name` 冲突 | |||||
| ### 默认推荐策略 | |||||
| - 无冲突:推荐 `create_new` | |||||
| - 仅 `aff_code` 冲突:推荐 `create_new` | |||||
| - 同邮箱且无其他矛盾:推荐 `merge_into_existing` | |||||
| - 任意 OAuth 多对一、一对多关系:强制人工处理 | |||||
| --- | |||||
| ## 单用户迁移执行流程 | |||||
| 执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。 | |||||
| ### `create_new` | |||||
| 1. 从海外拉取该用户最新快照 | |||||
| 2. 在国内开启事务 | |||||
| 3. 通过“按哈希导入”的专用逻辑创建国内主账号 | |||||
| - 不能走普通 `Insert()` | |||||
| - 否则密码会再次哈希 | |||||
| 4. 复制主字段: | |||||
| - `username` | |||||
| - `password` | |||||
| - `email` | |||||
| - `display_name` | |||||
| - `status` | |||||
| - `role` | |||||
| - `group` | |||||
| - `created_at` | |||||
| - `setting` | |||||
| 5. 复制内置 OAuth ID 字段 | |||||
| 6. 复制 `user_oauth_bindings` | |||||
| - 先通过 `slug` 将海外 `provider_id` 映射为国内 `provider_id` | |||||
| 7. 处理邀请码 | |||||
| - 如冲突则生成新的 `aff_code` | |||||
| 8. 将国内额度设置为海外当前最终额度 | |||||
| 9. 国内事务提交,得到 `cn_user_id` | |||||
| 10. 调海外内部接口,将原海外用户转换为 `synced` | |||||
| - `source = synced` | |||||
| - `remote_user_id = cn_user_id` | |||||
| - `synced_quota = 当前额度` | |||||
| - `last_sync_at = now` | |||||
| 11. 更新迁移项为成功 | |||||
| ### `merge_into_existing` | |||||
| 1. 锁定目标国内账号 | |||||
| 2. 重新校验该用户仍应合并到这个目标 | |||||
| 3. 补齐国内为空、海外有值的字段 | |||||
| 4. 补齐国内缺失的 OAuth 绑定 | |||||
| 5. 额度合并规则: | |||||
| - `国内最终额度 = 国内原额度 + 海外当前额度` | |||||
| 6. 提交国内事务 | |||||
| 7. 调海外接口将原账号转换为 `synced` | |||||
| 8. 更新迁移项为成功 | |||||
| ### 为什么不删除海外原用户 | |||||
| - 海外已有 session 兼容性更好 | |||||
| - 海外已有 API token 兼容性更好 | |||||
| - 现有 `synced user` 逻辑天然支持这种模型 | |||||
| - 回退成本远低于删除账号再重建 | |||||
| --- | |||||
| ## 海外用户转换接口 | |||||
| 不建议国内直接操作海外数据库,建议由海外站提供内部接口执行切换。 | |||||
| ### 推荐接口 | |||||
| - `GET /api/internal/migration/users` | |||||
| - 分页返回候选用户快照 | |||||
| - `GET /api/internal/migration/users/:id` | |||||
| - 返回单用户最新快照 | |||||
| - `POST /api/internal/migration/users/:id/convert-to-synced` | |||||
| - 将海外原用户降级为 `synced` | |||||
| ### 转换接口行为 | |||||
| 接口需要做以下校验: | |||||
| - 仅允许内部受信任调用 | |||||
| - 用户存在 | |||||
| - 用户尚未迁移,或已迁移且目标一致 | |||||
| - `remote_user_id` 不为空 | |||||
| 幂等语义: | |||||
| - 如果该用户已经是 `source=synced` | |||||
| - 且 `remote_user_id` 与请求目标一致 | |||||
| - 则直接返回成功 | |||||
| --- | |||||
| ## 管理端页面设计 | |||||
| 新增一个 root-only 页面:`用户迁移中心`。 | |||||
| 页面建议拆为四个区域: | |||||
| ### 1. 批次列表 | |||||
| 展示: | |||||
| - 批次名 | |||||
| - 状态 | |||||
| - 总用户数 | |||||
| - 冲突数 | |||||
| - 已迁移数 | |||||
| - 失败数 | |||||
| - 创建人 | |||||
| - 创建时间 | |||||
| ### 2. 批次详情 | |||||
| 展示用户级迁移项列表: | |||||
| - 海外用户 ID | |||||
| - 用户名 | |||||
| - 邮箱 | |||||
| - 海外额度 | |||||
| - 冲突类型 | |||||
| - 处理策略 | |||||
| - 当前状态 | |||||
| - 目标国内账号 | |||||
| - 错误信息 | |||||
| ### 3. 冲突处理面板 | |||||
| 对单个冲突用户允许操作: | |||||
| - `create_new` | |||||
| - `merge_into_existing` | |||||
| - `skip` | |||||
| 并展示推荐策略及依据。 | |||||
| ### 4. 执行与验证面板 | |||||
| 提供以下操作: | |||||
| - 预检查 | |||||
| - 开始迁移 | |||||
| - 重试失败项 | |||||
| - 迁移后验证 | |||||
| - 导出失败项 | |||||
| --- | |||||
| ## 后端 API 设计 | |||||
| 新增一组管理端 API,不混入普通 `region_sync`。 | |||||
| ### 国内管理端 API | |||||
| - `POST /api/user-migrations/batches` | |||||
| - 创建批次 | |||||
| - `GET /api/user-migrations/batches` | |||||
| - 批次列表 | |||||
| - `GET /api/user-migrations/batches/:id` | |||||
| - 批次详情 | |||||
| - `POST /api/user-migrations/batches/:id/scan` | |||||
| - 触发预检查 | |||||
| - `POST /api/user-migrations/items/:id/resolve` | |||||
| - 设置处理策略 | |||||
| - `POST /api/user-migrations/batches/:id/execute` | |||||
| - 执行迁移 | |||||
| - `POST /api/user-migrations/batches/:id/retry` | |||||
| - 仅重试失败项 | |||||
| - `POST /api/user-migrations/batches/:id/verify` | |||||
| - 执行迁移后校验 | |||||
| ### 鉴权要求 | |||||
| - 仅 root 管理员可访问 | |||||
| - 不能开放给普通管理员 | |||||
| --- | |||||
| ## 幂等要求 | |||||
| 幂等是本设计的核心要求。 | |||||
| ### 扫描幂等 | |||||
| - 同一批次重复 `scan` | |||||
| - 不得重复插入相同 `source_user_id` | |||||
| - 只能刷新已有条目的快照与冲突分析结果 | |||||
| ### 执行幂等 | |||||
| - 批次级重试时,已成功的条目必须跳过 | |||||
| - `create_new` 条目如已存在 `target_user_id`,不得再次创建账号 | |||||
| - `merge_into_existing` 额度叠加必须有一次性保护 | |||||
| ### 海外转换幂等 | |||||
| - 已是 `synced` | |||||
| - 且 `remote_user_id` 一致 | |||||
| - 直接返回成功 | |||||
| ### OAuth 复制幂等 | |||||
| - 已存在同一绑定则跳过 | |||||
| - 不因重复插入报错 | |||||
| ### 额度合并幂等 | |||||
| 通过 `migration_token` 或同等级唯一标识保证: | |||||
| - 每个迁移项对国内额度的修改最多成功一次 | |||||
| --- | |||||
| ## 冻结窗口设计 | |||||
| 本次迁移不要求全站停机,但建议对“待迁批次中的海外用户”做短冻结窗口。 | |||||
| 冻结范围建议仅限该批次用户: | |||||
| - 禁止充值 | |||||
| - 禁止兑换码 | |||||
| - 禁止额度转移 | |||||
| - 禁止发起新请求 | |||||
| - 等待已在途请求自然结束 | |||||
| 目的: | |||||
| - 稳定海外最终额度 | |||||
| - 避免预检查和执行之间继续发生额度变化 | |||||
| --- | |||||
| ## 回滚边界 | |||||
| 本次设计不提供整批自动回滚,只提供有限的单用户回退能力。 | |||||
| ### `create_new` 的可回退场景 | |||||
| 如果国内主账号刚创建成功,但海外尚未转换为 `synced`: | |||||
| - 可直接删除国内主账号 | |||||
| 如果海外已经完成降级: | |||||
| - 海外用户改回 `source=local` | |||||
| - 清空 `remote_user_id` | |||||
| - 将确认后的额度写回海外 `quota` | |||||
| - 国内新账号按策略禁用或删除 | |||||
| ### `merge_into_existing` 的回退限制 | |||||
| 不建议自动回滚,因为: | |||||
| - 额度已合并进既有国内账号 | |||||
| - 自动扣减可能伤及已发生的后续业务动作 | |||||
| 这类只建议人工修正。 | |||||
| ### 系统提供的回退能力 | |||||
| - 查看失败现场 | |||||
| - 导出失败项 | |||||
| - 对 `create_new` 类型执行受限回退 | |||||
| --- | |||||
| ## 迁移后验证清单 | |||||
| 系统至少应提供以下自动校验: | |||||
| - 国内目标主账号存在 | |||||
| - 海外原账号已为 `source=synced` | |||||
| - 海外 `remote_user_id` 指向正确国内账号 | |||||
| - 国内额度与迁移决议一致 | |||||
| - 海外 `synced_quota` 与国内额度一致,或在允许误差范围内一致 | |||||
| - 密码登录抽样验证可用 | |||||
| - OAuth 绑定抽样验证存在 | |||||
| - 冲突用户未被误迁 | |||||
| - 重跑执行不会重复加额度 | |||||
| --- | |||||
| ## 实施顺序 | |||||
| 建议按以下顺序推进: | |||||
| 1. 新增迁移数据模型与数据库迁移 | |||||
| 2. 实现国内迁移服务内核 | |||||
| 3. 实现海外内部迁移接口 | |||||
| 4. 实现国内管理端 API | |||||
| 5. 实现管理端迁移页面 | |||||
| 6. 补充测试 | |||||
| - 无冲突迁移 | |||||
| - 邮箱冲突合并 | |||||
| - OAuth 冲突阻断 | |||||
| - 中断后重试 | |||||
| - 已迁移项重复执行 | |||||
| - 海外重复转换幂等 | |||||
| 7. 先在测试库验证 | |||||
| 8. 线上先跑 5-10 个用户的灰度批次 | |||||
| 9. 观察成功率和验证结果后再执行全量 | |||||
| --- | |||||
| ## 关键实现注意事项 | |||||
| ### 1. 密码导入必须是“导入哈希” | |||||
| 当前用户创建逻辑会对密码再次哈希。迁移实现必须提供专用导入路径,直接写入海外已有哈希值。 | |||||
| ### 2. 自定义 OAuth provider 不能直接按 `provider_id` 迁移 | |||||
| 必须按 `slug` 对齐国内外 provider 后再复制绑定,否则绑定会错位。 | |||||
| ### 3. 不能直接复用现有 `SyncUserCreate` | |||||
| 现有 `region_sync` 的建用户请求字段不足,不能覆盖: | |||||
| - 完整冲突处理 | |||||
| - 自定义 OAuth 绑定复制 | |||||
| - 哈希密码导入控制 | |||||
| - 合并已有国内用户 | |||||
| 本次迁移需要单独实现迁移接口和迁移服务。 | |||||
| ### 4. 与现有 `synced user` 模型兼容 | |||||
| 本方案完成后,海外侧行为应回归现有同步用户路径: | |||||
| - 海外读取本地 `synced_quota` | |||||
| - 海外消费额度走批量回写主站 | |||||
| - 海外周期性从主站拉取额度 | |||||
| --- | |||||
| ## 结论 | |||||
| 本次海外历史用户迁移的最佳路径是: | |||||
| - 不做双主 | |||||
| - 不做纯脚本黑盒迁移 | |||||
| - 使用“单独迁移界面 + 后端幂等迁移任务内核” | |||||
| - 将海外原用户保留并降级为 `synced` | |||||
| - 将国内账号作为唯一主账号 | |||||
| 这条路径最符合当前代码库已有的 `region_sync` 架构,改动集中、风险可控、审计清晰,且能保留用户现有登录方式和海外站访问体验。 | |||||