# 海外已注册用户迁移到国内主站设计文档 **日期**: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` - 同邮箱且无其他矛盾、且目标国内用户尚未在海外存在其他 `synced` 副本:推荐 `merge_into_existing` - 任意 OAuth 多对一、一对多关系:强制人工处理 --- ## 执行一致性约束 - 预检查快照只用于冲突决策和界面展示,不作为最终额度结算依据。 - 执行阶段必须重新拉取海外用户最新快照,并以该快照中的最终额度为准。 - V1 版本要求在运维冻结窗口内执行批次。冻结窗口至少要覆盖待迁用户的充值、兑换码、额度转移和新请求入口,并等待已在途请求自然结束。 - 如果执行时最新快照与预检查结论发生冲突性漂移(例如邮箱归属变化、OAuth 绑定目标变化、原本计划合并的国内账号已存在其他海外 `synced` 副本),该条目必须失败并要求重新扫描,不得静默沿用旧决议。 --- ## 单用户迁移执行流程 执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。执行阶段读取的“最新快照”必须发生在冻结窗口内。 ### `create_new` 1. 在冻结窗口内,从海外拉取该用户最新快照 2. 在国内开启事务 3. 通过“按哈希导入”的专用逻辑创建国内主账号 - 不能走普通 `Insert()` - 否则密码会再次哈希 4. 在同一个国内事务中完成以下动作: - 创建国内主账号 - 复制主字段 - 复制内置 OAuth 字段 - 复制 `user_oauth_bindings` - 处理 `aff_code` 冲突 - 回写 `user_migration_items.target_user_id` 5. 如在执行阶段遇到新的唯一键冲突: - 直接将该迁移项标记为失败 - 要求重新扫描或人工修正 - 不允许按用户名/邮箱自动认领为“上次已创建成功”的账号 6. 复制主字段: - `username` - `password` - `email` - `display_name` - `status` - `role` - `group` - `created_at` - `setting` 7. 复制内置 OAuth ID 字段 8. 复制 `user_oauth_bindings` - 先通过 `slug` 将海外 `provider_id` 映射为国内 `provider_id` 9. 处理邀请码 - 如冲突则生成新的 `aff_code` 10. 将国内额度设置为“执行时最新快照中的最终额度” 11. 国内事务提交,得到 `cn_user_id` 12. 调海外内部接口,将原海外用户转换为 `synced` - `source = synced` - `remote_user_id = cn_user_id` - `synced_quota = 国内提交后的最终额度` - `last_sync_at = now` 13. 更新迁移项为成功 ### `merge_into_existing` 1. 在冻结窗口内拉取该用户最新快照 2. 锁定目标国内账号 3. 重新校验该用户仍应合并到这个目标 - 同邮箱或唯一 OAuth 目标仍然成立 - 目标国内账号在海外不存在其他 `synced` 副本 4. 补齐国内为空、海外有值的字段 5. 补齐国内缺失的 OAuth 绑定 6. 额度合并规则: - `国内最终额度 = 国内原额度 + 执行时最新海外额度` - 通过 `migration_token` 保证每个迁移项最多叠加一次 7. 提交国内事务 8. 调海外接口将原账号转换为 `synced` 9. 更新迁移项为成功 ### 为什么不删除海外原用户 - 海外已有 session 兼容性更好 - 海外已有 API token 兼容性更好 - 现有 `synced user` 逻辑天然支持这种模型 - 回退成本远低于删除账号再重建 --- ## 海外用户转换接口 不建议国内直接操作海外数据库,建议由海外站提供内部接口执行切换。 ### 推荐接口 - `GET /api/internal/migration/users` - 分页返回候选用户快照 - 仅返回 `source=local` 的海外用户 - `GET /api/internal/migration/users/:id` - 返回单用户最新快照 - `GET /api/internal/migration/users/check-synced-copy?cn_user_id=:id` - 检查某个国内用户是否已在海外存在 `synced` 副本 - `POST /api/internal/migration/users/:id/convert-to-synced` - 将海外原用户降级为 `synced` ### 转换接口行为 接口需要做以下校验: - 仅允许内部受信任调用 - 用户存在 - 用户尚未迁移,或已迁移且目标一致 - `remote_user_id` 不为空 - 如果用户已是 `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` - 设置处理策略 - 成功后重新统计批次状态;若所有项都已变为 `ready` 或 `skipped`,批次自动转为 `ready` - `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` 或同等级唯一标识保证: - 每个迁移项对国内额度的修改最多成功一次 --- ## 冻结窗口设计 本次迁移不要求全站停机,但 V1 版本将“冻结窗口”定义为执行前的强制运维前置条件,而不是可选建议。 冻结范围应仅限该批次用户: - 禁止充值 - 禁止兑换码 - 禁止额度转移 - 禁止发起新请求 - 等待已在途请求自然结束 执行要求: - root 操作员在点击“开始迁移”前确认冻结窗口已经生效 - 批次执行期间如果发现冻结条件被破坏,应立即停止后续条目并重新扫描 目的: - 稳定海外最终额度 - 避免预检查和执行之间继续发生额度变化 --- ## 回滚边界 本次设计不提供整批自动回滚,只提供有限的单用户回退能力。 ### `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` 架构,改动集中、风险可控、审计清晰,且能保留用户现有登录方式和海外站访问体验。