feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉
- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token - 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信 - 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id - 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面 - 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失 - msg_key 统一算法(回调/补拉共享, 跨路径去重) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import com.imutil.entity.AdminUser;
|
||||
|
||||
/**
|
||||
* 管理后台用户服务
|
||||
* <p>
|
||||
* 登录校验(PBKDF2)+ 首次启动默认管理员初始化。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface AdminUserService {
|
||||
|
||||
/**
|
||||
* 登录校验
|
||||
*
|
||||
* @return 凭证正确且账号启用返回 AdminUser,否则 null
|
||||
*/
|
||||
AdminUser login(String username, String password);
|
||||
|
||||
/**
|
||||
* 应用启动时确保存在至少一个管理员(表空则按 app.yml imutil.admin 初始化)
|
||||
*/
|
||||
void ensureDefaultAdmin();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.imutil.service;
|
||||
|
||||
/**
|
||||
* 腾讯回调处理服务
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface CallbackService {
|
||||
|
||||
/**
|
||||
* 处理腾讯回调:落库消息(消息类回调)+ 写分发队列
|
||||
*
|
||||
* @param callbackCommand 回调命令,如 C2C.CallbackAfterRecvMsg
|
||||
* @param body 回调请求体 JSON
|
||||
* @return 处理结果,OK/FAIL
|
||||
*/
|
||||
String handleCallback(String callbackCommand, String body);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import com.imutil.entity.DistQueue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 回调分发服务
|
||||
* <p>
|
||||
* 从 dist_queue 抢占待分发记录,HTTP 转发业务系统,按回执更新状态。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface DispatchService {
|
||||
|
||||
/**
|
||||
* 抢占一批 pending 记录置为 processing(事务内 FOR UPDATE SKIP LOCKED + lock)
|
||||
*
|
||||
* @param workerName 工作线程标识
|
||||
* @return 抢占到的记录列表
|
||||
*/
|
||||
List<DistQueue> fetchAndLock(String workerName);
|
||||
|
||||
/**
|
||||
* 分发单条记录:HTTP 转发 → 回执成功 markDone / 失败重试或死信
|
||||
*/
|
||||
void dispatch(DistQueue task, String workerName);
|
||||
|
||||
/**
|
||||
* 巡检:重置超时未回执的 processing 回到 pending(工作线程宕机恢复)
|
||||
*
|
||||
* @return 重置条数
|
||||
*/
|
||||
int recoverStuck();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.imutil.service;
|
||||
|
||||
/**
|
||||
* 历史消息补拉服务
|
||||
* <p>
|
||||
* 回调链路的兜底:腾讯回调丢失或本工具落库失败时,定期主动从腾讯拉取最新消息补全本地,
|
||||
* 保证 im_message 不丢、业务系统不漏收(对应「已知问题」第 3 条)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface PullService {
|
||||
|
||||
/**
|
||||
* 执行一轮补拉:取最久未补拉的若干会话,逐个调腾讯 API 拉最新消息,
|
||||
* msg_key 幂等落库(source=PULL_BACK)并入队分发,最后推进水位线
|
||||
*/
|
||||
void pullRound();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import com.imutil.entity.Tenant;
|
||||
|
||||
/**
|
||||
* 租户服务
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface TenantService {
|
||||
|
||||
/**
|
||||
* 根据 app_key 查询租户(带缓存)
|
||||
*
|
||||
* @param appKey 业务系统凭证 key
|
||||
* @return 租户实体,不存在返回 null
|
||||
*/
|
||||
Tenant getByAppKey(String appKey);
|
||||
|
||||
/**
|
||||
* 根据 tenant_id 查询租户(带缓存)
|
||||
*/
|
||||
Tenant getById(String tenantId);
|
||||
|
||||
/**
|
||||
* 失效租户缓存(租户变更时调用)
|
||||
*/
|
||||
void evictCache(String tenantId);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.imutil.service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 用量统计服务
|
||||
* <p>
|
||||
* 按小时窗口从 im_message / api_call_log 聚合用量到 usage_stat(计费拆账依据)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface UsageStatService {
|
||||
|
||||
/**
|
||||
* 聚合某小时窗口 [start, end) 的用量,upsert 到 usage_stat(stat_level=1)
|
||||
* <p>
|
||||
* 幂等:ON CONFLICT,重复跑不产生重复数据,取最新聚合值。
|
||||
*
|
||||
* @param start 窗口起点(含,整点)
|
||||
* @param end 窗口终点(不含)
|
||||
*/
|
||||
void aggregateHour(OffsetDateTime start, OffsetDateTime end);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.imutil.service;
|
||||
|
||||
/**
|
||||
* 账号映射服务
|
||||
* <p>
|
||||
* 维护业务用户ID ↔ IM UserID(带租户前缀)映射,首次使用时调腾讯 account_import 创建 IM 账号。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
public interface UserMappingService {
|
||||
|
||||
/**
|
||||
* 获取或创建 IM 用户ID
|
||||
* <p>
|
||||
* 不存在则调腾讯 account_import 创建 IM 账号并写映射;存在则直接返回。
|
||||
* im_user_id = tenant_id + '_' + biz_user_id(前缀法保证跨租户不撞号)。
|
||||
*
|
||||
* @param tenantId 租户ID(即前缀码)
|
||||
* @param bizUserId 业务用户ID
|
||||
* @param nick 昵称(首次创建时同步到 IM,可选)
|
||||
* @param faceUrl 头像(可选)
|
||||
* @return IM 用户ID
|
||||
*/
|
||||
String getOrCreate(String tenantId, String bizUserId, String nick, String faceUrl);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.PasswordUtil;
|
||||
import com.imutil.entity.AdminUser;
|
||||
import com.imutil.mapper.AdminUserMapper;
|
||||
import com.imutil.service.AdminUserService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Init;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* 管理后台用户服务实现
|
||||
* <p>
|
||||
* 密码用 {@link PasswordUtil}(PBKDF2)哈希存储与校验。
|
||||
* {@code @Init} 在容器启动后检查 admin_user 表,为空则按 app.yml imutil.admin 初始化默认管理员。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AdminUserServiceImpl implements AdminUserService {
|
||||
|
||||
@Inject
|
||||
private AdminUserMapper adminUserMapper;
|
||||
|
||||
@Inject("${imutil.admin.defaultUsername:admin}")
|
||||
private String defaultUsername;
|
||||
|
||||
@Inject("${imutil.admin.defaultPassword:admin123}")
|
||||
private String defaultPassword;
|
||||
|
||||
/**
|
||||
* 应用启动后初始化默认管理员(仅当表为空)
|
||||
*/
|
||||
@Init
|
||||
public void init() {
|
||||
ensureDefaultAdmin();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUser login(String username, String password) {
|
||||
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
AdminUser u = adminUserMapper.selectOne(Wrappers.<AdminUser>lambdaQuery()
|
||||
.eq(AdminUser::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
if (u == null) {
|
||||
return null;
|
||||
}
|
||||
// 停用账号拒绝
|
||||
if (u.getStatus() == null || u.getStatus() != 1) {
|
||||
return null;
|
||||
}
|
||||
if (!PasswordUtil.verify(password, u.getPasswordHash())) {
|
||||
return null;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureDefaultAdmin() {
|
||||
Long cnt = adminUserMapper.selectCount(null);
|
||||
if (cnt != null && cnt > 0) {
|
||||
return;
|
||||
}
|
||||
AdminUser u = new AdminUser();
|
||||
u.setUsername(defaultUsername);
|
||||
u.setPasswordHash(PasswordUtil.hash(defaultPassword));
|
||||
u.setRole("admin");
|
||||
u.setStatus(1);
|
||||
adminUserMapper.insert(u);
|
||||
log.info("已初始化默认管理员账号: {}(请尽快修改默认密码)", defaultUsername);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.MsgKeys;
|
||||
import com.imutil.entity.DistQueue;
|
||||
import com.imutil.entity.GroupMapping;
|
||||
import com.imutil.entity.ImMessage;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.mapper.GroupMappingMapper;
|
||||
import com.imutil.mapper.ImMessageMapper;
|
||||
import com.imutil.mapper.PullWatermarkMapper;
|
||||
import com.imutil.service.CallbackService;
|
||||
import com.imutil.service.TenantService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.snack4.ONode;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.data.annotation.Tran;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* 腾讯回调处理服务实现
|
||||
* <p>
|
||||
* 流程:识别租户 → 消息类回调落 im_message(幂等)→ 所有回调写 dist_queue 分发业务系统。
|
||||
* {@code @Tran} 保证消息落库与分发入队同事务:要么同时成功,要么都不入库(避免半写)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CallbackServiceImpl implements CallbackService {
|
||||
|
||||
/** 消息类回调命令关键字(命中则额外落 im_message) */
|
||||
private static final String[] MSG_COMMAND_KEYWORDS = {"SendMsg", "RecvMsg"};
|
||||
|
||||
@Inject
|
||||
private ImMessageMapper imMessageMapper;
|
||||
|
||||
@Inject
|
||||
private DistQueueMapper distQueueMapper;
|
||||
|
||||
@Inject
|
||||
private GroupMappingMapper groupMappingMapper;
|
||||
|
||||
@Inject
|
||||
private PullWatermarkMapper pullWatermarkMapper;
|
||||
|
||||
@Inject
|
||||
private TenantService tenantService;
|
||||
|
||||
@Override
|
||||
@Tran
|
||||
public String handleCallback(String callbackCommand, String body) {
|
||||
if (callbackCommand == null || callbackCommand.isEmpty()) {
|
||||
return ok();
|
||||
}
|
||||
ONode node;
|
||||
try {
|
||||
node = ONode.ofJson(body == null ? "{}" : body);
|
||||
} catch (Exception e) {
|
||||
log.warn("回调 body 解析失败 command={} : {}", callbackCommand, e.getMessage());
|
||||
return ok();
|
||||
}
|
||||
|
||||
// 1. 识别租户
|
||||
String tenantId = identifyTenant(callbackCommand, node);
|
||||
if (tenantId == null) {
|
||||
// 无法识别租户(如腾讯系统消息 administrator),不落库不分发
|
||||
log.debug("回调无法识别租户,跳过 command={} from={}", callbackCommand, node.get("FromAccount").getString());
|
||||
return ok();
|
||||
}
|
||||
|
||||
// 2. 消息类回调落库 im_message(幂等:msg_key 存在则跳过)
|
||||
// msg_key = command:from:convTarget:msgSeq:msgRandom,含 MsgSeq+MsgRandom 全局唯一,单独作幂等键;
|
||||
// 不依赖 MsgTimeStamp(避免腾讯回调时间戳偏差导致漏判)。
|
||||
// DB 主键 (msg_key, msg_time) 因分区表约束保留 msg_time,作兜底防护。
|
||||
String msgKey = null;
|
||||
if (isMessageCallback(callbackCommand)) {
|
||||
ImMessage msg = parseMessage(callbackCommand, node, tenantId);
|
||||
if (msg != null) {
|
||||
msgKey = msg.getMsgKey();
|
||||
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
|
||||
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
||||
if (exists == 0) {
|
||||
imMessageMapper.insert(msg);
|
||||
// 推进补拉水位线:记录该会话最新消息位置,补拉任务据此增量拉取(兜底回调丢失)
|
||||
pullWatermarkMapper.upsert(tenantId, msg.getConvId(), msg.getConvType(),
|
||||
node.get("MsgSeq").getLong(), msg.getMsgTime(), OffsetDateTime.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别租户:优先 FromAccount 前缀,群回调用 GroupId 查 group_mapping
|
||||
*
|
||||
* @return 租户ID,识别失败返回 null
|
||||
*/
|
||||
private String identifyTenant(String command, ONode node) {
|
||||
String from = node.get("FromAccount").getString();
|
||||
if (from != null && !from.isEmpty()) {
|
||||
String tenantId = parsePrefix(from);
|
||||
if (tenantId != null && tenantService.getById(tenantId) != null) {
|
||||
return tenantId;
|
||||
}
|
||||
}
|
||||
// 群回调:GroupId 查映射
|
||||
String groupId = node.get("GroupId").getString();
|
||||
if (groupId != null && !groupId.isEmpty()) {
|
||||
GroupMapping gm = groupMappingMapper.selectOne(Wrappers.<GroupMapping>lambdaQuery()
|
||||
.eq(GroupMapping::getImGroupId, groupId).last("LIMIT 1"));
|
||||
if (gm != null) {
|
||||
return gm.getTenantId();
|
||||
}
|
||||
// 群ID也带前缀
|
||||
String tid = parsePrefix(groupId);
|
||||
if (tid != null && tenantService.getById(tid) != null) {
|
||||
return tid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从带前缀的 IM 标识提取租户码(第一个下划线前部分)
|
||||
*/
|
||||
private String parsePrefix(String imId) {
|
||||
if (imId == null || imId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int idx = imId.indexOf('_');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
return imId.substring(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为消息类回调(需落 im_message)
|
||||
*/
|
||||
private boolean isMessageCallback(String command) {
|
||||
for (String kw : MSG_COMMAND_KEYWORDS) {
|
||||
if (command.contains(kw)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析消息体,构造 ImMessage(含幂等键 msg_key)
|
||||
*/
|
||||
private ImMessage parseMessage(String command, ONode node, String tenantId) {
|
||||
String from = node.get("FromAccount").getString();
|
||||
String to = node.get("ToAccount").getString();
|
||||
if (to == null || to.isEmpty()) {
|
||||
to = node.get("ToPlayerId").getString();
|
||||
}
|
||||
String groupId = node.get("GroupId").getString();
|
||||
long msgSeq = node.get("MsgSeq").getLong();
|
||||
long msgRandom = node.get("MsgRandom").getLong();
|
||||
long msgTs = node.get("MsgTimeStamp").getLong();
|
||||
|
||||
if (msgTs == 0) {
|
||||
msgTs = System.currentTimeMillis() / 1000L;
|
||||
}
|
||||
OffsetDateTime msgTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(msgTs), ZoneId.systemDefault());
|
||||
|
||||
// msg_key 组合保证全局唯一与幂等(与补拉路径共享 MsgKeys 算法,跨路径去重)
|
||||
String convTarget = (groupId != null && !groupId.isEmpty()) ? groupId : (to != null ? to : "");
|
||||
String msgKey = MsgKeys.build(from, convTarget, msgSeq, msgRandom);
|
||||
|
||||
ImMessage msg = new ImMessage();
|
||||
msg.setMsgKey(msgKey);
|
||||
msg.setTenantId(tenantId);
|
||||
msg.setMsgTime(msgTime);
|
||||
msg.setConvType(groupId != null && !groupId.isEmpty() ? 2 : 1);
|
||||
msg.setConvId(groupId != null && !groupId.isEmpty() ? groupId : (to != null ? to : ""));
|
||||
msg.setFromAccount(from);
|
||||
msg.setToAccount(groupId != null && !groupId.isEmpty() ? null : to);
|
||||
msg.setGroupId(groupId);
|
||||
msg.setMsgType(node.get("MsgType").getString());
|
||||
msg.setMsgBody(node.get("MsgBody").toString());
|
||||
msg.setSource("CALLBACK");
|
||||
msg.setIsCrossTenant(false);
|
||||
msg.setDistStatus(0);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取会话ID(用于 dist_queue 保序分桶)
|
||||
*/
|
||||
private String extractConvId(String command, ONode node) {
|
||||
String groupId = node.get("GroupId").getString();
|
||||
if (groupId != null && !groupId.isEmpty()) {
|
||||
return groupId;
|
||||
}
|
||||
String to = node.get("ToAccount").getString();
|
||||
if (to == null || to.isEmpty()) {
|
||||
to = node.get("ToPlayerId").getString();
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回腾讯回调成功响应
|
||||
*/
|
||||
private String ok() {
|
||||
return "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.imutil.common.Httpx;
|
||||
import com.imutil.entity.DistQueue;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.service.DispatchService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.data.annotation.Tran;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 回调分发服务实现
|
||||
* <p>
|
||||
* 抢占消费:{@code @Tran} 内 fetchPending(FOR UPDATE SKIP LOCKED) + lock,提交后释放行锁,
|
||||
* 记录置 processing;随后 HTTP 转发业务系统,按回执更新 done/retry/dead。
|
||||
* 失败采用指数退避:backoff = retryBaseMs * 2^min(retryCount, 6)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DispatchServiceImpl implements DispatchService {
|
||||
|
||||
@Inject
|
||||
private DistQueueMapper distQueueMapper;
|
||||
|
||||
@Inject("${imutil.dispatch.fetchBatch:50}")
|
||||
private int fetchBatch;
|
||||
|
||||
@Inject("${imutil.dispatch.maxRetry:5}")
|
||||
private int maxRetry;
|
||||
|
||||
@Inject("${imutil.dispatch.retryBaseMs:2000}")
|
||||
private long retryBaseMs;
|
||||
|
||||
@Inject("${imutil.dispatch.lockTimeoutMin:3}")
|
||||
private int lockTimeoutMin;
|
||||
|
||||
@Override
|
||||
@Tran
|
||||
public List<DistQueue> fetchAndLock(String workerName) {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
List<DistQueue> pending = distQueueMapper.fetchPending(now, fetchBatch);
|
||||
List<DistQueue> locked = new ArrayList<>(pending.size());
|
||||
for (DistQueue q : pending) {
|
||||
int n = distQueueMapper.lock(q.getId(), workerName, now);
|
||||
if (n > 0) {
|
||||
q.setLockedBy(workerName);
|
||||
locked.add(q);
|
||||
}
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispatch(DistQueue task, String workerName) {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
try {
|
||||
int code = Httpx.postJson(task.getTargetUrl(), task.getPayload());
|
||||
if (code >= 200 && code < 300) {
|
||||
// 业务系统回执成功
|
||||
distQueueMapper.markDone(task.getId(), now);
|
||||
log.debug("分发成功 id={} tenant={} code={}", task.getId(), task.getTenantId(), code);
|
||||
return;
|
||||
}
|
||||
log.warn("分发回执非2xx id={} tenant={} code={}", task.getId(), task.getTenantId(), code);
|
||||
retryOrFail(task, now);
|
||||
} catch (Exception e) {
|
||||
log.warn("分发异常 id={} tenant={} : {}", task.getId(), task.getTenantId(), e.toString());
|
||||
retryOrFail(task, now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试或标记死信:retry_count+1,达上限置 dead,否则置 pending 并安排下次重试时间
|
||||
*/
|
||||
private void retryOrFail(DistQueue task, OffsetDateTime now) {
|
||||
long backoffMs = retryBaseMs * (1L << Math.min(task.getRetryCount(), 6));
|
||||
OffsetDateTime nextRetry = now.plusNanos(backoffMs * 1_000_000L);
|
||||
distQueueMapper.markRetry(task.getId(), maxRetry, nextRetry, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int recoverStuck() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
OffsetDateTime threshold = now.minusMinutes(lockTimeoutMin);
|
||||
return distQueueMapper.recoverStuck(threshold, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.MsgKeys;
|
||||
import com.imutil.common.TenantContext;
|
||||
import com.imutil.entity.DistQueue;
|
||||
import com.imutil.entity.ImMessage;
|
||||
import com.imutil.entity.PullWatermark;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.mapper.DistQueueMapper;
|
||||
import com.imutil.mapper.ImMessageMapper;
|
||||
import com.imutil.mapper.PullWatermarkMapper;
|
||||
import com.imutil.service.PullService;
|
||||
import com.imutil.service.TenantService;
|
||||
import com.imutil.tencent.TencentImClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.snack4.ONode;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 历史消息补拉服务实现
|
||||
* <p>
|
||||
* 流程:取最久未补拉的 N 个会话 → 按会话类型调腾讯 API 拉最新消息 →
|
||||
* 逐条 msg_key 幂等(与回调路径共享 {@link MsgKeys} 算法)→ 本地不存在则落库(source=PULL_BACK)+入队分发 → 推进水位线。
|
||||
* <p>
|
||||
* 仅"本次新插入"的消息入队分发,避免对回调已正常落库的消息重复推送业务系统。
|
||||
* 每轮串行执行 + 会话数/条数上限,受腾讯 API QPS 约束。
|
||||
* <p>
|
||||
* C2C 会话需 from+to 配对,水位线仅存对端(conv_id),本端取该会话最近一条消息的 from_account;
|
||||
* 多发送方场景仅覆盖最近一个 from(已知限制,由腾讯 get_roam_msg 成对特性决定)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PullServiceImpl implements PullService {
|
||||
|
||||
@Inject
|
||||
private PullWatermarkMapper pullWatermarkMapper;
|
||||
|
||||
@Inject
|
||||
private ImMessageMapper imMessageMapper;
|
||||
|
||||
@Inject
|
||||
private DistQueueMapper distQueueMapper;
|
||||
|
||||
@Inject
|
||||
private TencentImClient tencentImClient;
|
||||
|
||||
@Inject
|
||||
private TenantService tenantService;
|
||||
|
||||
@Inject("${imutil.pull.convsPerRound:20}")
|
||||
private int convsPerRound;
|
||||
|
||||
@Inject("${imutil.pull.maxMsgPerConv:20}")
|
||||
private int maxMsgPerConv;
|
||||
|
||||
@Inject("${imutil.pull.lookbackMinutes:30}")
|
||||
private int lookbackMinutes;
|
||||
|
||||
@Override
|
||||
public void pullRound() {
|
||||
List<PullWatermark> convs = pullWatermarkMapper.selectNeedCheck(convsPerRound);
|
||||
if (convs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int totalNew = 0;
|
||||
for (PullWatermark wm : convs) {
|
||||
try {
|
||||
totalNew += pullOne(wm);
|
||||
} catch (Throwable e) {
|
||||
// 单会话异常不影响其他会话;仍推进 updated_at 避免该会话一直排在前
|
||||
log.warn("补拉会话异常 tenant={} conv={} : {}", wm.getTenantId(), wm.getConvId(), e.toString());
|
||||
advanceWatermark(wm, 0, null);
|
||||
}
|
||||
}
|
||||
log.info("补拉一轮完成 会话数={} 新增消息={}", convs.size(), totalNew);
|
||||
}
|
||||
|
||||
/**
|
||||
* 补拉单个会话:设置租户上下文(供腾讯 API 审计)→ 按会话类型分发
|
||||
*/
|
||||
private int pullOne(PullWatermark wm) {
|
||||
TenantContext.set(wm.getTenantId());
|
||||
try {
|
||||
if (wm.getConvType() != null && wm.getConvType() == 2) {
|
||||
return pullGroup(wm);
|
||||
}
|
||||
return pullC2C(wm);
|
||||
} finally {
|
||||
TenantContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补拉群消息:getGroupMsg 取最新 N 条
|
||||
*/
|
||||
private int pullGroup(PullWatermark wm) {
|
||||
String resp = tencentImClient.getGroupMsg(wm.getConvId(), maxMsgPerConv);
|
||||
ONode list = extractMsgList(resp, "RspMsgList");
|
||||
if (list == null) {
|
||||
advanceWatermark(wm, 0, null);
|
||||
return 0;
|
||||
}
|
||||
return saveMessages(wm, list, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 补拉单聊消息:getRoamMsg 按时间窗拉取,本端取该会话最近 from
|
||||
*/
|
||||
private int pullC2C(PullWatermark wm) {
|
||||
String to = wm.getConvId();
|
||||
String from = latestFrom(wm.getTenantId(), to);
|
||||
if (from == null) {
|
||||
// 无历史记录无法确定本端账号,跳过本轮
|
||||
advanceWatermark(wm, 0, null);
|
||||
return 0;
|
||||
}
|
||||
long maxInterval = lookbackMinutes * 60L;
|
||||
long minTime = Instant.now().getEpochSecond() - maxInterval;
|
||||
String resp = tencentImClient.getRoamMsg(from, to, maxMsgPerConv, minTime, maxInterval);
|
||||
ONode list = extractMsgList(resp, "MsgList");
|
||||
if (list == null) {
|
||||
advanceWatermark(wm, 0, null);
|
||||
return 0;
|
||||
}
|
||||
return saveMessages(wm, list, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐条幂等落库 + 入队分发,返回新增条数
|
||||
*
|
||||
* @param wm 会话水位线
|
||||
* @param list 消息数组节点
|
||||
* @param isGroup 是否群消息(决定解析方式)
|
||||
*/
|
||||
private int saveMessages(PullWatermark wm, ONode list, boolean isGroup) {
|
||||
long maxSeq = 0;
|
||||
OffsetDateTime maxTime = null;
|
||||
int newCount = 0;
|
||||
for (int i = 0, sz = list.size(); i < sz; i++) {
|
||||
ONode m = list.get(i);
|
||||
ImMessage msg = isGroup ? parseGroupMsg(m, wm.getTenantId()) : parseC2CMsg(m, wm.getTenantId());
|
||||
if (msg == null) {
|
||||
continue;
|
||||
}
|
||||
// 跟踪本轮最大 seq/time 用于推进水位线
|
||||
long seq = m.get("MsgSeq").getLong();
|
||||
if (seq > maxSeq) {
|
||||
maxSeq = seq;
|
||||
}
|
||||
if (msg.getMsgTime() != null && (maxTime == null || msg.getMsgTime().isAfter(maxTime))) {
|
||||
maxTime = msg.getMsgTime();
|
||||
}
|
||||
// 幂等:本地已存在则跳过(不重复分发)
|
||||
long exists = imMessageMapper.selectCount(Wrappers.<ImMessage>lambdaQuery()
|
||||
.eq(ImMessage::getMsgKey, msg.getMsgKey()));
|
||||
if (exists > 0) {
|
||||
continue;
|
||||
}
|
||||
imMessageMapper.insert(msg);
|
||||
newCount++;
|
||||
// 仅新插入的消息入队分发
|
||||
enqueue(wm.getTenantId(), msg);
|
||||
}
|
||||
advanceWatermark(wm, maxSeq, maxTime);
|
||||
return newCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入分发队列(复用回调分发链路,payload 用结构化 JSON)
|
||||
*/
|
||||
private void enqueue(String tenantId, ImMessage msg) {
|
||||
Tenant t = tenantService.getById(tenantId);
|
||||
if (t == null || t.getCallbackUrl() == null || t.getCallbackUrl().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ONode payload = ONode.ofJson("{}");
|
||||
payload.set("source", msg.getSource());
|
||||
payload.set("msgKey", msg.getMsgKey());
|
||||
payload.set("tenantId", tenantId);
|
||||
payload.set("convType", msg.getConvType());
|
||||
payload.set("convId", msg.getConvId());
|
||||
payload.set("fromAccount", msg.getFromAccount());
|
||||
payload.set("toAccount", msg.getToAccount());
|
||||
payload.set("groupId", msg.getGroupId());
|
||||
payload.set("msgTime", msg.getMsgTime());
|
||||
payload.set("msgType", msg.getMsgType());
|
||||
if (msg.getMsgBody() != null) {
|
||||
try {
|
||||
payload.set("msgBody", ONode.ofJson(msg.getMsgBody()));
|
||||
} catch (Exception e) {
|
||||
payload.set("msgBody", msg.getMsgBody());
|
||||
}
|
||||
}
|
||||
DistQueue q = new DistQueue();
|
||||
q.setMsgKey(msg.getMsgKey());
|
||||
q.setTenantId(tenantId);
|
||||
q.setConvId(msg.getConvId());
|
||||
q.setTargetUrl(t.getCallbackUrl());
|
||||
q.setPayload(payload.toJson());
|
||||
q.setStatus(0);
|
||||
q.setRetryCount(0);
|
||||
q.setNextRetryAt(OffsetDateTime.now());
|
||||
distQueueMapper.insert(q);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析群消息节点为 ImMessage
|
||||
*/
|
||||
private ImMessage parseGroupMsg(ONode m, String tenantId) {
|
||||
String groupId = m.get("GroupId").getString();
|
||||
if (groupId == null || groupId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
long msgSeq = m.get("MsgSeq").getLong();
|
||||
long msgRandom = m.get("MsgRandom").getLong();
|
||||
long msgTs = m.get("MsgTimeStamp").getLong();
|
||||
String from = m.get("From_Account").getString();
|
||||
ONode body = m.get("MsgBody");
|
||||
ImMessage msg = new ImMessage();
|
||||
msg.setMsgKey(MsgKeys.build(from, groupId, msgSeq, msgRandom));
|
||||
msg.setTenantId(tenantId);
|
||||
msg.setMsgTime(toTime(msgTs));
|
||||
msg.setConvType(2);
|
||||
msg.setConvId(groupId);
|
||||
msg.setFromAccount(from);
|
||||
msg.setGroupId(groupId);
|
||||
msg.setMsgType(firstBodyType(body));
|
||||
msg.setMsgBody(body.toString());
|
||||
msg.setSource("PULL_BACK");
|
||||
msg.setIsCrossTenant(false);
|
||||
msg.setDistStatus(0);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单聊消息节点为 ImMessage
|
||||
*/
|
||||
private ImMessage parseC2CMsg(ONode m, String tenantId) {
|
||||
long msgSeq = m.get("MsgSeq").getLong();
|
||||
long msgRandom = m.get("MsgRandom").getLong();
|
||||
long msgTs = m.get("MsgTimeStamp").getLong();
|
||||
String from = m.get("From_Account").getString();
|
||||
String to = m.get("To_Account").getString();
|
||||
ONode body = m.get("MsgBody");
|
||||
ImMessage msg = new ImMessage();
|
||||
msg.setMsgKey(MsgKeys.build(from, to, msgSeq, msgRandom));
|
||||
msg.setTenantId(tenantId);
|
||||
msg.setMsgTime(toTime(msgTs));
|
||||
msg.setConvType(1);
|
||||
msg.setConvId(to != null ? to : "");
|
||||
msg.setFromAccount(from);
|
||||
msg.setToAccount(to);
|
||||
msg.setMsgType(firstBodyType(body));
|
||||
msg.setMsgBody(body.toString());
|
||||
msg.setSource("PULL_BACK");
|
||||
msg.setIsCrossTenant(false);
|
||||
msg.setDistStatus(0);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推进水位线(last_seq/last_time 取本轮最大,updated_at 始终刷新驱动轮询顺序)
|
||||
*/
|
||||
private void advanceWatermark(PullWatermark wm, long maxSeq, OffsetDateTime maxTime) {
|
||||
int convType = wm.getConvType() == null ? 1 : wm.getConvType();
|
||||
pullWatermarkMapper.upsert(wm.getTenantId(), wm.getConvId(), convType, maxSeq, maxTime, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查该 C2C 会话最近发送方(配对 get_roam_msg 的本端账号)
|
||||
*/
|
||||
private String latestFrom(String tenantId, String to) {
|
||||
ImMessage latest = imMessageMapper.selectOne(Wrappers.<ImMessage>lambdaQuery()
|
||||
.eq(ImMessage::getTenantId, tenantId)
|
||||
.eq(ImMessage::getConvId, to)
|
||||
.isNotNull(ImMessage::getFromAccount)
|
||||
.ne(ImMessage::getFromAccount, "")
|
||||
.orderByDesc(ImMessage::getMsgTime)
|
||||
.last("LIMIT 1"));
|
||||
return latest == null ? null : latest.getFromAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析腾讯响应,校验 ActionStatus=OK 并返回消息数组节点;失败/空返回 null
|
||||
*
|
||||
* @param resp 腾讯原始响应 JSON
|
||||
* @param listKey 消息列表字段名(C2C=MsgList,GROUP=RspMsgList)
|
||||
*/
|
||||
private ONode extractMsgList(String resp, String listKey) {
|
||||
if (resp == null || resp.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ONode root = ONode.ofJson(resp);
|
||||
if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) {
|
||||
log.warn("补拉腾讯API返回非OK : {}", resp);
|
||||
return null;
|
||||
}
|
||||
ONode list = root.get(listKey);
|
||||
return list.isArray() ? list : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("补拉响应解析失败 : {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消息体首个元素的类型(MsgBody 为数组,如 TIMTextElem/TIMImageElem)
|
||||
*/
|
||||
private String firstBodyType(ONode body) {
|
||||
if (body == null || !body.isArray() || body.size() == 0) {
|
||||
return null;
|
||||
}
|
||||
return body.get(0).get("MsgType").getString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 秒级时间戳转 OffsetDateTime(0/负数兜底为当前时间)
|
||||
*/
|
||||
private OffsetDateTime toTime(long epochSecond) {
|
||||
if (epochSecond <= 0) {
|
||||
return OffsetDateTime.now();
|
||||
}
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.LocalCache;
|
||||
import com.imutil.common.RedisService;
|
||||
import com.imutil.entity.Tenant;
|
||||
import com.imutil.mapper.TenantMapper;
|
||||
import com.imutil.service.TenantService;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 租户服务实现
|
||||
* <p>
|
||||
* app_key → tenant 查询走 Redis 主缓存 + Caffeine 本地兜底,降低 DB 压力。
|
||||
* 缓存 TTL 5 分钟,租户变更需主动 evict。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Component
|
||||
public class TenantServiceImpl implements TenantService {
|
||||
|
||||
private static final String CACHE_KEY_BY_KEY = "imutil:tenant:bykey:";
|
||||
private static final String CACHE_KEY_BY_ID = "imutil:tenant:byid:";
|
||||
private static final long CACHE_TTL_SEC = 300;
|
||||
|
||||
@Inject
|
||||
private TenantMapper tenantMapper;
|
||||
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
|
||||
@Inject
|
||||
private LocalCache localCache;
|
||||
|
||||
@Override
|
||||
public Tenant getByAppKey(String appKey) {
|
||||
if (appKey == null || appKey.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String key = CACHE_KEY_BY_KEY + appKey;
|
||||
// 1. 本地缓存
|
||||
Tenant t = localCache.get(key, Tenant.class);
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
// 2. Redis
|
||||
t = redisService.getJson(key, Tenant.class);
|
||||
if (t == null) {
|
||||
// 3. DB
|
||||
t = tenantMapper.selectOne(Wrappers.<Tenant>lambdaQuery().eq(Tenant::getAppKey, appKey).last("LIMIT 1"));
|
||||
if (t != null) {
|
||||
redisService.setJson(key, t, CACHE_TTL_SEC, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
if (t != null) {
|
||||
localCache.put(key, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Tenant getById(String tenantId) {
|
||||
if (tenantId == null || tenantId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String key = CACHE_KEY_BY_ID + tenantId;
|
||||
Tenant t = localCache.get(key, Tenant.class);
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
t = redisService.getJson(key, Tenant.class);
|
||||
if (t == null) {
|
||||
t = tenantMapper.selectById(tenantId);
|
||||
if (t != null) {
|
||||
redisService.setJson(key, t, CACHE_TTL_SEC, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
if (t != null) {
|
||||
localCache.put(key, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evictCache(String tenantId) {
|
||||
Tenant t = tenantMapper.selectById(tenantId);
|
||||
String k1 = CACHE_KEY_BY_ID + tenantId;
|
||||
redisService.del(k1);
|
||||
localCache.invalidate(k1);
|
||||
if (t != null) {
|
||||
String k2 = CACHE_KEY_BY_KEY + t.getAppKey();
|
||||
redisService.del(k2);
|
||||
localCache.invalidate(k2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.imutil.mapper.UsageStatMapper;
|
||||
import com.imutil.service.UsageStatService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 用量统计服务实现
|
||||
* <p>
|
||||
* 两个数据源各自 upsert 指定列(见 {@link UsageStatMapper}),互不覆盖:
|
||||
* - im_message → im_msg_count / im_dau
|
||||
* - api_call_log → api_call_count
|
||||
* trtc_duration_sec / trtc_max_concurrent_room 待音视频模块实现后补充。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class UsageStatServiceImpl implements UsageStatService {
|
||||
|
||||
@Inject
|
||||
private UsageStatMapper usageStatMapper;
|
||||
|
||||
@Override
|
||||
public void aggregateHour(OffsetDateTime start, OffsetDateTime end) {
|
||||
// statTime 用窗口起点(整点),作为 usage_stat 的统计时刻
|
||||
OffsetDateTime statTime = start;
|
||||
int msgTenants = usageStatMapper.aggregateMsgHour(start, end, statTime);
|
||||
int apiTenants = usageStatMapper.aggregateApiCallHour(start, end, statTime);
|
||||
log.info("用量小时聚合完成 window=[{}, {}) msg租户数={} api租户数={}", start, end, msgTenants, apiTenants);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.imutil.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.imutil.common.BizException;
|
||||
import com.imutil.entity.UserMapping;
|
||||
import com.imutil.mapper.UserMappingMapper;
|
||||
import com.imutil.service.UserMappingService;
|
||||
import com.imutil.tencent.TencentImClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.data.annotation.Tran;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 账号映射服务实现
|
||||
* <p>
|
||||
* im_user_id = tenant_id + '_' + biz_user_id(前缀法)。
|
||||
* 首次创建:调腾讯 account_import 导入 IM 账号 → 写 user_mapping(同事务)。
|
||||
* 兼容账号已存在场景(account_import 失败但 account_check 命中则视为成功)。
|
||||
*
|
||||
* @author imutil
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class UserMappingServiceImpl implements UserMappingService {
|
||||
|
||||
@Inject
|
||||
private UserMappingMapper userMappingMapper;
|
||||
|
||||
@Inject
|
||||
private TencentImClient tencentImClient;
|
||||
|
||||
@Override
|
||||
@Tran
|
||||
public String getOrCreate(String tenantId, String bizUserId, String nick, String faceUrl) {
|
||||
// 0. 拼接 IM UserID 并校验合法性(腾讯约束:UTF-8 ≤32字节,仅字母/数字/下划线/横线)
|
||||
String imUserId = tenantId + "_" + bizUserId;
|
||||
validateImUserId(imUserId);
|
||||
|
||||
// 1. 查映射是否存在
|
||||
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
|
||||
.eq(UserMapping::getTenantId, tenantId)
|
||||
.eq(UserMapping::getBizUserId, bizUserId)
|
||||
.last("LIMIT 1"));
|
||||
if (exist != null) {
|
||||
// 封禁账号(status=0)拒绝签发 UserSig
|
||||
if (exist.getStatus() != null && exist.getStatus() == 0) {
|
||||
throw new BizException(403, "IM 账号已封禁: " + imUserId);
|
||||
}
|
||||
return exist.getImUserId();
|
||||
}
|
||||
|
||||
// 2. 不存在则导入 IM 账号
|
||||
boolean ok = tencentImClient.accountImport(imUserId, nick, faceUrl);
|
||||
if (!ok) {
|
||||
// 兼容账号已存在(重试/迁移场景)
|
||||
if (!tencentImClient.accountCheck(imUserId)) {
|
||||
throw new BizException("创建 IM 账号失败: " + imUserId);
|
||||
}
|
||||
log.info("IM 账号已存在,补写映射 imUserId={}", imUserId);
|
||||
}
|
||||
|
||||
// 3. 写映射
|
||||
UserMapping m = new UserMapping();
|
||||
m.setTenantId(tenantId);
|
||||
m.setBizUserId(bizUserId);
|
||||
m.setImUserId(imUserId);
|
||||
m.setIsDefault(false);
|
||||
m.setIsGlobal(false);
|
||||
m.setStatus(1);
|
||||
userMappingMappingInsert(m);
|
||||
return imUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入映射,并发下唯一索引冲突则回查(幂等)
|
||||
*/
|
||||
private void userMappingMappingInsert(UserMapping m) {
|
||||
try {
|
||||
userMappingMapper.insert(m);
|
||||
} catch (Exception e) {
|
||||
UserMapping exist = userMappingMapper.selectOne(Wrappers.<UserMapping>lambdaQuery()
|
||||
.eq(UserMapping::getImUserId, m.getImUserId())
|
||||
.last("LIMIT 1"));
|
||||
if (exist == null) {
|
||||
throw new BizException(500, "写入用户映射失败: " + m.getImUserId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 IM UserID 合法性
|
||||
* <p>
|
||||
* 腾讯 IM 约束:长度 ≤ 32 字节(UTF-8),允许字母/数字/下划线/横线。
|
||||
* bizUserId 由业务系统传入,可能含中文或特殊字符,需在拼出 imUserId 后前置校验,
|
||||
* 避免透传腾讯后台的错误码(业务侧语意不清晰)。
|
||||
*
|
||||
* @param imUserId 待校验的 IM 用户ID
|
||||
*/
|
||||
private void validateImUserId(String imUserId) {
|
||||
if (imUserId == null || imUserId.isEmpty()) {
|
||||
throw new BizException(400, "IM UserID 不能为空");
|
||||
}
|
||||
if (imUserId.getBytes(StandardCharsets.UTF_8).length > 32) {
|
||||
throw new BizException(400, "IM UserID 过长(>32字节),请缩短 bizUserId");
|
||||
}
|
||||
for (int i = 0; i < imUserId.length(); i++) {
|
||||
char c = imUserId.charAt(i);
|
||||
boolean legal = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
if (!legal) {
|
||||
throw new BizException(400, "IM UserID 含非法字符 '" + c + "',仅允许字母/数字/下划线/横线");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user