Files
tencent-im-util/src/main/java/com/imutil/service/impl/SyncServiceImpl.java
T
连龙刚andClaude 968a5f2e42 fix(t17): 数据同步联调修复——群消息死循环 + 字段名 + schema自动升级 + maxGroups精确
联调(新疆信创测试应用 1600134460,租户sa 67群)发现并修复4类问题:
1.群消息死循环:group_msg_get_simple 的 IsFinished=2 表"还有更旧可拉",占位消息群恒为2;
  原 oldestSeq=max(seq) 游标方向反,反复拉同一批致2217次/200s超时。
  改 min(seq) 向旧翻页 + 游标未推进即停兜底 + 过滤 IsPlaceMsg=1 占位消息(119次/15.7s)
2.get_group_info 请求体字段名 GroupId→GroupIdList(腾讯要求 GroupIdList:["id"] 字符串数组)
3.运行库缺列致 upserted=0:init.sql 仅新建库执行,新增 SchemaUpgradeBootstrap 启动幂等ALTER(6条DDL)
4.maxGroups 防护:break 由 for 外移入 for 内,精确限制避免单页海量群绕过上限

联调验证(租户sa 67群):群组67/67、群成员241/13、群消息119次/imported=3,死循环消除

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 09:55:48 +08:00

495 lines
20 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.BizException;
import com.imutil.common.MsgKeys;
import com.imutil.entity.GroupMapping;
import com.imutil.entity.ImMessage;
import com.imutil.entity.MigrateTask;
import com.imutil.entity.SourceApp;
import com.imutil.entity.Tenant;
import com.imutil.entity.UserMapping;
import com.imutil.mapper.GroupMappingMapper;
import com.imutil.mapper.ImMessageMapper;
import com.imutil.mapper.MigrateTaskMapper;
import com.imutil.mapper.SourceAppMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UserMappingMapper;
import com.imutil.service.SyncService;
import com.imutil.tencent.TencentImClient;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.List;
/**
* 数据同步服务实现(T17
* <p>
* 只读拉取腾讯 IM 数据到本地表,供后台按租户查看。与 MigrateService(带写回副作用)互补。
* <ul>
* <li>群组:get_appid_group_list 全量 Next 分页 + 逐群 get_group_info → upsert group_mapping</li>
* <li>群成员(→用户):遍历该租户已同步群,取 MemberList → upsert user_mapping(腾讯无全量用户接口,靠群成员反推)</li>
* <li>群消息:遍历该租户已同步群,getGroupMsg + IsFinished 滚动全量 → 写 im_message(source=SYNC)</li>
* </ul>
* 数据源密钥:sourceAppId=null 走主应用全局密钥(需指定归属租户);非空走 source_app 密钥(租户取其关联值)。
* 幂等可重跑:群/用户按 imGroupId/imUserId 唯一索引 upsert;消息按 msgKey 查重跳过。
*
* @author imutil
*/
@Slf4j
@Component
public class SyncServiceImpl implements SyncService {
@Inject
private SourceAppMapper sourceAppMapper;
@Inject
private TenantMapper tenantMapper;
@Inject
private GroupMappingMapper groupMappingMapper;
@Inject
private UserMappingMapper userMappingMapper;
@Inject
private ImMessageMapper imMessageMapper;
@Inject
private MigrateTaskMapper migrateTaskMapper;
@Inject
private TencentImClient tencentImClient;
/** 主应用全局密钥(sourceAppId=null 时用) */
@Inject("${imutil.tencent.sdkAppId:0}")
private long configSdkAppId;
@Inject("${imutil.tencent.secretKey:}")
private String configSecretKey;
/** 拉群列表每页数量(get_appid_group_list Limit */
@Inject("${imutil.sync.groupListPageSize:100}")
private int groupListPageSize;
/** 拉群消息每页条数(getGroupMsg reqMsgNumber */
@Inject("${imutil.sync.groupMsgPerPage:100}")
private int groupMsgPerPage;
/** 单群消息分页上限(防异常死循环) */
@Inject("${imutil.sync.groupMsgMaxPages:500}")
private int groupMsgMaxPages;
/** 单次同步群组数量上限(同步阻塞 HTTP,避免海量群卡死;如需全量调大) */
@Inject("${imutil.sync.maxGroups:500}")
private int maxGroups;
/** 数据源密钥解析结果 */
private static class SyncCtx {
long sdkAppId;
String secretKey;
String tenantId;
/** migrate_task.source_app_id;主应用记 0(该字段 NOT NULL,0 表示主应用) */
Long taskSourceAppId;
}
/**
* 解析数据源密钥 + 归属租户
*/
private SyncCtx resolveCtx(Long sourceAppId, String tenantId) {
SyncCtx ctx = new SyncCtx();
if (sourceAppId == null) {
if (tenantId == null || tenantId.isEmpty()) {
throw new BizException("主应用同步需指定归属租户");
}
Tenant t = tenantMapper.selectById(tenantId);
if (t == null) {
throw new BizException("租户不存在: " + tenantId);
}
ctx.sdkAppId = configSdkAppId;
ctx.secretKey = configSecretKey;
ctx.tenantId = tenantId;
ctx.taskSourceAppId = 0L;
} else {
SourceApp app = sourceAppMapper.selectById(sourceAppId);
if (app == null) {
throw new BizException("老应用不存在");
}
ctx.sdkAppId = Long.parseLong(app.getSdkAppId());
ctx.secretKey = app.getSecretKey();
ctx.tenantId = app.getTenantId();
ctx.taskSourceAppId = sourceAppId;
}
if (ctx.sdkAppId <= 0 || ctx.secretKey == null || ctx.secretKey.isEmpty()) {
throw new BizException("数据源密钥未配置");
}
return ctx;
}
private MigrateTask startTask(SyncCtx ctx, String objectType) {
MigrateTask task = new MigrateTask();
task.setSourceAppId(ctx.taskSourceAppId);
task.setObjectType(objectType);
task.setStatus(1);
task.setStartedAt(OffsetDateTime.now());
migrateTaskMapper.insert(task);
return task;
}
// ==================== 同步群组 ====================
@Override
public MigrateTask syncGroups(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "GROUP_SYNC");
try {
int total = 0;
int upserted = 0;
String next = "";
int guard = 0;
// Next 游标分页拉全量群,直到返回 Next 为空
while (guard++ < 100000) {
String resp = tencentImClient.getAppidGroupList(groupListPageSize, next, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
throw new BizException("拉群组列表失败: " + safeErr(root));
}
ONode groupIds = root.get("GroupIdList");
int n = groupIds.size();
for (int i = 0; i < n; i++) {
// 实测 GroupIdList 元素为对象 {GroupId:"..."},兼容字符串形态
ONode gidNode = groupIds.get(i);
String groupId = gidNode.isObject() ? gidNode.get("GroupId").getString() : gidNode.getString();
if (groupId == null || groupId.isEmpty()) {
continue;
}
total++;
try {
if (upsertGroupFromRemote(ctx, groupId)) {
upserted++;
}
} catch (Exception ge) {
log.warn("同步单群失败 {} : {}", groupId, ge.toString());
}
// 达上限停止(for 内精确限制,避免单页海量群卡死同步 HTTP)
if (total >= maxGroups) {
log.warn("群组同步达上限 {},停止(调大 imutil.sync.maxGroups 可继续)", maxGroups);
break;
}
}
task.setTotalCount((long) total);
task.setProcessedCount((long) upserted);
task.setPosCursor("page:" + guard);
migrateTaskMapper.updateById(task);
// 达上限停止(由 for 内触发并告警,此处仅跳出 while)
if (total >= maxGroups) {
break;
}
// Next 游标推进;为空或未变化则结束(防死循环)
String respNext = root.get("Next").getString();
if (respNext == null || respNext.isEmpty() || respNext.equals(next)) {
break;
}
next = respNext;
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"total\":%d,\"upserted\":%d}", total, upserted));
migrateTaskMapper.updateById(task);
log.info("群组同步完成 tenant={} total={} upserted={}", ctx.tenantId, total, upserted);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群组同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* 拉单群资料 → upsert group_mapping
*
* @return 是否成功处理(已记录为 true)
*/
private boolean upsertGroupFromRemote(SyncCtx ctx, String groupId) {
String resp = tencentImClient.getGroupInfo(groupId, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
return false;
}
ONode groupInfo = root.get("GroupInfo");
if (groupInfo.size() == 0) {
return false;
}
ONode g = groupInfo.get(0);
String name = g.get("Name").getString();
String type = g.get("Type").getString();
String owner = g.get("Owner_Account").getString();
long memberNum = g.get("MemberNum").getLong();
OffsetDateTime now = OffsetDateTime.now();
GroupMapping exist = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getImGroupId, groupId).last("LIMIT 1"));
if (exist != null) {
exist.setTenantId(ctx.tenantId);
exist.setBizGroupId(groupId);
exist.setGroupType(type != null && !type.isEmpty() ? type : "Public");
exist.setName(name);
exist.setOwnerAccount(owner);
exist.setMemberCount((int) memberNum);
exist.setLastSyncedAt(now);
groupMappingMapper.updateById(exist);
} else {
GroupMapping gm = new GroupMapping();
gm.setTenantId(ctx.tenantId);
gm.setBizGroupId(groupId);
gm.setImGroupId(groupId);
gm.setGroupType(type != null && !type.isEmpty() ? type : "Public");
gm.setName(name);
gm.setOwnerAccount(owner);
gm.setMemberCount((int) memberNum);
gm.setLastSyncedAt(now);
groupMappingMapper.insert(gm);
}
return true;
}
// ==================== 同步群成员 → 用户 ====================
@Override
public MigrateTask syncGroupMembers(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "USER_SYNC");
try {
List<GroupMapping> groups = groupMappingMapper.selectList(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getTenantId, ctx.tenantId));
task.setTotalCount((long) groups.size());
migrateTaskMapper.updateById(task);
int memberSeen = 0;
int upserted = 0;
for (int gi = 0; gi < groups.size(); gi++) {
String groupId = groups.get(gi).getImGroupId();
try {
String resp = tencentImClient.getGroupInfo(groupId, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
continue;
}
ONode groupInfo = root.get("GroupInfo");
if (groupInfo.size() == 0) {
continue;
}
ONode memberList = groupInfo.get(0).get("MemberList");
int mc = memberList.size();
for (int j = 0; j < mc; j++) {
ONode mem = memberList.get(j);
String accountId = mem.get("Member_Account").getString();
if (accountId == null || accountId.isEmpty()) {
continue;
}
memberSeen++;
String nick = mem.get("Name").getString();
if (upsertUser(ctx, accountId, nick)) {
upserted++;
}
}
} catch (Exception ge) {
log.warn("同步群成员失败 {} : {}", groupId, ge.getMessage());
}
task.setProcessedCount((long) (gi + 1));
task.setPosCursor("group:" + (gi + 1));
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"groups\":%d,\"memberSeen\":%d,\"upserted\":%d}",
groups.size(), memberSeen, upserted));
migrateTaskMapper.updateById(task);
log.info("群成员同步完成 tenant={} groups={} memberSeen={} upserted={}",
ctx.tenantId, groups.size(), memberSeen, upserted);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群成员同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* upsert user_mapping(按 im_user_id 唯一索引)
*
* @return 是否为本次新增
*/
private boolean upsertUser(SyncCtx ctx, String imUserId, String nick) {
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getImUserId, imUserId).last("LIMIT 1"));
OffsetDateTime now = OffsetDateTime.now();
if (exist != null) {
exist.setNick(nick);
exist.setLastSyncedAt(now);
if (exist.getStatus() == null) {
exist.setStatus(1);
}
userMappingMapper.updateById(exist);
return false;
}
UserMapping um = new UserMapping();
um.setTenantId(ctx.tenantId);
// 同步场景无业务侧 ID,用 IM ID 兜底(biz_user_id 仅做本地标识)
um.setBizUserId(imUserId);
um.setImUserId(imUserId);
um.setIsDefault(false);
um.setIsGlobal(false);
um.setStatus(1);
um.setNick(nick);
um.setLastSyncedAt(now);
userMappingMapper.insert(um);
return true;
}
// ==================== 同步群消息 ====================
@Override
public MigrateTask syncGroupMessages(Long sourceAppId, String tenantId) {
SyncCtx ctx = resolveCtx(sourceAppId, tenantId);
MigrateTask task = startTask(ctx, "GMSG_SYNC");
try {
List<GroupMapping> groups = groupMappingMapper.selectList(Wrappers.<GroupMapping>lambdaQuery()
.eq(GroupMapping::getTenantId, ctx.tenantId));
task.setTotalCount((long) groups.size());
migrateTaskMapper.updateById(task);
int imported = 0;
for (int gi = 0; gi < groups.size(); gi++) {
String groupId = groups.get(gi).getImGroupId();
try {
long reqSeq = 0;
int pages = 0;
// IsFinished 滚动全量:reqSeq 取本页最旧 seq 继续向前
while (pages++ < groupMsgMaxPages) {
String resp = tencentImClient.getGroupMsgAs(groupId, groupMsgPerPage, reqSeq, ctx.sdkAppId, ctx.secretKey);
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
break;
}
ONode rspList = root.get("RspMsgList");
int n = rspList.size();
long oldestSeq = Long.MAX_VALUE; // 本页最小 seq(最旧)
for (int i = 0; i < n; i++) {
ONode m = rspList.get(i);
long seq = m.get("MsgSeq").getLong();
if (seq > 0 && seq < oldestSeq) {
oldestSeq = seq;
}
ImMessage msg = parseGroupMsgForSync(m, ctx.tenantId, groupId);
if (msg == null) {
continue;
}
// 幂等:本地已存在跳过
long cnt = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
if (cnt > 0) {
continue;
}
imMessageMapper.insert(msg);
imported++;
}
boolean finished = root.get("IsFinished").getLong() == 1;
// 终止:已拉完 / 空页 / 本页无有效 seq
if (finished || n == 0 || oldestSeq == Long.MAX_VALUE) {
break;
}
// 游标未向更旧方向推进则停(占位消息群 IsFinished 恒为 2,靠此兜底防死循环)
if (reqSeq > 0 && oldestSeq >= reqSeq) {
break;
}
reqSeq = oldestSeq;
}
} catch (Exception ge) {
log.warn("同步群消息失败 {} : {}", groupId, ge.getMessage());
}
task.setProcessedCount((long) imported);
task.setPosCursor("group:" + (gi + 1));
migrateTaskMapper.updateById(task);
}
task.setStatus(3);
task.setFinishedAt(OffsetDateTime.now());
task.setVerifyResult(String.format("{\"groups\":%d,\"imported\":%d}", groups.size(), imported));
migrateTaskMapper.updateById(task);
log.info("群消息同步完成 tenant={} groups={} imported={}", ctx.tenantId, groups.size(), imported);
return task;
} catch (Exception e) {
failTask(task, e);
log.error("群消息同步失败 tenant={}", ctx.tenantId, e);
throw e;
}
}
/**
* 解析腾讯群消息节点为 ImMessagesource=SYNCdist_status=1 不进分发)
*/
private ImMessage parseGroupMsgForSync(ONode m, String tenantId, String groupId) {
// 过滤占位/系统消息(IsPlaceMsg=1From_Account 与 MsgBody 均空,非真实消息,不入库)
if (m.get("IsPlaceMsg").getLong() == 1) {
return null;
}
long msgSeq = m.get("MsgSeq").getLong();
long msgRandom = m.get("MsgRandom").getLong();
long msgTs = m.get("MsgTimeStamp").getLong();
String from = m.get("From_Account").getString();
ONode body = m.get("MsgBody");
ImMessage msg = new ImMessage();
msg.setMsgKey(MsgKeys.build(from, groupId, msgSeq, msgRandom));
msg.setTenantId(tenantId);
msg.setMsgTime(toTime(msgTs));
msg.setConvType(2);
msg.setConvId(groupId);
msg.setFromAccount(from);
msg.setGroupId(groupId);
msg.setMsgType(firstBodyType(body));
msg.setMsgBody(body.toString());
msg.setSource("SYNC");
msg.setIsCrossTenant(false);
msg.setDistStatus(1);
return msg;
}
// ==================== 工具方法 ====================
private void failTask(MigrateTask task, Exception e) {
task.setStatus(4);
task.setErrorMsg(e.getClass().getSimpleName() + ": " + e.getMessage());
task.setFinishedAt(OffsetDateTime.now());
migrateTaskMapper.updateById(task);
}
private String safeErr(ONode root) {
try {
String info = root.get("ErrorInfo").getString();
return info == null ? "" : info;
} catch (Exception e) {
return "";
}
}
private String firstBodyType(ONode body) {
if (body == null || !body.isArray() || body.size() == 0) {
return null;
}
return body.get(0).get("MsgType").getString();
}
private OffsetDateTime toTime(long epochSecond) {
if (epochSecond <= 0) {
return OffsetDateTime.now();
}
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault());
}
}