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:
@@ -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.ImMessageMapper;
|
||||||
import com.imutil.mapper.PullWatermarkMapper;
|
import com.imutil.mapper.PullWatermarkMapper;
|
||||||
import com.imutil.service.CallbackService;
|
import com.imutil.service.CallbackService;
|
||||||
|
import com.imutil.service.CrossTenantService;
|
||||||
import com.imutil.service.TenantService;
|
import com.imutil.service.TenantService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.noear.snack4.ONode;
|
import org.noear.snack4.ONode;
|
||||||
@@ -52,6 +53,9 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
@Inject
|
@Inject
|
||||||
private TenantService tenantService;
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private CrossTenantService crossTenantService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Tran
|
@Tran
|
||||||
public String handleCallback(String callbackCommand, String body) {
|
public String handleCallback(String callbackCommand, String body) {
|
||||||
@@ -79,10 +83,25 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
|
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
|
||||||
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
|
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
|
||||||
String msgKey = null;
|
String msgKey = null;
|
||||||
|
// 跨租户授权命中的对端账号(非空表示该消息为授权放行的跨租户消息,需额外分发+审计)
|
||||||
|
String crossToImUser = null;
|
||||||
|
Long crossGrantId = null;
|
||||||
if (isMessageCallback(callbackCommand)) {
|
if (isMessageCallback(callbackCommand)) {
|
||||||
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
||||||
if (msg != null) {
|
if (msg != null) {
|
||||||
msgKey = msg.getMsgKey();
|
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()
|
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
|
||||||
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
||||||
if (exists == 0) {
|
if (exists == 0) {
|
||||||
@@ -94,23 +113,45 @@ public class CallbackServiceImpl implements CallbackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 写分发队列(payload=原始回调体,供业务系统消费)
|
// 3. 分发给 from 租户(payload=原始回调体,供业务系统消费)
|
||||||
Tenant tenant = tenantService.getById(tenantId);
|
String convId = extractConvId(callbackCommand, node);
|
||||||
if (tenant != null && tenant.getCallbackUrl() != null && !tenant.getCallbackUrl().isEmpty()) {
|
enqueueIfConfigured(tenantId, msgKey, convId, body);
|
||||||
DistQueue q = new DistQueue();
|
|
||||||
q.setMsgKey(msgKey);
|
// 4. 跨租户授权放行:额外分发给 to 租户 + 记录审计
|
||||||
q.setTenantId(tenantId);
|
if (crossGrantId != null && crossToImUser != null) {
|
||||||
q.setConvId(extractConvId(callbackCommand, node));
|
String toTenant = parsePrefix(crossToImUser);
|
||||||
q.setTargetUrl(tenant.getCallbackUrl());
|
enqueueIfConfigured(toTenant, msgKey, convId, body);
|
||||||
q.setPayload(body);
|
crossTenantService.audit(crossGrantId, msgKey,
|
||||||
q.setStatus(0);
|
node.get("FromAccount").getString(), crossToImUser);
|
||||||
q.setRetryCount(0);
|
|
||||||
q.setNextRetryAt(OffsetDateTime.now());
|
|
||||||
distQueueMapper.insert(q);
|
|
||||||
}
|
}
|
||||||
return ok();
|
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
|
* 识别租户:优先 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_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.<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user