feat(t14-s1s2): 数据迁移基础设施 + 用户迁移

T14 数据迁移工具 S1/S2 阶段:

S1 基础设施:
- source_app/migrate_task 建表 + 实体 + Mapper
- TencentImClient 双密钥 callApiAs(老应用拉取,不走主应用限流,审计记 migrate)
- 后台「老应用配置」页(CRUD + 测试连通)

S2 用户迁移:
- batchImport(multiaccount_import,每批≤100)
- MigrateService.migrateUsers(前缀转换+导入+映射+校验,幂等)
- 后台「数据迁移」页(上传清单文件/粘贴列表 + 任务记录)

联调通过:3用户清单→加sa前缀→腾讯导入OK→user_mapping→校验3/3/3。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yixiong
2026-07-09 11:12:25 +08:00
co-authored by Claude
parent b4c6652f66
commit 7b1385e160
14 changed files with 754 additions and 11 deletions
@@ -66,14 +66,7 @@ public class TencentImClient {
private LocalCache localCache;
/**
* 生成管理员 UserSig(长效,用于调后台 API
*/
private String genAdminSig() {
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
}
/**
* 调用 IM REST API
* 调用主应用 IM REST API(用主应用密钥,走出站限流
*
* @param command 命令路径,如 im_open_login_svc/account_import
* @param bodyJson 请求体 JSON
@@ -83,16 +76,38 @@ public class TencentImClient {
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
String tenantId = TenantContext.get();
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
// 出站腾讯 API 限流:按租户配额 + 全局共享配额(对齐设计 3.2),超限抛 429
// system(无租户上下文)用默认配额;补拉等后台任务超限由调用方 catch 跳过本轮
rateLimiter.checkApiLimit(tid, resolveTenantQps(tid));
return doCallApi(command, bodyJson, sdkAppId, secretKey, tid);
}
/**
* 调用老应用 IM REST API(数据迁移拉取用,指定老应用密钥)
* <p>
* 老应用拉取消耗的是老应用自身套餐配额(非主应用),故不走主应用限流;
* 审计 tid 记 "migrate" 便于与业务调用区分。管理员 identifier 默认 administrator
* (腾讯各应用默认管理员账号),如某老应用管理员不同,后续扩展 source_app 字段。
*
* @param srcSdkAppId 老应用 SDKAppId
* @param srcSecretKey 老应用密钥
* @return 响应 JSON 字符串
*/
public String callApiAs(String command, String bodyJson, long srcSdkAppId, String srcSecretKey) {
return doCallApi(command, bodyJson, srcSdkAppId, srcSecretKey, "migrate");
}
/**
* 调用腾讯 API 的公共实现:生成管理员 UserSig + 拼 URL + POST + 审计落库
* <p>
* 限流由调用方决定:主应用 callApi 走限流,老应用 callApiAs 不走(消耗老应用配额)。
*/
private String doCallApi(String command, String bodyJson, long appid, String key, String tid) {
String result;
try {
String adminSig = genAdminSig();
String adminSig = UserSigUtil.genSig(appid, key, adminUserId, 30L * 86400);
String url = "https://" + apiHost + "/v4/" + command
+ "?sdkappid=" + sdkAppId
+ "?sdkappid=" + appid
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
+ "&contenttype=json&platform=10&apn=1";
@@ -182,6 +197,35 @@ public class TencentImClient {
return isOk(resp);
}
/**
* 批量导入账号(multiaccount_import,每批 ≤ 100,主应用密钥)
* <p>
* 幂等:已存在账号亦成功。数据迁移用户导入用:老应用 UserID 加租户前缀后批量导入主应用。
* 分批调用,返回成功导入数(整批 ActionStatus=OK 记为全部成功;个别非法 ID 场景后续可解析 RetItemList 精确化)。
*
* @param imUserIds 已加前缀的 IM 用户 ID 列表
* @return 成功导入数
*/
public int batchImport(List<String> imUserIds) {
if (imUserIds == null || imUserIds.isEmpty()) {
return 0;
}
int success = 0;
int batchSize = 100; // 腾讯 multiaccount_import 单批上限 100
for (int i = 0; i < imUserIds.size(); i += batchSize) {
List<String> sub = imUserIds.subList(i, Math.min(i + batchSize, imUserIds.size()));
Map<String, Object> body = new HashMap<>();
body.put("Accounts", sub);
String resp = callApi("im_open_login_svc/multiaccount_import", Jsons.stringify(body));
if (isOk(resp)) {
success += sub.size();
} else {
log.warn("批量导入失败批次 {} size={} resp={}", i / batchSize, sub.size(), resp);
}
}
return success;
}
/**
* 检查账号状态(只读)
* <p>