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
@@ -0,0 +1,137 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.BizException;
import com.imutil.entity.MigrateTask;
import com.imutil.entity.SourceApp;
import com.imutil.entity.Tenant;
import com.imutil.entity.UserMapping;
import com.imutil.mapper.MigrateTaskMapper;
import com.imutil.mapper.SourceAppMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UserMappingMapper;
import com.imutil.service.MigrateService;
import com.imutil.tencent.TencentImClient;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 数据迁移服务实现(T14
*
* @author imutil
*/
@Slf4j
@Component
public class MigrateServiceImpl implements MigrateService {
@Inject
private SourceAppMapper sourceAppMapper;
@Inject
private TenantMapper tenantMapper;
@Inject
private UserMappingMapper userMappingMapper;
@Inject
private MigrateTaskMapper migrateTaskMapper;
@Inject
private TencentImClient tencentImClient;
@Override
public MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds) {
SourceApp app = sourceAppMapper.selectById(sourceAppId);
if (app == null) {
throw new BizException("老应用不存在");
}
Tenant tenant = tenantMapper.selectById(app.getTenantId());
if (tenant == null) {
throw new BizException("关联租户不存在: " + app.getTenantId());
}
String prefix = tenant.getPrefixCode();
// 建迁移任务记录(running
MigrateTask task = new MigrateTask();
task.setSourceAppId(sourceAppId);
task.setObjectType("USER");
task.setStatus(1);
task.setTotalCount(oldUserIds == null ? 0L : (long) oldUserIds.size());
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
try {
// 老用户 ID 加租户前缀(对齐 user_mapping: im_user_id = prefix + '_' + biz_user_id
List<String> newImUserIds = new ArrayList<>();
Map<String, String> oldToNew = new HashMap<>();
if (oldUserIds != null) {
for (String oldId : oldUserIds) {
if (oldId == null || oldId.trim().isEmpty()) {
continue;
}
String oldTrim = oldId.trim();
String newId = prefix + "_" + oldTrim;
newImUserIds.add(newId);
oldToNew.put(oldTrim, newId);
}
}
// 批量导入主应用(走主应用密钥+限流;幂等,已存在账号亦成功)
int imported = tencentImClient.batchImport(newImUserIds);
// 写 user_mapping(幂等:同 tenant+bizUserId 已存在则跳过)
int mapped = 0;
for (Map.Entry<String, String> e : oldToNew.entrySet()) {
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getTenantId, app.getTenantId())
.eq(UserMapping::getBizUserId, e.getKey())
.last("LIMIT 1"));
if (exist != null) {
continue;
}
UserMapping um = new UserMapping();
um.setTenantId(app.getTenantId());
um.setBizUserId(e.getKey());
um.setImUserId(e.getValue());
um.setIsDefault(false);
um.setIsGlobal(false);
um.setStatus(1);
userMappingMapper.insert(um);
mapped++;
}
// 校验:老清单数 vs 导入成功数 vs 新建映射数
task.setProcessedCount((long) imported);
task.setStatus(3); // done
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format(
"{\"oldCount\":%d,\"imported\":%d,\"mapped\":%d}",
oldToNew.size(), imported, mapped));
migrateTaskMapper.updateById(task);
log.info("用户迁移完成 sourceApp={} old={} imported={} mapped={}",
sourceAppId, oldToNew.size(), imported, mapped);
return task;
} catch (Exception e) {
task.setStatus(4); // failed
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
task.setFinishedAt(OffsetDateTime.now());
migrateTaskMapper.updateById(task);
log.error("用户迁移失败 sourceApp={}", sourceAppId, e);
throw e;
}
}
@Override
public List<MigrateTask> listTasks() {
return migrateTaskMapper.selectList(Wrappers.<MigrateTask>lambdaQuery()
.orderByDesc(MigrateTask::getId)
.last("LIMIT 100"));
}
}
@@ -0,0 +1,54 @@
package com.imutil.service.impl;
import com.imutil.entity.SourceApp;
import com.imutil.mapper.SourceAppMapper;
import com.imutil.service.SourceAppService;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.util.List;
/**
* 老应用配置服务实现
*
* @author imutil
*/
@Component
public class SourceAppServiceImpl implements SourceAppService {
@Inject
private SourceAppMapper sourceAppMapper;
@Override
public List<SourceApp> list() {
return sourceAppMapper.selectList(null);
}
@Override
public SourceApp getById(Long id) {
if (id == null) {
return null;
}
return sourceAppMapper.selectById(id);
}
@Override
public void save(SourceApp app) {
if (app.getId() == null) {
if (app.getStatus() == null) {
app.setStatus(1);
}
sourceAppMapper.insert(app);
} else {
sourceAppMapper.updateById(app);
}
}
@Override
public void toggle(Long id, Integer status) {
SourceApp app = new SourceApp();
app.setId(id);
app.setStatus(status == null ? 0 : status);
sourceAppMapper.updateById(app);
}
}