diff --git a/docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md b/docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md new file mode 100644 index 0000000..f7ab3d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md @@ -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` 架构,改动集中、风险可控、审计清晰,且能保留用户现有登录方式和海外站访问体验。