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,335 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.MsgKeys;
import com.imutil.common.TenantContext;
import com.imutil.entity.DistQueue;
import com.imutil.entity.ImMessage;
import com.imutil.entity.PullWatermark;
import com.imutil.entity.Tenant;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.mapper.ImMessageMapper;
import com.imutil.mapper.PullWatermarkMapper;
import com.imutil.service.PullService;
import com.imutil.service.TenantService;
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;
/**
* 历史消息补拉服务实现
* <p>
* 流程:取最久未补拉的 N 个会话 → 按会话类型调腾讯 API 拉最新消息 →
* 逐条 msg_key 幂等(与回调路径共享 {@link MsgKeys} 算法)→ 本地不存在则落库(source=PULL_BACK)+入队分发 → 推进水位线。
* <p>
* 仅"本次新插入"的消息入队分发,避免对回调已正常落库的消息重复推送业务系统。
* 每轮串行执行 + 会话数/条数上限,受腾讯 API QPS 约束。
* <p>
* C2C 会话需 from+to 配对,水位线仅存对端(conv_id),本端取该会话最近一条消息的 from_account
* 多发送方场景仅覆盖最近一个 from(已知限制,由腾讯 get_roam_msg 成对特性决定)。
*
* @author imutil
*/
@Slf4j
@Component
public class PullServiceImpl implements PullService {
@Inject
private PullWatermarkMapper pullWatermarkMapper;
@Inject
private ImMessageMapper imMessageMapper;
@Inject
private DistQueueMapper distQueueMapper;
@Inject
private TencentImClient tencentImClient;
@Inject
private TenantService tenantService;
@Inject("${imutil.pull.convsPerRound:20}")
private int convsPerRound;
@Inject("${imutil.pull.maxMsgPerConv:20}")
private int maxMsgPerConv;
@Inject("${imutil.pull.lookbackMinutes:30}")
private int lookbackMinutes;
@Override
public void pullRound() {
List<PullWatermark> convs = pullWatermarkMapper.selectNeedCheck(convsPerRound);
if (convs.isEmpty()) {
return;
}
int totalNew = 0;
for (PullWatermark wm : convs) {
try {
totalNew += pullOne(wm);
} catch (Throwable e) {
// 单会话异常不影响其他会话;仍推进 updated_at 避免该会话一直排在前
log.warn("补拉会话异常 tenant={} conv={} : {}", wm.getTenantId(), wm.getConvId(), e.toString());
advanceWatermark(wm, 0, null);
}
}
log.info("补拉一轮完成 会话数={} 新增消息={}", convs.size(), totalNew);
}
/**
* 补拉单个会话:设置租户上下文(供腾讯 API 审计)→ 按会话类型分发
*/
private int pullOne(PullWatermark wm) {
TenantContext.set(wm.getTenantId());
try {
if (wm.getConvType() != null && wm.getConvType() == 2) {
return pullGroup(wm);
}
return pullC2C(wm);
} finally {
TenantContext.clear();
}
}
/**
* 补拉群消息:getGroupMsg 取最新 N 条
*/
private int pullGroup(PullWatermark wm) {
String resp = tencentImClient.getGroupMsg(wm.getConvId(), maxMsgPerConv);
ONode list = extractMsgList(resp, "RspMsgList");
if (list == null) {
advanceWatermark(wm, 0, null);
return 0;
}
return saveMessages(wm, list, true);
}
/**
* 补拉单聊消息:getRoamMsg 按时间窗拉取,本端取该会话最近 from
*/
private int pullC2C(PullWatermark wm) {
String to = wm.getConvId();
String from = latestFrom(wm.getTenantId(), to);
if (from == null) {
// 无历史记录无法确定本端账号,跳过本轮
advanceWatermark(wm, 0, null);
return 0;
}
long maxInterval = lookbackMinutes * 60L;
long minTime = Instant.now().getEpochSecond() - maxInterval;
String resp = tencentImClient.getRoamMsg(from, to, maxMsgPerConv, minTime, maxInterval);
ONode list = extractMsgList(resp, "MsgList");
if (list == null) {
advanceWatermark(wm, 0, null);
return 0;
}
return saveMessages(wm, list, false);
}
/**
* 逐条幂等落库 + 入队分发,返回新增条数
*
* @param wm 会话水位线
* @param list 消息数组节点
* @param isGroup 是否群消息(决定解析方式)
*/
private int saveMessages(PullWatermark wm, ONode list, boolean isGroup) {
long maxSeq = 0;
OffsetDateTime maxTime = null;
int newCount = 0;
for (int i = 0, sz = list.size(); i < sz; i++) {
ONode m = list.get(i);
ImMessage msg = isGroup ? parseGroupMsg(m, wm.getTenantId()) : parseC2CMsg(m, wm.getTenantId());
if (msg == null) {
continue;
}
// 跟踪本轮最大 seq/time 用于推进水位线
long seq = m.get("MsgSeq").getLong();
if (seq > maxSeq) {
maxSeq = seq;
}
if (msg.getMsgTime() != null && (maxTime == null || msg.getMsgTime().isAfter(maxTime))) {
maxTime = msg.getMsgTime();
}
// 幂等:本地已存在则跳过(不重复分发)
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
if (exists > 0) {
continue;
}
imMessageMapper.insert(msg);
newCount++;
// 仅新插入的消息入队分发
enqueue(wm.getTenantId(), msg);
}
advanceWatermark(wm, maxSeq, maxTime);
return newCount;
}
/**
* 写入分发队列(复用回调分发链路,payload 用结构化 JSON
*/
private void enqueue(String tenantId, ImMessage msg) {
Tenant t = tenantService.getById(tenantId);
if (t == null || t.getCallbackUrl() == null || t.getCallbackUrl().isEmpty()) {
return;
}
ONode payload = ONode.ofJson("{}");
payload.set("source", msg.getSource());
payload.set("msgKey", msg.getMsgKey());
payload.set("tenantId", tenantId);
payload.set("convType", msg.getConvType());
payload.set("convId", msg.getConvId());
payload.set("fromAccount", msg.getFromAccount());
payload.set("toAccount", msg.getToAccount());
payload.set("groupId", msg.getGroupId());
payload.set("msgTime", msg.getMsgTime());
payload.set("msgType", msg.getMsgType());
if (msg.getMsgBody() != null) {
try {
payload.set("msgBody", ONode.ofJson(msg.getMsgBody()));
} catch (Exception e) {
payload.set("msgBody", msg.getMsgBody());
}
}
DistQueue q = new DistQueue();
q.setMsgKey(msg.getMsgKey());
q.setTenantId(tenantId);
q.setConvId(msg.getConvId());
q.setTargetUrl(t.getCallbackUrl());
q.setPayload(payload.toJson());
q.setStatus(0);
q.setRetryCount(0);
q.setNextRetryAt(OffsetDateTime.now());
distQueueMapper.insert(q);
}
/**
* 解析群消息节点为 ImMessage
*/
private ImMessage parseGroupMsg(ONode m, String tenantId) {
String groupId = m.get("GroupId").getString();
if (groupId == null || groupId.isEmpty()) {
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("PULL_BACK");
msg.setIsCrossTenant(false);
msg.setDistStatus(0);
return msg;
}
/**
* 解析单聊消息节点为 ImMessage
*/
private ImMessage parseC2CMsg(ONode m, String tenantId) {
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();
String to = m.get("To_Account").getString();
ONode body = m.get("MsgBody");
ImMessage msg = new ImMessage();
msg.setMsgKey(MsgKeys.build(from, to, msgSeq, msgRandom));
msg.setTenantId(tenantId);
msg.setMsgTime(toTime(msgTs));
msg.setConvType(1);
msg.setConvId(to != null ? to : "");
msg.setFromAccount(from);
msg.setToAccount(to);
msg.setMsgType(firstBodyType(body));
msg.setMsgBody(body.toString());
msg.setSource("PULL_BACK");
msg.setIsCrossTenant(false);
msg.setDistStatus(0);
return msg;
}
/**
* 推进水位线(last_seq/last_time 取本轮最大,updated_at 始终刷新驱动轮询顺序)
*/
private void advanceWatermark(PullWatermark wm, long maxSeq, OffsetDateTime maxTime) {
int convType = wm.getConvType() == null ? 1 : wm.getConvType();
pullWatermarkMapper.upsert(wm.getTenantId(), wm.getConvId(), convType, maxSeq, maxTime, OffsetDateTime.now());
}
/**
* 查该 C2C 会话最近发送方(配对 get_roam_msg 的本端账号)
*/
private String latestFrom(String tenantId, String to) {
ImMessage latest = imMessageMapper.selectOne(Wrappers.<ImMessage>lambdaQuery()
.eq(ImMessage::getTenantId, tenantId)
.eq(ImMessage::getConvId, to)
.isNotNull(ImMessage::getFromAccount)
.ne(ImMessage::getFromAccount, "")
.orderByDesc(ImMessage::getMsgTime)
.last("LIMIT 1"));
return latest == null ? null : latest.getFromAccount();
}
/**
* 解析腾讯响应,校验 ActionStatus=OK 并返回消息数组节点;失败/空返回 null
*
* @param resp 腾讯原始响应 JSON
* @param listKey 消息列表字段名(C2C=MsgListGROUP=RspMsgList
*/
private ONode extractMsgList(String resp, String listKey) {
if (resp == null || resp.isEmpty()) {
return null;
}
try {
ONode root = ONode.ofJson(resp);
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
log.warn("补拉腾讯API返回非OK : {}", resp);
return null;
}
ONode list = root.get(listKey);
return list.isArray() ? list : null;
} catch (Exception e) {
log.warn("补拉响应解析失败 : {}", e.toString());
return null;
}
}
/**
* 取消息体首个元素的类型(MsgBody 为数组,如 TIMTextElem/TIMImageElem
*/
private String firstBodyType(ONode body) {
if (body == null || !body.isArray() || body.size() == 0) {
return null;
}
return body.get(0).get("MsgType").getString();
}
/**
* 秒级时间戳转 OffsetDateTime0/负数兜底为当前时间)
*/
private OffsetDateTime toTime(long epochSecond) {
if (epochSecond <= 0) {
return OffsetDateTime.now();
}
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault());
}
}