From e3a64bb861b1acb0edff7265ab25b14d803307e2 Mon Sep 17 00:00:00 2001 From: yixiong Date: Thu, 9 Jul 2026 00:20:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(t8):=20=E8=B7=A8=E7=A7=9F=E6=88=B7=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E5=AF=B9=E9=BD=90=E8=AE=BE=E8=AE=A15.4=E2=80=94?= =?UTF-8?q?=E2=80=94before=E9=98=BB=E6=96=AD=E6=9B=BF=E4=BB=A3after?= =?UTF-8?q?=E9=A2=9D=E5=A4=96=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CallbackServiceImpl: 新增 handleBeforeSendMsg 做 C2C.CallbackBeforeSendMsg 跨租户拦截决策(未授权返回 FAIL,腾讯不投递);after 移除「额外推 to 租户」, is_cross_tenant 改在 after 标记;新增 fail() 响应(ErrorCode 80001) - 解决旧实现(after 额外分发)无法阻断未授权跨租户消息的核心隔离缺陷 - 联调验证:before 同租户OK/无授权FAIL/有授权OK+审计;after 落库+按from分发 - 详见 实施记录第六章 + T8 跨租户拦截-方案决策记录.md Co-Authored-By: Claude --- .../service/impl/CallbackServiceImpl.java | 91 ++++++++++++++----- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java index 3805b4a..972fcfc 100644 --- a/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java +++ b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java @@ -26,7 +26,10 @@ import java.time.ZoneId; /** * 腾讯回调处理服务实现 *

- * 流程:识别租户 → 消息类回调落 im_message(幂等)→ 所有回调写 dist_queue 分发业务系统。 + * 流程: + * 1. 发单聊消息之前回调(C2C.CallbackBeforeSendMsg):跨租户拦截决策(对齐设计 5.4)—— + * 同租户放行;跨租户查 cross_tenant_grant,命中授权放行并写审计,未命中返回 FAIL(腾讯不投递); + * 2. 其他回调(含发消息之后回调):识别租户 → 消息类回调落 im_message(幂等)→ 按 from 租户写 dist_queue 分发。 * {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。 * * @author imutil @@ -70,7 +73,13 @@ public class CallbackServiceImpl implements CallbackService { return ok(); } - // 1. 识别租户 + // 1. 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4) + // 未授权的跨租户消息在此返回 FAIL,腾讯不投递、也不触发 after 回调 → 从源头阻断串扰 + if (isBeforeSendMsg(callbackCommand)) { + return handleBeforeSendMsg(node); + } + + // 2. 识别租户 String tenantId = identifyTenant(callbackCommand, node); if (tenantId == null) { // 无法识别租户(如腾讯系统消息 administrator),不落库不分发 @@ -78,28 +87,21 @@ public class CallbackServiceImpl implements CallbackService { return ok(); } - // 2. 消息类回调落库 im_message(幂等:msg_key 存在则跳过) - // msg_key = command:from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键; + // 3. 消息类回调(发消息后)落库 im_message(幂等:msg_key 存在则跳过) + // msg_key = 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;群消息跨租户语义为群成员维度,本设计不处理) + // 跨租户标记:能到达 after 的跨租户 C2C 消息必经 before 放行(有授权); + // 未授权的已被 before 拦截(腾讯不投递、不触发 after),故 after 见到跨租户即标记。 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(); - } + msg.setIsCrossTenant(true); } } long exists = imMessageMapper.selectCount(Wrappers.lambdaQuery() @@ -113,20 +115,63 @@ public class CallbackServiceImpl implements CallbackService { } } - // 3. 分发给 from 租户(payload=原始回调体,供业务系统消费) + // 4. 按发送方租户分发(payload=原始回调体,供业务系统消费) + // 设计 5.4/5.4.1 未要求额外推给 to 租户(其客户端已由腾讯直接投递); + // 跨租户审计已在 before 阶段(handleBeforeSendMsg)写入。 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(); } + /** + * 发单聊消息之前回调:跨租户拦截决策(对齐设计 5.4) + *

+ * 同租户放行;跨租户查 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 便于追溯) + long msgSeq = node.get("MsgSeq").getLong(); + long msgRandom = node.get("MsgRandom").getLong(); + String msgKey = MsgKeys.build(from, to, msgSeq, msgRandom); + 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\"}"; + } + /** * 租户配置了回调地址则入队分发 *