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:
@@ -7,11 +7,20 @@ import com.imutil.entity.CrossTenantGrant;
|
||||
import com.imutil.entity.DistQueue;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.entity.UsageStat;
|
||||
import com.imutil.entity.SourceApp;
|
||||
import com.imutil.mapper.CrossTenantGrantMapper;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.mapper.TenantMapper;
|
||||
import com.imutil.mapper.UsageStatMapper;
|
||||
import com.imutil.service.AdminUserService;
|
||||
import com.imutil.service.SourceAppService;
|
||||
import com.imutil.tencent.TencentImClient;
|
||||
import com.imutil.entity.MigrateTask;
|
||||
import com.imutil.service.MigrateService;
|
||||
import org.noear.solon.core.handle.UploadedFile;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
@@ -53,6 +62,15 @@ public class AdminController {
|
||||
@Inject
|
||||
private UsageStatMapper usageStatMapper;
|
||||
|
||||
@Inject
|
||||
private SourceAppService sourceAppService;
|
||||
|
||||
@Inject
|
||||
private TencentImClient tencentImClient;
|
||||
|
||||
@Inject
|
||||
private MigrateService migrateService;
|
||||
|
||||
// ==================== 登录 / 登出 ====================
|
||||
|
||||
@Get
|
||||
@@ -234,6 +252,121 @@ public class AdminController {
|
||||
return mv;
|
||||
}
|
||||
|
||||
// ==================== 老应用配置(数据迁移源) ====================
|
||||
|
||||
@Get
|
||||
@Mapping("/sourceapp")
|
||||
public Object sourceAppPage(@Param(defaultValue = "") String msg) {
|
||||
ModelAndView mv = view("sourceapp.ftl", "老应用配置", "sourceapp");
|
||||
mv.put("apps", sourceAppService.list());
|
||||
mv.put("tenants", tenantMapper.selectList(null));
|
||||
mv.put("msg", msg);
|
||||
return mv;
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/sourceapp/save")
|
||||
public void sourceAppSave(SourceApp app, Context ctx) throws Throwable {
|
||||
if (app.getSdkAppId() == null || app.getSdkAppId().isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=sdkappid_required");
|
||||
return;
|
||||
}
|
||||
if (app.getTenantId() == null || app.getTenantId().isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=tenant_required");
|
||||
return;
|
||||
}
|
||||
sourceAppService.save(app);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=saved");
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/sourceapp/toggle")
|
||||
public void sourceAppToggle(@Param Long id, @Param Integer status, Context ctx) throws Throwable {
|
||||
sourceAppService.toggle(id, status);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=toggled");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试老应用连通性:用老应用密钥调 account_import(幂等,导入 administrator,无实质副作用),
|
||||
* 验证 SDKAppId/密钥/sig 正确。腾讯返回业务级响应(非 70003)即说明 sig 鉴权通过、连通成功。
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/sourceapp/test")
|
||||
public void sourceAppTest(@Param Long id, Context ctx) throws Throwable {
|
||||
SourceApp app = sourceAppService.getById(id);
|
||||
if (app == null) {
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=notfound");
|
||||
return;
|
||||
}
|
||||
// account_import:幂等导入 administrator(主应用/老应用本就需要该管理员账号),必返 ActionStatus=OK
|
||||
String body = "{\"Identifier\":\"administrator\"}";
|
||||
String resp = tencentImClient.callApiAs("im_open_login_svc/account_import", body,
|
||||
Long.parseLong(app.getSdkAppId()), app.getSecretKey());
|
||||
// 连通判定:网络异常(ErrorCode=-1,doCallApi 兜底)→失败;70003=UserSig 错误(密钥不匹配);
|
||||
// 其余任何腾讯业务级响应都说明请求已达腾讯且 sig 鉴权通过 → 连通成功
|
||||
String msg;
|
||||
if (resp.contains("\"ErrorCode\":-1")) {
|
||||
msg = "test_fail";
|
||||
} else if (resp.contains("70003")) {
|
||||
msg = "test_sigfail";
|
||||
} else {
|
||||
msg = "test_ok";
|
||||
}
|
||||
log.info("老应用连通测试 id={} sdk={} resp={}", id, app.getSdkAppId(), resp);
|
||||
ctx.redirect(basePath + "/admin/sourceapp?msg=" + msg);
|
||||
}
|
||||
|
||||
// ==================== 数据迁移(T14) ====================
|
||||
|
||||
@Get
|
||||
@Mapping("/migrate")
|
||||
public Object migratePage(@Param(defaultValue = "") String msg,
|
||||
@Param(defaultValue = "") String imported) {
|
||||
ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate");
|
||||
mv.put("tasks", migrateService.listTasks());
|
||||
mv.put("apps", sourceAppService.list());
|
||||
mv.put("msg", msg);
|
||||
mv.put("imported", imported);
|
||||
return mv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户迁移:上传用户清单文件(每行一个老 UserID)或粘贴列表 → 加前缀 → 批量导入主应用 → 写映射 → 校验
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/migrate/users")
|
||||
public void migrateUsers(@Param Long sourceAppId,
|
||||
@Param(defaultValue = "") String userText,
|
||||
Context ctx) throws Throwable {
|
||||
List<String> userIds = new ArrayList<>();
|
||||
UploadedFile file = ctx.file("file");
|
||||
if (file != null) {
|
||||
parseUserIds(new String(file.getContentAsBytes(), StandardCharsets.UTF_8), userIds);
|
||||
} else if (!userText.isEmpty()) {
|
||||
parseUserIds(userText, userIds);
|
||||
}
|
||||
if (sourceAppId == null) {
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=noapp");
|
||||
return;
|
||||
}
|
||||
if (userIds.isEmpty()) {
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=empty");
|
||||
return;
|
||||
}
|
||||
MigrateTask task = migrateService.migrateUsers(sourceAppId, userIds);
|
||||
ctx.redirect(basePath + "/admin/migrate?msg=done&imported=" + task.getProcessedCount());
|
||||
}
|
||||
|
||||
/** 解析用户清单:每行一个 UserID,空行与 # 注释跳过 */
|
||||
private void parseUserIds(String content, List<String> out) {
|
||||
for (String line : content.split("\\r?\\n")) {
|
||||
line = line.trim();
|
||||
if (!line.isEmpty() && !line.startsWith("#")) {
|
||||
out.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公共:构造页面模型 ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user