diff --git a/src/main/java/com/imutil/mapper/PartitionMapper.java b/src/main/java/com/imutil/mapper/PartitionMapper.java new file mode 100644 index 0000000..1097e59 --- /dev/null +++ b/src/main/java/com/imutil/mapper/PartitionMapper.java @@ -0,0 +1,25 @@ +package com.imutil.mapper; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; + +/** + * 分区表 DDL 执行 Mapper + *

+ * im_message 按月 RANGE 分区,分区名含动态年月(im_message_yyyyMM),无法用静态 SQL, + * 故提供 ${sql} 占位的通用执行入口。 + *

+ * 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); +} diff --git a/src/main/java/com/imutil/service/CrossTenantService.java b/src/main/java/com/imutil/service/CrossTenantService.java new file mode 100644 index 0000000..0693b0e --- /dev/null +++ b/src/main/java/com/imutil/service/CrossTenantService.java @@ -0,0 +1,37 @@ +package com.imutil.service; + +/** + * 跨租户通讯授权服务 + *

+ * 基于 cross_tenant_grant 判断跨租户通讯是否被授权放行, + * 并对放行的跨租户消息记录审计(cross_tenant_audit)。 + * + * @author imutil + */ +public interface CrossTenantService { + + /** + * 检查发消息(send_msg)的跨租户授权 + *

+ * 匹配规则:正向授权(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); +} diff --git a/src/main/java/com/imutil/service/PartitionService.java b/src/main/java/com/imutil/service/PartitionService.java new file mode 100644 index 0000000..856b0d4 --- /dev/null +++ b/src/main/java/com/imutil/service/PartitionService.java @@ -0,0 +1,16 @@ +package com.imutil.service; + +/** + * im_message 分区管理服务 + *

+ * 按月滚动建分区,避免跨月数据落入 default 分区导致查询性能退化。 + * + * @author imutil + */ +public interface PartitionService { + + /** + * 创建当月与下月分区(CREATE IF NOT EXISTS 幂等),跨月前确保下月分区就绪 + */ + void createCurrentAndNextMonth(); +} diff --git a/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java index c99dddf..3805b4a 100644 --- a/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java +++ b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java @@ -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.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 * diff --git a/src/main/java/com/imutil/service/impl/CrossTenantServiceImpl.java b/src/main/java/com/imutil/service/impl/CrossTenantServiceImpl.java new file mode 100644 index 0000000..d899862 --- /dev/null +++ b/src/main/java/com/imutil/service/impl/CrossTenantServiceImpl.java @@ -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; + +/** + * 跨租户通讯授权服务实现 + *

+ * 授权匹配:正向(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_user(NULL 通配由 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.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(); + } +} diff --git a/src/main/java/com/imutil/service/impl/PartitionServiceImpl.java b/src/main/java/com/imutil/service/impl/PartitionServiceImpl.java new file mode 100644 index 0000000..9be865b --- /dev/null +++ b/src/main/java/com/imutil/service/impl/PartitionServiceImpl.java @@ -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 分区管理服务实现 + *

+ * 每次建当月+下月分区(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); + } +} diff --git a/src/main/java/com/imutil/task/PartitionCreateTask.java b/src/main/java/com/imutil/task/PartitionCreateTask.java new file mode 100644 index 0000000..069d0af --- /dev/null +++ b/src/main/java/com/imutil/task/PartitionCreateTask.java @@ -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 分区自建任务 + *

+ * 每天凌晨检查并创建当月+下月分区,确保跨月前下月分区已就绪、消息不落 default 分区。 + * 对应 app.yml 的 solon.scheduling.job.partitionCreateJob。 + *

+ * 启动时的首次自检由 {@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); + } + } +}