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,25 @@
package com.imutil.service;
import com.imutil.entity.AdminUser;
/**
* 管理后台用户服务
* <p>
* 登录校验(PBKDF2)+ 首次启动默认管理员初始化。
*
* @author imutil
*/
public interface AdminUserService {
/**
* 登录校验
*
* @return 凭证正确且账号启用返回 AdminUser,否则 null
*/
AdminUser login(String username, String password);
/**
* 应用启动时确保存在至少一个管理员(表空则按 app.yml imutil.admin 初始化)
*/
void ensureDefaultAdmin();
}
@@ -0,0 +1,18 @@
package com.imutil.service;
/**
* 腾讯回调处理服务
*
* @author imutil
*/
public interface CallbackService {
/**
* 处理腾讯回调:落库消息(消息类回调)+ 写分发队列
*
* @param callbackCommand 回调命令,如 C2C.CallbackAfterRecvMsg
* @param body 回调请求体 JSON
* @return 处理结果,OK/FAIL
*/
String handleCallback(String callbackCommand, String body);
}
@@ -0,0 +1,35 @@
package com.imutil.service;
import com.imutil.entity.DistQueue;
import java.util.List;
/**
* 回调分发服务
* <p>
* 从 dist_queue 抢占待分发记录,HTTP 转发业务系统,按回执更新状态。
*
* @author imutil
*/
public interface DispatchService {
/**
* 抢占一批 pending 记录置为 processing(事务内 FOR UPDATE SKIP LOCKED + lock
*
* @param workerName 工作线程标识
* @return 抢占到的记录列表
*/
List<DistQueue> fetchAndLock(String workerName);
/**
* 分发单条记录:HTTP 转发 → 回执成功 markDone / 失败重试或死信
*/
void dispatch(DistQueue task, String workerName);
/**
* 巡检:重置超时未回执的 processing 回到 pending(工作线程宕机恢复)
*
* @return 重置条数
*/
int recoverStuck();
}
@@ -0,0 +1,18 @@
package com.imutil.service;
/**
* 历史消息补拉服务
* <p>
* 回调链路的兜底:腾讯回调丢失或本工具落库失败时,定期主动从腾讯拉取最新消息补全本地,
* 保证 im_message 不丢、业务系统不漏收(对应「已知问题」第 3 条)。
*
* @author imutil
*/
public interface PullService {
/**
* 执行一轮补拉:取最久未补拉的若干会话,逐个调腾讯 API 拉最新消息,
* msg_key 幂等落库(source=PULL_BACK)并入队分发,最后推进水位线
*/
void pullRound();
}
@@ -0,0 +1,29 @@
package com.imutil.service;
import com.imutil.entity.Tenant;
/**
* 租户服务
*
* @author imutil
*/
public interface TenantService {
/**
* 根据 app_key 查询租户(带缓存)
*
* @param appKey 业务系统凭证 key
* @return 租户实体,不存在返回 null
*/
Tenant getByAppKey(String appKey);
/**
* 根据 tenant_id 查询租户(带缓存)
*/
Tenant getById(String tenantId);
/**
* 失效租户缓存(租户变更时调用)
*/
void evictCache(String tenantId);
}
@@ -0,0 +1,23 @@
package com.imutil.service;
import java.time.OffsetDateTime;
/**
* 用量统计服务
* <p>
* 按小时窗口从 im_message / api_call_log 聚合用量到 usage_stat(计费拆账依据)。
*
* @author imutil
*/
public interface UsageStatService {
/**
* 聚合某小时窗口 [start, end) 的用量,upsert 到 usage_statstat_level=1
* <p>
* 幂等:ON CONFLICT,重复跑不产生重复数据,取最新聚合值。
*
* @param start 窗口起点(含,整点)
* @param end 窗口终点(不含)
*/
void aggregateHour(OffsetDateTime start, OffsetDateTime end);
}
@@ -0,0 +1,25 @@
package com.imutil.service;
/**
* 账号映射服务
* <p>
* 维护业务用户ID ↔ IM UserID(带租户前缀)映射,首次使用时调腾讯 account_import 创建 IM 账号。
*
* @author imutil
*/
public interface UserMappingService {
/**
* 获取或创建 IM 用户ID
* <p>
* 不存在则调腾讯 account_import 创建 IM 账号并写映射;存在则直接返回。
* im_user_id = tenant_id + '_' + biz_user_id(前缀法保证跨租户不撞号)。
*
* @param tenantId 租户ID(即前缀码)
* @param bizUserId 业务用户ID
* @param nick 昵称(首次创建时同步到 IM,可选)
* @param faceUrl 头像(可选)
* @return IM 用户ID
*/
String getOrCreate(String tenantId, String bizUserId, String nick, String faceUrl);
}
@@ -0,0 +1,77 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.PasswordUtil;
import com.imutil.entity.AdminUser;
import com.imutil.mapper.AdminUserMapper;
import com.imutil.service.AdminUserService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Init;
import org.noear.solon.annotation.Inject;
/**
* 管理后台用户服务实现
* <p>
* 密码用 {@link PasswordUtil}PBKDF2)哈希存储与校验。
* {@code @Init} 在容器启动后检查 admin_user 表,为空则按 app.yml imutil.admin 初始化默认管理员。
*
* @author imutil
*/
@Slf4j
@Component
public class AdminUserServiceImpl implements AdminUserService {
@Inject
private AdminUserMapper adminUserMapper;
@Inject("${imutil.admin.defaultUsername:admin}")
private String defaultUsername;
@Inject("${imutil.admin.defaultPassword:admin123}")
private String defaultPassword;
/**
* 应用启动后初始化默认管理员(仅当表为空)
*/
@Init
public void init() {
ensureDefaultAdmin();
}
@Override
public AdminUser login(String username, String password) {
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
return null;
}
AdminUser u = adminUserMapper.selectOne(Wrappers.<AdminUser>lambdaQuery()
.eq(AdminUser::getUsername, username)
.last("LIMIT 1"));
if (u == null) {
return null;
}
// 停用账号拒绝
if (u.getStatus() == null || u.getStatus() != 1) {
return null;
}
if (!PasswordUtil.verify(password, u.getPasswordHash())) {
return null;
}
return u;
}
@Override
public void ensureDefaultAdmin() {
Long cnt = adminUserMapper.selectCount(null);
if (cnt != null && cnt > 0) {
return;
}
AdminUser u = new AdminUser();
u.setUsername(defaultUsername);
u.setPasswordHash(PasswordUtil.hash(defaultPassword));
u.setRole("admin");
u.setStatus(1);
adminUserMapper.insert(u);
log.info("已初始化默认管理员账号: {}(请尽快修改默认密码)", defaultUsername);
}
}
@@ -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\":\"\"}";
}
}
@@ -0,0 +1,94 @@
package com.imutil.service.impl;
import com.imutil.common.Httpx;
import com.imutil.entity.DistQueue;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.service.DispatchService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.data.annotation.Tran;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 回调分发服务实现
* <p>
* 抢占消费:{@code @Tran} 内 fetchPending(FOR UPDATE SKIP LOCKED) + lock,提交后释放行锁,
* 记录置 processing;随后 HTTP 转发业务系统,按回执更新 done/retry/dead。
* 失败采用指数退避:backoff = retryBaseMs * 2^min(retryCount, 6)。
*
* @author imutil
*/
@Slf4j
@Component
public class DispatchServiceImpl implements DispatchService {
@Inject
private DistQueueMapper distQueueMapper;
@Inject("${imutil.dispatch.fetchBatch:50}")
private int fetchBatch;
@Inject("${imutil.dispatch.maxRetry:5}")
private int maxRetry;
@Inject("${imutil.dispatch.retryBaseMs:2000}")
private long retryBaseMs;
@Inject("${imutil.dispatch.lockTimeoutMin:3}")
private int lockTimeoutMin;
@Override
@Tran
public List<DistQueue> fetchAndLock(String workerName) {
OffsetDateTime now = OffsetDateTime.now();
List<DistQueue> pending = distQueueMapper.fetchPending(now, fetchBatch);
List<DistQueue> locked = new ArrayList<>(pending.size());
for (DistQueue q : pending) {
int n = distQueueMapper.lock(q.getId(), workerName, now);
if (n > 0) {
q.setLockedBy(workerName);
locked.add(q);
}
}
return locked;
}
@Override
public void dispatch(DistQueue task, String workerName) {
OffsetDateTime now = OffsetDateTime.now();
try {
int code = Httpx.postJson(task.getTargetUrl(), task.getPayload());
if (code >= 200 && code < 300) {
// 业务系统回执成功
distQueueMapper.markDone(task.getId(), now);
log.debug("分发成功 id={} tenant={} code={}", task.getId(), task.getTenantId(), code);
return;
}
log.warn("分发回执非2xx id={} tenant={} code={}", task.getId(), task.getTenantId(), code);
retryOrFail(task, now);
} catch (Exception e) {
log.warn("分发异常 id={} tenant={} : {}", task.getId(), task.getTenantId(), e.toString());
retryOrFail(task, now);
}
}
/**
* 重试或标记死信:retry_count+1,达上限置 dead,否则置 pending 并安排下次重试时间
*/
private void retryOrFail(DistQueue task, OffsetDateTime now) {
long backoffMs = retryBaseMs * (1L << Math.min(task.getRetryCount(), 6));
OffsetDateTime nextRetry = now.plusNanos(backoffMs * 1_000_000L);
distQueueMapper.markRetry(task.getId(), maxRetry, nextRetry, now);
}
@Override
public int recoverStuck() {
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime threshold = now.minusMinutes(lockTimeoutMin);
return distQueueMapper.recoverStuck(threshold, now);
}
}
@@ -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());
}
}
@@ -0,0 +1,99 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.LocalCache;
import com.imutil.common.RedisService;
import com.imutil.entity.Tenant;
import com.imutil.mapper.TenantMapper;
import com.imutil.service.TenantService;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.util.concurrent.TimeUnit;
/**
* 租户服务实现
* <p>
* app_key → tenant 查询走 Redis 主缓存 + Caffeine 本地兜底,降低 DB 压力。
* 缓存 TTL 5 分钟,租户变更需主动 evict。
*
* @author imutil
*/
@Component
public class TenantServiceImpl implements TenantService {
private static final String CACHE_KEY_BY_KEY = "imutil:tenant:bykey:";
private static final String CACHE_KEY_BY_ID = "imutil:tenant:byid:";
private static final long CACHE_TTL_SEC = 300;
@Inject
private TenantMapper tenantMapper;
@Inject
private RedisService redisService;
@Inject
private LocalCache localCache;
@Override
public Tenant getByAppKey(String appKey) {
if (appKey == null || appKey.isEmpty()) {
return null;
}
String key = CACHE_KEY_BY_KEY + appKey;
// 1. 本地缓存
Tenant t = localCache.get(key, Tenant.class);
if (t != null) {
return t;
}
// 2. Redis
t = redisService.getJson(key, Tenant.class);
if (t == null) {
// 3. DB
t = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery().eq(Tenant::getAppKey, appKey).last("LIMIT 1"));
if (t != null) {
redisService.setJson(key, t, CACHE_TTL_SEC, TimeUnit.SECONDS);
}
}
if (t != null) {
localCache.put(key, t);
}
return t;
}
@Override
public Tenant getById(String tenantId) {
if (tenantId == null || tenantId.isEmpty()) {
return null;
}
String key = CACHE_KEY_BY_ID + tenantId;
Tenant t = localCache.get(key, Tenant.class);
if (t != null) {
return t;
}
t = redisService.getJson(key, Tenant.class);
if (t == null) {
t = tenantMapper.selectById(tenantId);
if (t != null) {
redisService.setJson(key, t, CACHE_TTL_SEC, TimeUnit.SECONDS);
}
}
if (t != null) {
localCache.put(key, t);
}
return t;
}
@Override
public void evictCache(String tenantId) {
Tenant t = tenantMapper.selectById(tenantId);
String k1 = CACHE_KEY_BY_ID + tenantId;
redisService.del(k1);
localCache.invalidate(k1);
if (t != null) {
String k2 = CACHE_KEY_BY_KEY + t.getAppKey();
redisService.del(k2);
localCache.invalidate(k2);
}
}
}
@@ -0,0 +1,36 @@
package com.imutil.service.impl;
import com.imutil.mapper.UsageStatMapper;
import com.imutil.service.UsageStatService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.time.OffsetDateTime;
/**
* 用量统计服务实现
* <p>
* 两个数据源各自 upsert 指定列(见 {@link UsageStatMapper}),互不覆盖:
* - im_message → im_msg_count / im_dau
* - api_call_log → api_call_count
* trtc_duration_sec / trtc_max_concurrent_room 待音视频模块实现后补充。
*
* @author imutil
*/
@Slf4j
@Component
public class UsageStatServiceImpl implements UsageStatService {
@Inject
private UsageStatMapper usageStatMapper;
@Override
public void aggregateHour(OffsetDateTime start, OffsetDateTime end) {
// statTime 用窗口起点(整点),作为 usage_stat 的统计时刻
OffsetDateTime statTime = start;
int msgTenants = usageStatMapper.aggregateMsgHour(start, end, statTime);
int apiTenants = usageStatMapper.aggregateApiCallHour(start, end, statTime);
log.info("用量小时聚合完成 window=[{}, {}) msg租户数={} api租户数={}", start, end, msgTenants, apiTenants);
}
}
@@ -0,0 +1,118 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.common.BizException;
import com.imutil.entity.UserMapping;
import com.imutil.mapper.UserMappingMapper;
import com.imutil.service.UserMappingService;
import com.imutil.tencent.TencentImClient;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.data.annotation.Tran;
import java.nio.charset.StandardCharsets;
/**
* 账号映射服务实现
* <p>
* im_user_id = tenant_id + '_' + biz_user_id(前缀法)。
* 首次创建:调腾讯 account_import 导入 IM 账号 → 写 user_mapping(同事务)。
* 兼容账号已存在场景(account_import 失败但 account_check 命中则视为成功)。
*
* @author imutil
*/
@Slf4j
@Component
public class UserMappingServiceImpl implements UserMappingService {
@Inject
private UserMappingMapper userMappingMapper;
@Inject
private TencentImClient tencentImClient;
@Override
@Tran
public String getOrCreate(String tenantId, String bizUserId, String nick, String faceUrl) {
// 0. 拼接 IM UserID 并校验合法性(腾讯约束:UTF-8 ≤32字节,仅字母/数字/下划线/横线)
String imUserId = tenantId + "_" + bizUserId;
validateImUserId(imUserId);
// 1. 查映射是否存在
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getTenantId, tenantId)
.eq(UserMapping::getBizUserId, bizUserId)
.last("LIMIT 1"));
if (exist != null) {
// 封禁账号(status=0)拒绝签发 UserSig
if (exist.getStatus() != null && exist.getStatus() == 0) {
throw new BizException(403, "IM 账号已封禁: " + imUserId);
}
return exist.getImUserId();
}
// 2. 不存在则导入 IM 账号
boolean ok = tencentImClient.accountImport(imUserId, nick, faceUrl);
if (!ok) {
// 兼容账号已存在(重试/迁移场景)
if (!tencentImClient.accountCheck(imUserId)) {
throw new BizException("创建 IM 账号失败: " + imUserId);
}
log.info("IM 账号已存在,补写映射 imUserId={}", imUserId);
}
// 3. 写映射
UserMapping m = new UserMapping();
m.setTenantId(tenantId);
m.setBizUserId(bizUserId);
m.setImUserId(imUserId);
m.setIsDefault(false);
m.setIsGlobal(false);
m.setStatus(1);
userMappingMappingInsert(m);
return imUserId;
}
/**
* 插入映射,并发下唯一索引冲突则回查(幂等)
*/
private void userMappingMappingInsert(UserMapping m) {
try {
userMappingMapper.insert(m);
} catch (Exception e) {
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
.eq(UserMapping::getImUserId, m.getImUserId())
.last("LIMIT 1"));
if (exist == null) {
throw new BizException(500, "写入用户映射失败: " + m.getImUserId(), e);
}
}
}
/**
* 校验 IM UserID 合法性
* <p>
* 腾讯 IM 约束:长度 ≤ 32 字节(UTF-8),允许字母/数字/下划线/横线。
* bizUserId 由业务系统传入,可能含中文或特殊字符,需在拼出 imUserId 后前置校验,
* 避免透传腾讯后台的错误码(业务侧语意不清晰)。
*
* @param imUserId 待校验的 IM 用户ID
*/
private void validateImUserId(String imUserId) {
if (imUserId == null || imUserId.isEmpty()) {
throw new BizException(400, "IM UserID 不能为空");
}
if (imUserId.getBytes(StandardCharsets.UTF_8).length > 32) {
throw new BizException(400, "IM UserID 过长(>32字节),请缩短 bizUserId");
}
for (int i = 0; i < imUserId.length(); i++) {
char c = imUserId.charAt(i);
boolean legal = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '_' || c == '-';
if (!legal) {
throw new BizException(400, "IM UserID 含非法字符 '" + c + "',仅允许字母/数字/下划线/横线");
}
}
}
}