fix(t8): 跨租户拦截对齐设计5.4——before阻断替代after额外分发

- 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 <noreply@anthropic.com>
This commit is contained in:
yixiong
2026-07-09 00:20:43 +08:00
co-authored by Claude
parent 8c9037fc4f
commit e3a64bb861
@@ -26,7 +26,10 @@ import java.time.ZoneId;
/**
* 腾讯回调处理服务实现
* <p>
* 流程:识别租户 → 消息类回调落 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.<ImMessage>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)
* <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 便于追溯)
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\"}";
}
/**
* 租户配置了回调地址则入队分发
*