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:
@@ -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 随建群带入,> 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,返回 GroupIdList(GroupId/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_member,Silence=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_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入群消息(历史消息,主应用)
|
||||
* <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 返回是否成功
|
||||
*/
|
||||
|
||||
@@ -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 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">
|
||||
|
||||
Reference in New Issue
Block a user