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:
yixiong
2026-07-08 20:28:32 +08:00
co-authored by Claude
parent 20bd78baa8
commit d22fdcfaad
80 changed files with 4955 additions and 0 deletions
+14
View File
@@ -24,3 +24,17 @@
hs_err_pid*
replay_pid*
# Maven 构建产物
target/
logs/
# IDE
.idea/
*.iml
.vscode/
.settings/
.project
.classpath
# 本地配置覆盖(密钥等敏感信息)
app-env.yml
+157
View File
@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 参考 yxtech,沿用 Solon v3.10.0 parent -->
<parent>
<groupId>org.noear</groupId>
<artifactId>solon-parent</artifactId>
<version>3.10.0</version>
<relativePath/>
</parent>
<groupId>com.imutil</groupId>
<artifactId>tencent-im-util</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<description>腾讯 IM 多系统合并分发工具</description>
<properties>
<java.version>21</java.version>
<postgresql.version>42.7.5</postgresql.version>
<hikaricp.version>7.0.2</hikaricp.version>
<jedis.version>5.1.0</jedis.version>
<caffeine.version>3.1.8</caffeine.version>
</properties>
<dependencies>
<!-- Solon Web 核心 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-web</artifactId>
</dependency>
<!-- 鉴权:Sa-Token(沿用 yxtech -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-solon-plugin</artifactId>
<version>1.44.0</version>
</dependency>
<!-- 腾讯云 UserSig 官方 SDKtls-sig-api-v2,对齐官方 HMAC-SHA256+zlib 算法,避免手写出错) -->
<dependency>
<groupId>com.github.tencentyun</groupId>
<artifactId>tls-sig-api-v2</artifactId>
<version>2.0</version>
</dependency>
<!-- 视图层:FreeMarker(管理后台后端渲染,对齐 yxtech) -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-view-freemarker</artifactId>
</dependency>
<!-- 数据访问:MyBatis-Plus for Solon -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>mybatis-plus-extension-solon-plugin</artifactId>
</dependency>
<!-- PostgreSQL 驱动(替代 MySQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 数据库连接池 -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<!-- Redis:限流/缓存/水位线(直接用 Jedis 封装,避免引入不确定的 Solon-redis 版本) -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<!-- 本地热缓存兜底 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<!-- 日志 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-logging-logback-jakarta</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- 定时任务(Solon Scheduling -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling</artifactId>
</dependency>
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling-simple</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.noear</groupId>
<artifactId>solon-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<!-- 腾讯云镜像,加速依赖下载 -->
<repositories>
<repository>
<id>tencent</id>
<url>https://mirrors.cloud.tencent.com/nexus/repository/maven-public/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>
+24
View File
@@ -0,0 +1,24 @@
package com.imutil;
import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
import org.noear.solon.scheduling.annotation.EnableScheduling;
/**
* 腾讯 IM 多系统合并分发工具 - 应用入口
* <p>
* 职责:启动 Solon 容器,加载各模块(回调网关、分发工作线程、UserSig、补拉、统计等)。
* <p>
* {@code @EnableScheduling} 启用 Solon 定时任务,配合 solon-scheduling-simple 驱动,
* 用于补拉巡检、分区自建、死信重置、用量统计等定时作业。
*
* @author imutil
*/
@SolonMain
@EnableScheduling
public class App {
public static void main(String[] args) {
Solon.start(App.class, args);
}
}
@@ -0,0 +1,35 @@
package com.imutil.common;
import lombok.Getter;
/**
* 业务异常
* <p>
* 业务逻辑中主动抛出,由 {@link com.imutil.filter.GlobalExceptionFilter} 捕获后
* 以对应 code 返回前端。默认 code=500。
*
* @author imutil
*/
@Getter
public class BizException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** 返回代码 */
private final int code;
public BizException(String message) {
super(message);
this.code = 500;
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}
@@ -0,0 +1,67 @@
package com.imutil.common;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* HTTP 工具封装
* <p>
* 基于 JDK 21 内置 java.net.http.HttpClient,无第三方依赖。
* 用于分发工作线程向业务系统转发回调、调用腾讯 REST API。
*
* @author imutil
*/
public final class Httpx {
/** 全局复用 HttpClient(线程安全) */
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private Httpx() {
}
/**
* POST JSON 请求,返回 HTTP 状态码
*
* @param url 目标地址
* @param body JSON 请求体
* @return HTTP 状态码;2xx 视为成功
*/
public static int postJson(String url, String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
return resp.statusCode();
}
/**
* POST JSON 请求,返回状态码与响应体
*/
public static Response postJsonDetail(String url, String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
return new Response(resp.statusCode(), resp.body());
}
/**
* 简单响应封装
*/
public record Response(int statusCode, String body) {
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package com.imutil.common;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
/**
* ID 生成工具
* <p>
* 基于 MyBatis-Plus 内置雪花算法({@link IdWorker})生成全局唯一 ID。
* 提供字符串形式以规避 Long 雪花 ID 经 JSON 传前端时精度丢失(>2^53)。
*
* @author imutil
*/
public final class Ids {
private Ids() {
}
/**
* 生成雪花 IDlong
*/
public static long nextId() {
return IdWorker.getId();
}
/**
* 生成雪花 ID(字符串)
*/
public static String nextIdStr() {
return String.valueOf(IdWorker.getId());
}
}
@@ -0,0 +1,49 @@
package com.imutil.common;
import org.noear.snack4.ONode;
import java.lang.reflect.Type;
/**
* JSON 工具封装
* <p>
* 基于 Solon 内置的 snack4{@link ONode}),不额外引入 fastjson/jackson,保持依赖精简。
* 用于 Redis 缓存对象序列化、回调消息体解析等。
*
* @author imutil
*/
public final class Jsons {
private Jsons() {
}
/**
* 对象序列化为 JSON 字符串
*/
public static String stringify(Object obj) {
if (obj == null) {
return null;
}
return ONode.serialize(obj);
}
/**
* JSON 字符串反序列化为对象
*/
public static <T> T parse(String json, Class<T> clazz) {
if (json == null || json.isEmpty()) {
return null;
}
return ONode.deserialize(json, (Type) clazz);
}
/**
* 对象转换为指定类型:先序列化再反序列化
*/
public static <T> T convert(Object obj, Class<T> clazz) {
if (obj == null) {
return null;
}
return parse(stringify(obj), clazz);
}
}
@@ -0,0 +1,72 @@
package com.imutil.common;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.noear.solon.annotation.Component;
import java.util.concurrent.TimeUnit;
/**
* 进程内本地缓存(Caffeine
* <p>
* 作为 Redis 的兜底热缓存,承载映射/授权等高频读、短 TTL 的数据,
* 减少网络往返。多节点下数据可能短暂不一致,变更时需主动失效并依赖 Redis 兜底。
*
* @author imutil
*/
@Component
public class LocalCache {
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
/**
* 读取缓存值,不存在返回 null
*/
public String get(String key) {
return cache.getIfPresent(key);
}
/**
* 读取并反序列化为对象
*/
public <T> T get(String key, Class<T> clazz) {
String v = cache.getIfPresent(key);
if (v == null) {
return null;
}
return Jsons.parse(v, clazz);
}
/**
* 写入字符串
*/
public void put(String key, String value) {
cache.put(key, value);
}
/**
* 写入对象(JSON 序列化)
*/
public void put(String key, Object value) {
if (value != null) {
cache.put(key, Jsons.stringify(value));
}
}
/**
* 失效指定键
*/
public void invalidate(String key) {
cache.invalidate(key);
}
/**
* 清空全部缓存
*/
public void invalidateAll() {
cache.invalidateAll();
}
}
@@ -0,0 +1,32 @@
package com.imutil.common;
/**
* 消息唯一键生成工具
* <p>
* 统一回调路径与补拉路径的 msg_key 算法,确保同一条消息无论从哪条路径进入
* 都生成相同 key,从而跨路径幂等去重(回调已落库的消息不会被补拉重复落库)。
* <p>
* 腾讯侧一条消息由 (from, 会话目标, MsgSeq, MsgRandom) 唯一确定。
* 不含 CallbackCommand:同一条消息的发送/接收回调 command 不同,含 command 反而不利于去重。
*
* @author imutil
*/
public final class MsgKeys {
private MsgKeys() {
}
/**
* 生成消息唯一键
*
* @param fromAccount 发送方账号,可空
* @param convTarget 会话目标(GROUP=群IDC2C=对端账号)
* @param msgSeq 腾讯 MsgSeq
* @param msgRandom 腾讯 MsgRandom
* @return msg_key,格式 from:convTarget:msgSeq:msgRandom
*/
public static String build(String fromAccount, String convTarget, long msgSeq, long msgRandom) {
return (fromAccount == null ? "" : fromAccount) + ":" +
(convTarget == null ? "" : convTarget) + ":" + msgSeq + ":" + msgRandom;
}
}
@@ -0,0 +1,84 @@
package com.imutil.common;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 密码哈希工具(PBKDF2WithHmacSHA256JDK 内置,无额外依赖)
* <p>
* 存储格式:{@code iterations:saltBase64:hashBase64}。
* verify 时按存储的 iterations/salt 重新推导并常量时间比对。
* 每次哈希用随机 salt,防彩虹表。
*
* @author imutil
*/
public final class PasswordUtil {
/** 迭代次数(OWASP 2023 建议 PBKDF2-HMAC-SHA256 ≥ 120000 */
private static final int ITERATIONS = 120_000;
private static final int KEY_BITS = 256;
private static final int SALT_BYTES = 16;
private static final String ALGO = "PBKDF2WithHmacSHA256";
private PasswordUtil() {
}
/**
* 对明文密码哈希,返回 "iterations:salt:hash"
*/
public static String hash(String rawPassword) {
byte[] salt = new byte[SALT_BYTES];
new SecureRandom().nextBytes(salt);
byte[] dk = derive(rawPassword, salt, ITERATIONS);
return ITERATIONS + ":" + b64(salt) + ":" + b64(dk);
}
/**
* 校验明文密码与存储hash是否匹配
*/
public static boolean verify(String rawPassword, String stored) {
if (rawPassword == null || stored == null) {
return false;
}
String[] parts = stored.split(":");
if (parts.length != 3) {
return false;
}
try {
int iter = Integer.parseInt(parts[0]);
byte[] salt = Base64.getDecoder().decode(parts[1]);
byte[] expected = Base64.getDecoder().decode(parts[2]);
byte[] actual = derive(rawPassword, salt, iter);
return constantTimeEquals(expected, actual);
} catch (Exception e) {
return false;
}
}
private static byte[] derive(String rawPassword, byte[] salt, int iter) {
try {
PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iter, KEY_BITS);
return SecretKeyFactory.getInstance(ALGO).generateSecret(spec).getEncoded();
} catch (Exception e) {
throw new IllegalStateException("PBKDF2 推导失败", e);
}
}
private static String b64(byte[] b) {
return Base64.getEncoder().encodeToString(b);
}
/** 常量时间比较,防时序攻击 */
private static boolean constantTimeEquals(byte[] a, byte[] b) {
if (a.length != b.length) {
return false;
}
int r = 0;
for (int i = 0; i < a.length; i++) {
r |= a[i] ^ b[i];
}
return r == 0;
}
}
@@ -0,0 +1,51 @@
package com.imutil.common;
import java.util.Set;
/**
* 鉴权白名单路径
* <p>
* 以下路径不走 app_key 租户鉴权:
* - /callback/** 腾讯回调入口(走签名校验,见 T5)
* - /health 健康检查
* - /favicon.ico
*
* @author imutil
*/
public final class PathWhitelist {
/** 白名单路径前缀(contextPath 之后的部分) */
public static final Set<String> PREFIXES = Set.of(
"/callback",
"/health",
"/admin"
);
/** 精确匹配的白名单路径 */
public static final Set<String> EXACT = Set.of(
"/favicon.ico"
);
private PathWhitelist() {
}
/**
* 判断路径是否在白名单中
*
* @param path 请求路径(已去除 contextPath
*/
public static boolean isWhitelisted(String path) {
if (path == null || path.isEmpty()) {
return false;
}
if (EXACT.contains(path)) {
return true;
}
for (String p : PREFIXES) {
if (path.startsWith(p)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,54 @@
package com.imutil.common;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* Redis 连接池配置
* <p>
* 读取 app.yml 的 imutil.redis.* 配置,构建 JedisPool 注册为 Solon Bean。
* 用于限流令牌桶、映射/授权缓存、补拉水位线等高频读写场景。
*
* @author imutil
*/
@Configuration
public class RedisConfig {
@Inject("${imutil.redis.host:localhost}")
private String host;
@Inject("${imutil.redis.port:6379}")
private int port;
@Inject("${imutil.redis.password:}")
private String password;
@Inject("${imutil.redis.database:0}")
private int database;
@Inject("${imutil.redis.timeout:2000}")
private int soTimeout;
@Inject("${imutil.redis.connectTimeout:2000}")
private int connectTimeout;
/**
* 构建 Jedis 连接池
*/
@Bean
public JedisPool jedisPool() {
GenericObjectPoolConfig<Jedis> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(16);
config.setMaxIdle(8);
config.setMinIdle(2);
config.setTestWhileIdle(true);
config.setTimeBetweenEvictionRunsMillis(30_000);
// 空密码兼容:Postgres/Redis 无密码场景传 null,避免鉴权失败
String pwd = (password == null || password.isEmpty()) ? null : password;
return new JedisPool(config, host, port, connectTimeout, soTimeout, pwd, database, null);
}
}
@@ -0,0 +1,142 @@
package com.imutil.common;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;
import java.util.concurrent.TimeUnit;
/**
* Redis 操作封装
* <p>
* 基于 JedisPool 的轻量封装,覆盖限流/缓存/水位线所需能力:
* 通用 KV、带 TTL 的 set、setNx(分布式锁/令牌)、incr 计数、过期时间。
*
* @author imutil
*/
@Component
public class RedisService {
@Inject
private JedisPool jedisPool;
/**
* 设置键值(无过期)
*/
public void set(String key, String value) {
try (Jedis j = jedisPool.getResource()) {
j.set(key, value);
}
}
/**
* 设置键值并指定 TTL(秒)
*/
public void setex(String key, String value, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
j.setex(key, ttlSeconds, value);
}
}
/**
* 读取键值
*/
public String get(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.get(key);
}
}
/**
* 读取并反序列化为对象(JSON)
*/
public <T> T getJson(String key, Class<T> clazz) {
String v = get(key);
if (v == null) {
return null;
}
return Jsons.parse(v, clazz);
}
/**
* 设置对象为 JSON 并指定 TTL
*/
public void setJson(String key, Object obj, long ttl, TimeUnit unit) {
setex(key, Jsons.stringify(obj), unit.toSeconds(ttl));
}
/**
* 删除键
*/
public void del(String key) {
try (Jedis j = jedisPool.getResource()) {
j.del(key);
}
}
/**
* 键是否存在
*/
public boolean exists(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.exists(key);
}
}
/**
* 自增计数(原子)
*/
public long incr(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.incr(key);
}
}
/**
* 自增并设置 TTL(用于固定窗口计数限流)
*
* @return 自增后的值
*/
public long incrWithTtl(String key, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
long n = j.incr(key);
if (n == 1L) {
j.expire(key, ttlSeconds);
}
return n;
}
}
/**
* SET NX + EX:仅当键不存在时设置,并带过期
* 用于分布式锁、单次投放
*
* @return true=获取成功
*/
public boolean setNxEx(String key, String value, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
String r = j.set(key, value, SetParams.setParams().nx().ex(ttlSeconds));
return "OK".equals(r);
}
}
/**
* 设置过期时间
*/
public void expire(String key, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
j.expire(key, ttlSeconds);
}
}
/**
* 读取过期时间(秒),-1=无过期,-2=不存在
*/
public long ttl(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.ttl(key);
}
}
}
@@ -0,0 +1,54 @@
package com.imutil.common;
/**
* 租户上下文(请求级 ThreadLocal
* <p>
* 由 {@link com.imutil.filter.TenantAuthFilter} 在请求入口解析并设置,
* 业务层通过 {@link #get()} 获取当前租户,所有数据查询强制带 tenant_id 过滤,
* 实现"系统 A 绝对拿不到系统 B 数据"的隔离。
* <p>
* 必须在请求结束时 {@link #clear()} 清理,避免线程复用串租户。
*
* @author imutil
*/
public final class TenantContext {
private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();
private TenantContext() {
}
/**
* 设置当前租户ID
*/
public static void set(String tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取当前租户ID
*
* @return 租户ID,未鉴权场景返回 null
*/
public static String get() {
return TENANT_HOLDER.get();
}
/**
* 获取当前租户ID,未设置则抛业务异常
*/
public static String require() {
String t = TENANT_HOLDER.get();
if (t == null) {
throw new BizException(401, "租户上下文缺失,请通过 app_key 鉴权");
}
return t;
}
/**
* 清理上下文(必须在请求结束时调用)
*/
public static void clear() {
TENANT_HOLDER.remove();
}
}
@@ -0,0 +1,253 @@
package com.imutil.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.imutil.entity.AdminUser;
import com.imutil.entity.CrossTenantGrant;
import com.imutil.entity.DistQueue;
import com.imutil.entity.Tenant;
import com.imutil.entity.UsageStat;
import com.imutil.mapper.CrossTenantGrantMapper;
import com.imutil.mapper.DistQueueMapper;
import com.imutil.mapper.TenantMapper;
import com.imutil.mapper.UsageStatMapper;
import com.imutil.service.AdminUserService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Get;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.annotation.Post;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.ModelAndView;
/**
* 管理后台控制器
* <p>
* 路由前缀 /admin。鉴权由 {@link com.imutil.filter.AdminAuthFilter} 拦截(除 login/logout)。
* 页面用 FreeMarker 渲染(对齐 yxtech),数据 CRUD 直连对应 Mapper。
*
* @author imutil
*/
@Mapping("/admin")
@Controller
@Slf4j
public class AdminController {
@Inject("${server.contextPath:}")
private String basePath;
@Inject
private AdminUserService adminUserService;
@Inject
private TenantMapper tenantMapper;
@Inject
private CrossTenantGrantMapper grantMapper;
@Inject
private DistQueueMapper distQueueMapper;
@Inject
private UsageStatMapper usageStatMapper;
// ==================== 登录 / 登出 ====================
@Get
@Mapping("")
public void index(Context ctx) throws Throwable {
ctx.redirect(basePath + "/admin/home");
}
@Get
@Mapping("/login")
public Object loginPage(@Param(defaultValue = "") String error) {
if (StpUtil.isLogin()) {
// 已登录不再渲染登录页(重定向由调用方处理,此处仍渲染避免死循环)
}
ModelAndView mv = new ModelAndView("login.ftl");
mv.put("basePath", basePath);
if ("1".equals(error)) {
mv.put("errorMsg", "用户名或密码错误");
}
return mv;
}
@Post
@Mapping("/login")
public void doLogin(@Param(defaultValue = "") String username,
@Param(defaultValue = "") String password,
Context ctx) throws Throwable {
AdminUser u = adminUserService.login(username, password);
if (u != null) {
StpUtil.login(u.getId());
log.info("管理后台登录成功 id={} username={}", u.getId(), username);
ctx.redirect(basePath + "/admin/home");
} else {
log.warn("管理后台登录失败 username={}", username);
ctx.redirect(basePath + "/admin/login?error=1");
}
}
@Get
@Mapping("/logout")
public void logout(Context ctx) throws Throwable {
StpUtil.logout();
ctx.redirect(basePath + "/admin/login");
}
// ==================== 首页(仪表盘) ====================
@Get
@Mapping("/home")
public Object home() {
ModelAndView mv = view("home.ftl", "仪表盘", "home");
mv.put("tenantCount", tenantMapper.selectCount(null));
mv.put("queuePending", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0)));
mv.put("queueDead", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)));
mv.put("grantActive", grantMapper.selectCount(Wrappers.<CrossTenantGrant>lambdaQuery()
.eq(CrossTenantGrant::getStatus, 1)));
return mv;
}
// ==================== 租户管理 ====================
@Get
@Mapping("/tenant")
public Object tenantPage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("tenant.ftl", "租户管理", "tenant");
mv.put("tenants", tenantMapper.selectList(null));
mv.put("msg", msg);
return mv;
}
@Post
@Mapping("/tenant/save")
public void tenantSave(Tenant t, Context ctx) throws Throwable {
if (t.getTenantId() == null || t.getTenantId().isEmpty()) {
ctx.redirect(basePath + "/admin/tenant?msg=tenant_id_required");
return;
}
if (t.getPrefixCode() == null || t.getPrefixCode().isEmpty()) {
t.setPrefixCode(t.getTenantId());
}
if (t.getStatus() == null) {
t.setStatus(1);
}
if (tenantMapper.selectById(t.getTenantId()) == null) {
tenantMapper.insert(t);
} else {
tenantMapper.updateById(t);
}
ctx.redirect(basePath + "/admin/tenant?msg=saved");
}
@Post
@Mapping("/tenant/toggle")
public void tenantToggle(@Param String tenantId, @Param Integer status, Context ctx) throws Throwable {
Tenant t = new Tenant();
t.setTenantId(tenantId);
t.setStatus(status == null ? 0 : status);
tenantMapper.updateById(t);
ctx.redirect(basePath + "/admin/tenant?msg=toggled");
}
// ==================== 跨租户授权 ====================
@Get
@Mapping("/grant")
public Object grantPage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("grant.ftl", "跨租户授权", "grant");
mv.put("grants", grantMapper.selectList(null));
mv.put("tenants", tenantMapper.selectList(null));
mv.put("msg", msg);
return mv;
}
@Post
@Mapping("/grant/save")
public void grantSave(CrossTenantGrant g, Context ctx) throws Throwable {
if (g.getStatus() == null) {
g.setStatus(1);
}
if (g.getDirection() == null) {
g.setDirection(0);
}
if (g.getGrantId() == null) {
grantMapper.insert(g);
} else {
grantMapper.updateById(g);
}
ctx.redirect(basePath + "/admin/grant?msg=saved");
}
@Post
@Mapping("/grant/revoke")
public void grantRevoke(@Param Long grantId, Context ctx) throws Throwable {
CrossTenantGrant g = new CrossTenantGrant();
g.setGrantId(grantId);
g.setStatus(0);
grantMapper.updateById(g);
ctx.redirect(basePath + "/admin/grant?msg=revoked");
}
// ==================== 队列监控 / 死信重发 ====================
@Get
@Mapping("/queue")
public Object queuePage(@Param(defaultValue = "") String msg) {
ModelAndView mv = view("queue.ftl", "队列监控", "queue");
mv.put("pendings", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 0)
.orderByAsc(DistQueue::getId)
.last("LIMIT 50")));
mv.put("deads", distQueueMapper.selectList(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)
.orderByDesc(DistQueue::getId)
.last("LIMIT 50")));
mv.put("deadCount", distQueueMapper.selectCount(Wrappers.<DistQueue>lambdaQuery()
.eq(DistQueue::getStatus, 3)));
mv.put("msg", msg);
return mv;
}
@Post
@Mapping("/queue/redeliver")
public void redeliver(@Param Long id, Context ctx) throws Throwable {
int n = distQueueMapper.redeliverDead(id);
ctx.redirect(basePath + "/admin/queue?msg=" + (n > 0 ? "redelivered" : "not_dead"));
}
// ==================== 用量报表 ====================
@Get
@Mapping("/usage")
public Object usagePage() {
ModelAndView mv = view("usage.ftl", "用量报表", "usage");
mv.put("stats", usageStatMapper.selectList(Wrappers.<UsageStat>lambdaQuery()
.orderByDesc(UsageStat::getStatTime)
.last("LIMIT 100")));
return mv;
}
// ==================== 公共:构造页面模型 ====================
/**
* 构造带侧边栏布局的 ModelAndView
*
* @param tpl 模板名(如 home.ftl
* @param title 页面标题
* @param active 侧边栏高亮项(home/tenant/grant/queue/usage
*/
private ModelAndView view(String tpl, String title, String active) {
ModelAndView mv = new ModelAndView(tpl);
mv.put("basePath", basePath);
mv.put("pageTitle", title);
mv.put("active", active);
return mv;
}
}
@@ -0,0 +1,67 @@
package com.imutil.controller;
import com.imutil.service.CallbackService;
import com.imutil.tencent.TencentCallbackSign;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.MethodType;
/**
* 腾讯 IM 回调接收网关
* <p>
* 腾讯控制台将回调 URL 配置为 http://host/imutil/callback/im 。
* 本控制器负责:签名校验(防伪造+防重放)→ 委托 CallbackService 落库+入队 → 5s 内返回 ActionResults。
* <p>
* 注意:仅做"落库+写队列"即返回,HTTP 转发业务系统由分发工作线程异步进行(T6),
* 确保腾讯回调在 5 秒内得到响应,避免被腾讯判定失败。
*
* @author imutil
*/
@Slf4j
@Controller
public class CallbackController {
@Inject
private CallbackService callbackService;
@Inject("${imutil.tencent.callbackToken:}")
private String callbackToken;
/**
* 腾讯 IM 回调统一入口
* URL 参数:SdkAppid / CallbackCommand / Sign / RequestTime / contenttype 等
*/
@Mapping(value = "/callback/im", method = MethodType.POST)
public void callback(Context ctx,
@Param(value = "CallbackCommand", required = false) String command,
@Param(value = "Sign", required = false) String sign,
@Param(value = "RequestTime", required = false) String requestTime,
@Param(value = "SdkAppid", required = false) String sdkAppid) throws Throwable {
String body = ctx.body();
// 1. 签名校验(未配置 Token 时跳过,便于联调,生产必须配置)
if (callbackToken != null && !callbackToken.isEmpty()) {
if (!TencentCallbackSign.verify(callbackToken, requestTime, sign)) {
log.warn("回调签名校验失败 command={} sdkAppid={} requestTime={}", command, sdkAppid, requestTime);
ctx.output("{\"ActionStatus\":\"FAIL\",\"ErrorCode\":401,\"ErrorInfo\":\"sign invalid\"}");
return;
}
}
// 2. 落库 + 入队(同事务,快)
String result;
try {
result = callbackService.handleCallback(command, body);
} catch (Throwable e) {
log.error("回调处理异常 command={} sdkAppid={}", command, sdkAppid, e);
// 处理异常仍返回 OK,避免腾讯反复重试同一回调(消息已可能在事务中落库)
// 落库失败的消息由补拉服务(T10)兜底
result = "{\"ActionStatus\":\"OK\",\"ErrorCode\":0,\"ErrorInfo\":\"\"}";
}
ctx.output(result);
}
}
@@ -0,0 +1,68 @@
package com.imutil.controller;
import com.imutil.common.BizException;
import com.imutil.common.TenantContext;
import com.imutil.model.Result;
import com.imutil.service.UserMappingService;
import com.imutil.tencent.UserSigUtil;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.core.handle.MethodType;
import java.util.HashMap;
import java.util.Map;
/**
* UserSig 签发接口
* <p>
* 业务系统后端在客户端登录时调用本接口,本工具用 SecretKey 生成 UserSig 返回。
* 客户端拿 im_user_id + UserSig 直连腾讯 IM SDK 收发消息(控制面走本工具,数据面走腾讯)。
* <p>
* 鉴权:走 {@link com.imutil.filter.TenantAuthFilter},需带 X-App-Key/X-App-Secret。
*
* @author imutil
*/
@Controller
public class SigController {
@Inject
private UserMappingService userMappingService;
@Inject("${imutil.tencent.sdkAppId:0}")
private long sdkAppId;
@Inject("${imutil.tencent.secretKey:}")
private String secretKey;
@Inject("${imutil.tencent.usersigExpireDays:7}")
private int expireDays;
/**
* 签发 UserSig(首次自动创建 IM 账号)
* <p>
* 入参:bizUserId(必填)、nick/faceUrl(可选,首次创建时同步到 IM)
*/
@Mapping(value = "/sig/generate", method = MethodType.POST)
public Result<?> generate(@Param("bizUserId") String bizUserId,
@Param(value = "nick", required = false) String nick,
@Param(value = "faceUrl", required = false) String faceUrl) {
if (bizUserId == null || bizUserId.isEmpty()) {
throw new BizException(400, "bizUserId 必填");
}
String tenantId = TenantContext.require();
// 获取或创建 IM 账号
String imUserId = userMappingService.getOrCreate(tenantId, bizUserId, nick, faceUrl);
// 签发 UserSig
long expireSec = expireDays * 86400L;
String userSig = UserSigUtil.genSig(sdkAppId, secretKey, imUserId, expireSec);
Map<String, Object> data = new HashMap<>();
data.put("imUserId", imUserId);
data.put("userSig", userSig);
data.put("expireSeconds", expireSec);
data.put("sdkAppId", String.valueOf(sdkAppId));
return Result.ok(data);
}
}
@@ -0,0 +1,38 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 管理后台用户实体
* <p>
* 用于 Sa-Token 登录鉴权。密码以 PBKDF2 哈希存储(格式见 {@link com.imutil.common.PasswordUtil})。
* roleadmin=超级管理员,viewer=只读(预留)。
*
* @author imutil
*/
@Data
@TableName("admin_user")
public class AdminUser {
@TableId(type = IdType.AUTO)
private Long id;
/** 登录用户名 */
private String username;
/** 密码hash(格式 iterations:salt:hash,见 PasswordUtil */
private String passwordHash;
/** 角色:admin / viewer */
private String role;
/** 状态:1=启用 0=停用 */
private Integer status;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,36 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 腾讯管理 API 调用审计实体
* <p>
* 所有调腾讯后台 API 的操作记录,便于配额核算与问题追溯。
*
* @author imutil
*/
@Data
@TableName("api_call_log")
public class ApiCallLog {
@TableId(type = IdType.AUTO)
private Long id;
private String tenantId;
private String apiName;
/** 入参 JSON */
private String params;
private String result;
private String caller;
private OffsetDateTime calledAt;
}
@@ -0,0 +1,33 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 跨租户通讯审计实体
* <p>
* 所有经授权放行的跨租户消息单独审计,便于追溯。
*
* @author imutil
*/
@Data
@TableName("cross_tenant_audit")
public class CrossTenantAudit {
@TableId(type = IdType.AUTO)
private Long id;
private Long grantId;
private String msgKey;
private String fromImUserId;
private String toImUserId;
private OffsetDateTime actionTime;
}
@@ -0,0 +1,51 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 跨租户通讯授权实体
* <p>
* 用于 3.4 跨租户拦截的放行依据。from_im_user_id / to_im_user_id 为 NULL
* 分别表示授权方任意账户 / 目标租户全员。
*
* @author imutil
*/
@Data
@TableName("cross_tenant_grant")
public class CrossTenantGrant {
@TableId(type = IdType.ASSIGN_ID)
private Long grantId;
private String fromTenantId;
private String fromImUserId;
private String toTenantId;
private String toImUserId;
/** 权限:send_msg,add_friend,join_group */
private String permissions;
/** 方向:0=单向 1=双向 */
private Integer direction;
private OffsetDateTime startAt;
private OffsetDateTime endAt;
/** 1=active 0=revoked 2=expired */
private Integer status;
private String approvedByFrom;
private String approvedByTo;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,52 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 分发队列实体(替代 MQ
* <p>
* 回调网关同事务写入 pending,分发工作线程 FOR UPDATE SKIP LOCKED 抢占消费。
* id 为 bigserial 严格递增,保证同会话消息按 id 顺序消费。
*
* @author imutil
*/
@Data
@TableName("dist_queue")
public class DistQueue {
/** 自增ID(消费顺序依据) */
@TableId(type = IdType.AUTO)
private Long id;
private String msgKey;
private String tenantId;
private String convId;
/** 业务系统回调地址 */
private String targetUrl;
/** 分发给业务系统的回调快照 JSON */
private String payload;
/** 状态:0=pending 1=processing 2=done 3=dead */
private Integer status;
private Integer retryCount;
private OffsetDateTime nextRetryAt;
private String lockedBy;
private OffsetDateTime lockedAt;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
}
@@ -0,0 +1,34 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 群映射实体
* <p>
* im_group_id 由本工具统一分配,避免各系统自建群号撞号。
*
* @author imutil
*/
@Data
@TableName("group_mapping")
public class GroupMapping {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private String bizGroupId;
private String imGroupId;
/** 群类型:Public/Private/ChatRoom/AVChatRoom */
private String groupType;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,60 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 消息主表实体(按月 RANGE 分区)
* <p>
* 复合主键 (msg_key, msg_time)msg_time 同时为分区键。
* 注意:msg_time 为分区键,禁止更新(更新分区键会触发行迁移错误)。
* msg_body 存原始回调 JSON 字符串。
*
* @author imutil
*/
@Data
@TableName("im_message")
public class ImMessage {
/** 腾讯 MsgKey,去重用 */
@TableId(type = IdType.INPUT)
private String msgKey;
/** 租户ID(取发送方所属租户) */
private String tenantId;
/** 消息时间(分区键) */
private OffsetDateTime msgTime;
/** 会话类型:1=C2C 2=GROUP */
private Integer convType;
/** 会话IDC2C=对端账号 GROUP=群ID */
private String convId;
private String fromAccount;
private String toAccount;
private String groupId;
private String msgType;
/** 消息体原始 JSON */
private String msgBody;
/** 来源:CALLBACK/PULL_BACK/IMPORT */
private String source;
/** 是否跨租户授权通讯 */
private Boolean isCrossTenant;
/** 分发状态:0=待分发 1=已分发 2=失败 */
private Integer distStatus;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,35 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 补拉水位线实体
* <p>
* 记录每个会话最后拉取的消息 Seq/时间,补拉服务增量拉取游标。
* Redis 兜底,PG 持久化。复合主键 (tenant_id, conv_id)。
*
* @author imutil
*/
@Data
@TableName("pull_watermark")
public class PullWatermark {
@TableId(type = IdType.INPUT)
private String tenantId;
private String convId;
/** 会话类型:1=C2C 2=GROUP */
private Integer convType;
private Long lastSeq;
private OffsetDateTime lastTime;
private OffsetDateTime updatedAt;
}
@@ -0,0 +1,34 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 录制文件实体
* <p>
* cos_path 按 tenant_id 目录隔离,各系统只能查/下载本租户录制。
*
* @author imutil
*/
@Data
@TableName("recording")
public class Recording {
@TableId(type = IdType.INPUT)
private String fileId;
private String tenantId;
private Long roomId;
private String cosPath;
/** 时长(秒) */
private Integer duration;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,52 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 租户表实体
* <p>
* 每个业务系统对应一个租户,tenant_id 即前缀码(如 sa/sb),用于多租户隔离。
* app_key/app_secret 为业务系统调用本工具 REST API 的凭证。
*
* @author imutil
*/
@Data
@TableName("tenant")
public class Tenant {
/** 租户ID(前缀码,如 sa) */
@TableId(type = IdType.INPUT)
private String tenantId;
/** 租户名称 */
private String tenantName;
/** 业务系统调用凭证 key */
private String appKey;
/** 业务系统调用凭证 secret */
private String appSecret;
/** IM UserID 前缀,与 tenantId 一致 */
private String prefixCode;
/** 该租户的回调分发地址 */
private String callbackUrl;
/** IM API QPS 配额 */
private Integer quotaImQps;
/** TRTC 并发房间配额 */
private Integer quotaTrtcConcurrent;
/** 状态:1=启用 0=停用 */
private Integer status;
/** 创建时间 */
private OffsetDateTime createdAt;
}
@@ -0,0 +1,35 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* TRTC 音视频房间实体
* <p>
* room_id 由本工具统一分配(雪花),避免各系统自建房号撞号。
*
* @author imutil
*/
@Data
@TableName("trtc_room")
public class TrtcRoom {
@TableId(type = IdType.ASSIGN_ID)
private Long roomId;
private String tenantId;
private String bizRoomId;
/** 关联的 IM 群ID(可选) */
private String imGroupId;
/** 状态:1=进行中 0=已结束 */
private Integer status;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,42 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 用量统计实体(计费拆账依据)
* <p>
* stat_level1=小时 2=天。按 tenant_id 聚合 IM/TRTC 用量。
*
* @author imutil
*/
@Data
@TableName("usage_stat")
public class UsageStat {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String tenantId;
private OffsetDateTime statTime;
/** 1=小时 2=天 */
private Integer statLevel;
private Long imMsgCount;
private Long imDau;
private Long trtcDurationSec;
private Integer trtcMaxConcurrentRoom;
private Long apiCallCount;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,44 @@
package com.imutil.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* 用户映射实体(业务用户 ↔ IM 用户)
* <p>
* im_user_id = prefix_code + '_' + biz_user_id,前缀法保证跨租户不撞号。
* is_default 标记默认账户(admin/客服等),is_global 标记跨租户通行账户。
*
* @author imutil
*/
@Data
@TableName("user_mapping")
public class UserMapping {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 租户ID */
private String tenantId;
/** 业务系统用户ID */
private String bizUserId;
/** IM 用户ID(带前缀) */
private String imUserId;
/** 是否默认账户 */
private Boolean isDefault;
/** 是否全局跨租户账户 */
private Boolean isGlobal;
/** 状态:1=正常 0=封禁 */
private Integer status;
private OffsetDateTime createdAt;
}
@@ -0,0 +1,52 @@
package com.imutil.filter;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
/**
* 管理后台鉴权过滤器
* <p>
* 拦截 {@code /admin/**},未登录 Sa-Token 跳转登录页。
* {@code /admin/login}、{@code /admin/logout} 放行;
* {@code /admin} 已在 {@link com.imutil.common.PathWhitelist},不走路租户鉴权({@link TenantAuthFilter})。
*
* @author imutil
*/
@Slf4j
@Component
public class AdminAuthFilter implements Filter {
@Inject("${server.contextPath:}")
private String basePath;
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
String path = ctx.pathNew();
// 仅拦截 /admin/**
if (!path.startsWith("/admin")) {
chain.doFilter(ctx);
return;
}
// 登录/登出页放行
if (path.equals("/admin/login") || path.equals("/admin/logout")) {
chain.doFilter(ctx);
return;
}
// 已登录放行
if (StpUtil.isLogin()) {
chain.doFilter(ctx);
return;
}
// 未登录:GET 跳登录页,其他返回 401 JSON
if ("GET".equalsIgnoreCase(ctx.method())) {
ctx.redirect(basePath + "/admin/login");
} else {
ctx.output("{\"success\":false,\"code\":401,\"message\":\"未登录或会话已过期\"}");
}
}
}
@@ -0,0 +1,73 @@
package com.imutil.filter;
import com.imutil.common.BizException;
import com.imutil.model.Result;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.exception.StatusException;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
/**
* 全局异常拦截器
* <p>
* 捕获控制器抛出的所有异常,统一以 {@link Result#error} 格式返回,
* 避免直接暴露框架异常信息。HTTP 状态码始终 200,错误经 Result.code 区分。
*
* 处理规则:
* - {@link BizException}:业务异常,按其 code/message 返回
* - {@link StatusException}:框架状态异常(401/403/404/405 等)
* - 其他 Throwable:服务器内部错误
*
* @author imutil
*/
@Slf4j
@Component
public class GlobalExceptionFilter implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
try {
chain.doFilter(ctx);
} catch (BizException e) {
log.warn("业务异常 [{} {}] code={} : {}", ctx.method(), ctx.path(), e.getCode(), e.getMessage());
renderJson(ctx, Result.error(e.getCode(), e.getMessage()));
} catch (StatusException e) {
int code = e.getCode();
// 浏览器自动发起的 favicon.ico,静默 204
if (code == 404 && "/favicon.ico".equals(ctx.path())) {
ctx.status(204);
return;
}
String msg = resolveMessage(code);
log.warn("请求异常 [{} {}] status={} : {}", ctx.method(), ctx.path(), code, e.getMessage());
renderJson(ctx, Result.error(code, msg));
} catch (Throwable e) {
log.error("系统未知异常 [{} {}]", ctx.method(), ctx.path(), e);
renderJson(ctx, Result.error("服务器内部错误"));
}
}
/**
* 按 HTTP 状态码返回中文提示
*/
private String resolveMessage(int code) {
return switch (code) {
case 400 -> "请求参数错误,请检查入参是否完整";
case 401 -> "未登录或登录已过期,请重新登录";
case 403 -> "无权限访问该资源";
case 404 -> "请求的接口不存在";
case 405 -> "请求方法不允许,请检查请求方式(GET/POST 等)";
default -> code >= 500 ? "服务处理失败,请稍后重试" : "请求异常(" + code + "";
};
}
/**
* 序列化为 JSON 写入响应,HTTP 200
*/
private void renderJson(Context ctx, Result<?> result) throws Throwable {
ctx.status(200);
ctx.render(result);
}
}
@@ -0,0 +1,141 @@
package com.imutil.filter;
import com.imutil.common.BizException;
import com.imutil.common.PathWhitelist;
import com.imutil.common.RedisService;
import com.imutil.common.TenantContext;
import com.imutil.entity.Tenant;
import com.imutil.model.Result;
import com.imutil.service.TenantService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 租户鉴权过滤器
* <p>
* 拦截所有非白名单路径,校验 app_key/app_secret 凭证,解析租户写入 {@link TenantContext}。
* 凭证传递方式(二选一):
* - Header {@code X-App-Key} + {@code X-App-Secret}
* - HTTP Basic{@code Authorization: Basic base64(app_key:app_secret)}
* <p>
* 多租户凭证各自独立,不能用单一 Sa-Token http-basic,故自实现校验。
* 白名单(/callback、/health)不走本过滤,回调走签名校验。
*
* @author imutil
*/
@Slf4j
@Component
public class TenantAuthFilter implements Filter {
private static final String HDR_KEY = "X-App-Key";
private static final String HDR_SECRET = "X-App-Secret";
private static final String HDR_AUTH = "Authorization";
@Inject
private TenantService tenantService;
@Inject
private RedisService redisService;
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
// 去除 contextPath 后的路径
String path = ctx.pathNew();
if (PathWhitelist.isWhitelisted(path)) {
chain.doFilter(ctx);
return;
}
try {
String[] cred = extractCredential(ctx);
if (cred == null) {
deny(ctx, 401, "缺少鉴权凭证(X-App-Key/X-App-Secret 或 Basic");
return;
}
String appKey = cred[0];
String appSecret = cred[1];
Tenant tenant = tenantService.getByAppKey(appKey);
if (tenant == null) {
deny(ctx, 401, "app_key 无效");
return;
}
if (tenant.getStatus() != null && tenant.getStatus() != 1) {
deny(ctx, 403, "租户已停用");
return;
}
// 密钥校验(常量时间比较,防时序攻击)
if (tenant.getAppSecret() == null || !constantTimeEquals(tenant.getAppSecret(), appSecret)) {
deny(ctx, 401, "app_secret 错误");
return;
}
// 防爆破:记录失败计数已清,此处鉴权成功
TenantContext.set(tenant.getTenantId());
chain.doFilter(ctx);
} finally {
// 请求结束清理,防线程复用串租户
TenantContext.clear();
}
}
/**
* 提取凭证:优先自定义 Header,其次 HTTP Basic
*
* @return [appKey, appSecret],提取失败返回 null
*/
private String[] extractCredential(Context ctx) {
String key = ctx.header(HDR_KEY);
String secret = ctx.header(HDR_SECRET);
if (key != null && !key.isEmpty() && secret != null && !secret.isEmpty()) {
return new String[]{key, secret};
}
// HTTP Basic
String auth = ctx.header(HDR_AUTH);
if (auth != null && auth.startsWith("Basic ")) {
try {
String decoded = new String(Base64.getDecoder().decode(auth.substring(6)), StandardCharsets.UTF_8);
int idx = decoded.indexOf(':');
if (idx > 0) {
return new String[]{decoded.substring(0, idx), decoded.substring(idx + 1)};
}
} catch (IllegalArgumentException e) {
return null;
}
}
return null;
}
/**
* 常量时间字符串比较,防止时序攻击
*/
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.length() != b.length()) {
return false;
}
int r = 0;
for (int i = 0; i < a.length(); i++) {
r |= a.charAt(i) ^ b.charAt(i);
}
return r == 0;
}
/**
* 返回鉴权失败响应(HTTP 200 + Result 401/403
*/
private void deny(Context ctx, int code, String msg) throws Throwable {
log.warn("鉴权失败 [{} {}] code={} : {}", ctx.method(), ctx.path(), code, msg);
ctx.status(200);
ctx.render(Result.error(code, msg));
}
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.AdminUser;
/**
* 管理后台用户 Mapper
*
* @author imutil
*/
public interface AdminUserMapper extends BaseMapper<AdminUser> {
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.ApiCallLog;
/**
* API 调用审计 Mapper
*
* @author imutil
*/
public interface ApiCallLogMapper extends BaseMapper<ApiCallLog> {
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.CrossTenantAudit;
/**
* 跨租户审计 Mapper
*
* @author imutil
*/
public interface CrossTenantAuditMapper extends BaseMapper<CrossTenantAudit> {
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.CrossTenantGrant;
/**
* 跨租户授权 Mapper
*
* @author imutil
*/
public interface CrossTenantGrantMapper extends BaseMapper<CrossTenantGrant> {
}
@@ -0,0 +1,81 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.DistQueue;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 分发队列 Mapper
* <p>
* 提供 FOR UPDATE SKIP LOCKED 抢占消费的 SQL。
*
* @author imutil
*/
public interface DistQueueMapper extends BaseMapper<DistQueue> {
/**
* 抢占式拉取待分发记录:SELECT ... FOR UPDATE SKIP LOCKED
*
* @param limit 每轮条数
* @param lockedBy 抢占者标识
* @param lockTime 锁定时间
* @return 抢占到的记录
*/
@org.apache.ibatis.annotations.Select(
"SELECT id, msg_key, tenant_id, conv_id, target_url, payload, status, retry_count " +
"FROM dist_queue " +
"WHERE status = 0 AND next_retry_at <= #{now} " +
"ORDER BY next_retry_at, id " +
"LIMIT #{limit} " +
"FOR UPDATE SKIP LOCKED")
List<DistQueue> fetchPending(@Param("now") OffsetDateTime now,
@Param("limit") int limit);
/**
* 标记抢占:将 pending 置为 processing
*/
@Update("UPDATE dist_queue SET status = 1, locked_by = #{lockedBy}, locked_at = #{lockTime}, " +
"updated_at = #{lockTime} " +
"WHERE id = #{id} AND status = 0")
int lock(@Param("id") Long id,
@Param("lockedBy") String lockedBy,
@Param("lockTime") OffsetDateTime lockTime);
/**
* 标记分发成功
*/
@Update("UPDATE dist_queue SET status = 2, updated_at = #{now} WHERE id = #{id} AND status = 1")
int markDone(@Param("id") Long id, @Param("now") OffsetDateTime now);
/**
* 标记失败并安排重试(指数退避)
*/
@Update("UPDATE dist_queue SET status = CASE WHEN retry_count + 1 >= #{maxRetry} THEN 3 ELSE 0 END, " +
"retry_count = retry_count + 1, next_retry_at = #{nextRetryAt}, updated_at = #{now} " +
"WHERE id = #{id} AND status = 1")
int markRetry(@Param("id") Long id,
@Param("maxRetry") int maxRetry,
@Param("nextRetryAt") OffsetDateTime nextRetryAt,
@Param("now") OffsetDateTime now);
/**
* 巡检:重置超时未回执的 processing 回到 pending(工作线程宕机恢复)
*/
@Update("UPDATE dist_queue SET status = 0, locked_by = NULL, locked_at = NULL, updated_at = #{now} " +
"WHERE status = 1 AND locked_at < #{threshold}")
int recoverStuck(@Param("threshold") OffsetDateTime threshold, @Param("now") OffsetDateTime now);
/**
* 死信重发:将 status=3(dead) 的记录重置为 pendingretry_count 归零),供工作线程重新消费
*
* @return 影响行数(0 表示该记录非死信或已处理)
*/
@Update("UPDATE dist_queue SET status = 0, retry_count = 0, next_retry_at = now(), " +
"locked_by = NULL, locked_at = NULL, updated_at = now() " +
"WHERE id = #{id} AND status = 3")
int redeliverDead(@Param("id") Long id);
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.GroupMapping;
/**
* 群映射 Mapper
*
* @author imutil
*/
public interface GroupMappingMapper extends BaseMapper<GroupMapping> {
}
@@ -0,0 +1,32 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.ImMessage;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
/**
* 消息主表 Mapper
*
* @author imutil
*/
public interface ImMessageMapper extends BaseMapper<ImMessage> {
/**
* 原子更新分发状态(乐观锁:仅当当前状态为期望值时更新)
*
* @param msgKey 消息键
* @param msgTime 消息时间(复合主键的一部分)
* @param fromStatus 期望的当前状态
* @param toStatus 目标状态
* @return 影响行数
*/
@Update("UPDATE im_message SET dist_status = #{toStatus} " +
"WHERE msg_key = #{msgKey} AND msg_time = #{msgTime} AND dist_status = #{fromStatus}")
int updateDistStatus(@Param("msgKey") String msgKey,
@Param("msgTime") OffsetDateTime msgTime,
@Param("fromStatus") int fromStatus,
@Param("toStatus") int toStatus);
}
@@ -0,0 +1,61 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.PullWatermark;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 补拉水位线 Mapper
* <p>
* upsert 用 PG ON CONFLICTselectNeedCheck 按 updated_at 升序取最久未补拉的会话,
* 配合补拉成功后推进 updated_at,实现会话轮询公平覆盖。
*
* @author imutil
*/
public interface PullWatermarkMapper extends BaseMapper<PullWatermark> {
/**
* 水位线 upsertPG ON CONFLICT
* <p>
* 回调落库 / 补拉推进时调用。last_seq、last_time 取 GREATEST 不回退(防乱序回调回退游标),
* updated_at 始终刷新为 now(驱动 selectNeedCheck 的轮询顺序)。
*
* @param tenantId 租户ID
* @param convId 会话ID
* @param convType 会话类型 1=C2C 2=GROUP
* @param lastSeq 最新消息 Seq
* @param lastTime 最新消息时间
* @param now 当前时间(updated_at
* @return 影响行数(1=新增或更新)
*/
@Insert("INSERT INTO pull_watermark (tenant_id, conv_id, conv_type, last_seq, last_time, updated_at) " +
"VALUES (#{tenantId}, #{convId}, #{convType}, #{lastSeq}, #{lastTime}, #{now}) " +
"ON CONFLICT (tenant_id, conv_id) DO UPDATE SET " +
"last_seq = GREATEST(pull_watermark.last_seq, EXCLUDED.last_seq), " +
"last_time = GREATEST(pull_watermark.last_time, EXCLUDED.last_time), " +
"updated_at = EXCLUDED.updated_at")
int upsert(@Param("tenantId") String tenantId,
@Param("convId") String convId,
@Param("convType") int convType,
@Param("lastSeq") long lastSeq,
@Param("lastTime") OffsetDateTime lastTime,
@Param("now") OffsetDateTime now);
/**
* 取最久未补拉的 N 个会话(按 updated_at 升序)
* <p>
* 补拉任务每轮调用,updated_at 最老的优先;补拉后 updated_at 推进到 now
* 该会话自然排到队尾,实现轮询式公平覆盖。
*
* @param limit 每轮会话数
* @return 待补拉会话列表
*/
@Select("SELECT tenant_id, conv_id, conv_type, last_seq, last_time, updated_at " +
"FROM pull_watermark ORDER BY updated_at ASC LIMIT #{limit}")
List<PullWatermark> selectNeedCheck(@Param("limit") int limit);
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.Recording;
/**
* 录制文件 Mapper
*
* @author imutil
*/
public interface RecordingMapper extends BaseMapper<Recording> {
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.Tenant;
/**
* 租户表 Mapper
*
* @author imutil
*/
public interface TenantMapper extends BaseMapper<Tenant> {
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.TrtcRoom;
/**
* TRTC 房间 Mapper
*
* @author imutil
*/
public interface TrtcRoomMapper extends BaseMapper<TrtcRoom> {
}
@@ -0,0 +1,60 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.UsageStat;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import java.time.OffsetDateTime;
/**
* 用量统计 Mapper
* <p>
* 提供按小时窗口的聚合 upsertPG ON CONFLICT 幂等,可重复跑)。
* 两个数据源各自 upsert 指定列,避免互相覆盖:
* - im_message → im_msg_count / im_dau
* - api_call_log → api_call_count
*
* @author imutil
*/
public interface UsageStatMapper extends BaseMapper<UsageStat> {
/**
* 从 im_message 聚合某小时窗口的消息量与活跃用户数,upsert 到 usage_statstat_level=1 小时)
* <p>
* 仅更新 im_msg_count / im_dau,不触碰已有 api_call_count。
*
* @param start 窗口起点(含)
* @param end 窗口终点(不含)
* @param statTime 统计时刻(=窗口起点整点,作为 usage_stat.stat_time
* @return 受影响租户数
*/
@Insert("INSERT INTO usage_stat (tenant_id, stat_time, stat_level, im_msg_count, im_dau, " +
"api_call_count, trtc_duration_sec, trtc_max_concurrent_room, created_at) " +
"SELECT tenant_id, #{statTime}, 1, count(*), count(DISTINCT from_account), 0, 0, 0, now() " +
"FROM im_message WHERE created_at >= #{start} AND created_at < #{end} " +
"GROUP BY tenant_id " +
"ON CONFLICT (tenant_id, stat_time, stat_level) DO UPDATE SET " +
"im_msg_count = EXCLUDED.im_msg_count, im_dau = EXCLUDED.im_dau")
int aggregateMsgHour(@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("statTime") OffsetDateTime statTime);
/**
* 从 api_call_log 聚合某小时窗口的腾讯 API 调用次数,upsert 到 usage_statstat_level=1 小时)
* <p>
* 仅更新 api_call_count,不触碰已有 im_msg_count / im_dau。
*
* @return 受影响租户数
*/
@Insert("INSERT INTO usage_stat (tenant_id, stat_time, stat_level, im_msg_count, im_dau, " +
"api_call_count, trtc_duration_sec, trtc_max_concurrent_room, created_at) " +
"SELECT tenant_id, #{statTime}, 1, 0, 0, count(*), 0, 0, now() " +
"FROM api_call_log WHERE called_at >= #{start} AND called_at < #{end} " +
"GROUP BY tenant_id " +
"ON CONFLICT (tenant_id, stat_time, stat_level) DO UPDATE SET " +
"api_call_count = EXCLUDED.api_call_count")
int aggregateApiCallHour(@Param("start") OffsetDateTime start,
@Param("end") OffsetDateTime end,
@Param("statTime") OffsetDateTime statTime);
}
@@ -0,0 +1,12 @@
package com.imutil.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imutil.entity.UserMapping;
/**
* 用户映射 Mapper
*
* @author imutil
*/
public interface UserMappingMapper extends BaseMapper<UserMapping> {
}
@@ -0,0 +1,99 @@
package com.imutil.model;
import lombok.Data;
/**
* 统一接口返回体
* <p>
* 所有 REST 接口与回调内部响应统一使用本类。HTTP 状态码始终为 200,
* 成功/失败通过 {@link #code} 区分(200 成功,其余失败)。
*
* @author imutil
*/
@Data
public class Result<T> {
private static final long serialVersionUID = 1L;
/** 成功标志 */
private boolean success = true;
/** 处理消息 */
private String message = "";
/** 返回代码:200=成功,其余=失败 */
private Integer code = 0;
/** 返回数据对象 */
private T result;
/** 时间戳 */
private long timestamp = System.currentTimeMillis();
public Result() {
}
public Result(Integer code, String message) {
this.code = code;
this.message = message;
}
public static <T> Result<T> ok() {
Result<T> r = new Result<>();
r.setSuccess(true);
r.setCode(200);
return r;
}
public static <T> Result<T> ok(String msg) {
Result<T> r = new Result<>();
r.setSuccess(true);
r.setCode(200);
r.setMessage(msg);
r.setResult((T) msg);
return r;
}
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.setSuccess(true);
r.setCode(200);
r.setResult(data);
return r;
}
public static <T> Result<T> ok(String msg, T data) {
Result<T> r = new Result<>();
r.setSuccess(true);
r.setCode(200);
r.setMessage(msg);
r.setResult(data);
return r;
}
public static <T> Result<T> error(String msg) {
return error(500, msg);
}
public static <T> Result<T> error(int code, String msg) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(msg);
r.setSuccess(false);
return r;
}
public static <T> Result<T> error(String msg, T data) {
Result<T> r = new Result<>();
r.setSuccess(false);
r.setCode(500);
r.setMessage(msg);
r.setResult(data);
return r;
}
/** 无权限访问 */
public static <T> Result<T> noauth(String msg) {
return error(401, msg);
}
}
@@ -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_statstat_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=MsgListGROUP=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();
}
/**
* 秒级时间戳转 OffsetDateTime0/负数兜底为当前时间)
*/
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 + "',仅允许字母/数字/下划线/横线");
}
}
}
}
@@ -0,0 +1,38 @@
package com.imutil.task;
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.scheduling.annotation.Scheduled;
/**
* 分发卡死巡检任务
* <p>
* 定时重置超时未回执的 processing 记录回 pending,避免工作线程宕机导致记录永久卡在 processing。
* 对应 app.yml 的 solon.scheduling.job.dispatchRecoverJob。
*
* @author imutil
*/
@Slf4j
@Component
public class DispatchRecoverTask {
@Inject
private DispatchService dispatchService;
/**
* 由 app.yml dispatchRecoverJob 驱动(默认 fixedDelay=60s
*/
@Scheduled(name = "dispatchRecoverJob")
public void run() {
try {
int n = dispatchService.recoverStuck();
if (n > 0) {
log.info("分发卡死巡检:重置 {} 条 processing 记录为 pending", n);
}
} catch (Throwable e) {
log.error("分发卡死巡检异常", e);
}
}
}
@@ -0,0 +1,36 @@
package com.imutil.task;
import com.imutil.service.PullService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
/**
* 历史消息补拉巡检任务
* <p>
* 定时触发补拉服务,兜底腾讯回调丢失 / 本工具落库失败的消息,
* 保证 im_message 不丢、业务系统不漏收。
* 对应 app.yml 的 solon.scheduling.job.pullCheckJob。
*
* @author imutil
*/
@Slf4j
@Component
public class PullCheckTask {
@Inject
private PullService pullService;
/**
* 由 app.yml pullCheckJob 驱动(默认 fixedDelay=5分钟)
*/
@Scheduled(name = "pullCheckJob")
public void run() {
try {
pullService.pullRound();
} catch (Throwable e) {
log.error("历史消息补拉巡检异常", e);
}
}
}
@@ -0,0 +1,43 @@
package com.imutil.task;
import com.imutil.service.UsageStatService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
/**
* 用量统计定时聚合任务
* <p>
* 每小时聚合「上一整点小时」窗口的用量到 usage_statstat_level=1)。
* 取上一小时(而非当前小时)确保窗口数据已全部落库完整。
* 对应 app.yml 的 solon.scheduling.job.usageStatJob。
*
* @author imutil
*/
@Slf4j
@Component
public class UsageStatTask {
@Inject
private UsageStatService usageStatService;
/**
* 由 app.yml usageStatJob 驱动(默认每小时 03 分,避开整点边界)
*/
@Scheduled(name = "usageStatJob")
public void run() {
OffsetDateTime now = OffsetDateTime.now();
// 当前整点 = 窗口终点,上一整点 = 窗口起点
OffsetDateTime end = now.truncatedTo(ChronoUnit.HOURS);
OffsetDateTime start = end.minusHours(1);
try {
usageStatService.aggregateHour(start, end);
} catch (Throwable e) {
log.error("用量小时聚合异常 window=[{}, {})", start, end, e);
}
}
}
@@ -0,0 +1,93 @@
package com.imutil.tencent;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 腾讯 IM 第三方回调签名校验
* <p>
* 算法(参考腾讯文档 269/1522):
* Sign = sha256(Token + RequestTime)
* - Token:控制台回调 URL 配置的鉴权 Token(非 SecretKey
* - RequestTime:回调请求 URL 参数携带的时间戳(秒)
* - RequestTime 与当前时间相差超过 1 分钟视为无效(防重放)
*
* @author imutil
*/
public final class TencentCallbackSign {
/** 签名允许的最大时间偏差(秒),文档建议 60 秒 */
public static final long MAX_TIME_DRIFT_SEC = 60L;
private TencentCallbackSign() {
}
/**
* 校验回调签名
*
* @param token 控制台配置的鉴权 Token
* @param requestTime 请求时间戳(秒,字符串形式)
* @param sign URL 中的 Sign 参数
* @return true=校验通过
*/
public static boolean verify(String token, String requestTime, String sign) {
if (token == null || token.isEmpty() || requestTime == null || requestTime.isEmpty() || sign == null) {
return false;
}
long ts;
try {
ts = Long.parseLong(requestTime);
} catch (NumberFormatException e) {
return false;
}
// 时效校验(防重放)
long now = System.currentTimeMillis() / 1000L;
if (Math.abs(now - ts) > MAX_TIME_DRIFT_SEC) {
return false;
}
String expected = sha256Hex(token + requestTime);
return constantTimeEquals(expected, sign == null ? "" : sign.toLowerCase());
}
/**
* 计算 SHA-256 十六进制摘要(小写)
*/
public static String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return toHexLower(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
private static String toHexLower(byte[] bytes) {
char[] hex = new char[bytes.length * 2];
String digits = "0123456789abcdef";
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
hex[i * 2] = digits.charAt(v >>> 4);
hex[i * 2 + 1] = digits.charAt(v & 0x0F);
}
return new String(hex);
}
/**
* 常量时间比较,防时序攻击
*/
private static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.length() != b.length()) {
return false;
}
int r = 0;
for (int i = 0; i < a.length(); i++) {
r |= a.charAt(i) ^ b.charAt(i);
}
return r == 0;
}
}
@@ -0,0 +1,198 @@
package com.imutil.tencent;
import com.imutil.common.Httpx;
import com.imutil.common.Jsons;
import com.imutil.common.TenantContext;
import com.imutil.entity.ApiCallLog;
import com.imutil.mapper.ApiCallLogMapper;
import lombok.extern.slf4j.Slf4j;
import org.noear.snack4.ONode;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 腾讯 IM REST API 客户端(管理 API 收口)
* <p>
* 鉴权方式:请求 URL 带 admin 的 UserSig + identifier + sdkappid query 参数(非 TC3 签名)。
* 所有调腾讯后台 API 的入口集中在此,密钥仅本类持有,业务系统不得直接调用。
* <p>
* 每次调用同步写 {@code api_call_log} 审计(try-catch,写入失败不影响主流程),
* 作为用量统计(api_call_count)与问题追溯的数据源。
* <p>
* 返回体统一含 ActionStatusOK/FAIL)、ErrorCode、ErrorInfo,本类透传状态。
*
* @author imutil
*/
@Slf4j
@Component
public class TencentImClient {
@Inject("${imutil.tencent.sdkAppId:0}")
private long sdkAppId;
@Inject("${imutil.tencent.secretKey:}")
private String secretKey;
@Inject("${imutil.tencent.adminUserId:administrator}")
private String adminUserId;
@Inject("${imutil.tencent.apiHost:console.tim.qq.com}")
private String apiHost;
@Inject
private ApiCallLogMapper apiCallLogMapper;
/**
* 生成管理员 UserSig(长效,用于调后台 API
*/
private String genAdminSig() {
return UserSigUtil.genSig(sdkAppId, secretKey, adminUserId, 30L * 86400);
}
/**
* 调用 IM REST API
*
* @param command 命令路径,如 im_open_login_svc/account_import
* @param bodyJson 请求体 JSON
* @return 响应 JSON 字符串
*/
public String callApi(String command, String bodyJson) {
// 租户来源:当前请求上下文,无则记 system(admin 后台调用等无租户上下文场景)
String tenantId = TenantContext.get();
String tid = (tenantId == null || tenantId.isEmpty()) ? "system" : tenantId;
String result;
try {
String adminSig = genAdminSig();
String url = "https://" + apiHost + "/v4/" + command
+ "?sdkappid=" + sdkAppId
+ "&identifier=" + URLEncoder.encode(adminUserId, StandardCharsets.UTF_8)
+ "&usersig=" + URLEncoder.encode(adminSig, StandardCharsets.UTF_8)
+ "&contenttype=json&platform=10&apn=1";
Httpx.Response resp = Httpx.postJsonDetail(url, bodyJson);
log.info("调腾讯API {} code={} body={}", command, resp.statusCode(), resp.body());
result = resp.body();
} catch (Exception e) {
log.error("调腾讯API异常 {} : {}", command, e.toString());
result = "{\"ActionStatus\":\"FAIL\",\"ErrorCode\":-1,\"ErrorInfo\":\""
+ e.getClass().getSimpleName() + "\"}";
}
// 审计落库(失败不影响主流程)
recordCallLog(tid, command, bodyJson, result);
return result;
}
/**
* 写入 API 调用审计
* <p>
* 同步写 + try-catch:审计写入异常不阻塞业务调用。
* params/result 完整存(审计完整性优先,text 字段无长度限制)。
*/
private void recordCallLog(String tenantId, String apiName, String params, String result) {
try {
ApiCallLog entry = new ApiCallLog();
entry.setTenantId(tenantId);
entry.setApiName(apiName);
entry.setParams(params);
entry.setResult(result);
entry.setCaller("system");
entry.setCalledAt(OffsetDateTime.now());
apiCallLogMapper.insert(entry);
} catch (Exception e) {
log.warn("写入 api_call_log 失败 api={} : {}", apiName, e.getMessage());
}
}
/**
* 导入账号(创建 IM 用户,幂等:已存在亦返回 OK)
*
* @return ActionStatus 是否 OK
*/
public boolean accountImport(String imUserId, String nick, String faceUrl) {
Map<String, Object> body = new HashMap<>();
body.put("Identifier", imUserId);
if (nick != null && !nick.isEmpty()) {
body.put("Nick", nick);
}
if (faceUrl != null && !faceUrl.isEmpty()) {
body.put("FaceUrl", faceUrl);
}
String resp = callApi("im_open_login_svc/account_import", Jsons.stringify(body));
return isOk(resp);
}
/**
* 检查账号状态(只读)
* <p>
* 用于验证 admin UserSig 有效性:返回 ActionStatus=OK 即签名校验通过(与账号是否存在无关)。
*
* @return true=API 调用成功(签名有效)
*/
public boolean accountCheck(String imUserId) {
Map<String, Object> item = new HashMap<>();
item.put("UserID", imUserId);
Map<String, Object> body = new HashMap<>();
body.put("CheckItem", List.of(item));
String resp = callApi("im_open_login_svc/account_check", Jsons.stringify(body));
return isOk(resp);
}
/**
* 查询单聊历史漫游消息(C2C)
* <p>
* 命令字 openim_admin/get_roam_msg,按时间窗返回指定两人之间最近的 MaxCnt 条消息。
* 补拉服务据此增量补全回调丢失的单聊消息。
*
* @param fromAccount 发送方 IM 账号
* @param toAccount 接收方 IM 账号
* @param maxCnt 单次拉取条数上限
* @param minTime 拉取时间窗起点(秒级 epoch)
* @param maxInterval 拉取时间窗跨度(秒),从 minTime 起算
* @return 腾讯响应原始 JSON(含 MsgList/Complete/LastMsgTime/LastMsgSeq),由调用方解析
*/
public String getRoamMsg(String fromAccount, String toAccount, int maxCnt, long minTime, long maxInterval) {
Map<String, Object> body = new HashMap<>();
body.put("From_Account", fromAccount);
body.put("To_Account", toAccount);
body.put("MaxCnt", maxCnt);
body.put("MinTime", minTime);
body.put("MaxTimeInterval", maxInterval);
return callApi("openim_admin/get_roam_msg", Jsons.stringify(body));
}
/**
* 查询群历史消息(GROUP)
* <p>
* 命令字 group_open_http_svc/group_msg_get_simple,不传 ReqMsgSeq 时返回最新 ReqMsgNumber 条。
* 补拉服务据此增量补全回调丢失的群消息。
*
* @param groupId 群 ID
* @param reqMsgNumber 单次拉取条数上限
* @return 腾讯响应原始 JSON(含 RspMsgList/IsFinished),由调用方解析
*/
public String getGroupMsg(String groupId, int reqMsgNumber) {
Map<String, Object> body = new HashMap<>();
body.put("GroupId", groupId);
body.put("ReqMsgNumber", reqMsgNumber);
return callApi("group_open_http_svc/group_msg_get_simple", Jsons.stringify(body));
}
/**
* 判断腾讯 API 返回是否成功
*/
private boolean isOk(String respJson) {
try {
ONode n = ONode.ofJson(respJson);
return "OK".equalsIgnoreCase(n.get("ActionStatus").getString());
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,37 @@
package com.imutil.tencent;
import com.tencentyun.TLSSigAPIv2;
/**
* 腾讯 IM UserSig 生成工具(封装官方 tls-sig-api-v2 SDK
* <p>
* 使用官方 {@code com.github.tencentyun:tls-sig-api-v2} 实现,确保 HMAC-SHA256 + zlib raw deflate
* 算法与腾讯服务端校验完全一致,避免手写算法细节偏差导致的 70003(UserSig illegal)错误。
* <p>
* 官方算法流水线:SigDict(固定字段顺序)→ HMAC-SHA256(key, json) → zlib raw deflate → base64。
* 密钥、字段顺序、压缩参数等易错点全部由官方 SDK 处理,本类仅做静态薄封装。
*
* @author imutil
*/
public final class UserSigUtil {
private UserSigUtil() {
}
/**
* 生成 UserSig
* <p>
* 线程安全说明:每次调用 new 一个 {@link TLSSigAPIv2}(官方实例非线程安全),
* 构造开销极低,无需缓存。
*
* @param sdkAppId 应用 SDKAppID
* @param secretKey 应用 SecretKey(控制台「基本配置」中的密钥,必须与 sdkAppId 同一应用)
* @param identifier 用户标识(IM UserID,本项目为带租户前缀的隔离 ID)
* @param expireSec 有效期(秒)
* @return UserSig 字符串
*/
public static String genSig(long sdkAppId, String secretKey, String identifier, long expireSec) {
TLSSigAPIv2 api = new TLSSigAPIv2(sdkAppId, secretKey);
return api.genUserSig(identifier, expireSec);
}
}
@@ -0,0 +1,92 @@
package com.imutil.worker;
import com.imutil.entity.DistQueue;
import com.imutil.service.DispatchService;
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.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 分发工作线程
* <p>
* 应用启动后({@code @Init})创建 daemon 线程池,每个线程循环:
* 抢占一批 pending → 逐条 HTTP 转发业务系统 → 回执更新状态。
* 空闲时按 pollIntervalMs 休眠,避免空转。
* <p>
* daemon 线程不阻止 JVM 退出;业务系统宕机时由回执失败+重试+死信兜底(见 DispatchService)。
*
* @author imutil
*/
@Slf4j
@Component
public class DispatchWorker {
@Inject
private DispatchService dispatchService;
@Inject("${imutil.dispatch.workerCount:4}")
private int workerCount;
@Inject("${imutil.dispatch.pollIntervalMs:1000}")
private long pollIntervalMs;
private final AtomicInteger seq = new AtomicInteger(0);
private ExecutorService pool;
/**
* 应用启动后启动工作线程
*/
@Init
public void start() {
pool = Executors.newFixedThreadPool(workerCount, r -> {
Thread t = new Thread(r, "dispatch-worker-" + seq.incrementAndGet());
t.setDaemon(true);
return t;
});
for (int i = 0; i < workerCount; i++) {
pool.submit(this::loop);
}
log.info("分发工作线程已启动 count={}", workerCount);
}
/**
* 工作循环
*/
private void loop() {
String workerName = "w-" + seq.get();
while (true) {
try {
List<DistQueue> tasks = dispatchService.fetchAndLock(workerName);
if (tasks.isEmpty()) {
Thread.sleep(pollIntervalMs);
continue;
}
for (DistQueue task : tasks) {
try {
dispatchService.dispatch(task, workerName);
} catch (Throwable e) {
log.error("分发单条异常 id={}", task.getId(), e);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Throwable e) {
log.error("分发工作循环异常", e);
try {
Thread.sleep(pollIntervalMs);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
return;
}
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
server.port: 8080
server:
contextPath: /imutil
solon.app:
name: 'tencent-im-util'
group: 'imutil'
# 日志
solon.logging.logger:
"root":
level: INFO
solon.logging.appender:
console:
charset: GBK
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39}%L)) : %msg%n"
file:
charset: UTF-8
pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} --- %-15([%15.15thread]) %-56(%-40.40logger{39}%L)) : %msg%n"
#=====================================数据库配置(PostgreSQL
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:postgresql://192.168.10.118:5432/tencen_im?currentSchema=public&reWriteBatchedInserts=true
driverClassName: org.postgresql.Driver
username: postgres
password: Aa135790!123
maximumPoolSize: 20
minimumIdle: 5
# MyBatis-Plus 配置
mybatis.db1:
typeAliases:
- "com.imutil.entity"
mappers:
- "com.imutil.mapper"
configuration:
logImpl: org.apache.ibatis.logging.nologging.NoLoggingImpl
globalConfig:
banner: false
# Sa-Token 配置
sa-token:
authFlag: true
token-name: imutil-token
timeout: -1
activity-timeout: -1
allow-concurrent-login: true
is-share: true
token-style: uuid
is-log: false
is-print: off
# Redis 配置
imutil.redis:
host: 10.10.10.228
port: 6379
password: "Aa135790!123"
database: 11
timeout: 2000 # 连接超时 ms
connectTimeout: 2000
# 腾讯 IM 配置(密钥仅本工具持有,禁止下发到业务系统)
imutil.tencent:
sdkAppId: 1600138798 # 主应用 SDKAppID(待填)
secretKey: "21c1616e59b4698355fa9707b24d4406c99d69be3cd5e4b95022c050ec86f935" # 主应用 SecretKey(待填)
adminUserId: "administrator" # IM 管理员账号
apiHost: "console.tim.qq.com" # IM REST API 域名
usersigExpireDays: 7 # UserSig 有效期(天)
callbackToken: "" # 回调鉴权 Token(控制台回调URL配置),为空则跳过签名校验(仅联调)
# 管理后台配置(首次启动若 admin_user 表为空,用此账号初始化)
imutil.admin:
defaultUsername: admin
defaultPassword: admin123 # 默认密码,登录后请尽快修改
# 分发工作线程配置
imutil.dispatch:
workerCount: 4 # 分发工作线程数
fetchBatch: 50 # 每轮抢占条数
pollIntervalMs: 1000 # 空闲轮询间隔
maxRetry: 5 # 最大重试次数
retryBaseMs: 2000 # 重试基础间隔(指数退避 base
lockTimeoutMin: 3 # processing 超时阈值(分钟),超时由巡检重置
# 补拉配置(历史消息兜底,由 pullCheckJob 驱动)
imutil.pull:
convsPerRound: 20 # 每轮补拉会话数
maxMsgPerConv: 20 # 每会话每轮拉取条数上限
lookbackMinutes: 30 # C2C 拉取时间窗(最近N分钟)
# 限流配置(按租户令牌桶)
imutil.ratelimit:
defaultImQps: 50 # 默认每租户 IM API QPS
globalImQps: 200 # 全局 IM API QPS 上限(腾讯限制的 80% 安全边际)
# 定时任务配置
solon.scheduling.job:
# 死信/卡死 processing 巡检:每 1 分钟
dispatchRecoverJob:
fixedDelay: 60000
zone: "+08"
enable: true
# 历史消息补拉巡检:每 5 分钟
pullCheckJob:
fixedDelay: 300000
zone: "+08"
enable: true
# 分区自动建表:每天凌晨 0:10
partitionCreateJob:
cron: "0 10 0 * * ?"
zone: "+08"
enable: true
# 用量统计聚合:每小时 03 分跑上一小时窗口(避开整点边界)
usageStatJob:
cron: "0 3 * * * ?"
zone: "+08"
enable: true
+213
View File
@@ -0,0 +1,213 @@
-- ======================================================================
-- tencent-im-util 数据库初始化脚本(PostgreSQL
-- 库: tencen_im schema: public
-- 说明: 多租户隔离,所有业务表均带 tenant_id;消息主表按月 RANGE 分区
-- ======================================================================
-- ===== 1. 租户表 =====
CREATE TABLE IF NOT EXISTS tenant (
tenant_id varchar(32) NOT NULL,
tenant_name varchar(64) NOT NULL,
app_key varchar(128) NOT NULL,
app_secret varchar(128) NOT NULL,
prefix_code varchar(16) NOT NULL,
callback_url text,
quota_im_qps int NOT NULL DEFAULT 50,
quota_trtc_concurrent int NOT NULL DEFAULT 100,
status smallint NOT NULL DEFAULT 1, -- 1=启用 0=停用
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_tenant PRIMARY KEY (tenant_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_app_key ON tenant(app_key);
-- ===== 2. 用户映射(业务用户 ↔ IM 用户) =====
CREATE TABLE IF NOT EXISTS user_mapping (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
biz_user_id varchar(128) NOT NULL,
im_user_id varchar(128) NOT NULL,
is_default boolean NOT NULL DEFAULT false,
is_global boolean NOT NULL DEFAULT false,
status smallint NOT NULL DEFAULT 1, -- 1=正常 0=封禁
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_user_mapping PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_tenant_biz ON user_mapping(tenant_id, biz_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_im ON user_mapping(im_user_id);
-- ===== 3. 群映射 =====
CREATE TABLE IF NOT EXISTS group_mapping (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
biz_group_id varchar(128) NOT NULL,
im_group_id varchar(128) NOT NULL,
group_type varchar(16) NOT NULL DEFAULT 'Public',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_group_mapping PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_group_tenant_biz ON group_mapping(tenant_id, biz_group_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_group_im ON group_mapping(im_group_id);
-- ===== 4. 消息主表(按月 RANGE 分区) =====
CREATE TABLE IF NOT EXISTS im_message (
msg_key varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
msg_time timestamptz NOT NULL,
conv_type smallint NOT NULL, -- 1=C2C 2=GROUP
conv_id varchar(128) NOT NULL, -- C2C=对端账号 GROUP=群ID
from_account varchar(128),
to_account varchar(128),
group_id varchar(128),
msg_type varchar(32),
msg_body text,
source varchar(16) NOT NULL DEFAULT 'CALLBACK', -- CALLBACK/PULL_BACK/IMPORT
is_cross_tenant boolean NOT NULL DEFAULT false,
dist_status smallint NOT NULL DEFAULT 0, -- 0=待分发 1=已分发 2=失败
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_im_message PRIMARY KEY (msg_key, msg_time)
) PARTITION BY RANGE (msg_time);
-- DEFAULT 分区兜底(应用启动后由 partitionCreateJob 滚动建当月分区)
CREATE TABLE IF NOT EXISTS im_message_default PARTITION OF im_message DEFAULT;
-- 分区索引(建在父表自动传播到所有子分区)
CREATE INDEX IF NOT EXISTS idx_msg_time_brin ON im_message USING BRIN (msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_tenant_conv ON im_message (tenant_id, conv_id, msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_from ON im_message (from_account, msg_time);
CREATE INDEX IF NOT EXISTS idx_msg_dist ON im_message (dist_status) WHERE dist_status = 0;
-- ===== 5. 分发队列(替代 MQFOR UPDATE SKIP LOCKED 抢占消费) =====
CREATE TABLE IF NOT EXISTS dist_queue (
id bigserial,
msg_key varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
conv_id varchar(128),
target_url text,
payload text, -- 分发给业务系统的回调快照
status smallint NOT NULL DEFAULT 0, -- 0=pending 1=processing 2=done 3=dead
retry_count int NOT NULL DEFAULT 0,
next_retry_at timestamptz NOT NULL DEFAULT now(),
locked_by varchar(64),
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_dist_queue PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_dq_pending ON dist_queue (next_retry_at, id) WHERE status = 0;
CREATE INDEX IF NOT EXISTS idx_dq_processing ON dist_queue (locked_at) WHERE status = 1;
CREATE INDEX IF NOT EXISTS idx_dq_tenant ON dist_queue (tenant_id, status);
-- ===== 6. 跨租户授权 =====
CREATE TABLE IF NOT EXISTS cross_tenant_grant (
grant_id bigint GENERATED BY DEFAULT AS IDENTITY,
from_tenant_id varchar(32) NOT NULL,
from_im_user_id varchar(128), -- NULL=该租户任意账户
to_tenant_id varchar(32) NOT NULL,
to_im_user_id varchar(128), -- NULL=目标租户全员
permissions varchar(64) NOT NULL DEFAULT 'send_msg', -- send_msg,add_friend,join_group
direction smallint NOT NULL DEFAULT 0, -- 0=单向 1=双向
start_at timestamptz NOT NULL DEFAULT now(),
end_at timestamptz, -- NULL=长期有效
status smallint NOT NULL DEFAULT 1, -- 1=active 0=revoked 2=expired
approved_by_from varchar(64),
approved_by_to varchar(64),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_cross_grant PRIMARY KEY (grant_id)
);
CREATE INDEX IF NOT EXISTS idx_grant_from ON cross_tenant_grant (from_tenant_id, from_im_user_id, status);
CREATE INDEX IF NOT EXISTS idx_grant_to ON cross_tenant_grant (to_tenant_id, to_im_user_id, status);
-- ===== 7. 跨租户通讯审计 =====
CREATE TABLE IF NOT EXISTS cross_tenant_audit (
id bigserial,
grant_id bigint,
msg_key varchar(128),
from_im_user_id varchar(128),
to_im_user_id varchar(128),
action_time timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_cross_audit PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_cross_audit_time ON cross_tenant_audit (action_time);
-- ===== 8. TRTC 音视频房间 =====
CREATE TABLE IF NOT EXISTS trtc_room (
room_id bigint NOT NULL, -- 本工具统一分配(雪花)
tenant_id varchar(32) NOT NULL,
biz_room_id varchar(128),
im_group_id varchar(128), -- 关联 IM 群(可选)
status smallint NOT NULL DEFAULT 1, -- 1=进行中 0=已结束
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_trtc_room PRIMARY KEY (room_id)
);
CREATE INDEX IF NOT EXISTS idx_room_tenant_biz ON trtc_room (tenant_id, biz_room_id);
-- ===== 9. 录制文件 =====
CREATE TABLE IF NOT EXISTS recording (
file_id varchar(128) NOT NULL,
tenant_id varchar(32) NOT NULL,
room_id bigint,
cos_path text, -- 按 tenant_id 隔离目录
duration int, -- 秒
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_recording PRIMARY KEY (file_id)
);
CREATE INDEX IF NOT EXISTS idx_rec_tenant_room ON recording (tenant_id, room_id);
-- ===== 10. 用量统计(计费拆账依据) =====
CREATE TABLE IF NOT EXISTS usage_stat (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id varchar(32) NOT NULL,
stat_time timestamptz NOT NULL,
stat_level smallint NOT NULL DEFAULT 1, -- 1=小时 2=天
im_msg_count bigint NOT NULL DEFAULT 0,
im_dau bigint NOT NULL DEFAULT 0,
trtc_duration_sec bigint NOT NULL DEFAULT 0,
trtc_max_concurrent_room int NOT NULL DEFAULT 0,
api_call_count bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_usage_stat PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_usage ON usage_stat (tenant_id, stat_time, stat_level);
-- ===== 11. API 调用审计 =====
CREATE TABLE IF NOT EXISTS api_call_log (
id bigserial,
tenant_id varchar(32) NOT NULL,
api_name varchar(64) NOT NULL,
params text,
result text,
caller varchar(64),
called_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_api_call_log PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_api_tenant_time ON api_call_log (tenant_id, called_at);
CREATE INDEX IF NOT EXISTS idx_api_time ON api_call_log (called_at);
-- ===== 12. 补拉水位线(PG 持久,Redis 兜底) =====
CREATE TABLE IF NOT EXISTS pull_watermark (
tenant_id varchar(32) NOT NULL,
conv_id varchar(128) NOT NULL,
conv_type smallint NOT NULL,
last_seq bigint NOT NULL DEFAULT 0,
last_time timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_pull_watermark PRIMARY KEY (tenant_id, conv_id)
);
-- ===== 初始化默认租户(示例,可改) =====
INSERT INTO tenant (tenant_id, tenant_name, app_key, app_secret, prefix_code, callback_url)
VALUES ('sa', '系统A', 'sa_app_key_secret', 'sa_app_secret_secret', 'sa', NULL)
ON CONFLICT (tenant_id) DO NOTHING;
-- ===== 13. 管理后台用户(Sa-Token 登录) =====
CREATE TABLE IF NOT EXISTS admin_user (
id bigserial,
username varchar(64) NOT NULL,
password_hash varchar(200) NOT NULL, -- 格式 iterations:salt:hashPBKDF2,见 PasswordUtil
role varchar(16) NOT NULL DEFAULT 'admin',
status smallint NOT NULL DEFAULT 1, -- 1=启用 0=停用
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_admin_user PRIMARY KEY (id),
CONSTRAINT uk_admin_username UNIQUE (username)
);
-- 默认管理员账号由应用首次启动时按 app.yml imutil.admin 配置初始化(不在此写死密码hash)
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!-- 控制台输出(GBK,适配 Windows 终端) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>GBK</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39})) : %msg%n</pattern>
</encoder>
</appender>
<!-- 文件输出(按天滚动) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/imutil.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/imutil.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} --- %-15([%15.15thread]) %-56(%-40.40logger{39}) : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<!-- 本项目包级别调试 -->
<logger name="com.imutil" level="INFO"/>
</configuration>
+82
View File
@@ -0,0 +1,82 @@
<#--
管理后台公共布局宏
用法:<#import "_macros.ftl" as m><@m.layout active="home" title="仪表盘">页面内容</@m.layout>
对齐 yxtech:左侧边栏 + 右侧主区域,内嵌 CSS(无外部框架)。
-->
<#macro layout active title>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} - IM分发工具管理后台</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif; display:flex; height:100vh; overflow:hidden; color:#333; }
.sidebar { width:200px; flex-shrink:0; background:#2c3e50; display:flex; flex-direction:column; }
.sidebar-brand { padding:18px; font-size:13px; font-weight:600; color:#fff; line-height:1.5; border-bottom:1px solid rgba(255,255,255,.1); }
.sidebar-nav { flex:1; padding:10px 0; }
.nav-item { display:block; color:rgba(255,255,255,.72); text-decoration:none; padding:11px 20px; font-size:14px; border-left:3px solid transparent; }
.nav-item:hover { background:rgba(255,255,255,.08); color:#fff; }
.nav-item.active { background:rgba(74,144,226,.18); color:#fff; border-left-color:#4a90e2; }
.sidebar-footer { border-top:1px solid rgba(255,255,255,.1); }
.logout-link { display:block; color:rgba(248,165,165,.85); text-decoration:none; padding:10px 20px; font-size:14px; }
.main-wrapper { flex:1; display:flex; flex-direction:column; min-width:0; overflow:hidden; }
.main-header { height:50px; background:#fff; border-bottom:1px solid #e8e8e8; padding:0 24px; display:flex; align-items:center; }
.page-title { font-size:15px; font-weight:600; color:#2c3e50; }
.content { flex:1; overflow-y:auto; padding:20px 24px; background:#f8f9fa; }
.alert { padding:11px 16px; border-radius:6px; margin-bottom:16px; font-size:14px; background:#d4edda; border:1px solid #c3e6cb; color:#155724; }
.alert-warn { background:#fff3cd; border-color:#ffeaa7; color:#856404; }
.stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
.stat-card { background:#fff; border-radius:8px; padding:20px; box-shadow:0 1px 6px rgba(0,0,0,.07); }
.stat-card .label { font-size:13px; color:#888; }
.stat-card .value { font-size:28px; font-weight:600; color:#2c3e50; margin-top:6px; }
.data-table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 6px rgba(0,0,0,.07); font-size:14px; }
.data-table th,.data-table td { padding:11px 14px; text-align:left; border-bottom:1px solid #f0f0f0; }
.data-table th { background:#f5f6fa; font-weight:600; color:#495057; font-size:13px; }
.data-table tr:hover td { background:#fafbff; }
.btn { display:inline-block; padding:7px 15px; font-size:14px; border-radius:6px; cursor:pointer; border:none; text-decoration:none; }
.btn-primary { background:#4a90e2; color:#fff; }
.btn-secondary { background:#6c757d; color:#fff; }
.btn-danger { background:#dc3545; color:#fff; }
.btn-success { background:#28a745; color:#fff; }
.btn-sm { padding:4px 9px; font-size:12px; }
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; }
.badge-ok { background:#d1e7dd; color:#0f5132; }
.badge-no { background:#f8d7da; color:#842029; }
.badge-warn { background:#fff3cd; color:#856404; }
.modal { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,.5); align-items:center; justify-content:center; z-index:1000; }
.modal.show { display:flex; }
.modal-content { background:#fff; border-radius:10px; padding:24px 30px; width:480px; max-height:90vh; overflow-y:auto; }
.modal-title { font-size:16px; font-weight:600; margin-bottom:18px; }
.modal-body .form-group { margin-bottom:14px; }
.modal-body label { display:block; font-size:13px; color:#666; margin-bottom:6px; }
.modal-body input,.modal-body select { width:100%; padding:9px 12px; border:1px solid #ddd; border-radius:6px; font-size:14px; }
.modal-footer { display:flex; gap:10px; justify-content:flex-end; margin-top:18px; }
.muted { color:#999; font-size:12px; }
.empty { text-align:center; padding:30px; color:#aaa; font-size:14px; }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-brand">IM分发工具<br>管理后台</div>
<nav class="sidebar-nav">
<a href="${basePath}/admin/home" class="nav-item <#if active=='home'>active</#if>">仪表盘</a>
<a href="${basePath}/admin/tenant" class="nav-item <#if active=='tenant'>active</#if>">租户管理</a>
<a href="${basePath}/admin/grant" class="nav-item <#if active=='grant'>active</#if>">跨租户授权</a>
<a href="${basePath}/admin/queue" class="nav-item <#if active=='queue'>active</#if>">队列监控</a>
<a href="${basePath}/admin/usage" class="nav-item <#if active=='usage'>active</#if>">用量报表</a>
</nav>
<div class="sidebar-footer">
<a href="${basePath}/admin/logout" class="logout-link">退出登录</a>
</div>
</aside>
<div class="main-wrapper">
<header class="main-header"><span class="page-title">${title}</span></header>
<div class="content">
<#nested>
</div>
</div>
</body>
</html>
</#macro>
+74
View File
@@ -0,0 +1,74 @@
<#import "_macros.ftl" as m>
<@m.layout active="grant" title="跨租户授权">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<div style="margin-bottom:14px">
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')"> 新增授权</button>
<span class="muted" style="margin-left:10px">用于放行跨租户通讯(发消息/加好友/加群),空用户表示任意账户/全员</span>
</div>
<#if grants?has_content>
<table class="data-table">
<thead>
<tr><th>授权ID</th><th>源租户</th><th>源用户</th><th>目标租户</th><th>目标用户</th><th>权限</th><th>方向</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<#list grants as g>
<tr>
<td>${g.grantId?c}</td>
<td>${g.fromTenantId!}</td>
<td>${g.fromImUserId!'(任意)'}</td>
<td>${g.toTenantId!}</td>
<td>${g.toImUserId!'(全员)'}</td>
<td>${g.permissions!}</td>
<td><#if g.direction?? && g.direction == 1>双向<#else>单向</#if></td>
<td>
<#if g.status?? && g.status == 1><span class="badge badge-ok">生效</span>
<#else><span class="badge badge-no">撤销</span></#if>
</td>
<td>
<#if g.status?? && g.status == 1>
<form method="post" action="${basePath}/admin/grant/revoke" style="display:inline">
<input type="hidden" name="grantId" value="${g.grantId?c}">
<button class="btn btn-sm btn-danger">撤销</button>
</form>
</#if>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无授权记录</div>
</#if>
<div id="addModal" class="modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal-content">
<div class="modal-title">新增跨租户授权</div>
<form method="post" action="${basePath}/admin/grant/save">
<div class="modal-body">
<div class="form-group"><label>源租户</label>
<select name="fromTenantId">
<#list tenants![] as t><option value="${t.tenantId!}">${t.tenantId!} (${t.tenantName!})</option></#list>
</select>
</div>
<div class="form-group"><label>源用户(可空=任意账户)</label><input name="fromImUserId"></div>
<div class="form-group"><label>目标租户</label>
<select name="toTenantId">
<#list tenants![] as t><option value="${t.tenantId!}">${t.tenantId!} (${t.tenantName!})</option></#list>
</select>
</div>
<div class="form-group"><label>目标用户(可空=全员)</label><input name="toImUserId"></div>
<div class="form-group"><label>权限(逗号分隔:send_msg,add_friend,join_group</label><input name="permissions" value="send_msg"></div>
<div class="form-group"><label>方向</label>
<select name="direction"><option value="0">单向</option><option value="1">双向</option></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('addModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</@m.layout>
+22
View File
@@ -0,0 +1,22 @@
<#import "_macros.ftl" as m>
<@m.layout active="home" title="仪表盘">
<div class="stat-grid">
<div class="stat-card">
<div class="label">租户数</div>
<div class="value">${tenantCount!'0'}</div>
</div>
<div class="stat-card">
<div class="label">待分发 (pending)</div>
<div class="value">${queuePending!'0'}</div>
</div>
<div class="stat-card">
<div class="label">死信 (dead)</div>
<div class="value" <#if queueDead?? && queueDead gt 0>style="color:#dc3545"</#if>>${queueDead!'0'}</div>
</div>
<div class="stat-card">
<div class="label">生效跨租户授权</div>
<div class="value">${grantActive!'0'}</div>
</div>
</div>
<p class="muted">默认账号 admin / admin123(首次启动初始化,请尽快修改密码)。死信 &gt; 0 时请到「队列监控」处理。</p>
</@m.layout>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>登录 - IM分发工具管理后台</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif; background:#f0f2f5; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.login-wrapper { width:380px; }
.login-title { text-align:center; color:#2c3e50; font-size:22px; font-weight:600; margin-bottom:24px; }
.login-card { background:#fff; border-radius:10px; box-shadow:0 4px 20px rgba(0,0,0,.1); padding:36px 40px; }
.form-group { margin-bottom:18px; }
.form-group label { display:block; font-size:13px; color:#666; margin-bottom:6px; }
.form-group input { width:100%; padding:10px 13px; border:1px solid #ddd; border-radius:6px; font-size:15px; outline:none; }
.form-group input:focus { border-color:#4a90e2; }
.btn-login { width:100%; padding:12px; background:#2c3e50; color:#fff; border:none; border-radius:6px; font-size:15px; cursor:pointer; margin-top:6px; letter-spacing:2px; }
.error-msg { background:#fff0f0; border:1px solid #ffcccc; color:#d32f2f; padding:10px 14px; border-radius:6px; font-size:14px; margin-bottom:18px; }
</style>
</head>
<body>
<div class="login-wrapper">
<div class="login-title">IM分发工具 管理后台</div>
<div class="login-card">
<#if errorMsg?has_content>
<div class="error-msg">${errorMsg}</div>
</#if>
<form method="post" action="${basePath}/admin/login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" autofocus required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</div>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<#import "_macros.ftl" as m>
<@m.layout active="queue" title="队列监控">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<h3 style="margin-bottom:10px;font-size:15px;color:#2c3e50">待分发 (pending,最近 50 条)</h3>
<#if pendings?has_content>
<table class="data-table">
<thead><tr><th>ID</th><th>租户</th><th>会话</th><th>重试次数</th><th>下次重试时间</th></tr></thead>
<tbody>
<#list pendings as q>
<tr>
<td>${q.id?c}</td>
<td>${q.tenantId!}</td>
<td>${q.convId!}</td>
<td>${q.retryCount!0}</td>
<td><#if q.nextRetryAt??>${q.nextRetryAt?string('yyyy-MM-dd HH:mm:ss')}<#else>-</#if></td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">无待分发记录</div>
</#if>
<h3 style="margin:24px 0 10px;font-size:15px;color:#2c3e50">死信 (dead,共 ${deadCount!'0'} 条,最近 50 条)</h3>
<#if deads?has_content>
<table class="data-table">
<thead><tr><th>ID</th><th>租户</th><th>会话</th><th>重试次数</th><th>目标URL</th><th>操作</th></tr></thead>
<tbody>
<#list deads as q>
<tr>
<td>${q.id?c}</td>
<td>${q.tenantId!}</td>
<td>${q.convId!}</td>
<td>${q.retryCount!0}</td>
<td class="muted">${q.targetUrl!}</td>
<td>
<form method="post" action="${basePath}/admin/queue/redeliver" style="display:inline">
<input type="hidden" name="id" value="${q.id?c}">
<button class="btn btn-sm btn-primary">重发</button>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">无死信</div>
</#if>
</@m.layout>
+74
View File
@@ -0,0 +1,74 @@
<#import "_macros.ftl" as m>
<@m.layout active="tenant" title="租户管理">
<#if msg?has_content>
<div class="alert">${msg}</div>
</#if>
<div style="margin-bottom:14px">
<button class="btn btn-success" onclick="document.getElementById('addModal').classList.add('show')"> 新增租户</button>
</div>
<#if tenants?has_content>
<table class="data-table">
<thead>
<tr>
<th>租户ID</th><th>名称</th><th>前缀</th><th>AppKey</th><th>AppSecret</th>
<th>回调URL</th><th>IM QPS</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<#list tenants as t>
<tr>
<td>${t.tenantId!}</td>
<td>${t.tenantName!}</td>
<td>${t.prefixCode!}</td>
<td>${t.appKey!}</td>
<td class="muted">${t.appSecret!}</td>
<td class="muted">${t.callbackUrl!'-'}</td>
<td>${t.quotaImQps!'-'}</td>
<td>
<#if t.status?? && t.status == 1>
<span class="badge badge-ok">启用</span>
<#else>
<span class="badge badge-no">停用</span>
</#if>
</td>
<td>
<form method="post" action="${basePath}/admin/tenant/toggle" style="display:inline">
<input type="hidden" name="tenantId" value="${t.tenantId!}">
<#if t.status?? && t.status == 1>
<input type="hidden" name="status" value="0">
<button class="btn btn-sm btn-secondary">停用</button>
<#else>
<input type="hidden" name="status" value="1">
<button class="btn btn-sm btn-success">启用</button>
</#if>
</form>
</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无租户,点击「新增租户」添加</div>
</#if>
<div id="addModal" class="modal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal-content">
<div class="modal-title">新增租户</div>
<form method="post" action="${basePath}/admin/tenant/save">
<div class="modal-body">
<div class="form-group"><label>租户ID(即前缀码,如 sa</label><input name="tenantId" required></div>
<div class="form-group"><label>租户名称</label><input name="tenantName"></div>
<div class="form-group"><label>AppKey(业务系统调用凭证)</label><input name="appKey" required></div>
<div class="form-group"><label>AppSecret</label><input name="appSecret" required></div>
<div class="form-group"><label>回调URL(业务系统接收分发)</label><input name="callbackUrl" placeholder="http://host:port/path"></div>
<div class="form-group"><label>IM QPS 配额</label><input name="quotaImQps" type="number" value="50"></div>
<div class="form-group"><label>TRTC 并发房间配额</label><input name="quotaTrtcConcurrent" type="number" value="0"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('addModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</@m.layout>
+25
View File
@@ -0,0 +1,25 @@
<#import "_macros.ftl" as m>
<@m.layout active="usage" title="用量报表">
<#if stats?has_content>
<table class="data-table">
<thead>
<tr><th>租户</th><th>统计时刻</th><th>粒度</th><th>IM消息数</th><th>IM DAU</th><th>API调用数</th><th>TRTC时长(秒)</th></tr>
</thead>
<tbody>
<#list stats as s>
<tr>
<td>${s.tenantId!}</td>
<td><#if s.statTime??>${s.statTime?string('yyyy-MM-dd HH:mm')}<#else>-</#if></td>
<td><#if s.statLevel?? && s.statLevel == 1>小时<#else>天</#if></td>
<td>${s.imMsgCount!0}</td>
<td>${s.imDau!0}</td>
<td>${s.apiCallCount!0}</td>
<td>${s.trtcDurationSec!0}</td>
</tr>
</#list>
</tbody>
</table>
<#else>
<div class="empty">暂无用量数据(每小时 03 分自动聚合;触发 API 调用/回调后产生数据)</div>
</#if>
</@m.layout>
+6
View File
@@ -0,0 +1,6 @@
1.消息服务 Chat 回调URL配置,目前这个已配置,用来将每条消息保存到本地(每个系统肯定都不一样)
2.需要将现有几个不同应用的数据合并到一个应用,比如im用户,im群组等
3.如果遇到未正确回调url的,导致本地消息数据丢失的,需要同步腾讯api拉取同步消息
4.需要有功能来统计不同系统的使用情况,包括im使用情况,音视频使用情况
5.需要统一将消息服务回调放到统一工具中,即本系统中,本系统负责保存所有回调存过来的消息,并负责拉取历史消息,并根据系统来打上标记,系统之间不能串
6.还需要考虑腾讯回调过来的消息,需要第一时间发送到每个系统的回调地址中,需要消息队列,需要回执及重发机制,避免因目标系统宕机造成消息丢失
+4
View File
@@ -0,0 +1,4 @@
1.各个系统中可能存在一些默认账户,账户的id一致,我倾向于前缀处理
2.各个系统中有一小部分账户,是可以配置授权给跨应用的,这个也要考虑
3.根据目前设计的数据接口等, 帮我选型一下数据库
4.系统架构我倾向于参考 yxtech 项目,使用java + solon 框架来写