diff --git a/.gitignore b/.gitignore index 9154f4c..b988dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,17 @@ hs_err_pid* replay_pid* +# Maven 构建产物 +target/ +logs/ + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.project +.classpath + +# 本地配置覆盖(密钥等敏感信息) +app-env.yml diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..914c415 --- /dev/null +++ b/pom.xml @@ -0,0 +1,157 @@ + + + 4.0.0 + + + + org.noear + solon-parent + 3.10.0 + + + + com.imutil + tencent-im-util + 1.0 + jar + + 腾讯 IM 多系统合并分发工具 + + + 21 + 42.7.5 + 7.0.2 + 5.1.0 + 3.1.8 + + + + + + org.noear + solon-web + + + + + cn.dev33 + sa-token-solon-plugin + 1.44.0 + + + + + com.github.tencentyun + tls-sig-api-v2 + 2.0 + + + + + org.noear + solon-view-freemarker + + + + + org.noear + mybatis-plus-extension-solon-plugin + + + + + org.postgresql + postgresql + ${postgresql.version} + runtime + + + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + + + redis.clients + jedis + ${jedis.version} + + + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + + + org.noear + solon-logging-logback-jakarta + + + + + org.projectlombok + lombok + provided + + + + + org.noear + solon-scheduling + + + org.noear + solon-scheduling-simple + + + + + org.noear + solon-test + test + + + + + ${project.artifactId} + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.noear + solon-maven-plugin + + + + + + + + tencent + https://mirrors.cloud.tencent.com/nexus/repository/maven-public/ + + false + + + + + diff --git a/src/main/java/com/imutil/App.java b/src/main/java/com/imutil/App.java new file mode 100644 index 0000000..09bf155 --- /dev/null +++ b/src/main/java/com/imutil/App.java @@ -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 多系统合并分发工具 - 应用入口 + *

+ * 职责:启动 Solon 容器,加载各模块(回调网关、分发工作线程、UserSig、补拉、统计等)。 + *

+ * {@code @EnableScheduling} 启用 Solon 定时任务,配合 solon-scheduling-simple 驱动, + * 用于补拉巡检、分区自建、死信重置、用量统计等定时作业。 + * + * @author imutil + */ +@SolonMain +@EnableScheduling +public class App { + + public static void main(String[] args) { + Solon.start(App.class, args); + } +} diff --git a/src/main/java/com/imutil/common/BizException.java b/src/main/java/com/imutil/common/BizException.java new file mode 100644 index 0000000..248cd8e --- /dev/null +++ b/src/main/java/com/imutil/common/BizException.java @@ -0,0 +1,35 @@ +package com.imutil.common; + +import lombok.Getter; + +/** + * 业务异常 + *

+ * 业务逻辑中主动抛出,由 {@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; + } +} diff --git a/src/main/java/com/imutil/common/Httpx.java b/src/main/java/com/imutil/common/Httpx.java new file mode 100644 index 0000000..7569908 --- /dev/null +++ b/src/main/java/com/imutil/common/Httpx.java @@ -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 工具封装 + *

+ * 基于 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 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 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; + } + } +} diff --git a/src/main/java/com/imutil/common/Ids.java b/src/main/java/com/imutil/common/Ids.java new file mode 100644 index 0000000..36c0ded --- /dev/null +++ b/src/main/java/com/imutil/common/Ids.java @@ -0,0 +1,31 @@ +package com.imutil.common; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; + +/** + * ID 生成工具 + *

+ * 基于 MyBatis-Plus 内置雪花算法({@link IdWorker})生成全局唯一 ID。 + * 提供字符串形式以规避 Long 雪花 ID 经 JSON 传前端时精度丢失(>2^53)。 + * + * @author imutil + */ +public final class Ids { + + private Ids() { + } + + /** + * 生成雪花 ID(long) + */ + public static long nextId() { + return IdWorker.getId(); + } + + /** + * 生成雪花 ID(字符串) + */ + public static String nextIdStr() { + return String.valueOf(IdWorker.getId()); + } +} diff --git a/src/main/java/com/imutil/common/Jsons.java b/src/main/java/com/imutil/common/Jsons.java new file mode 100644 index 0000000..dd9de63 --- /dev/null +++ b/src/main/java/com/imutil/common/Jsons.java @@ -0,0 +1,49 @@ +package com.imutil.common; + +import org.noear.snack4.ONode; + +import java.lang.reflect.Type; + +/** + * JSON 工具封装 + *

+ * 基于 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 parse(String json, Class clazz) { + if (json == null || json.isEmpty()) { + return null; + } + return ONode.deserialize(json, (Type) clazz); + } + + /** + * 对象转换为指定类型:先序列化再反序列化 + */ + public static T convert(Object obj, Class clazz) { + if (obj == null) { + return null; + } + return parse(stringify(obj), clazz); + } +} diff --git a/src/main/java/com/imutil/common/LocalCache.java b/src/main/java/com/imutil/common/LocalCache.java new file mode 100644 index 0000000..7ae96b7 --- /dev/null +++ b/src/main/java/com/imutil/common/LocalCache.java @@ -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) + *

+ * 作为 Redis 的兜底热缓存,承载映射/授权等高频读、短 TTL 的数据, + * 减少网络往返。多节点下数据可能短暂不一致,变更时需主动失效并依赖 Redis 兜底。 + * + * @author imutil + */ +@Component +public class LocalCache { + + private final Cache cache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(60, TimeUnit.SECONDS) + .build(); + + /** + * 读取缓存值,不存在返回 null + */ + public String get(String key) { + return cache.getIfPresent(key); + } + + /** + * 读取并反序列化为对象 + */ + public T get(String key, Class 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(); + } +} diff --git a/src/main/java/com/imutil/common/MsgKeys.java b/src/main/java/com/imutil/common/MsgKeys.java new file mode 100644 index 0000000..295c098 --- /dev/null +++ b/src/main/java/com/imutil/common/MsgKeys.java @@ -0,0 +1,32 @@ +package com.imutil.common; + +/** + * 消息唯一键生成工具 + *

+ * 统一回调路径与补拉路径的 msg_key 算法,确保同一条消息无论从哪条路径进入 + * 都生成相同 key,从而跨路径幂等去重(回调已落库的消息不会被补拉重复落库)。 + *

+ * 腾讯侧一条消息由 (from, 会话目标, MsgSeq, MsgRandom) 唯一确定。 + * 不含 CallbackCommand:同一条消息的发送/接收回调 command 不同,含 command 反而不利于去重。 + * + * @author imutil + */ +public final class MsgKeys { + + private MsgKeys() { + } + + /** + * 生成消息唯一键 + * + * @param fromAccount 发送方账号,可空 + * @param convTarget 会话目标(GROUP=群ID,C2C=对端账号) + * @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; + } +} diff --git a/src/main/java/com/imutil/common/PasswordUtil.java b/src/main/java/com/imutil/common/PasswordUtil.java new file mode 100644 index 0000000..d972ddd --- /dev/null +++ b/src/main/java/com/imutil/common/PasswordUtil.java @@ -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; + +/** + * 密码哈希工具(PBKDF2WithHmacSHA256,JDK 内置,无额外依赖) + *

+ * 存储格式:{@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; + } +} diff --git a/src/main/java/com/imutil/common/PathWhitelist.java b/src/main/java/com/imutil/common/PathWhitelist.java new file mode 100644 index 0000000..c715438 --- /dev/null +++ b/src/main/java/com/imutil/common/PathWhitelist.java @@ -0,0 +1,51 @@ +package com.imutil.common; + +import java.util.Set; + +/** + * 鉴权白名单路径 + *

+ * 以下路径不走 app_key 租户鉴权: + * - /callback/** 腾讯回调入口(走签名校验,见 T5) + * - /health 健康检查 + * - /favicon.ico + * + * @author imutil + */ +public final class PathWhitelist { + + /** 白名单路径前缀(contextPath 之后的部分) */ + public static final Set PREFIXES = Set.of( + "/callback", + "/health", + "/admin" + ); + + /** 精确匹配的白名单路径 */ + public static final Set 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; + } +} diff --git a/src/main/java/com/imutil/common/RedisConfig.java b/src/main/java/com/imutil/common/RedisConfig.java new file mode 100644 index 0000000..f21d5be --- /dev/null +++ b/src/main/java/com/imutil/common/RedisConfig.java @@ -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 连接池配置 + *

+ * 读取 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 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); + } +} diff --git a/src/main/java/com/imutil/common/RedisService.java b/src/main/java/com/imutil/common/RedisService.java new file mode 100644 index 0000000..93f806a --- /dev/null +++ b/src/main/java/com/imutil/common/RedisService.java @@ -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 操作封装 + *

+ * 基于 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 getJson(String key, Class 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); + } + } +} diff --git a/src/main/java/com/imutil/common/TenantContext.java b/src/main/java/com/imutil/common/TenantContext.java new file mode 100644 index 0000000..b11b704 --- /dev/null +++ b/src/main/java/com/imutil/common/TenantContext.java @@ -0,0 +1,54 @@ +package com.imutil.common; + +/** + * 租户上下文(请求级 ThreadLocal) + *

+ * 由 {@link com.imutil.filter.TenantAuthFilter} 在请求入口解析并设置, + * 业务层通过 {@link #get()} 获取当前租户,所有数据查询强制带 tenant_id 过滤, + * 实现"系统 A 绝对拿不到系统 B 数据"的隔离。 + *

+ * 必须在请求结束时 {@link #clear()} 清理,避免线程复用串租户。 + * + * @author imutil + */ +public final class TenantContext { + + private static final ThreadLocal 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(); + } +} diff --git a/src/main/java/com/imutil/controller/AdminController.java b/src/main/java/com/imutil/controller/AdminController.java new file mode 100644 index 0000000..7d3756f --- /dev/null +++ b/src/main/java/com/imutil/controller/AdminController.java @@ -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; + +/** + * 管理后台控制器 + *

+ * 路由前缀 /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.lambdaQuery() + .eq(DistQueue::getStatus, 0))); + mv.put("queueDead", distQueueMapper.selectCount(Wrappers.lambdaQuery() + .eq(DistQueue::getStatus, 3))); + mv.put("grantActive", grantMapper.selectCount(Wrappers.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.lambdaQuery() + .eq(DistQueue::getStatus, 0) + .orderByAsc(DistQueue::getId) + .last("LIMIT 50"))); + mv.put("deads", distQueueMapper.selectList(Wrappers.lambdaQuery() + .eq(DistQueue::getStatus, 3) + .orderByDesc(DistQueue::getId) + .last("LIMIT 50"))); + mv.put("deadCount", distQueueMapper.selectCount(Wrappers.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.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; + } +} diff --git a/src/main/java/com/imutil/controller/CallbackController.java b/src/main/java/com/imutil/controller/CallbackController.java new file mode 100644 index 0000000..4f04479 --- /dev/null +++ b/src/main/java/com/imutil/controller/CallbackController.java @@ -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 回调接收网关 + *

+ * 腾讯控制台将回调 URL 配置为 http://host/imutil/callback/im 。 + * 本控制器负责:签名校验(防伪造+防重放)→ 委托 CallbackService 落库+入队 → 5s 内返回 ActionResults。 + *

+ * 注意:仅做"落库+写队列"即返回,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); + } +} diff --git a/src/main/java/com/imutil/controller/SigController.java b/src/main/java/com/imutil/controller/SigController.java new file mode 100644 index 0000000..779a749 --- /dev/null +++ b/src/main/java/com/imutil/controller/SigController.java @@ -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 签发接口 + *

+ * 业务系统后端在客户端登录时调用本接口,本工具用 SecretKey 生成 UserSig 返回。 + * 客户端拿 im_user_id + UserSig 直连腾讯 IM SDK 收发消息(控制面走本工具,数据面走腾讯)。 + *

+ * 鉴权:走 {@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 账号) + *

+ * 入参: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 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); + } +} diff --git a/src/main/java/com/imutil/entity/AdminUser.java b/src/main/java/com/imutil/entity/AdminUser.java new file mode 100644 index 0000000..310907f --- /dev/null +++ b/src/main/java/com/imutil/entity/AdminUser.java @@ -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; + +/** + * 管理后台用户实体 + *

+ * 用于 Sa-Token 登录鉴权。密码以 PBKDF2 哈希存储(格式见 {@link com.imutil.common.PasswordUtil})。 + * role:admin=超级管理员,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; +} diff --git a/src/main/java/com/imutil/entity/ApiCallLog.java b/src/main/java/com/imutil/entity/ApiCallLog.java new file mode 100644 index 0000000..ae53191 --- /dev/null +++ b/src/main/java/com/imutil/entity/ApiCallLog.java @@ -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 调用审计实体 + *

+ * 所有调腾讯后台 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; +} diff --git a/src/main/java/com/imutil/entity/CrossTenantAudit.java b/src/main/java/com/imutil/entity/CrossTenantAudit.java new file mode 100644 index 0000000..61a2f94 --- /dev/null +++ b/src/main/java/com/imutil/entity/CrossTenantAudit.java @@ -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; + +/** + * 跨租户通讯审计实体 + *

+ * 所有经授权放行的跨租户消息单独审计,便于追溯。 + * + * @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; +} diff --git a/src/main/java/com/imutil/entity/CrossTenantGrant.java b/src/main/java/com/imutil/entity/CrossTenantGrant.java new file mode 100644 index 0000000..a7d63c8 --- /dev/null +++ b/src/main/java/com/imutil/entity/CrossTenantGrant.java @@ -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; + +/** + * 跨租户通讯授权实体 + *

+ * 用于 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; +} diff --git a/src/main/java/com/imutil/entity/DistQueue.java b/src/main/java/com/imutil/entity/DistQueue.java new file mode 100644 index 0000000..2f4b7a5 --- /dev/null +++ b/src/main/java/com/imutil/entity/DistQueue.java @@ -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) + *

+ * 回调网关同事务写入 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; +} diff --git a/src/main/java/com/imutil/entity/GroupMapping.java b/src/main/java/com/imutil/entity/GroupMapping.java new file mode 100644 index 0000000..c41ef9b --- /dev/null +++ b/src/main/java/com/imutil/entity/GroupMapping.java @@ -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; + +/** + * 群映射实体 + *

+ * 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; +} diff --git a/src/main/java/com/imutil/entity/ImMessage.java b/src/main/java/com/imutil/entity/ImMessage.java new file mode 100644 index 0000000..db984b0 --- /dev/null +++ b/src/main/java/com/imutil/entity/ImMessage.java @@ -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 分区) + *

+ * 复合主键 (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; + + /** 会话ID:C2C=对端账号 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; +} diff --git a/src/main/java/com/imutil/entity/PullWatermark.java b/src/main/java/com/imutil/entity/PullWatermark.java new file mode 100644 index 0000000..ea4daa1 --- /dev/null +++ b/src/main/java/com/imutil/entity/PullWatermark.java @@ -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; + +/** + * 补拉水位线实体 + *

+ * 记录每个会话最后拉取的消息 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; +} diff --git a/src/main/java/com/imutil/entity/Recording.java b/src/main/java/com/imutil/entity/Recording.java new file mode 100644 index 0000000..b3eb79e --- /dev/null +++ b/src/main/java/com/imutil/entity/Recording.java @@ -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; + +/** + * 录制文件实体 + *

+ * 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; +} diff --git a/src/main/java/com/imutil/entity/Tenant.java b/src/main/java/com/imutil/entity/Tenant.java new file mode 100644 index 0000000..bfaf84d --- /dev/null +++ b/src/main/java/com/imutil/entity/Tenant.java @@ -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; + +/** + * 租户表实体 + *

+ * 每个业务系统对应一个租户,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; +} diff --git a/src/main/java/com/imutil/entity/TrtcRoom.java b/src/main/java/com/imutil/entity/TrtcRoom.java new file mode 100644 index 0000000..1edea1c --- /dev/null +++ b/src/main/java/com/imutil/entity/TrtcRoom.java @@ -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 音视频房间实体 + *

+ * 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; +} diff --git a/src/main/java/com/imutil/entity/UsageStat.java b/src/main/java/com/imutil/entity/UsageStat.java new file mode 100644 index 0000000..1b801cf --- /dev/null +++ b/src/main/java/com/imutil/entity/UsageStat.java @@ -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; + +/** + * 用量统计实体(计费拆账依据) + *

+ * stat_level:1=小时 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; +} diff --git a/src/main/java/com/imutil/entity/UserMapping.java b/src/main/java/com/imutil/entity/UserMapping.java new file mode 100644 index 0000000..7b76776 --- /dev/null +++ b/src/main/java/com/imutil/entity/UserMapping.java @@ -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 用户) + *

+ * 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; +} diff --git a/src/main/java/com/imutil/filter/AdminAuthFilter.java b/src/main/java/com/imutil/filter/AdminAuthFilter.java new file mode 100644 index 0000000..c906e6a --- /dev/null +++ b/src/main/java/com/imutil/filter/AdminAuthFilter.java @@ -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; + +/** + * 管理后台鉴权过滤器 + *

+ * 拦截 {@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\":\"未登录或会话已过期\"}"); + } + } +} diff --git a/src/main/java/com/imutil/filter/GlobalExceptionFilter.java b/src/main/java/com/imutil/filter/GlobalExceptionFilter.java new file mode 100644 index 0000000..979188d --- /dev/null +++ b/src/main/java/com/imutil/filter/GlobalExceptionFilter.java @@ -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; + +/** + * 全局异常拦截器 + *

+ * 捕获控制器抛出的所有异常,统一以 {@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); + } +} diff --git a/src/main/java/com/imutil/filter/TenantAuthFilter.java b/src/main/java/com/imutil/filter/TenantAuthFilter.java new file mode 100644 index 0000000..f968561 --- /dev/null +++ b/src/main/java/com/imutil/filter/TenantAuthFilter.java @@ -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; + +/** + * 租户鉴权过滤器 + *

+ * 拦截所有非白名单路径,校验 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)} + *

+ * 多租户凭证各自独立,不能用单一 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)); + } +} diff --git a/src/main/java/com/imutil/mapper/AdminUserMapper.java b/src/main/java/com/imutil/mapper/AdminUserMapper.java new file mode 100644 index 0000000..d6892e3 --- /dev/null +++ b/src/main/java/com/imutil/mapper/AdminUserMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/ApiCallLogMapper.java b/src/main/java/com/imutil/mapper/ApiCallLogMapper.java new file mode 100644 index 0000000..ea168aa --- /dev/null +++ b/src/main/java/com/imutil/mapper/ApiCallLogMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/CrossTenantAuditMapper.java b/src/main/java/com/imutil/mapper/CrossTenantAuditMapper.java new file mode 100644 index 0000000..26ecb5c --- /dev/null +++ b/src/main/java/com/imutil/mapper/CrossTenantAuditMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/CrossTenantGrantMapper.java b/src/main/java/com/imutil/mapper/CrossTenantGrantMapper.java new file mode 100644 index 0000000..f7239f4 --- /dev/null +++ b/src/main/java/com/imutil/mapper/CrossTenantGrantMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/DistQueueMapper.java b/src/main/java/com/imutil/mapper/DistQueueMapper.java new file mode 100644 index 0000000..58f9a96 --- /dev/null +++ b/src/main/java/com/imutil/mapper/DistQueueMapper.java @@ -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 + *

+ * 提供 FOR UPDATE SKIP LOCKED 抢占消费的 SQL。 + * + * @author imutil + */ +public interface DistQueueMapper extends BaseMapper { + + /** + * 抢占式拉取待分发记录: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 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) 的记录重置为 pending(retry_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); +} diff --git a/src/main/java/com/imutil/mapper/GroupMappingMapper.java b/src/main/java/com/imutil/mapper/GroupMappingMapper.java new file mode 100644 index 0000000..9d52945 --- /dev/null +++ b/src/main/java/com/imutil/mapper/GroupMappingMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/ImMessageMapper.java b/src/main/java/com/imutil/mapper/ImMessageMapper.java new file mode 100644 index 0000000..0df2d64 --- /dev/null +++ b/src/main/java/com/imutil/mapper/ImMessageMapper.java @@ -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 { + + /** + * 原子更新分发状态(乐观锁:仅当当前状态为期望值时更新) + * + * @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); +} diff --git a/src/main/java/com/imutil/mapper/PullWatermarkMapper.java b/src/main/java/com/imutil/mapper/PullWatermarkMapper.java new file mode 100644 index 0000000..31281cd --- /dev/null +++ b/src/main/java/com/imutil/mapper/PullWatermarkMapper.java @@ -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 + *

+ * upsert 用 PG ON CONFLICT,selectNeedCheck 按 updated_at 升序取最久未补拉的会话, + * 配合补拉成功后推进 updated_at,实现会话轮询公平覆盖。 + * + * @author imutil + */ +public interface PullWatermarkMapper extends BaseMapper { + + /** + * 水位线 upsert(PG ON CONFLICT) + *

+ * 回调落库 / 补拉推进时调用。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 升序) + *

+ * 补拉任务每轮调用,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 selectNeedCheck(@Param("limit") int limit); +} diff --git a/src/main/java/com/imutil/mapper/RecordingMapper.java b/src/main/java/com/imutil/mapper/RecordingMapper.java new file mode 100644 index 0000000..1a09b96 --- /dev/null +++ b/src/main/java/com/imutil/mapper/RecordingMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/TenantMapper.java b/src/main/java/com/imutil/mapper/TenantMapper.java new file mode 100644 index 0000000..4c88684 --- /dev/null +++ b/src/main/java/com/imutil/mapper/TenantMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/TrtcRoomMapper.java b/src/main/java/com/imutil/mapper/TrtcRoomMapper.java new file mode 100644 index 0000000..a85929e --- /dev/null +++ b/src/main/java/com/imutil/mapper/TrtcRoomMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/mapper/UsageStatMapper.java b/src/main/java/com/imutil/mapper/UsageStatMapper.java new file mode 100644 index 0000000..62b0264 --- /dev/null +++ b/src/main/java/com/imutil/mapper/UsageStatMapper.java @@ -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 + *

+ * 提供按小时窗口的聚合 upsert(PG ON CONFLICT 幂等,可重复跑)。 + * 两个数据源各自 upsert 指定列,避免互相覆盖: + * - im_message → im_msg_count / im_dau + * - api_call_log → api_call_count + * + * @author imutil + */ +public interface UsageStatMapper extends BaseMapper { + + /** + * 从 im_message 聚合某小时窗口的消息量与活跃用户数,upsert 到 usage_stat(stat_level=1 小时) + *

+ * 仅更新 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_stat(stat_level=1 小时) + *

+ * 仅更新 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); +} diff --git a/src/main/java/com/imutil/mapper/UserMappingMapper.java b/src/main/java/com/imutil/mapper/UserMappingMapper.java new file mode 100644 index 0000000..9a77e63 --- /dev/null +++ b/src/main/java/com/imutil/mapper/UserMappingMapper.java @@ -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 { +} diff --git a/src/main/java/com/imutil/model/Result.java b/src/main/java/com/imutil/model/Result.java new file mode 100644 index 0000000..e72c054 --- /dev/null +++ b/src/main/java/com/imutil/model/Result.java @@ -0,0 +1,99 @@ +package com.imutil.model; + +import lombok.Data; + +/** + * 统一接口返回体 + *

+ * 所有 REST 接口与回调内部响应统一使用本类。HTTP 状态码始终为 200, + * 成功/失败通过 {@link #code} 区分(200 成功,其余失败)。 + * + * @author imutil + */ +@Data +public class Result { + + 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 Result ok() { + Result r = new Result<>(); + r.setSuccess(true); + r.setCode(200); + return r; + } + + public static Result ok(String msg) { + Result r = new Result<>(); + r.setSuccess(true); + r.setCode(200); + r.setMessage(msg); + r.setResult((T) msg); + return r; + } + + public static Result ok(T data) { + Result r = new Result<>(); + r.setSuccess(true); + r.setCode(200); + r.setResult(data); + return r; + } + + public static Result ok(String msg, T data) { + Result r = new Result<>(); + r.setSuccess(true); + r.setCode(200); + r.setMessage(msg); + r.setResult(data); + return r; + } + + public static Result error(String msg) { + return error(500, msg); + } + + public static Result error(int code, String msg) { + Result r = new Result<>(); + r.setCode(code); + r.setMessage(msg); + r.setSuccess(false); + return r; + } + + public static Result error(String msg, T data) { + Result r = new Result<>(); + r.setSuccess(false); + r.setCode(500); + r.setMessage(msg); + r.setResult(data); + return r; + } + + /** 无权限访问 */ + public static Result noauth(String msg) { + return error(401, msg); + } +} diff --git a/src/main/java/com/imutil/service/AdminUserService.java b/src/main/java/com/imutil/service/AdminUserService.java new file mode 100644 index 0000000..5f7c517 --- /dev/null +++ b/src/main/java/com/imutil/service/AdminUserService.java @@ -0,0 +1,25 @@ +package com.imutil.service; + +import com.imutil.entity.AdminUser; + +/** + * 管理后台用户服务 + *

+ * 登录校验(PBKDF2)+ 首次启动默认管理员初始化。 + * + * @author imutil + */ +public interface AdminUserService { + + /** + * 登录校验 + * + * @return 凭证正确且账号启用返回 AdminUser,否则 null + */ + AdminUser login(String username, String password); + + /** + * 应用启动时确保存在至少一个管理员(表空则按 app.yml imutil.admin 初始化) + */ + void ensureDefaultAdmin(); +} diff --git a/src/main/java/com/imutil/service/CallbackService.java b/src/main/java/com/imutil/service/CallbackService.java new file mode 100644 index 0000000..e135c0b --- /dev/null +++ b/src/main/java/com/imutil/service/CallbackService.java @@ -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); +} diff --git a/src/main/java/com/imutil/service/DispatchService.java b/src/main/java/com/imutil/service/DispatchService.java new file mode 100644 index 0000000..cd3b96e --- /dev/null +++ b/src/main/java/com/imutil/service/DispatchService.java @@ -0,0 +1,35 @@ +package com.imutil.service; + +import com.imutil.entity.DistQueue; + +import java.util.List; + +/** + * 回调分发服务 + *

+ * 从 dist_queue 抢占待分发记录,HTTP 转发业务系统,按回执更新状态。 + * + * @author imutil + */ +public interface DispatchService { + + /** + * 抢占一批 pending 记录置为 processing(事务内 FOR UPDATE SKIP LOCKED + lock) + * + * @param workerName 工作线程标识 + * @return 抢占到的记录列表 + */ + List fetchAndLock(String workerName); + + /** + * 分发单条记录:HTTP 转发 → 回执成功 markDone / 失败重试或死信 + */ + void dispatch(DistQueue task, String workerName); + + /** + * 巡检:重置超时未回执的 processing 回到 pending(工作线程宕机恢复) + * + * @return 重置条数 + */ + int recoverStuck(); +} diff --git a/src/main/java/com/imutil/service/PullService.java b/src/main/java/com/imutil/service/PullService.java new file mode 100644 index 0000000..badafef --- /dev/null +++ b/src/main/java/com/imutil/service/PullService.java @@ -0,0 +1,18 @@ +package com.imutil.service; + +/** + * 历史消息补拉服务 + *

+ * 回调链路的兜底:腾讯回调丢失或本工具落库失败时,定期主动从腾讯拉取最新消息补全本地, + * 保证 im_message 不丢、业务系统不漏收(对应「已知问题」第 3 条)。 + * + * @author imutil + */ +public interface PullService { + + /** + * 执行一轮补拉:取最久未补拉的若干会话,逐个调腾讯 API 拉最新消息, + * msg_key 幂等落库(source=PULL_BACK)并入队分发,最后推进水位线 + */ + void pullRound(); +} diff --git a/src/main/java/com/imutil/service/TenantService.java b/src/main/java/com/imutil/service/TenantService.java new file mode 100644 index 0000000..db7cce7 --- /dev/null +++ b/src/main/java/com/imutil/service/TenantService.java @@ -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); +} diff --git a/src/main/java/com/imutil/service/UsageStatService.java b/src/main/java/com/imutil/service/UsageStatService.java new file mode 100644 index 0000000..87b8c09 --- /dev/null +++ b/src/main/java/com/imutil/service/UsageStatService.java @@ -0,0 +1,23 @@ +package com.imutil.service; + +import java.time.OffsetDateTime; + +/** + * 用量统计服务 + *

+ * 按小时窗口从 im_message / api_call_log 聚合用量到 usage_stat(计费拆账依据)。 + * + * @author imutil + */ +public interface UsageStatService { + + /** + * 聚合某小时窗口 [start, end) 的用量,upsert 到 usage_stat(stat_level=1) + *

+ * 幂等:ON CONFLICT,重复跑不产生重复数据,取最新聚合值。 + * + * @param start 窗口起点(含,整点) + * @param end 窗口终点(不含) + */ + void aggregateHour(OffsetDateTime start, OffsetDateTime end); +} diff --git a/src/main/java/com/imutil/service/UserMappingService.java b/src/main/java/com/imutil/service/UserMappingService.java new file mode 100644 index 0000000..a035908 --- /dev/null +++ b/src/main/java/com/imutil/service/UserMappingService.java @@ -0,0 +1,25 @@ +package com.imutil.service; + +/** + * 账号映射服务 + *

+ * 维护业务用户ID ↔ IM UserID(带租户前缀)映射,首次使用时调腾讯 account_import 创建 IM 账号。 + * + * @author imutil + */ +public interface UserMappingService { + + /** + * 获取或创建 IM 用户ID + *

+ * 不存在则调腾讯 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); +} diff --git a/src/main/java/com/imutil/service/impl/AdminUserServiceImpl.java b/src/main/java/com/imutil/service/impl/AdminUserServiceImpl.java new file mode 100644 index 0000000..fcbe454 --- /dev/null +++ b/src/main/java/com/imutil/service/impl/AdminUserServiceImpl.java @@ -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; + +/** + * 管理后台用户服务实现 + *

+ * 密码用 {@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.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); + } +} diff --git a/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java new file mode 100644 index 0000000..c99dddf --- /dev/null +++ b/src/main/java/com/imutil/service/impl/CallbackServiceImpl.java @@ -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; + +/** + * 腾讯回调处理服务实现 + *

+ * 流程:识别租户 → 消息类回调落 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.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.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\":\"\"}"; + } +} diff --git a/src/main/java/com/imutil/service/impl/DispatchServiceImpl.java b/src/main/java/com/imutil/service/impl/DispatchServiceImpl.java new file mode 100644 index 0000000..08900ce --- /dev/null +++ b/src/main/java/com/imutil/service/impl/DispatchServiceImpl.java @@ -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; + +/** + * 回调分发服务实现 + *

+ * 抢占消费:{@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 fetchAndLock(String workerName) { + OffsetDateTime now = OffsetDateTime.now(); + List pending = distQueueMapper.fetchPending(now, fetchBatch); + List 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); + } +} diff --git a/src/main/java/com/imutil/service/impl/PullServiceImpl.java b/src/main/java/com/imutil/service/impl/PullServiceImpl.java new file mode 100644 index 0000000..121a64b --- /dev/null +++ b/src/main/java/com/imutil/service/impl/PullServiceImpl.java @@ -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; + +/** + * 历史消息补拉服务实现 + *

+ * 流程:取最久未补拉的 N 个会话 → 按会话类型调腾讯 API 拉最新消息 → + * 逐条 msg_key 幂等(与回调路径共享 {@link MsgKeys} 算法)→ 本地不存在则落库(source=PULL_BACK)+入队分发 → 推进水位线。 + *

+ * 仅"本次新插入"的消息入队分发,避免对回调已正常落库的消息重复推送业务系统。 + * 每轮串行执行 + 会话数/条数上限,受腾讯 API QPS 约束。 + *

+ * 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 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.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.lambdaQuery() + .eq(ImMessage::getTenantId, tenantId) + .eq(ImMessage::getConvId, to) + .isNotNull(ImMessage::getFromAccount) + .ne(ImMessage::getFromAccount, "") + .orderByDesc(ImMessage::getMsgTime) + .last("LIMIT 1")); + return latest == null ? null : latest.getFromAccount(); + } + + /** + * 解析腾讯响应,校验 ActionStatus=OK 并返回消息数组节点;失败/空返回 null + * + * @param resp 腾讯原始响应 JSON + * @param listKey 消息列表字段名(C2C=MsgList,GROUP=RspMsgList) + */ + private ONode extractMsgList(String resp, String listKey) { + if (resp == null || resp.isEmpty()) { + return null; + } + try { + ONode root = ONode.ofJson(resp); + if (!"OK".equalsIgnoreCase(root.get("ActionStatus").getString())) { + log.warn("补拉腾讯API返回非OK : {}", resp); + return null; + } + ONode list = root.get(listKey); + return list.isArray() ? list : null; + } catch (Exception e) { + log.warn("补拉响应解析失败 : {}", e.toString()); + return null; + } + } + + /** + * 取消息体首个元素的类型(MsgBody 为数组,如 TIMTextElem/TIMImageElem) + */ + private String firstBodyType(ONode body) { + if (body == null || !body.isArray() || body.size() == 0) { + return null; + } + return body.get(0).get("MsgType").getString(); + } + + /** + * 秒级时间戳转 OffsetDateTime(0/负数兜底为当前时间) + */ + private OffsetDateTime toTime(long epochSecond) { + if (epochSecond <= 0) { + return OffsetDateTime.now(); + } + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault()); + } +} diff --git a/src/main/java/com/imutil/service/impl/TenantServiceImpl.java b/src/main/java/com/imutil/service/impl/TenantServiceImpl.java new file mode 100644 index 0000000..d97c164 --- /dev/null +++ b/src/main/java/com/imutil/service/impl/TenantServiceImpl.java @@ -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; + +/** + * 租户服务实现 + *

+ * 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.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); + } + } +} diff --git a/src/main/java/com/imutil/service/impl/UsageStatServiceImpl.java b/src/main/java/com/imutil/service/impl/UsageStatServiceImpl.java new file mode 100644 index 0000000..67554fd --- /dev/null +++ b/src/main/java/com/imutil/service/impl/UsageStatServiceImpl.java @@ -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; + +/** + * 用量统计服务实现 + *

+ * 两个数据源各自 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); + } +} diff --git a/src/main/java/com/imutil/service/impl/UserMappingServiceImpl.java b/src/main/java/com/imutil/service/impl/UserMappingServiceImpl.java new file mode 100644 index 0000000..415434c --- /dev/null +++ b/src/main/java/com/imutil/service/impl/UserMappingServiceImpl.java @@ -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; + +/** + * 账号映射服务实现 + *

+ * 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.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.lambdaQuery() + .eq(UserMapping::getImUserId, m.getImUserId()) + .last("LIMIT 1")); + if (exist == null) { + throw new BizException(500, "写入用户映射失败: " + m.getImUserId(), e); + } + } + } + + /** + * 校验 IM UserID 合法性 + *

+ * 腾讯 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 + "',仅允许字母/数字/下划线/横线"); + } + } + } +} diff --git a/src/main/java/com/imutil/task/DispatchRecoverTask.java b/src/main/java/com/imutil/task/DispatchRecoverTask.java new file mode 100644 index 0000000..2442325 --- /dev/null +++ b/src/main/java/com/imutil/task/DispatchRecoverTask.java @@ -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; + +/** + * 分发卡死巡检任务 + *

+ * 定时重置超时未回执的 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); + } + } +} diff --git a/src/main/java/com/imutil/task/PullCheckTask.java b/src/main/java/com/imutil/task/PullCheckTask.java new file mode 100644 index 0000000..7ef9b29 --- /dev/null +++ b/src/main/java/com/imutil/task/PullCheckTask.java @@ -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; + +/** + * 历史消息补拉巡检任务 + *

+ * 定时触发补拉服务,兜底腾讯回调丢失 / 本工具落库失败的消息, + * 保证 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); + } + } +} diff --git a/src/main/java/com/imutil/task/UsageStatTask.java b/src/main/java/com/imutil/task/UsageStatTask.java new file mode 100644 index 0000000..a8b2d46 --- /dev/null +++ b/src/main/java/com/imutil/task/UsageStatTask.java @@ -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; + +/** + * 用量统计定时聚合任务 + *

+ * 每小时聚合「上一整点小时」窗口的用量到 usage_stat(stat_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); + } + } +} diff --git a/src/main/java/com/imutil/tencent/TencentCallbackSign.java b/src/main/java/com/imutil/tencent/TencentCallbackSign.java new file mode 100644 index 0000000..c415ec5 --- /dev/null +++ b/src/main/java/com/imutil/tencent/TencentCallbackSign.java @@ -0,0 +1,93 @@ +package com.imutil.tencent; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * 腾讯 IM 第三方回调签名校验 + *

+ * 算法(参考腾讯文档 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; + } +} diff --git a/src/main/java/com/imutil/tencent/TencentImClient.java b/src/main/java/com/imutil/tencent/TencentImClient.java new file mode 100644 index 0000000..01af1f6 --- /dev/null +++ b/src/main/java/com/imutil/tencent/TencentImClient.java @@ -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 收口) + *

+ * 鉴权方式:请求 URL 带 admin 的 UserSig + identifier + sdkappid query 参数(非 TC3 签名)。 + * 所有调腾讯后台 API 的入口集中在此,密钥仅本类持有,业务系统不得直接调用。 + *

+ * 每次调用同步写 {@code api_call_log} 审计(try-catch,写入失败不影响主流程), + * 作为用量统计(api_call_count)与问题追溯的数据源。 + *

+ * 返回体统一含 ActionStatus(OK/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 调用审计 + *

+ * 同步写 + 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 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); + } + + /** + * 检查账号状态(只读) + *

+ * 用于验证 admin UserSig 有效性:返回 ActionStatus=OK 即签名校验通过(与账号是否存在无关)。 + * + * @return true=API 调用成功(签名有效) + */ + public boolean accountCheck(String imUserId) { + Map item = new HashMap<>(); + item.put("UserID", imUserId); + Map 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) + *

+ * 命令字 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 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) + *

+ * 命令字 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 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; + } + } +} diff --git a/src/main/java/com/imutil/tencent/UserSigUtil.java b/src/main/java/com/imutil/tencent/UserSigUtil.java new file mode 100644 index 0000000..a9fd4e9 --- /dev/null +++ b/src/main/java/com/imutil/tencent/UserSigUtil.java @@ -0,0 +1,37 @@ +package com.imutil.tencent; + +import com.tencentyun.TLSSigAPIv2; + +/** + * 腾讯 IM UserSig 生成工具(封装官方 tls-sig-api-v2 SDK) + *

+ * 使用官方 {@code com.github.tencentyun:tls-sig-api-v2} 实现,确保 HMAC-SHA256 + zlib raw deflate + * 算法与腾讯服务端校验完全一致,避免手写算法细节偏差导致的 70003(UserSig illegal)错误。 + *

+ * 官方算法流水线:SigDict(固定字段顺序)→ HMAC-SHA256(key, json) → zlib raw deflate → base64。 + * 密钥、字段顺序、压缩参数等易错点全部由官方 SDK 处理,本类仅做静态薄封装。 + * + * @author imutil + */ +public final class UserSigUtil { + + private UserSigUtil() { + } + + /** + * 生成 UserSig + *

+ * 线程安全说明:每次调用 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); + } +} diff --git a/src/main/java/com/imutil/worker/DispatchWorker.java b/src/main/java/com/imutil/worker/DispatchWorker.java new file mode 100644 index 0000000..1b971e4 --- /dev/null +++ b/src/main/java/com/imutil/worker/DispatchWorker.java @@ -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; + +/** + * 分发工作线程 + *

+ * 应用启动后({@code @Init})创建 daemon 线程池,每个线程循环: + * 抢占一批 pending → 逐条 HTTP 转发业务系统 → 回执更新状态。 + * 空闲时按 pollIntervalMs 休眠,避免空转。 + *

+ * 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 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; + } + } + } + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..bcc826f --- /dev/null +++ b/src/main/resources/app.yml @@ -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 diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql new file mode 100644 index 0000000..bee36b5 --- /dev/null +++ b/src/main/resources/db/init.sql @@ -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. 分发队列(替代 MQ,FOR 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:hash(PBKDF2,见 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) diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..d5862f1 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + + GBK + %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:-}) --- %-15([%15.15thread]) %-56(%cyan(%-40.40logger{39})) : %msg%n + + + + + + logs/imutil.log + + logs/imutil.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 5GB + + + UTF-8 + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:-} --- %-15([%15.15thread]) %-56(%-40.40logger{39}) : %msg%n + + + + + + + + + + + + diff --git a/src/main/resources/templates/_macros.ftl b/src/main/resources/templates/_macros.ftl new file mode 100644 index 0000000..f4e3a36 --- /dev/null +++ b/src/main/resources/templates/_macros.ftl @@ -0,0 +1,82 @@ +<#-- + 管理后台公共布局宏 + 用法:<#import "_macros.ftl" as m><@m.layout active="home" title="仪表盘">页面内容 + 对齐 yxtech:左侧边栏 + 右侧主区域,内嵌 CSS(无外部框架)。 +--> +<#macro layout active title> + + + + + + ${title} - IM分发工具管理后台 + + + +

+
+
${title}
+
+ <#nested> +
+
+ + + diff --git a/src/main/resources/templates/grant.ftl b/src/main/resources/templates/grant.ftl new file mode 100644 index 0000000..71e72a9 --- /dev/null +++ b/src/main/resources/templates/grant.ftl @@ -0,0 +1,74 @@ +<#import "_macros.ftl" as m> +<@m.layout active="grant" title="跨租户授权"> + <#if msg?has_content> +
${msg}
+ +
+ + 用于放行跨租户通讯(发消息/加好友/加群),空用户表示任意账户/全员 +
+ <#if grants?has_content> + + + + + + <#list grants as g> + + + + + + + + + + + + + +
授权ID源租户源用户目标租户目标用户权限方向状态操作
${g.grantId?c}${g.fromTenantId!}${g.fromImUserId!'(任意)'}${g.toTenantId!}${g.toImUserId!'(全员)'}${g.permissions!}<#if g.direction?? && g.direction == 1>双向<#else>单向 + <#if g.status?? && g.status == 1>生效 + <#else>撤销 + + <#if g.status?? && g.status == 1> +
+ + +
+ +
+ <#else> +
暂无授权记录
+ + + + diff --git a/src/main/resources/templates/home.ftl b/src/main/resources/templates/home.ftl new file mode 100644 index 0000000..9d31d4c --- /dev/null +++ b/src/main/resources/templates/home.ftl @@ -0,0 +1,22 @@ +<#import "_macros.ftl" as m> +<@m.layout active="home" title="仪表盘"> +
+
+
租户数
+
${tenantCount!'0'}
+
+
+
待分发 (pending)
+
${queuePending!'0'}
+
+
+
死信 (dead)
+
style="color:#dc3545">${queueDead!'0'}
+
+
+
生效跨租户授权
+
${grantActive!'0'}
+
+
+

默认账号 admin / admin123(首次启动初始化,请尽快修改密码)。死信 > 0 时请到「队列监控」处理。

+ diff --git a/src/main/resources/templates/login.ftl b/src/main/resources/templates/login.ftl new file mode 100644 index 0000000..8f9a0d5 --- /dev/null +++ b/src/main/resources/templates/login.ftl @@ -0,0 +1,42 @@ + + + + + + 登录 - IM分发工具管理后台 + + + +
+
IM分发工具 管理后台
+
+ <#if errorMsg?has_content> +
${errorMsg}
+ +
+
+ + +
+
+ + +
+ +
+
+
+ + diff --git a/src/main/resources/templates/queue.ftl b/src/main/resources/templates/queue.ftl new file mode 100644 index 0000000..dbf01c6 --- /dev/null +++ b/src/main/resources/templates/queue.ftl @@ -0,0 +1,52 @@ +<#import "_macros.ftl" as m> +<@m.layout active="queue" title="队列监控"> + <#if msg?has_content> +
${msg}
+ + +

待分发 (pending,最近 50 条)

+ <#if pendings?has_content> + + + + <#list pendings as q> + + + + + + + + + +
ID租户会话重试次数下次重试时间
${q.id?c}${q.tenantId!}${q.convId!}${q.retryCount!0}<#if q.nextRetryAt??>${q.nextRetryAt?string('yyyy-MM-dd HH:mm:ss')}<#else>-
+ <#else> +
无待分发记录
+ + +

死信 (dead,共 ${deadCount!'0'} 条,最近 50 条)

+ <#if deads?has_content> + + + + <#list deads as q> + + + + + + + + + + +
ID租户会话重试次数目标URL操作
${q.id?c}${q.tenantId!}${q.convId!}${q.retryCount!0}${q.targetUrl!} +
+ + +
+
+ <#else> +
无死信
+ + diff --git a/src/main/resources/templates/tenant.ftl b/src/main/resources/templates/tenant.ftl new file mode 100644 index 0000000..75b139d --- /dev/null +++ b/src/main/resources/templates/tenant.ftl @@ -0,0 +1,74 @@ +<#import "_macros.ftl" as m> +<@m.layout active="tenant" title="租户管理"> + <#if msg?has_content> +
${msg}
+ +
+ +
+ <#if tenants?has_content> + + + + + + + + + <#list tenants as t> + + + + + + + + + + + + + +
租户ID名称前缀AppKeyAppSecret回调URLIM QPS状态操作
${t.tenantId!}${t.tenantName!}${t.prefixCode!}${t.appKey!}${t.appSecret!}${t.callbackUrl!'-'}${t.quotaImQps!'-'} + <#if t.status?? && t.status == 1> + 启用 + <#else> + 停用 + + +
+ + <#if t.status?? && t.status == 1> + + + <#else> + + + +
+
+ <#else> +
暂无租户,点击「新增租户」添加
+ + + + diff --git a/src/main/resources/templates/usage.ftl b/src/main/resources/templates/usage.ftl new file mode 100644 index 0000000..d3bb27b --- /dev/null +++ b/src/main/resources/templates/usage.ftl @@ -0,0 +1,25 @@ +<#import "_macros.ftl" as m> +<@m.layout active="usage" title="用量报表"> + <#if stats?has_content> + + + + + + <#list stats as s> + + + + + + + + + + + +
租户统计时刻粒度IM消息数IM DAUAPI调用数TRTC时长(秒)
${s.tenantId!}<#if s.statTime??>${s.statTime?string('yyyy-MM-dd HH:mm')}<#else>-<#if s.statLevel?? && s.statLevel == 1>小时<#else>天${s.imMsgCount!0}${s.imDau!0}${s.apiCallCount!0}${s.trtcDurationSec!0}
+ <#else> +
暂无用量数据(每小时 03 分自动聚合;触发 API 调用/回调后产生数据)
+ + diff --git a/已知需要考虑的问题.txt b/已知需要考虑的问题.txt new file mode 100644 index 0000000..44c75f6 --- /dev/null +++ b/已知需要考虑的问题.txt @@ -0,0 +1,6 @@ +1.消息服务 Chat 回调URL配置,目前这个已配置,用来将每条消息保存到本地(每个系统肯定都不一样) +2.需要将现有几个不同应用的数据合并到一个应用,比如im用户,im群组等 +3.如果遇到未正确回调url的,导致本地消息数据丢失的,需要同步腾讯api拉取同步消息 +4.需要有功能来统计不同系统的使用情况,包括im使用情况,音视频使用情况 +5.需要统一将消息服务回调放到统一工具中,即本系统中,本系统负责保存所有回调存过来的消息,并负责拉取历史消息,并根据系统来打上标记,系统之间不能串 +6.还需要考虑腾讯回调过来的消息,需要第一时间发送到每个系统的回调地址中,需要消息队列,需要回执及重发机制,避免因目标系统宕机造成消息丢失 diff --git a/补充细则.txt b/补充细则.txt new file mode 100644 index 0000000..56329ee --- /dev/null +++ b/补充细则.txt @@ -0,0 +1,4 @@ +1.各个系统中可能存在一些默认账户,账户的id一致,我倾向于前缀处理 +2.各个系统中有一小部分账户,是可以配置授权给跨应用的,这个也要考虑 +3.根据目前设计的数据接口等, 帮我选型一下数据库 +4.系统架构我倾向于参考 yxtech 项目,使用java + solon 框架来写 \ No newline at end of file