feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉

- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token
- 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信
- 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id
- 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面
- 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失
- msg_key 统一算法(回调/补拉共享, 跨路径去重)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yixiong
2026-07-08 20:28:32 +08:00
co-authored by Claude
parent 20bd78baa8
commit d22fdcfaad
80 changed files with 4955 additions and 0 deletions
@@ -0,0 +1,231 @@
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;
/**
* 腾讯回调处理服务实现
* <p>
* 流程:识别租户 → 消息类回调落 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.<ImMessage>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.<GroupMapping>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\":\"\"}";
}
}