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; /** * 腾讯回调处理服务实现 *
* 流程:
* 1. 发单聊消息之前回调(C2C.CallbackBeforeSendMsg):跨租户拦截决策(对齐设计 5.4)——
* 同租户放行;跨租户查 cross_tenant_grant,命中授权放行并写审计,未命中返回 FAIL(腾讯不投递);
* 2. 其他回调(含发消息之后回调):识别租户 → 消息类回调落 im_message(幂等)→ 按 from 租户写 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. 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
// 未授权的跨租户消息在此返回 FAIL,腾讯不投递、也不触发 after 回调 → 从源头阻断串扰
if (isBeforeSendMsg(callbackCommand)) {
return handleBeforeSendMsg(node);
}
// 2. 识别租户
String tenantId = identifyTenant(callbackCommand, node);
if (tenantId == null) {
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发
log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("FromAccount").getString());
return ok();
}
// 3. 消息类回调(发消息后)落库 im_message(幂等:msg_key 存在则跳过)
// msg_key = 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();
// 跨租户标记:复查授权确认(before 已拦截未授权)。
// 若 before 未开启/失效,未授权消息会漏到 after,此处查不到授权即告警(兜底发现隔离异常)。
if (msg.getConvType() != null && msg.getConvType() == 1) {
String toTenant = parsePrefix(msg.getConvId());
if (toTenant != null && !toTenant.equals(tenantId)) {
if (crossTenantService.checkSendMsgGrant(
tenantId, msg.getFromAccount(), toTenant, msg.getConvId()) != null) {
msg.setIsCrossTenant(true);
} else {
log.warn("疑似未授权跨租户消息到达 after(before 可能未开启/失效)from={} to={}",
msg.getFromAccount(), msg.getConvId());
}
}
}
long exists = imMessageMapper.selectCount(Wrappers.
* 同租户放行;跨租户查 cross_tenant_grant,命中授权则放行并写审计,未命中返回 FAIL(腾讯不投递消息)。
*
* @param node 回调体
* @return 放行 ok() 或拦截 fail()
*/
private String handleBeforeSendMsg(ONode node) {
String from = node.get("FromAccount").getString();
String to = node.get("ToAccount").getString();
if (to == null || to.isEmpty()) {
to = node.get("ToPlayerId").getString();
}
String fromTenant = parsePrefix(from);
String toTenant = parsePrefix(to);
// 同租户或任一租户无法识别(如系统消息):不拦截,放行正常通讯
if (fromTenant == null || toTenant == null || fromTenant.equals(toTenant)) {
return ok();
}
// 跨租户:查 send_msg 授权
Long grantId = crossTenantService.checkSendMsgGrant(fromTenant, from, toTenant, to);
if (grantId == null) {
log.info("跨租户通讯未授权,拦截 from={} to={}", from, to);
return fail();
}
// 命中授权:放行 + 写审计(before 阶段记录放行决策,msgKey 便于追溯)
// MsgSeq/MsgRandom 缺失时审计 msgKey 留空,不影响拦截决策(授权检查已完成)
String msgKey = null;
try {
long msgSeq = node.get("MsgSeq").getLong();
long msgRandom = node.get("MsgRandom").getLong();
msgKey = MsgKeys.build(from, to, msgSeq, msgRandom);
} catch (Exception ignore) {
// 回调体缺字段无法算 msgKey,审计仍写(msgKey=null)
}
crossTenantService.audit(grantId, msgKey, from, to);
log.info("跨租户授权放行 from={} to={} grant={}", from, to, grantId);
return ok();
}
/**
* 是否为发单聊消息之前回调(跨租户拦截决策入口)
*/
private boolean isBeforeSendMsg(String command) {
return command != null && command.contains("CallbackBeforeSendMsg");
}
/**
* 返回腾讯回调拦截响应(消息不投递,客户端收到 ErrorCode + ErrorInfo)
*/
private String fail() {
return "{\"ActionStatus\":\"FAIL\",\"ErrorCode\":80001,\"ErrorInfo\":\"cross-tenant not authorized\"}";
}
/**
* 租户配置了回调地址则入队分发
*
* @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.