Files
tencent-im-util/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java
T
yixiongandClaude b4c6652f66 fix(review): 代码审查 6 项修复(M2/M3/m1/m2/m3/m5)
集中修复 T8/T9/T11/T15 已实现代码的审查问题:
- M2 令牌桶 Lua 改用 Redis TIME(消除多节点时钟偏斜)
- M3 after 跨租户标记复查授权 + 无授权告警兜底
- m1 before 审计 MsgSeq/MsgRandom null 防御
- m2 HealthService 加 forceDown 开关,实测 DOWN 真返 503
- m3 countByStatus 排除 done(WHERE status IN (0,1,3))
- m5 配额查询异常不缓存(下次重试)
- 附带 app.yml console charset GBK→UTF-8
M1 限流审计/事务收窄暂缓。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 09:52:13 +08:00

330 lines
13 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>
* 流程:
* 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.<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());
}
}
}
// 4. 按发送方租户分发(payload=原始回调体,供业务系统消费)
// 设计 5.4/5.4.1 未要求额外推给 to 租户(其客户端已由腾讯直接投递);
// 跨租户审计已在 before 阶段(handleBeforeSendMsg)写入。
String convId = extractConvId(callbackCommand, node);
enqueueIfConfigured(tenantId, msgKey, convId, body);
return ok();
}
/**
* 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4)
* <p>
* 同租户放行;跨租户查 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.<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\":\"\"}";
}
}