feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉

- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token
- 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信
- 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id
- 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面
- 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失
- msg_key 统一算法(回调/补拉共享, 跨路径去重)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yixiong
2026-07-08 20:28:32 +08:00
co-authored by Claude
parent 20bd78baa8
commit d22fdcfaad
80 changed files with 4955 additions and 0 deletions
@@ -0,0 +1,35 @@
package com.imutil.common;
import lombok.Getter;
/**
* 业务异常
* <p>
* 业务逻辑中主动抛出,由 {@link com.imutil.filter.GlobalExceptionFilter} 捕获后
* 以对应 code 返回前端。默认 code=500。
*
* @author imutil
*/
@Getter
public class BizException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** 返回代码 */
private final int code;
public BizException(String message) {
super(message);
this.code = 500;
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}
@@ -0,0 +1,67 @@
package com.imutil.common;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* HTTP 工具封装
* <p>
* 基于 JDK 21 内置 java.net.http.HttpClient,无第三方依赖。
* 用于分发工作线程向业务系统转发回调、调用腾讯 REST API。
*
* @author imutil
*/
public final class Httpx {
/** 全局复用 HttpClient(线程安全) */
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private Httpx() {
}
/**
* POST JSON 请求,返回 HTTP 状态码
*
* @param url 目标地址
* @param body JSON 请求体
* @return HTTP 状态码;2xx 视为成功
*/
public static int postJson(String url, String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
return resp.statusCode();
}
/**
* POST JSON 请求,返回状态码与响应体
*/
public static Response postJsonDetail(String url, String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
return new Response(resp.statusCode(), resp.body());
}
/**
* 简单响应封装
*/
public record Response(int statusCode, String body) {
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package com.imutil.common;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
/**
* ID 生成工具
* <p>
* 基于 MyBatis-Plus 内置雪花算法({@link IdWorker})生成全局唯一 ID。
* 提供字符串形式以规避 Long 雪花 ID 经 JSON 传前端时精度丢失(>2^53)。
*
* @author imutil
*/
public final class Ids {
private Ids() {
}
/**
* 生成雪花 IDlong
*/
public static long nextId() {
return IdWorker.getId();
}
/**
* 生成雪花 ID(字符串)
*/
public static String nextIdStr() {
return String.valueOf(IdWorker.getId());
}
}
@@ -0,0 +1,49 @@
package com.imutil.common;
import org.noear.snack4.ONode;
import java.lang.reflect.Type;
/**
* JSON 工具封装
* <p>
* 基于 Solon 内置的 snack4{@link ONode}),不额外引入 fastjson/jackson,保持依赖精简。
* 用于 Redis 缓存对象序列化、回调消息体解析等。
*
* @author imutil
*/
public final class Jsons {
private Jsons() {
}
/**
* 对象序列化为 JSON 字符串
*/
public static String stringify(Object obj) {
if (obj == null) {
return null;
}
return ONode.serialize(obj);
}
/**
* JSON 字符串反序列化为对象
*/
public static <T> T parse(String json, Class<T> clazz) {
if (json == null || json.isEmpty()) {
return null;
}
return ONode.deserialize(json, (Type) clazz);
}
/**
* 对象转换为指定类型:先序列化再反序列化
*/
public static <T> T convert(Object obj, Class<T> clazz) {
if (obj == null) {
return null;
}
return parse(stringify(obj), clazz);
}
}
@@ -0,0 +1,72 @@
package com.imutil.common;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.noear.solon.annotation.Component;
import java.util.concurrent.TimeUnit;
/**
* 进程内本地缓存(Caffeine
* <p>
* 作为 Redis 的兜底热缓存,承载映射/授权等高频读、短 TTL 的数据,
* 减少网络往返。多节点下数据可能短暂不一致,变更时需主动失效并依赖 Redis 兜底。
*
* @author imutil
*/
@Component
public class LocalCache {
private final Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
/**
* 读取缓存值,不存在返回 null
*/
public String get(String key) {
return cache.getIfPresent(key);
}
/**
* 读取并反序列化为对象
*/
public <T> T get(String key, Class<T> clazz) {
String v = cache.getIfPresent(key);
if (v == null) {
return null;
}
return Jsons.parse(v, clazz);
}
/**
* 写入字符串
*/
public void put(String key, String value) {
cache.put(key, value);
}
/**
* 写入对象(JSON 序列化)
*/
public void put(String key, Object value) {
if (value != null) {
cache.put(key, Jsons.stringify(value));
}
}
/**
* 失效指定键
*/
public void invalidate(String key) {
cache.invalidate(key);
}
/**
* 清空全部缓存
*/
public void invalidateAll() {
cache.invalidateAll();
}
}
@@ -0,0 +1,32 @@
package com.imutil.common;
/**
* 消息唯一键生成工具
* <p>
* 统一回调路径与补拉路径的 msg_key 算法,确保同一条消息无论从哪条路径进入
* 都生成相同 key,从而跨路径幂等去重(回调已落库的消息不会被补拉重复落库)。
* <p>
* 腾讯侧一条消息由 (from, 会话目标, MsgSeq, MsgRandom) 唯一确定。
* 不含 CallbackCommand:同一条消息的发送/接收回调 command 不同,含 command 反而不利于去重。
*
* @author imutil
*/
public final class MsgKeys {
private MsgKeys() {
}
/**
* 生成消息唯一键
*
* @param fromAccount 发送方账号,可空
* @param convTarget 会话目标(GROUP=群IDC2C=对端账号)
* @param msgSeq 腾讯 MsgSeq
* @param msgRandom 腾讯 MsgRandom
* @return msg_key,格式 from:convTarget:msgSeq:msgRandom
*/
public static String build(String fromAccount, String convTarget, long msgSeq, long msgRandom) {
return (fromAccount == null ? "" : fromAccount) + ":" +
(convTarget == null ? "" : convTarget) + ":" + msgSeq + ":" + msgRandom;
}
}
@@ -0,0 +1,84 @@
package com.imutil.common;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 密码哈希工具(PBKDF2WithHmacSHA256JDK 内置,无额外依赖)
* <p>
* 存储格式:{@code iterations:saltBase64:hashBase64}。
* verify 时按存储的 iterations/salt 重新推导并常量时间比对。
* 每次哈希用随机 salt,防彩虹表。
*
* @author imutil
*/
public final class PasswordUtil {
/** 迭代次数(OWASP 2023 建议 PBKDF2-HMAC-SHA256 ≥ 120000 */
private static final int ITERATIONS = 120_000;
private static final int KEY_BITS = 256;
private static final int SALT_BYTES = 16;
private static final String ALGO = "PBKDF2WithHmacSHA256";
private PasswordUtil() {
}
/**
* 对明文密码哈希,返回 "iterations:salt:hash"
*/
public static String hash(String rawPassword) {
byte[] salt = new byte[SALT_BYTES];
new SecureRandom().nextBytes(salt);
byte[] dk = derive(rawPassword, salt, ITERATIONS);
return ITERATIONS + ":" + b64(salt) + ":" + b64(dk);
}
/**
* 校验明文密码与存储hash是否匹配
*/
public static boolean verify(String rawPassword, String stored) {
if (rawPassword == null || stored == null) {
return false;
}
String[] parts = stored.split(":");
if (parts.length != 3) {
return false;
}
try {
int iter = Integer.parseInt(parts[0]);
byte[] salt = Base64.getDecoder().decode(parts[1]);
byte[] expected = Base64.getDecoder().decode(parts[2]);
byte[] actual = derive(rawPassword, salt, iter);
return constantTimeEquals(expected, actual);
} catch (Exception e) {
return false;
}
}
private static byte[] derive(String rawPassword, byte[] salt, int iter) {
try {
PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iter, KEY_BITS);
return SecretKeyFactory.getInstance(ALGO).generateSecret(spec).getEncoded();
} catch (Exception e) {
throw new IllegalStateException("PBKDF2 推导失败", e);
}
}
private static String b64(byte[] b) {
return Base64.getEncoder().encodeToString(b);
}
/** 常量时间比较,防时序攻击 */
private static boolean constantTimeEquals(byte[] a, byte[] b) {
if (a.length != b.length) {
return false;
}
int r = 0;
for (int i = 0; i < a.length; i++) {
r |= a[i] ^ b[i];
}
return r == 0;
}
}
@@ -0,0 +1,51 @@
package com.imutil.common;
import java.util.Set;
/**
* 鉴权白名单路径
* <p>
* 以下路径不走 app_key 租户鉴权:
* - /callback/** 腾讯回调入口(走签名校验,见 T5)
* - /health 健康检查
* - /favicon.ico
*
* @author imutil
*/
public final class PathWhitelist {
/** 白名单路径前缀(contextPath 之后的部分) */
public static final Set<String> PREFIXES = Set.of(
"/callback",
"/health",
"/admin"
);
/** 精确匹配的白名单路径 */
public static final Set<String> EXACT = Set.of(
"/favicon.ico"
);
private PathWhitelist() {
}
/**
* 判断路径是否在白名单中
*
* @param path 请求路径(已去除 contextPath
*/
public static boolean isWhitelisted(String path) {
if (path == null || path.isEmpty()) {
return false;
}
if (EXACT.contains(path)) {
return true;
}
for (String p : PREFIXES) {
if (path.startsWith(p)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,54 @@
package com.imutil.common;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* Redis 连接池配置
* <p>
* 读取 app.yml 的 imutil.redis.* 配置,构建 JedisPool 注册为 Solon Bean。
* 用于限流令牌桶、映射/授权缓存、补拉水位线等高频读写场景。
*
* @author imutil
*/
@Configuration
public class RedisConfig {
@Inject("${imutil.redis.host:localhost}")
private String host;
@Inject("${imutil.redis.port:6379}")
private int port;
@Inject("${imutil.redis.password:}")
private String password;
@Inject("${imutil.redis.database:0}")
private int database;
@Inject("${imutil.redis.timeout:2000}")
private int soTimeout;
@Inject("${imutil.redis.connectTimeout:2000}")
private int connectTimeout;
/**
* 构建 Jedis 连接池
*/
@Bean
public JedisPool jedisPool() {
GenericObjectPoolConfig<Jedis> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(16);
config.setMaxIdle(8);
config.setMinIdle(2);
config.setTestWhileIdle(true);
config.setTimeBetweenEvictionRunsMillis(30_000);
// 空密码兼容:Postgres/Redis 无密码场景传 null,避免鉴权失败
String pwd = (password == null || password.isEmpty()) ? null : password;
return new JedisPool(config, host, port, connectTimeout, soTimeout, pwd, database, null);
}
}
@@ -0,0 +1,142 @@
package com.imutil.common;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;
import java.util.concurrent.TimeUnit;
/**
* Redis 操作封装
* <p>
* 基于 JedisPool 的轻量封装,覆盖限流/缓存/水位线所需能力:
* 通用 KV、带 TTL 的 set、setNx(分布式锁/令牌)、incr 计数、过期时间。
*
* @author imutil
*/
@Component
public class RedisService {
@Inject
private JedisPool jedisPool;
/**
* 设置键值(无过期)
*/
public void set(String key, String value) {
try (Jedis j = jedisPool.getResource()) {
j.set(key, value);
}
}
/**
* 设置键值并指定 TTL(秒)
*/
public void setex(String key, String value, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
j.setex(key, ttlSeconds, value);
}
}
/**
* 读取键值
*/
public String get(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.get(key);
}
}
/**
* 读取并反序列化为对象(JSON)
*/
public <T> T getJson(String key, Class<T> clazz) {
String v = get(key);
if (v == null) {
return null;
}
return Jsons.parse(v, clazz);
}
/**
* 设置对象为 JSON 并指定 TTL
*/
public void setJson(String key, Object obj, long ttl, TimeUnit unit) {
setex(key, Jsons.stringify(obj), unit.toSeconds(ttl));
}
/**
* 删除键
*/
public void del(String key) {
try (Jedis j = jedisPool.getResource()) {
j.del(key);
}
}
/**
* 键是否存在
*/
public boolean exists(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.exists(key);
}
}
/**
* 自增计数(原子)
*/
public long incr(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.incr(key);
}
}
/**
* 自增并设置 TTL(用于固定窗口计数限流)
*
* @return 自增后的值
*/
public long incrWithTtl(String key, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
long n = j.incr(key);
if (n == 1L) {
j.expire(key, ttlSeconds);
}
return n;
}
}
/**
* SET NX + EX:仅当键不存在时设置,并带过期
* 用于分布式锁、单次投放
*
* @return true=获取成功
*/
public boolean setNxEx(String key, String value, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
String r = j.set(key, value, SetParams.setParams().nx().ex(ttlSeconds));
return "OK".equals(r);
}
}
/**
* 设置过期时间
*/
public void expire(String key, long ttlSeconds) {
try (Jedis j = jedisPool.getResource()) {
j.expire(key, ttlSeconds);
}
}
/**
* 读取过期时间(秒),-1=无过期,-2=不存在
*/
public long ttl(String key) {
try (Jedis j = jedisPool.getResource()) {
return j.ttl(key);
}
}
}
@@ -0,0 +1,54 @@
package com.imutil.common;
/**
* 租户上下文(请求级 ThreadLocal
* <p>
* 由 {@link com.imutil.filter.TenantAuthFilter} 在请求入口解析并设置,
* 业务层通过 {@link #get()} 获取当前租户,所有数据查询强制带 tenant_id 过滤,
* 实现"系统 A 绝对拿不到系统 B 数据"的隔离。
* <p>
* 必须在请求结束时 {@link #clear()} 清理,避免线程复用串租户。
*
* @author imutil
*/
public final class TenantContext {
private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();
private TenantContext() {
}
/**
* 设置当前租户ID
*/
public static void set(String tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取当前租户ID
*
* @return 租户ID,未鉴权场景返回 null
*/
public static String get() {
return TENANT_HOLDER.get();
}
/**
* 获取当前租户ID,未设置则抛业务异常
*/
public static String require() {
String t = TENANT_HOLDER.get();
if (t == null) {
throw new BizException(401, "租户上下文缺失,请通过 app_key 鉴权");
}
return t;
}
/**
* 清理上下文(必须在请求结束时调用)
*/
public static void clear() {
TENANT_HOLDER.remove();
}
}