Files
tencent-im-util/src/main/java/com/imutil/service/impl/PullServiceImpl.java
T
yixiongandClaude d22fdcfaad 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>
2026-07-08 20:28:32 +08:00

336 lines
12 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.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());
}
}