feat: 初始化腾讯IM分发工具骨架,含回调分发链路与历史消息补拉
- 应用骨架: Solon + MyBatis-Plus + PostgreSQL + Redis + Sa-Token - 核心链路: 回调网关 → 消息落库(幂等) → 分发队列(FOR UPDATE SKIP LOCKED) → worker消费/重试/死信 - 多租户隔离: 前缀法账号映射, 所有业务表带 tenant_id - 管理后台: FreeMarker 渲染, 租户/授权/队列/用量管理页面 - 历史消息补拉: 水位线驱动, getRoamMsg/getGroupMsg 增量拉取, 兜底回调丢失 - msg_key 统一算法(回调/补拉共享, 跨路径去重) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user