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.
+ * 授权匹配:正向(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.
+ * 每次建当月+下月分区(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);
+ }
+ }
+}