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