Files
tencent-im-util/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java
T
yixiongandClaude 8c9037fc4f fix: 跨租户授权联调修复 + 新增分区自建模块
- 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>
2026-07-08 23:56:32 +08:00

273 lines
10 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.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\":\"\"}";
}
}