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
@@ -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<String[]> 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<String> 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<String> out) {
for (String line : content.split("\\r?\\n")) {
@@ -367,6 +434,23 @@ public class AdminController {
}
}
/** 解析会话清单:每行 from,to(逗号或空格分隔),空行与 # 注释跳过 */
private void parseConvPairs(String content, List<String[]> 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]});
}
}
}
// ==================== 公共:构造页面模型 ====================
/**
@@ -26,6 +26,39 @@ public interface MigrateService {
*/
MigrateTask migrateUsers(Long sourceAppId, List<String> oldUserIds);
/**
* 群迁移:get_appid_groups 拉老应用所有群 → 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping → 校验。
* <p>
* 群清单可从腾讯 API 拉取(与用户清单不同);成员账号需已迁移(依赖用户迁移已完成)。
* 单群成员 ≤ 100 随建群带入,&gt; 100 用 add_group_member 补充。
*
* @param sourceAppId 老应用配置 id
* @return 迁移任务记录
*/
MigrateTask migrateGroups(Long sourceAppId);
/**
* 单聊消息迁移:按会话清单(from-to 对)→ getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥)→ 校验。
* <p>
* 会话清单由调用方提供(后台上传,腾讯无「列出所有会话」API)。v1 每会话拉最近 100 条,
* 断点续传按会话索引(pos_cursor=pair:N);限速由 callApi 的 RateLimiter 兜底。
* 群消息迁移依赖 S3 群结果(group_mapping),作为遗留。
*
* @param c2cPairs 单聊会话清单,每项 [from, to](老应用 UserID
* @return 迁移任务记录
*/
MigrateTask migrateMessages(Long sourceAppId, List<String[]> c2cPairs);
/**
* 关系链迁移:逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)→ 校验。
* <p>
* 输入用户清单(后台上传,与 S2 同源);好友账号需已迁移。
*
* @param oldUserIds 老应用用户 ID 清单(对其逐个拉好友并导入)
* @return 迁移任务记录
*/
MigrateTask migrateRoster(Long sourceAppId, List<String> oldUserIds);
/**
* 迁移任务列表(最近 100 条,按 id 倒序)
*/
@@ -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()
@@ -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 下所有群(老应用拉群清单,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_appid_groups,返回 GroupIdListGroupId/Type)。
* 与用户清单不同,群清单可从腾讯 API 直接拉取。
*
* @return 腾讯响应原始 JSON(含 GroupIdList),由调用方解析
*/
public String getAppidGroups(long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("Limit", 10000);
return callApiAs("group_open_http_svc/get_appid_groups", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 查群资料 + 成员列表(老应用拉群详情,用老密钥)
* <p>
* 命令字 group_open_http_svc/get_group_info,返回 GroupInfo[].MemberList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String getGroupInfo(String groupId, long srcSdkAppId, String srcSecretKey) {
Map<String, Object> body = new HashMap<>();
body.put("GroupId", List.of(groupId));
return callApiAs("group_open_http_svc/get_group_info", Jsons.stringify(body), srcSdkAppId, srcSecretKey);
}
/**
* 建群(主应用,可带初始成员 ≤ 100)
* <p>
* 命令字 group_open_http_svc/create_group。迁移时 newGroupId = 前缀 + 老 GroupId。
* 成员账号需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean createGroup(String groupId, String type, String name, String ownerAccount, List<String> memberAccounts) {
Map<String, Object> 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<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> 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 时分批补充;成员账号需已存在)
* <p>
* 命令字 group_open_http_svc/add_group_memberSilence=1 静默加入。
*
* @return ActionStatus 是否 OK
*/
public boolean addGroupMember(String groupId, List<String> memberAccounts) {
if (memberAccounts == null || memberAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("GroupId", groupId);
List<Map<String, String>> ml = new ArrayList<>();
for (String m : memberAccounts) {
Map<String, String> 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 ====================
/**
* 导入单聊消息(历史消息,不计未读,主应用)
* <p>
* 命令字 openim_http_svc/import_msgSyncFromOldSystem=1 标记为旧系统迁移消息(不计未读数)。
* From/To 需已存在于主应用(用户迁移已完成)。MsgBody 透传(来自 getRoamMsg 的原始消息体 JSON)。
*
* @param msgBodyJson MsgBody 原始 JSON 数组字符串(来自漫游消息,直接透传)
* @return ActionStatus 是否 OK
*/
public boolean importMsg(String fromAccount, String toAccount, long msgTimeStamp, String msgBodyJson) {
// 手动拼 bodyMsgBody 直接嵌入(已是合法 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);
}
/**
* 导入群消息(历史消息,主应用)
* <p>
* 命令字 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 ====================
/**
* 导入好友关系(主应用,历史关系链迁移)
* <p>
* 命令字 sns/openim_http_svc/import_friend。From/To 需已存在于主应用(用户迁移已完成)。
*
* @return ActionStatus 是否 OK
*/
public boolean importFriend(String fromAccount, List<String> friendAccounts) {
if (friendAccounts == null || friendAccounts.isEmpty()) {
return true;
}
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
List<Map<String, String>> fl = new ArrayList<>();
for (String f : friendAccounts) {
Map<String, String> 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);
}
/**
* 拉取用户好友列表(老应用拉关系链,用老密钥)
* <p>
* 命令字 sns/openim_http_svc/friend_get_list,返回 FriendList。
*
* @return 腾讯响应原始 JSON,由调用方解析
*/
public String friendGetList(String account, long srcSdkAppId, String srcSecretKey) {
Map<String, Object> 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 返回是否成功
*/
+64
View File
@@ -3,6 +3,9 @@
<#if msg?has_content>
<div class="alert">
<#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 @@
</form>
</div>
<div class="stat-card" style="margin-bottom:20px">
<div class="modal-title">群迁移</div>
<p class="muted">从老应用拉取所有群(get_appid_groups)→ 逐群拉成员 → 加前缀建群(主应用)→ 导入成员 → 写 group_mapping。群清单由腾讯 API 拉取(无需文件);成员账号需已迁移(请先做用户迁移)。</p>
<form method="post" action="${basePath}/admin/migrate/groups" style="margin-top:14px">
<div class="modal-body">
<div class="form-group">
<label>老应用</label>
<select name="sourceAppId" required>
<option value="">请选择老应用</option>
<#list apps as a>
<option value="${a.id!}">${a.appName!}SDK ${a.sdkAppId!} → 租户 ${a.tenantId!}</option>
</#list>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary">开始群迁移</button>
</form>
</div>
<div class="stat-card" style="margin-bottom:20px">
<div class="modal-title">单聊消息迁移</div>
<p class="muted">上传会话清单(每行 from,to 逗号或空格分隔,# 注释)或粘贴,选老应用后触发。getRoamMsg(老密钥)拉 → 加前缀 → importMsg(主密钥,不计未读)。v1 每会话拉最近 100 条,断点续传按会话索引。用户需已迁移。</p>
<form method="post" action="${basePath}/admin/migrate/messages" enctype="multipart/form-data" style="margin-top:14px">
<div class="modal-body">
<div class="form-group">
<label>老应用</label>
<select name="sourceAppId" required>
<option value="">请选择老应用</option>
<#list apps as a>
<option value="${a.id!}">${a.appName!}SDK ${a.sdkAppId!} → 租户 ${a.tenantId!}</option>
</#list>
</select>
</div>
<div class="form-group"><label>上传会话清单(每行 from,to</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
<div class="form-group"><label>或粘贴会话列表(每行 from,to</label><textarea name="convText" rows="5" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px" placeholder="user_a,user_b&#10;user_c,user_d"></textarea></div>
</div>
<button type="submit" class="btn btn-primary">开始消息迁移</button>
</form>
</div>
<div class="stat-card" style="margin-bottom:20px">
<div class="modal-title">关系链迁移</div>
<p class="muted">上传用户清单(每行一个 UserID)或粘贴,选老应用后触发。逐用户 friendGetList(老密钥)拉好友 → 加前缀 → importFriend(主密钥)。用户与好友账号需已迁移。</p>
<form method="post" action="${basePath}/admin/migrate/roster" enctype="multipart/form-data" style="margin-top:14px">
<div class="modal-body">
<div class="form-group">
<label>老应用</label>
<select name="sourceAppId" required>
<option value="">请选择老应用</option>
<#list apps as a>
<option value="${a.id!}">${a.appName!}SDK ${a.sdkAppId!} → 租户 ${a.tenantId!}</option>
</#list>
</select>
</div>
<div class="form-group"><label>上传用户清单(每行一个 UserID</label><input type="file" name="file" style="padding:7px;border:1px solid #ddd;border-radius:6px;width:100%"></div>
<div class="form-group"><label>或粘贴用户 ID 列表</label><textarea name="userText" rows="4" style="width:100%;padding:9px;border:1px solid #ddd;border-radius:6px;font-family:monospace;font-size:13px"></textarea></div>
</div>
<button type="submit" class="btn btn-primary">开始关系链迁移</button>
</form>
</div>
<h3 style="margin:20px 0 10px;font-size:15px;color:#2c3e50">迁移任务记录</h3>
<#if tasks?has_content>
<table class="data-table">