From 460a31b52f4e6a4e566cf6860a17f8c7f50caaea Mon Sep 17 00:00:00 2001 From: yixiong Date: Thu, 9 Jul 2026 12:12:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(t14-s345):=20=E7=BE=A4=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20+=20=E6=B6=88=E6=81=AF=E8=BF=81=E7=A7=BB=20+=20=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E9=93=BE=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../imutil/controller/AdminController.java | 86 +++++- .../com/imutil/service/MigrateService.java | 33 +++ .../service/impl/MigrateServiceImpl.java | 258 ++++++++++++++++++ .../com/imutil/tencent/TencentImClient.java | 163 +++++++++++ src/main/resources/templates/migrate.ftl | 64 +++++ 5 files changed, 603 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/imutil/controller/AdminController.java b/src/main/java/com/imutil/controller/AdminController.java index 09a3655..e533777 100644 --- a/src/main/java/com/imutil/controller/AdminController.java +++ b/src/main/java/com/imutil/controller/AdminController.java @@ -321,12 +321,14 @@ public class AdminController { @Get @Mapping("/migrate") public Object migratePage(@Param(defaultValue = "") String msg, - @Param(defaultValue = "") String imported) { + @Param(defaultValue = "") String imported, + @Param(defaultValue = "") String migrated) { ModelAndView mv = view("migrate.ftl", "数据迁移", "migrate"); mv.put("tasks", migrateService.listTasks()); mv.put("apps", sourceAppService.list()); mv.put("msg", msg); mv.put("imported", imported); + mv.put("migrated", migrated); return mv; } @@ -357,6 +359,71 @@ public class AdminController { ctx.redirect(basePath + "/admin/migrate?msg=done&imported=" + task.getProcessedCount()); } + /** + * 群迁移:选老应用 → getAppidGroups 拉群 → 逐群拉成员 → 加前缀建群 → 导入成员 → 写 group_mapping + */ + @Post + @Mapping("/migrate/groups") + public void migrateGroups(@Param Long sourceAppId, Context ctx) throws Throwable { + if (sourceAppId == null) { + ctx.redirect(basePath + "/admin/migrate?msg=noapp"); + return; + } + MigrateTask task = migrateService.migrateGroups(sourceAppId); + ctx.redirect(basePath + "/admin/migrate?msg=gdone&migrated=" + task.getProcessedCount()); + } + + /** + * 单聊消息迁移:上传会话清单(每行 from,to)或粘贴 → getRoamMsg 拉 → 加前缀 → importMsg + */ + @Post + @Mapping("/migrate/messages") + public void migrateMessages(@Param Long sourceAppId, + @Param(defaultValue = "") String convText, + Context ctx) throws Throwable { + UploadedFile file = ctx.file("file"); + String content = file != null ? new String(file.getContentAsBytes(), StandardCharsets.UTF_8) : convText; + List pairs = new ArrayList<>(); + parseConvPairs(content, pairs); + if (sourceAppId == null) { + ctx.redirect(basePath + "/admin/migrate?msg=noapp"); + return; + } + if (pairs.isEmpty()) { + ctx.redirect(basePath + "/admin/migrate?msg=empty"); + return; + } + MigrateTask task = migrateService.migrateMessages(sourceAppId, pairs); + ctx.redirect(basePath + "/admin/migrate?msg=mdone&imported=" + task.getProcessedCount()); + } + + /** + * 关系链迁移:上传用户清单(每行一个 UserID)或粘贴 → 逐用户拉好友 → 加前缀 → importFriend + */ + @Post + @Mapping("/migrate/roster") + public void migrateRoster(@Param Long sourceAppId, + @Param(defaultValue = "") String userText, + Context ctx) throws Throwable { + UploadedFile file = ctx.file("file"); + List userIds = new ArrayList<>(); + 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.migrateRoster(sourceAppId, userIds); + ctx.redirect(basePath + "/admin/migrate?msg=rdone&imported=" + task.getProcessedCount()); + } + /** 解析用户清单:每行一个 UserID,空行与 # 注释跳过 */ private void parseUserIds(String content, List out) { for (String line : content.split("\\r?\\n")) { @@ -367,6 +434,23 @@ public class AdminController { } } + /** 解析会话清单:每行 from,to(逗号或空格分隔),空行与 # 注释跳过 */ + private void parseConvPairs(String content, List out) { + if (content == null || content.isEmpty()) { + return; + } + for (String line : content.split("\\r?\\n")) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] parts = line.split("[,,\\s]+"); + if (parts.length >= 2) { + out.add(new String[]{parts[0], parts[1]}); + } + } + } + // ==================== 公共:构造页面模型 ==================== /** diff --git a/src/main/java/com/imutil/service/MigrateService.java b/src/main/java/com/imutil/service/MigrateService.java index 33634bf..7d9542e 100644 --- a/src/main/java/com/imutil/service/MigrateService.java +++ b/src/main/java/com/imutil/service/MigrateService.java @@ -26,6 +26,39 @@ public interface MigrateService { */ MigrateTask migrateUsers(Long sourceAppId, List oldUserIds); + /** + * 群迁移:get_appid_groups 拉老应用所有群 → 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping → 校验。 + *

+ * 群清单可从腾讯 API 拉取(与用户清单不同);成员账号需已迁移(依赖用户迁移已完成)。 + * 单群成员 ≤ 100 随建群带入,> 100 用 add_group_member 补充。 + * + * @param sourceAppId 老应用配置 id + * @return 迁移任务记录 + */ + MigrateTask migrateGroups(Long sourceAppId); + + /** + * 单聊消息迁移:按会话清单(from-to 对)→ getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥)→ 校验。 + *

+ * 会话清单由调用方提供(后台上传,腾讯无「列出所有会话」API)。v1 每会话拉最近 100 条, + * 断点续传按会话索引(pos_cursor=pair:N);限速由 callApi 的 RateLimiter 兜底。 + * 群消息迁移依赖 S3 群结果(group_mapping),作为遗留。 + * + * @param c2cPairs 单聊会话清单,每项 [from, to](老应用 UserID) + * @return 迁移任务记录 + */ + MigrateTask migrateMessages(Long sourceAppId, List c2cPairs); + + /** + * 关系链迁移:逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)→ 校验。 + *

+ * 输入用户清单(后台上传,与 S2 同源);好友账号需已迁移。 + * + * @param oldUserIds 老应用用户 ID 清单(对其逐个拉好友并导入) + * @return 迁移任务记录 + */ + MigrateTask migrateRoster(Long sourceAppId, List oldUserIds); + /** * 迁移任务列表(最近 100 条,按 id 倒序) */ diff --git a/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java b/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java index 3ed3b6c..0362a64 100644 --- a/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java +++ b/src/main/java/com/imutil/service/impl/MigrateServiceImpl.java @@ -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 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 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.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 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 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 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 listTasks() { return migrateTaskMapper.selectList(Wrappers.lambdaQuery() diff --git a/src/main/java/com/imutil/tencent/TencentImClient.java b/src/main/java/com/imutil/tencent/TencentImClient.java index fcab12b..560de4e 100644 --- a/src/main/java/com/imutil/tencent/TencentImClient.java +++ b/src/main/java/com/imutil/tencent/TencentImClient.java @@ -17,6 +17,7 @@ import org.noear.solon.annotation.Inject; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -282,6 +283,168 @@ public class TencentImClient { return callApi("group_open_http_svc/group_msg_get_simple", Jsons.stringify(body)); } + // ==================== 群迁移相关(T14 S3) ==================== + + /** + * 列出 SDKAppId 下所有群(老应用拉群清单,用老密钥) + *

+ * 命令字 group_open_http_svc/get_appid_groups,返回 GroupIdList(GroupId/Type)。 + * 与用户清单不同,群清单可从腾讯 API 直接拉取。 + * + * @return 腾讯响应原始 JSON(含 GroupIdList),由调用方解析 + */ + public String getAppidGroups(long srcSdkAppId, String srcSecretKey) { + Map body = new HashMap<>(); + body.put("Limit", 10000); + return callApiAs("group_open_http_svc/get_appid_groups", Jsons.stringify(body), srcSdkAppId, srcSecretKey); + } + + /** + * 查群资料 + 成员列表(老应用拉群详情,用老密钥) + *

+ * 命令字 group_open_http_svc/get_group_info,返回 GroupInfo[].MemberList。 + * + * @return 腾讯响应原始 JSON,由调用方解析 + */ + public String getGroupInfo(String groupId, long srcSdkAppId, String srcSecretKey) { + Map body = new HashMap<>(); + body.put("GroupId", List.of(groupId)); + return callApiAs("group_open_http_svc/get_group_info", Jsons.stringify(body), srcSdkAppId, srcSecretKey); + } + + /** + * 建群(主应用,可带初始成员 ≤ 100) + *

+ * 命令字 group_open_http_svc/create_group。迁移时 newGroupId = 前缀 + 老 GroupId。 + * 成员账号需已存在于主应用(用户迁移已完成)。 + * + * @return ActionStatus 是否 OK + */ + public boolean createGroup(String groupId, String type, String name, String ownerAccount, List memberAccounts) { + Map body = new HashMap<>(); + body.put("Type", type == null || type.isEmpty() ? "Public" : type); + body.put("GroupId", groupId); + body.put("Name", name == null ? groupId : name); + if (ownerAccount != null && !ownerAccount.isEmpty()) { + body.put("Owner_Account", ownerAccount); + } + if (memberAccounts != null && !memberAccounts.isEmpty()) { + List> ml = new ArrayList<>(); + for (String m : memberAccounts) { + Map item = new HashMap<>(); + item.put("Member_Account", m); + item.put("MemberRole", "Member"); + ml.add(item); + } + body.put("MemberList", ml); + } + String resp = callApi("group_open_http_svc/create_group", Jsons.stringify(body)); + return isOk(resp); + } + + /** + * 增加群成员(主应用,成员 > 100 时分批补充;成员账号需已存在) + *

+ * 命令字 group_open_http_svc/add_group_member,Silence=1 静默加入。 + * + * @return ActionStatus 是否 OK + */ + public boolean addGroupMember(String groupId, List memberAccounts) { + if (memberAccounts == null || memberAccounts.isEmpty()) { + return true; + } + Map body = new HashMap<>(); + body.put("GroupId", groupId); + List> ml = new ArrayList<>(); + for (String m : memberAccounts) { + Map item = new HashMap<>(); + item.put("Member_Account", m); + ml.add(item); + } + body.put("MemberList", ml); + body.put("Silence", 1); + String resp = callApi("group_open_http_svc/add_group_member", Jsons.stringify(body)); + return isOk(resp); + } + + // ==================== 消息迁移相关(T14 S4) ==================== + + /** + * 导入单聊消息(历史消息,不计未读,主应用) + *

+ * 命令字 openim_http_svc/import_msg,SyncFromOldSystem=1 标记为旧系统迁移消息(不计未读数)。 + * From/To 需已存在于主应用(用户迁移已完成)。MsgBody 透传(来自 getRoamMsg 的原始消息体 JSON)。 + * + * @param msgBodyJson MsgBody 原始 JSON 数组字符串(来自漫游消息,直接透传) + * @return ActionStatus 是否 OK + */ + public boolean importMsg(String fromAccount, String toAccount, long msgTimeStamp, String msgBodyJson) { + // 手动拼 body:MsgBody 直接嵌入(已是合法 JSON 数组),from/to 为安全 IM ID 无需转义 + String body = "{\"SyncFromOldSystem\":1,\"From_Account\":\"" + fromAccount + + "\",\"To_Account\":\"" + toAccount + + "\",\"MsgTimeStamp\":" + msgTimeStamp + + ",\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}"; + String resp = callApi("openim_http_svc/import_msg", body); + return isOk(resp); + } + + /** + * 导入群消息(历史消息,主应用) + *

+ * 命令字 group_open_http_svc/import_group_msg。GroupId/From 需已迁移(群迁移 + 用户迁移已完成)。 + * + * @return ActionStatus 是否 OK + */ + public boolean importGroupMsg(String groupId, String fromAccount, long msgTimeStamp, String msgBodyJson) { + String body = "{\"GroupId\":\"" + groupId + + "\",\"From_Account\":\"" + (fromAccount == null ? "" : fromAccount) + + "\",\"MsgTimeStamp\":" + msgTimeStamp + + ",\"SyncFromOldSystem\":1,\"MsgBody\":" + (msgBodyJson == null ? "[]" : msgBodyJson) + "}"; + String resp = callApi("group_open_http_svc/import_group_msg", body); + return isOk(resp); + } + + // ==================== 关系链迁移相关(T14 S5) ==================== + + /** + * 导入好友关系(主应用,历史关系链迁移) + *

+ * 命令字 sns/openim_http_svc/import_friend。From/To 需已存在于主应用(用户迁移已完成)。 + * + * @return ActionStatus 是否 OK + */ + public boolean importFriend(String fromAccount, List friendAccounts) { + if (friendAccounts == null || friendAccounts.isEmpty()) { + return true; + } + Map body = new HashMap<>(); + body.put("From_Account", fromAccount); + List> fl = new ArrayList<>(); + for (String f : friendAccounts) { + Map item = new HashMap<>(); + item.put("To_Account", f); + item.put("AddSource", "AddSource_Type_System"); + fl.add(item); + } + body.put("FriendList", fl); + String resp = callApi("sns/openim_http_svc/import_friend", Jsons.stringify(body)); + return isOk(resp); + } + + /** + * 拉取用户好友列表(老应用拉关系链,用老密钥) + *

+ * 命令字 sns/openim_http_svc/friend_get_list,返回 FriendList。 + * + * @return 腾讯响应原始 JSON,由调用方解析 + */ + public String friendGetList(String account, long srcSdkAppId, String srcSecretKey) { + Map body = new HashMap<>(); + body.put("From_Account", account); + body.put("StartSequence", 0L); + return callApiAs("sns/openim_http_svc/friend_get_list", Jsons.stringify(body), srcSdkAppId, srcSecretKey); + } + /** * 判断腾讯 API 返回是否成功 */ diff --git a/src/main/resources/templates/migrate.ftl b/src/main/resources/templates/migrate.ftl index a16d243..9a8496d 100644 --- a/src/main/resources/templates/migrate.ftl +++ b/src/main/resources/templates/migrate.ftl @@ -3,6 +3,9 @@ <#if msg?has_content>

<#if msg=='done'>✅ 迁移完成(成功导入 ${imported!'0'} 用户,详见任务记录) + <#elseif msg=='gdone'>✅ 群迁移完成(迁移 ${migrated!'0'} 群,详见任务记录) + <#elseif msg=='mdone'>✅ 消息迁移完成(导入 ${imported!'0'} 条,详见任务记录) + <#elseif msg=='rdone'>✅ 关系链迁移完成(导入 ${imported!'0'} 用户好友,详见任务记录) <#elseif msg=='empty'>❌ 用户清单为空(每行一个 UserID,# 开头为注释) <#elseif msg=='noapp'>❌ 请选择老应用 <#else>${msg} @@ -31,6 +34,67 @@
+
+ +

从老应用拉取所有群(get_appid_groups)→ 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping。群清单由腾讯 API 拉取(无需文件);成员账号需已迁移(请先做用户迁移)。

+
+ + +
+
+ +
+ +

上传会话清单(每行 from,to 逗号或空格分隔,# 注释)或粘贴,选老应用后触发。getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥,不计未读)。v1 每会话拉最近 100 条,断点续传按会话索引。用户需已迁移。

+
+ + +
+
+ +
+ +

上传用户清单(每行一个 UserID)或粘贴,选老应用后触发。逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)。用户与好友账号需已迁移。

+
+ + +
+
+

迁移任务记录

<#if tasks?has_content>