Kaynağa Gözat

docs: add overseas user migration design

master
fengsilin 1 hafta önce
ebeveyn
işleme
0c279e6662
1 değiştirilmiş dosya ile 670 ekleme ve 0 silme
  1. +670
    -0
      docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md

+ 670
- 0
docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md Dosyayı Görüntüle

@@ -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` 架构,改动集中、风险可控、审计清晰,且能保留用户现有登录方式和海外站访问体验。

Yükleniyor…
İptal
Kaydet