- CrossTenantServiceImpl: 有效期判断改用 apply("start_at <= now()"),规避
OffsetDateTime 绑定 timestamptz 比较异常(见实施记录 6.5)
- CallbackServiceImpl: 集成 C2C 跨租户授权检查(额外分发+审计+is_cross_tenant),
提取 enqueueIfConfigured
- 新增 PartitionService/Task: im_message 按月 RANGE 分区滚动建表
(当月+下月,@Init 启动自检 + 每日 0:10)
- 新增 CrossTenantService: 正反向授权匹配 + 跨租户审计
Co-Authored-By: Claude <noreply@anthropic.com>
273 lines
10 KiB
Java
273 lines
10 KiB
Java
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.CrossTenantService;
|
||
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;
|
||
|
||
@Inject
|
||
private CrossTenantService crossTenantService;
|
||
|
||
@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;
|
||
// 跨租户授权命中的对端账号(非空表示该消息为授权放行的跨租户消息,需额外分发+审计)
|
||
String crossToImUser = null;
|
||
Long crossGrantId = null;
|
||
if (isMessageCallback(callbackCommand)) {
|
||
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
||
if (msg != null) {
|
||
msgKey = msg.getMsgKey();
|
||
// 跨租户授权检查(仅 C2C;群消息跨租户语义为群成员维度,本设计不处理)
|
||
if (msg.getConvType() != null && msg.getConvType() == 1) {
|
||
String toTenant = parsePrefix(msg.getConvId());
|
||
if (toTenant != null && !toTenant.equals(tenantId)) {
|
||
crossGrantId = crossTenantService.checkSendMsgGrant(
|
||
tenantId, msg.getFromAccount(), toTenant, msg.getConvId());
|
||
if (crossGrantId != null) {
|
||
msg.setIsCrossTenant(true);
|
||
crossToImUser = msg.getConvId();
|
||
}
|
||
}
|
||
}
|
||
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. 分发给 from 租户(payload=原始回调体,供业务系统消费)
|
||
String convId = extractConvId(callbackCommand, node);
|
||
enqueueIfConfigured(tenantId, msgKey, convId, body);
|
||
|
||
// 4. 跨租户授权放行:额外分发给 to 租户 + 记录审计
|
||
if (crossGrantId != null && crossToImUser != null) {
|
||
String toTenant = parsePrefix(crossToImUser);
|
||
enqueueIfConfigured(toTenant, msgKey, convId, body);
|
||
crossTenantService.audit(crossGrantId, msgKey,
|
||
node.get("FromAccount").getString(), crossToImUser);
|
||
}
|
||
return ok();
|
||
}
|
||
|
||
/**
|
||
* 租户配置了回调地址则入队分发
|
||
*
|
||
* @param tenantId 目标租户
|
||
* @param msgKey 消息键(非消息类回调可为 null)
|
||
* @param convId 会话ID(保序分桶用)
|
||
* @param payload 分发载荷(原始回调体)
|
||
*/
|
||
private void enqueueIfConfigured(String tenantId, String msgKey, String convId, String payload) {
|
||
Tenant tenant = tenantService.getById(tenantId);
|
||
if (tenant == null || tenant.getCallbackUrl() == null || tenant.getCallbackUrl().isEmpty()) {
|
||
return;
|
||
}
|
||
DistQueue q = new DistQueue();
|
||
q.setMsgKey(msgKey);
|
||
q.setTenantId(tenantId);
|
||
q.setConvId(convId);
|
||
q.setTargetUrl(tenant.getCallbackUrl());
|
||
q.setPayload(payload);
|
||
q.setStatus(0);
|
||
q.setRetryCount(0);
|
||
q.setNextRetryAt(OffsetDateTime.now());
|
||
distQueueMapper.insert(q);
|
||
}
|
||
|
||
/**
|
||
* 识别租户:优先 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\":\"\"}";
|
||
}
|
||
}
|