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>
This commit is contained in:
yixiong
2026-07-08 23:56:32 +08:00
co-authored by Claude
parent d22fdcfaad
commit 8c9037fc4f
7 changed files with 336 additions and 13 deletions
@@ -0,0 +1,25 @@
package com.imutil.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 分区表 DDL 执行 Mapper
* <p>
* im_message 按月 RANGE 分区,分区名含动态年月(im_message_yyyyMM),无法用静态 SQL
* 故提供 ${sql} 占位的通用执行入口。
* <p>
* SQL 由 {@link com.imutil.service.PartitionService} 按年月在服务端拼装,无外部输入,不存在注入风险。
*
* @author imutil
*/
public interface PartitionMapper {
/**
* 执行任意 DDL(建分区表)
*
* @param sql 形如 CREATE TABLE IF NOT EXISTS im_message_yyyyMM PARTITION OF im_message FOR VALUES FROM (...) TO (...)
*/
@Update("${sql}")
void execute(@Param("sql") String sql);
}
@@ -0,0 +1,37 @@
package com.imutil.service;
/**
* 跨租户通讯授权服务
* <p>
* 基于 cross_tenant_grant 判断跨租户通讯是否被授权放行,
* 并对放行的跨租户消息记录审计(cross_tenant_audit)。
*
* @author imutil
*/
public interface CrossTenantService {
/**
* 检查发消息(send_msg)的跨租户授权
* <p>
* 匹配规则:正向授权(from→to) 或 反向双向授权(to→from 且 direction=1)
* 授权需 status=1、在有效期内、permissions 含 send_msg
* from_im_user_id / to_im_user_id 为 NULL 表示通配(任意账户/全员)。
*
* @param fromTenant 发送方租户
* @param fromUser 发送方 IM 账号
* @param toTenant 接收方租户
* @param toUser 接收方 IM 账号
* @return 命中的授权ID,无授权或同租户返回 null
*/
Long checkSendMsgGrant(String fromTenant, String fromUser, String toTenant, String toUser);
/**
* 记录一条跨租户通讯审计
*
* @param grantId 命中的授权ID
* @param msgKey 消息键
* @param fromUser 发送方 IM 账号
* @param toUser 接收方 IM 账号
*/
void audit(Long grantId, String msgKey, String fromUser, String toUser);
}
@@ -0,0 +1,16 @@
package com.imutil.service;
/**
* im_message 分区管理服务
* <p>
* 按月滚动建分区,避免跨月数据落入 default 分区导致查询性能退化。
*
* @author imutil
*/
public interface PartitionService {
/**
* 创建当月与下月分区(CREATE IF NOT EXISTS 幂等),跨月前确保下月分区就绪
*/
void createCurrentAndNextMonth();
}
@@ -11,6 +11,7 @@ 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;
@@ -52,6 +53,9 @@ public class CallbackServiceImpl implements CallbackService {
@Inject
private TenantService tenantService;
@Inject
private CrossTenantService crossTenantService;
@Override
@Tran
public String handleCallback(String callbackCommand, String body) {
@@ -79,10 +83,25 @@ public class CallbackServiceImpl implements CallbackService {
// 不依赖 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) {
@@ -94,23 +113,45 @@ public class CallbackServiceImpl implements CallbackService {
}
}
// 3. 分发队列(payload=原始回调体,供业务系统消费)
Tenant tenant = tenantService.getById(tenantId);
if (tenant != null && tenant.getCallbackUrl() != null && !tenant.getCallbackUrl().isEmpty()) {
DistQueue q = new DistQueue();
q.setMsgKey(msgKey);
q.setTenantId(tenantId);
q.setConvId(extractConvId(callbackCommand, node));
q.setTargetUrl(tenant.getCallbackUrl());
q.setPayload(body);
q.setStatus(0);
q.setRetryCount(0);
q.setNextRetryAt(OffsetDateTime.now());
distQueueMapper.insert(q);
// 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
*
@@ -0,0 +1,93 @@
package com.imutil.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.entity.CrossTenantAudit;
import com.imutil.entity.CrossTenantGrant;
import com.imutil.mapper.CrossTenantAuditMapper;
import com.imutil.mapper.CrossTenantGrantMapper;
import com.imutil.service.CrossTenantService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.time.OffsetDateTime;
/**
* 跨租户通讯授权服务实现
* <p>
* 授权匹配:正向(from→to) 优先,未命中再查反向双向(to→from 且 direction=1)。
* user 字段 NULL 表示通配;permissions 用 LIKE 匹配是否含 send_msg。
*
* @author imutil
*/
@Slf4j
@Component
public class CrossTenantServiceImpl implements CrossTenantService {
private static final String PERM_SEND_MSG = "send_msg";
@Inject
private CrossTenantGrantMapper grantMapper;
@Inject
private CrossTenantAuditMapper auditMapper;
@Override
public Long checkSendMsgGrant(String fromTenant, String fromUser, String toTenant, String toUser) {
// 同租户或任一租户缺失,不算跨租户通讯
if (fromTenant == null || toTenant == null || fromTenant.equals(toTenant)) {
return null;
}
// 1. 正向授权 from→to
Long gid = matchDirected(fromTenant, fromUser, toTenant, toUser, false);
if (gid != null) {
return gid;
}
// 2. 反向双向授权:记录方向为 to→from 且 direction=1
return matchDirected(toTenant, toUser, fromTenant, fromUser, true);
}
@Override
public void audit(Long grantId, String msgKey, String fromUser, String toUser) {
try {
CrossTenantAudit a = new CrossTenantAudit();
a.setGrantId(grantId);
a.setMsgKey(msgKey);
a.setFromImUserId(fromUser);
a.setToImUserId(toUser);
a.setActionTime(OffsetDateTime.now());
auditMapper.insert(a);
} catch (Exception e) {
// 审计写入失败不阻塞主流程
log.warn("跨租户审计写入失败 grant={} msgKey={} : {}", grantId, msgKey, e.getMessage());
}
}
/**
* 按指定方向匹配一条 send_msg 授权
*
* @param matchFromTenant 查询条件 from_tenant
* @param matchFromUser 查询条件 from_im_userNULL 通配由 SQL 处理)
* @param matchToTenant 查询条件 to_tenant
* @param matchToUser 查询条件 to_im_user
* @param now 当前时间(校验有效期)
* @param requireBidir 是否要求 direction=1(反向匹配时)
* @return 命中的 grantId,未命中返回 null
*/
private Long matchDirected(String matchFromTenant, String matchFromUser,
String matchToTenant, String matchToUser,
boolean requireBidir) {
CrossTenantGrant g = grantMapper.selectOne(Wrappers.<CrossTenantGrant>lambdaQuery()
.eq(CrossTenantGrant::getFromTenantId, matchFromTenant)
.eq(CrossTenantGrant::getToTenantId, matchToTenant)
.eq(CrossTenantGrant::getStatus, 1)
.eq(requireBidir, CrossTenantGrant::getDirection, 1)
.like(CrossTenantGrant::getPermissions, PERM_SEND_MSG)
.and(w -> w.isNull(CrossTenantGrant::getStartAt).or().apply("start_at <= now()"))
.and(w -> w.isNull(CrossTenantGrant::getEndAt).or().apply("end_at >= now()"))
.and(w -> w.isNull(CrossTenantGrant::getFromImUserId).or().eq(CrossTenantGrant::getFromImUserId, matchFromUser))
.and(w -> w.isNull(CrossTenantGrant::getToImUserId).or().eq(CrossTenantGrant::getToImUserId, matchToUser))
.last("LIMIT 1"));
return g == null ? null : g.getGrantId();
}
}
@@ -0,0 +1,73 @@
package com.imutil.service.impl;
import com.imutil.mapper.PartitionMapper;
import com.imutil.service.PartitionService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Init;
import org.noear.solon.annotation.Inject;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
/**
* im_message 分区管理服务实现
* <p>
* 每次建当月+下月分区(CREATE TABLE IF NOT EXISTS 幂等),保证:
* - 启动时当月分区就绪(init.sql 仅建了 default 兜底分区)
* - 跨月前下月分区已存在,消息不落 default 分区(避免查询性能退化)
* 分区按自然月 RANGE(msg_time)+08 时区边界。
*
* @author imutil
*/
@Slf4j
@Component
public class PartitionServiceImpl implements PartitionService {
private static final ZoneId ZONE = ZoneId.of("+08");
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
/** PG timestamptz 字面量格式:2026-07-01 00:00:00+08:00 */
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssxxx");
@Inject
private PartitionMapper partitionMapper;
/**
* 应用启动后自检一次:首次启动当月分区缺失则补建
*/
@Init
public void init() {
try {
createCurrentAndNextMonth();
log.info("启动分区自检完成(当月+下月)");
} catch (Throwable e) {
log.error("启动分区自检异常", e);
}
}
@Override
public void createCurrentAndNextMonth() {
ZonedDateTime now = ZonedDateTime.now(ZONE);
ensureMonth(now.getYear(), now.getMonthValue());
// 下月:用 plusMonths 计算避免年底跨月
ZonedDateTime next = now.plusMonths(1);
ensureMonth(next.getYear(), next.getMonthValue());
}
/**
* 确保指定年月的分区存在(不存在则创建,幂等)
*/
private void ensureMonth(int year, int month) {
// 用 YearMonth 拼表名与边界,规避 1月/12月 跨年的日历计算坑
java.time.YearMonth ym = java.time.YearMonth.of(year, month);
String name = "im_message_" + ym.format(MONTH_FMT);
ZonedDateTime start = ym.atDay(1).atStartOfDay(ZONE);
ZonedDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(ZONE);
String sql = "CREATE TABLE IF NOT EXISTS " + name +
" PARTITION OF im_message FOR VALUES FROM ('" +
start.format(TS_FMT) + "') TO ('" + end.format(TS_FMT) + "')";
partitionMapper.execute(sql);
log.debug("分区就绪 {}", name);
}
}
@@ -0,0 +1,38 @@
package com.imutil.task;
import com.imutil.service.PartitionService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
/**
* im_message 分区自建任务
* <p>
* 每天凌晨检查并创建当月+下月分区,确保跨月前下月分区已就绪、消息不落 default 分区。
* 对应 app.yml 的 solon.scheduling.job.partitionCreateJob。
* <p>
* 启动时的首次自检由 {@link com.imutil.service.impl.PartitionServiceImpl} 的 @Init 负责,
* 此处仅负责运行期的每日巡检。
*
* @author imutil
*/
@Slf4j
@Component
public class PartitionCreateTask {
@Inject
private PartitionService partitionService;
/**
* 由 app.yml partitionCreateJob 驱动(默认每天 0:10
*/
@Scheduled(name = "partitionCreateJob")
public void run() {
try {
partitionService.createCurrentAndNextMonth();
} catch (Throwable e) {
log.error("分区自建任务异常", e);
}
}
}