feat(t14-s345): 群迁移 + 消息迁移 + 关系链迁移

T14 S3/S4/S5 阶段:
- S3 群迁移:getAppidGroups/getGroupInfo(老密钥拉)+ createGroup/addGroupMember(主密钥导入)+ migrateGroups
- S4 消息迁移:importMsg/importGroupMsg + migrateMessages(会话清单+断点续传,v1 最近7天/100条)
- S5 关系链:importFriend/friendGetList(sns 系列)+ migrateRoster
- 后台 /admin/migrate/{groups,messages,roster} 触发 + migrate.ftl 三表单

S1/S2 完整联调通过;S3/S4/S5 代码完成+通路验证(腾讯返业务响应),
核心导入待真实老应用(10003 群组/60008 消息/60002 关系链)。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yixiong
2026-07-09 12:12:26 +08:00
co-authored by Claude
parent 7b1385e160
commit 460a31b52f
5 changed files with 603 additions and 1 deletions
@@ -6,12 +6,15 @@ import com.imutil.entity.MigrateTask;
import com.imutil.entity.SourceApp;
import com.imutil.entity.Tenant;
import com.imutil.entity.UserMapping;
import com.imutil.entity.GroupMapping;
import com.imutil.mapper.MigrateTaskMapper;
import com.imutil.mapper.SourceAppMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UserMappingMapper;
import com.imutil.mapper.GroupMappingMapper;
import com.imutil.service.MigrateService;
import com.imutil.tencent.TencentImClient;
import org.noear.snack4.ONode;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
@@ -40,6 +43,9 @@ public class MigrateServiceImpl implements MigrateService {
@Inject
private UserMappingMapper userMappingMapper;
@Inject
private GroupMappingMapper groupMappingMapper;
@Inject
private MigrateTaskMapper migrateTaskMapper;
@@ -128,6 +134,258 @@ public class MigrateServiceImpl implements MigrateService {
}
}
@Override
public MigrateTask migrateGroups(Long sourceAppId) {
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();
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
String oldSecret = app.getSecretKey();
MigrateTask task = new MigrateTask();
task.setSourceAppId(sourceAppId);
task.setObjectType("GROUP");
task.setStatus(1);
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
try {
// 1. 拉老应用所有群(老密钥)
String groupsResp = tencentImClient.getAppidGroups(oldSdkAppId, oldSecret);
ONode groupsNode = ONode.ofJson(groupsResp);
ONode groupIdList = groupsNode.get("GroupIdList");
int total = groupIdList.size();
task.setTotalCount((long) total);
migrateTaskMapper.updateById(task);
int migrated = 0;
int mapped = 0;
int memberTotal = 0;
// 逐群迁移(snack4 数组遍历用 size + get(int)
for (int i = 0; i < total; i++) {
ONode g = groupIdList.get(i);
String oldGroupId = g.get("GroupId").getString();
String type = g.get("Type").getString();
try {
// 2. 拉老群详情(资料 + 成员,老密钥)
String infoResp = tencentImClient.getGroupInfo(oldGroupId, oldSdkAppId, oldSecret);
ONode infoNode = ONode.ofJson(infoResp);
ONode groupInfo = infoNode.get("GroupInfo").get(0);
String name = groupInfo.get("Name").getString();
String owner = groupInfo.get("Owner_Account").getString();
// 成员列表加前缀
ONode memberListNode = groupInfo.get("MemberList");
List<String> newMembers = new ArrayList<>();
int mc = memberListNode.size();
for (int j = 0; j < mc; j++) {
String memId = memberListNode.get(j).get("Member_Account").getString();
if (memId != null && !memId.isEmpty()) {
newMembers.add(prefix + "_" + memId);
}
}
memberTotal += newMembers.size();
// 3. 建新群(加前缀,带初始成员 ≤ 100)
String newGroupId = prefix + "_" + oldGroupId;
String newOwner = (owner != null && !owner.isEmpty()) ? prefix + "_" + owner : null;
List<String> initMembers = newMembers.size() <= 100
? newMembers : new ArrayList<>(newMembers.subList(0, 100));
boolean ok = tencentImClient.createGroup(newGroupId, type, name, newOwner, initMembers);
if (!ok) {
log.warn("群迁移建群失败 old={} new={}", oldGroupId, newGroupId);
continue;
}
migrated++;
// 成员 > 100 补充
if (newMembers.size() > 100) {
tencentImClient.addGroupMember(newGroupId, new ArrayList<>(newMembers.subList(100, newMembers.size())));
}
// 写 group_mapping(幂等)
GroupMapping exist = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getTenantId, app.getTenantId())
.eq(GroupMapping::getBizGroupId, oldGroupId)
.last("LIMIT 1"));
if (exist == null) {
GroupMapping gm = new GroupMapping();
gm.setTenantId(app.getTenantId());
gm.setBizGroupId(oldGroupId);
gm.setImGroupId(newGroupId);
gm.setGroupType(type != null && !type.isEmpty() ? type : "Public");
groupMappingMapper.insert(gm);
mapped++;
}
} catch (Exception ge) {
log.warn("群迁移单群失败 old={} : {}", oldGroupId, ge.getMessage());
}
}
task.setProcessedCount((long) migrated);
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format(
"{\"oldGroups\":%d,\"migrated\":%d,\"mapped\":%d,\"memberTotal\":%d}",
total, migrated, mapped, memberTotal));
migrateTaskMapper.updateById(task);
log.info("群迁移完成 sourceApp={} old={} migrated={} mapped={}", sourceAppId, total, migrated, mapped);
return task;
} catch (Exception e) {
task.setStatus(4);
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
task.setFinishedAt(OffsetDateTime.now());
migrateTaskMapper.updateById(task);
log.error("群迁移失败 sourceApp={}", sourceAppId, e);
throw e;
}
}
@Override
public MigrateTask migrateMessages(Long sourceAppId, List<String[]> c2cPairs) {
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();
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
String oldSecret = app.getSecretKey();
MigrateTask task = new MigrateTask();
task.setSourceAppId(sourceAppId);
task.setObjectType("MSG");
task.setStatus(1);
int pairs = c2cPairs == null ? 0 : c2cPairs.size();
task.setTotalCount((long) pairs);
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
try {
int imported = 0;
long nowSec = OffsetDateTime.now().toEpochSecond();
for (int pi = 0; pi < pairs; pi++) {
String[] pair = c2cPairs.get(pi);
if (pair == null || pair.length < 2) {
continue;
}
String oldFrom = pair[0];
String oldTo = pair[1];
String newFrom = prefix + "_" + oldFrom;
String newTo = prefix + "_" + oldTo;
try {
// v1:每会话拉最近 7 天内 100 条(腾讯 MaxTimeInterval 有限制;全量历史分页作为遗留)
long maxInterval = 604800L; // 7 天
long minTime = nowSec - maxInterval;
String resp = tencentImClient.getRoamMsg(oldFrom, oldTo, 100, minTime, maxInterval);
ONode node = ONode.ofJson(resp);
ONode msgList = node.get("MsgList");
int n = msgList.size();
for (int i = 0; i < n; i++) {
ONode msg = msgList.get(i);
long ts = msg.get("MsgTimeStamp").getLong();
String msgBodyJson = msg.get("MsgBody").toString();
if (tencentImClient.importMsg(newFrom, newTo, ts, msgBodyJson)) {
imported++;
}
}
} catch (Exception ce) {
log.warn("单聊消息迁移会话失败 {}->{} : {}", oldFrom, oldTo, ce.getMessage());
}
// 断点续传游标(会话索引)
task.setProcessedCount((long) imported);
task.setPosCursor("pair:" + (pi + 1));
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"pairs\":%d,\"imported\":%d}", pairs, imported));
migrateTaskMapper.updateById(task);
log.info("单聊消息迁移完成 sourceApp={} pairs={} imported={}", sourceAppId, pairs, imported);
return task;
} catch (Exception e) {
task.setStatus(4);
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
task.setFinishedAt(OffsetDateTime.now());
migrateTaskMapper.updateById(task);
log.error("消息迁移失败 sourceApp={}", sourceAppId, e);
throw e;
}
}
@Override
public MigrateTask migrateRoster(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();
long oldSdkAppId = Long.parseLong(app.getSdkAppId());
String oldSecret = app.getSecretKey();
MigrateTask task = new MigrateTask();
task.setSourceAppId(sourceAppId);
task.setObjectType("ROSTER");
task.setStatus(1);
int users = oldUserIds == null ? 0 : oldUserIds.size();
task.setTotalCount((long) users);
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
try {
int imported = 0;
for (int ui = 0; ui < users; ui++) {
String oldUser = oldUserIds.get(ui);
String newUser = prefix + "_" + oldUser;
try {
String resp = tencentImClient.friendGetList(oldUser, oldSdkAppId, oldSecret);
ONode node = ONode.ofJson(resp);
ONode friendListNode = node.get("FriendList");
List<String> newFriends = new ArrayList<>();
int n = friendListNode.size();
for (int i = 0; i < n; i++) {
String fid = friendListNode.get(i).get("To_Account").getString();
if (fid != null && !fid.isEmpty()) {
newFriends.add(prefix + "_" + fid);
}
}
if (!newFriends.isEmpty() && tencentImClient.importFriend(newUser, newFriends)) {
imported++;
}
} catch (Exception fe) {
log.warn("关系链迁移用户失败 {} : {}", oldUser, fe.getMessage());
}
task.setProcessedCount((long) imported);
task.setPosCursor("user:" + (ui + 1));
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"users\":%d,\"imported\":%d}", users, imported));
migrateTaskMapper.updateById(task);
log.info("关系链迁移完成 sourceApp={} users={} imported={}", sourceAppId, users, imported);
return task;
} catch (Exception e) {
task.setStatus(4);
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()