package com.imutil.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.imutil.common.MsgKeys; import com.imutil.entity.DistQueue; import com.imutil.entity.GroupMapping; import com.imutil.entity.ImMessage; import com.imutil.entity.Tenant; import com.imutil.mapper.DistQueueMapper; import com.imutil.mapper.GroupMappingMapper; import com.imutil.mapper.ImMessageMapper; import com.imutil.mapper.PullWatermarkMapper; import com.imutil.service.CallbackService; import com.imutil.service.TenantService; import lombok.extern.slf4j.Slf4j; import org.noear.snack4.ONode; import org.noear.solon.annotation.Component; import org.noear.solon.annotation.Inject; import org.noear.solon.data.annotation.Tran; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; /** * 腾讯回调处理服务实现 *

* 流程:识别租户 → 消息类回调落 im_message(幂等)→ 所有回调写 dist_queue 分发业务系统。 * {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。 * * @author imutil */ @Slf4j @Component public class CallbackServiceImpl implements CallbackService { /** 消息类回调命令关键字(命中则额外落 im_message) */ private static final String[] MSG_COMMAND_KEYWORDS = {"SendMsg", "RecvMsg"}; @Inject private ImMessageMapper imMessageMapper; @Inject private DistQueueMapper distQueueMapper; @Inject private GroupMappingMapper groupMappingMapper; @Inject private PullWatermarkMapper pullWatermarkMapper; @Inject private TenantService tenantService; @Override @Tran public String handleCallback(String callbackCommand, String body) { if (callbackCommand == null || callbackCommand.isEmpty()) { return ok(); } ONode node; try { node = ONode.ofJson(body == null ? "{}" : body); } catch (Exception e) { log.warn("回调 body 解析失败 command={} : {}", callbackCommand, e.getMessage()); return ok(); } // 1. 识别租户 String tenantId = identifyTenant(callbackCommand, node); if (tenantId == null) { // 无法识别租户(如腾讯系统消息 administrator),不落库不分发 log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("FromAccount").getString()); return ok(); } // 2. 消息类回调落库 im_message(幂等:msg_key 存在则跳过) // msg_key = command:from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键; // 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。 // DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。 String msgKey = null; if (isMessageCallback(callbackCommand)) { ImMessage msg = parseMessage(callbackCommand, node, tenantId); if (msg != null) { msgKey = msg.getMsgKey(); long exists = imMessageMapper.selectCount(Wrappers.lambdaQuery() .eq(ImMessage::getMsgKey, msg.getMsgKey())); if (exists == 0) { imMessageMapper.insert(msg); // 推进补拉水位线:记录该会话最新消息位置,补拉任务据此增量拉取(兜底回调丢失) pullWatermarkMapper.upsert(tenantId, msg.getConvId(), msg.getConvType(), node.get("MsgSeq").getLong(), msg.getMsgTime(), OffsetDateTime.now()); } } } // 3. 写分发队列(payload=原始回调体,供业务系统消费) Tenant tenant = tenantService.getById(tenantId); if (tenant != null && tenant.getCallbackUrl() != null && !tenant.getCallbackUrl().isEmpty()) { DistQueue q = new DistQueue(); q.setMsgKey(msgKey); q.setTenantId(tenantId); q.setConvId(extractConvId(callbackCommand, node)); q.setTargetUrl(tenant.getCallbackUrl()); q.setPayload(body); q.setStatus(0); q.setRetryCount(0); q.setNextRetryAt(OffsetDateTime.now()); distQueueMapper.insert(q); } return ok(); } /** * 识别租户:优先 FromAccount 前缀,群回调用 GroupId 查 group_mapping * * @return 租户ID,识别失败返回 null */ private String identifyTenant(String command, ONode node) { String from = node.get("FromAccount").getString(); if (from != null && !from.isEmpty()) { String tenantId = parsePrefix(from); if (tenantId != null && tenantService.getById(tenantId) != null) { return tenantId; } } // 群回调:GroupId 查映射 String groupId = node.get("GroupId").getString(); if (groupId != null && !groupId.isEmpty()) { GroupMapping gm = groupMappingMapper.selectOne(Wrappers.lambdaQuery() .eq(GroupMapping::getImGroupId, groupId).last("LIMIT 1")); if (gm != null) { return gm.getTenantId(); } // 群ID也带前缀 String tid = parsePrefix(groupId); if (tid != null && tenantService.getById(tid) != null) { return tid; } } return null; } /** * 从带前缀的 IM 标识提取租户码(第一个下划线前部分) */ private String parsePrefix(String imId) { if (imId == null || imId.isEmpty()) { return null; } int idx = imId.indexOf('_'); if (idx <= 0) { return null; } return imId.substring(0, idx); } /** * 是否为消息类回调(需落 im_message) */ private boolean isMessageCallback(String command) { for (String kw : MSG_COMMAND_KEYWORDS) { if (command.contains(kw)) { return true; } } return false; } /** * 解析消息体,构造 ImMessage(含幂等键 msg_key) */ private ImMessage parseMessage(String command, ONode node, String tenantId) { String from = node.get("FromAccount").getString(); String to = node.get("ToAccount").getString(); if (to == null || to.isEmpty()) { to = node.get("ToPlayerId").getString(); } String groupId = node.get("GroupId").getString(); long msgSeq = node.get("MsgSeq").getLong(); long msgRandom = node.get("MsgRandom").getLong(); long msgTs = node.get("MsgTimeStamp").getLong(); if (msgTs == 0) { msgTs = System.currentTimeMillis() / 1000L; } OffsetDateTime msgTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(msgTs), ZoneId.systemDefault()); // msg_key 组合保证全局唯一与幂等(与补拉路径共享 MsgKeys 算法,跨路径去重) String convTarget = (groupId != null && !groupId.isEmpty()) ? groupId : (to != null ? to : ""); String msgKey = MsgKeys.build(from, convTarget, msgSeq, msgRandom); ImMessage msg = new ImMessage(); msg.setMsgKey(msgKey); msg.setTenantId(tenantId); msg.setMsgTime(msgTime); msg.setConvType(groupId != null && !groupId.isEmpty() ? 2 : 1); msg.setConvId(groupId != null && !groupId.isEmpty() ? groupId : (to != null ? to : "")); msg.setFromAccount(from); msg.setToAccount(groupId != null && !groupId.isEmpty() ? null : to); msg.setGroupId(groupId); msg.setMsgType(node.get("MsgType").getString()); msg.setMsgBody(node.get("MsgBody").toString()); msg.setSource("CALLBACK"); msg.setIsCrossTenant(false); msg.setDistStatus(0); return msg; } /** * 提取会话ID(用于 dist_queue 保序分桶) */ private String extractConvId(String command, ONode node) { String groupId = node.get("GroupId").getString(); if (groupId != null && !groupId.isEmpty()) { return groupId; } String to = node.get("ToAccount").getString(); if (to == null || to.isEmpty()) { to = node.get("ToPlayerId").getString(); } return to; } /** * 返回腾讯回调成功响应 */ private String ok() { return "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}"; } }