yshop-pro init
This commit is contained in:
67
yshop-framework/yshop-spring-boot-starter-biz-tenant/pom.xml
Normal file
67
yshop-framework/yshop-spring-boot-starter-biz-tenant/pom.xml
Normal file
@ -0,0 +1,67 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>yshop-framework</artifactId>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>yshop-spring-boot-starter-biz-tenant</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>${project.artifactId}</name>
|
||||
<description>多租户</description>
|
||||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Web 相关 -->
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- DB 相关 -->
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-mybatis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Job 定时任务相关 -->
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-job</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 消息队列相关 -->
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-mq</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test 测试相关 -->
|
||||
<dependency>
|
||||
<groupId>co.yixiang.boot</groupId>
|
||||
<artifactId>yshop-spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类相关 -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@ -0,0 +1,42 @@
|
||||
package co.yixiang.yshop.framework.tenant.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 多租户配置
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "yshop.tenant")
|
||||
@Data
|
||||
public class TenantProperties {
|
||||
|
||||
/**
|
||||
* 租户是否开启
|
||||
*/
|
||||
private static final Boolean ENABLE_DEFAULT = true;
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
private Boolean enable = ENABLE_DEFAULT;
|
||||
|
||||
/**
|
||||
* 需要忽略多租户的请求
|
||||
*
|
||||
* 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API!
|
||||
*/
|
||||
private Set<String> ignoreUrls = Collections.emptySet();
|
||||
|
||||
/**
|
||||
* 需要忽略多租户的表
|
||||
*
|
||||
* 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
|
||||
*/
|
||||
private Set<String> ignoreTables = Collections.emptySet();
|
||||
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
package co.yixiang.yshop.framework.tenant.config;
|
||||
|
||||
import cn.hutool.core.annotation.AnnotationUtil;
|
||||
import co.yixiang.yshop.framework.common.enums.WebFilterOrderEnum;
|
||||
import co.yixiang.yshop.framework.mybatis.core.util.MyBatisUtils;
|
||||
import co.yixiang.yshop.framework.quartz.core.handler.JobHandler;
|
||||
import co.yixiang.yshop.framework.tenant.core.aop.TenantIgnoreAspect;
|
||||
import co.yixiang.yshop.framework.tenant.core.db.TenantDatabaseInterceptor;
|
||||
import co.yixiang.yshop.framework.tenant.core.job.TenantJob;
|
||||
import co.yixiang.yshop.framework.tenant.core.job.TenantJobHandlerDecorator;
|
||||
import co.yixiang.yshop.framework.tenant.core.mq.TenantRedisMessageInterceptor;
|
||||
import co.yixiang.yshop.framework.tenant.core.redis.TenantRedisCacheManager;
|
||||
import co.yixiang.yshop.framework.tenant.core.security.TenantSecurityWebFilter;
|
||||
import co.yixiang.yshop.framework.tenant.core.service.TenantFrameworkService;
|
||||
import co.yixiang.yshop.framework.tenant.core.service.TenantFrameworkServiceImpl;
|
||||
import co.yixiang.yshop.framework.tenant.core.web.TenantContextWebFilter;
|
||||
import co.yixiang.yshop.framework.web.config.WebProperties;
|
||||
import co.yixiang.yshop.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import co.yixiang.yshop.module.system.api.tenant.TenantApi;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.cache.RedisCacheWriter;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "yshop.tenant", value = "enable", matchIfMissing = true) // 允许使用 yshop.tenant.enable=false 禁用多租户
|
||||
@EnableConfigurationProperties(TenantProperties.class)
|
||||
public class YshopTenantAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public TenantFrameworkService tenantFrameworkService(TenantApi tenantApi) {
|
||||
return new TenantFrameworkServiceImpl(tenantApi);
|
||||
}
|
||||
|
||||
// ========== AOP ==========
|
||||
|
||||
@Bean
|
||||
public TenantIgnoreAspect tenantIgnoreAspect() {
|
||||
return new TenantIgnoreAspect();
|
||||
}
|
||||
|
||||
// ========== DB ==========
|
||||
|
||||
@Bean
|
||||
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties,
|
||||
MybatisPlusInterceptor interceptor) {
|
||||
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
|
||||
// 添加到 interceptor 中
|
||||
// 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定
|
||||
MyBatisUtils.addInterceptor(interceptor, inner, 0);
|
||||
return inner;
|
||||
}
|
||||
|
||||
// ========== WEB ==========
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() {
|
||||
FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
registrationBean.setFilter(new TenantContextWebFilter());
|
||||
registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER);
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
// ========== Security ==========
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties,
|
||||
WebProperties webProperties,
|
||||
GlobalExceptionHandler globalExceptionHandler,
|
||||
TenantFrameworkService tenantFrameworkService) {
|
||||
FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
registrationBean.setFilter(new TenantSecurityWebFilter(tenantProperties, webProperties,
|
||||
globalExceptionHandler, tenantFrameworkService));
|
||||
registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER);
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
// ========== MQ ==========
|
||||
|
||||
@Bean
|
||||
public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
|
||||
return new TenantRedisMessageInterceptor();
|
||||
}
|
||||
|
||||
// ========== Job ==========
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
||||
public BeanPostProcessor jobHandlerBeanPostProcessor(TenantFrameworkService tenantFrameworkService) {
|
||||
return new BeanPostProcessor() {
|
||||
|
||||
@Override
|
||||
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (!(bean instanceof JobHandler)) {
|
||||
return bean;
|
||||
}
|
||||
// 有 TenantJob 注解的情况下,才会进行处理
|
||||
if (!AnnotationUtil.hasAnnotation(bean.getClass(), TenantJob.class)) {
|
||||
return bean;
|
||||
}
|
||||
|
||||
// 使用 TenantJobHandlerDecorator 装饰
|
||||
return new TenantJobHandlerDecorator(tenantFrameworkService, (JobHandler) bean);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Redis ==========
|
||||
|
||||
@Bean
|
||||
@Primary // 引入租户时,tenantRedisCacheManager 为主 Bean
|
||||
public RedisCacheManager tenantRedisCacheManager(RedisTemplate<String, Object> redisTemplate,
|
||||
RedisCacheConfiguration redisCacheConfiguration) {
|
||||
// 创建 RedisCacheWriter 对象
|
||||
RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
|
||||
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);
|
||||
// 创建 TenantRedisCacheManager 对象
|
||||
return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.aop;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 忽略租户,标记指定方法不进行租户的自动过滤
|
||||
*
|
||||
* 注意,只有 DB 的场景会过滤,其它场景暂时不过滤:
|
||||
* 1、Redis 场景:因为是基于 Key 实现多租户的能力,所以忽略没有意义,不像 DB 是一个 column 实现的
|
||||
* 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @TenantIgnore 进行忽略
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Inherited
|
||||
public @interface TenantIgnore {
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.aop;
|
||||
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import co.yixiang.yshop.framework.tenant.core.util.TenantUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
|
||||
/**
|
||||
* 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。
|
||||
* 例如说,一个定时任务,读取所有数据,进行处理。
|
||||
* 又例如说,读取所有数据,进行缓存。
|
||||
*
|
||||
* 整体逻辑的实现,和 {@link TenantUtils#executeIgnore(Runnable)} 需要保持一致
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
public class TenantIgnoreAspect {
|
||||
|
||||
@Around("@annotation(tenantIgnore)")
|
||||
public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {
|
||||
Boolean oldIgnore = TenantContextHolder.isIgnore();
|
||||
try {
|
||||
TenantContextHolder.setIgnore(true);
|
||||
// 执行逻辑
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
TenantContextHolder.setIgnore(oldIgnore);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.context;
|
||||
|
||||
import co.yixiang.yshop.framework.common.enums.DocumentEnum;
|
||||
import com.alibaba.ttl.TransmittableThreadLocal;
|
||||
|
||||
/**
|
||||
* 多租户上下文 Holder
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantContextHolder {
|
||||
|
||||
/**
|
||||
* 当前租户编号
|
||||
*/
|
||||
private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 是否忽略租户
|
||||
*/
|
||||
private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 获得租户编号。
|
||||
*
|
||||
* @return 租户编号
|
||||
*/
|
||||
public static Long getTenantId() {
|
||||
return TENANT_ID.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得租户编号。如果不存在,则抛出 NullPointerException 异常
|
||||
*
|
||||
* @return 租户编号
|
||||
*/
|
||||
public static Long getRequiredTenantId() {
|
||||
Long tenantId = getTenantId();
|
||||
if (tenantId == null) {
|
||||
throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:"
|
||||
+ DocumentEnum.TENANT.getUrl());
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public static void setTenantId(Long tenantId) {
|
||||
TENANT_ID.set(tenantId);
|
||||
}
|
||||
|
||||
public static void setIgnore(Boolean ignore) {
|
||||
IGNORE.set(ignore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前是否忽略租户
|
||||
*
|
||||
* @return 是否忽略
|
||||
*/
|
||||
public static boolean isIgnore() {
|
||||
return Boolean.TRUE.equals(IGNORE.get());
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
TENANT_ID.remove();
|
||||
IGNORE.remove();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.db;
|
||||
|
||||
import co.yixiang.yshop.framework.mybatis.core.dataobject.BaseDO;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 拓展多租户的 BaseDO 基类
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public abstract class TenantBaseDO extends BaseDO {
|
||||
|
||||
/**
|
||||
* 多租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.db;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import co.yixiang.yshop.framework.tenant.config.TenantProperties;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantDatabaseInterceptor implements TenantLineHandler {
|
||||
|
||||
private final Set<String> ignoreTables = new HashSet<>();
|
||||
|
||||
public TenantDatabaseInterceptor(TenantProperties properties) {
|
||||
// 不同 DB 下,大小写的习惯不同,所以需要都添加进去
|
||||
properties.getIgnoreTables().forEach(table -> {
|
||||
ignoreTables.add(table.toLowerCase());
|
||||
ignoreTables.add(table.toUpperCase());
|
||||
});
|
||||
// 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错
|
||||
ignoreTables.add("DUAL");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
return new LongValue(TenantContextHolder.getRequiredTenantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户
|
||||
|| CollUtil.contains(ignoreTables, tableName); // 情况二,忽略多租户的表
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.job;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 多租户 Job 注解
|
||||
*/
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface TenantJob {
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.job;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import co.yixiang.yshop.framework.common.util.json.JsonUtils;
|
||||
import co.yixiang.yshop.framework.quartz.core.handler.JobHandler;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import co.yixiang.yshop.framework.tenant.core.service.TenantFrameworkService;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 多租户 JobHandler 装饰器
|
||||
* 任务执行时,会按照租户逐个执行 Job 的逻辑
|
||||
*
|
||||
* 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class TenantJobHandlerDecorator implements JobHandler {
|
||||
|
||||
private final TenantFrameworkService tenantFrameworkService;
|
||||
/**
|
||||
* 被装饰的 Job
|
||||
*/
|
||||
private final JobHandler jobHandler;
|
||||
|
||||
@Override
|
||||
public final String execute(String param) throws Exception {
|
||||
// 获得租户列表
|
||||
List<Long> tenantIds = tenantFrameworkService.getTenantIds();
|
||||
if (CollUtil.isEmpty(tenantIds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 逐个租户,执行 Job
|
||||
Map<Long, String> results = new ConcurrentHashMap<>();
|
||||
tenantIds.parallelStream().forEach(tenantId -> { // TODO yshop:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况
|
||||
try {
|
||||
// 设置租户
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
// 执行 Job
|
||||
String result = jobHandler.execute(param);
|
||||
// 添加结果
|
||||
results.put(tenantId, result);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
});
|
||||
return JsonUtils.toJsonString(results);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.mq;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import co.yixiang.yshop.framework.mq.core.interceptor.RedisMessageInterceptor;
|
||||
import co.yixiang.yshop.framework.mq.core.message.AbstractRedisMessage;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
|
||||
import static co.yixiang.yshop.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
|
||||
|
||||
/**
|
||||
* 多租户 {@link AbstractRedisMessage} 拦截器
|
||||
*
|
||||
* 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中
|
||||
* 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantRedisMessageInterceptor implements RedisMessageInterceptor {
|
||||
|
||||
@Override
|
||||
public void sendMessageBefore(AbstractRedisMessage message) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId != null) {
|
||||
message.addHeader(HEADER_TENANT_ID, tenantId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consumeMessageBefore(AbstractRedisMessage message) {
|
||||
String tenantIdStr = message.getHeader(HEADER_TENANT_ID);
|
||||
if (StrUtil.isNotEmpty(tenantIdStr)) {
|
||||
TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consumeMessageAfter(AbstractRedisMessage message) {
|
||||
// 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.redis;
|
||||
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.cache.RedisCacheWriter;
|
||||
|
||||
/**
|
||||
* 多租户的 {@link RedisCacheManager} 实现类
|
||||
*
|
||||
* 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀
|
||||
*
|
||||
* @author airhead
|
||||
*/
|
||||
@Slf4j
|
||||
public class TenantRedisCacheManager extends RedisCacheManager {
|
||||
|
||||
public TenantRedisCacheManager(RedisCacheWriter cacheWriter,
|
||||
RedisCacheConfiguration defaultCacheConfiguration) {
|
||||
super(cacheWriter, defaultCacheConfiguration);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cache getCache(String name) {
|
||||
// 如果开启多租户,则 name 拼接租户后缀
|
||||
if (!TenantContextHolder.isIgnore()
|
||||
&& TenantContextHolder.getTenantId() != null) {
|
||||
name = name + ":" + TenantContextHolder.getTenantId();
|
||||
}
|
||||
|
||||
// 继续基于父方法
|
||||
return super.getCache(name);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.redis;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import co.yixiang.yshop.framework.redis.core.RedisKeyDefine;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 多租户拓展的 RedisKeyDefine 实现类
|
||||
*
|
||||
* 由于 Redis 不同于 MySQL 有 column 字段,无法通过类似 WHERE tenant_id = ? 的方式过滤
|
||||
* 所以需要通过在 Redis Key 上增加后缀的方式,进行租户之间的隔离。具体的步骤是:
|
||||
* 1. 假设 Redis Key 是 user:%d,示例是 user:1;对应到多租户的 Redis Key 是 user:%d:%d,
|
||||
* 2. 在 Redis DAO 中,需要使用 {@link #formatKey(Object...)} 方法,进行 Redis Key 的格式化
|
||||
*
|
||||
* 注意,大多数情况下,并不用使用 TenantRedisKeyDefine 实现。主要的使用场景,还是 Redis Key 可能存在冲突的情况。
|
||||
* 例如说,租户 1 和 2 都有一个手机号作为 Key,则他们会存在冲突的问题
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantRedisKeyDefine extends RedisKeyDefine {
|
||||
|
||||
/**
|
||||
* 多租户的 KEY 模板
|
||||
*/
|
||||
private static final String KEY_TEMPLATE_SUFFIX = ":%d";
|
||||
|
||||
public TenantRedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class<?> valueType, Duration timeout) {
|
||||
super(memo, buildKeyTemplate(keyTemplate), keyType, valueType, timeout);
|
||||
}
|
||||
|
||||
public TenantRedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class<?> valueType, TimeoutTypeEnum timeoutType) {
|
||||
super(memo, buildKeyTemplate(keyTemplate), keyType, valueType, timeoutType);
|
||||
}
|
||||
|
||||
private static String buildKeyTemplate(String keyTemplate) {
|
||||
return keyTemplate + KEY_TEMPLATE_SUFFIX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatKey(Object... args) {
|
||||
args = ArrayUtil.append(args, TenantContextHolder.getRequiredTenantId());
|
||||
return super.formatKey(args);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.security;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import co.yixiang.yshop.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import co.yixiang.yshop.framework.common.pojo.CommonResult;
|
||||
import co.yixiang.yshop.framework.common.util.servlet.ServletUtils;
|
||||
import co.yixiang.yshop.framework.security.core.LoginUser;
|
||||
import co.yixiang.yshop.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import co.yixiang.yshop.framework.tenant.config.TenantProperties;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import co.yixiang.yshop.framework.tenant.core.service.TenantFrameworkService;
|
||||
import co.yixiang.yshop.framework.web.config.WebProperties;
|
||||
import co.yixiang.yshop.framework.web.core.filter.ApiRequestFilter;
|
||||
import co.yixiang.yshop.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 多租户 Security Web 过滤器
|
||||
* 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。
|
||||
* 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。
|
||||
* 3. 校验租户是合法,例如说被禁用、到期
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@Slf4j
|
||||
public class TenantSecurityWebFilter extends ApiRequestFilter {
|
||||
|
||||
private final TenantProperties tenantProperties;
|
||||
|
||||
private final AntPathMatcher pathMatcher;
|
||||
|
||||
private final GlobalExceptionHandler globalExceptionHandler;
|
||||
private final TenantFrameworkService tenantFrameworkService;
|
||||
|
||||
public TenantSecurityWebFilter(TenantProperties tenantProperties,
|
||||
WebProperties webProperties,
|
||||
GlobalExceptionHandler globalExceptionHandler,
|
||||
TenantFrameworkService tenantFrameworkService) {
|
||||
super(webProperties);
|
||||
this.tenantProperties = tenantProperties;
|
||||
this.pathMatcher = new AntPathMatcher();
|
||||
this.globalExceptionHandler = globalExceptionHandler;
|
||||
this.tenantFrameworkService = tenantFrameworkService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。
|
||||
LoginUser user = SecurityFrameworkUtils.getLoginUser();
|
||||
if (user != null) {
|
||||
// 如果获取不到租户编号,则尝试使用登陆用户的租户编号
|
||||
if (tenantId == null) {
|
||||
tenantId = user.getTenantId();
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
// 如果传递了租户编号,则进行比对租户编号,避免越权问题
|
||||
} else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
|
||||
log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
|
||||
user.getTenantId(), user.getId(), user.getUserType(),
|
||||
TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
|
||||
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
|
||||
"您无权访问该租户的数据"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果非允许忽略租户的 URL,则校验租户是否合法
|
||||
if (!isIgnoreUrl(request)) {
|
||||
// 2. 如果请求未带租户的编号,不允许访问。
|
||||
if (tenantId == null) {
|
||||
log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());
|
||||
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),
|
||||
"请求的租户标识未传递,请进行排查"));
|
||||
return;
|
||||
}
|
||||
// 3. 校验租户是合法,例如说被禁用、到期
|
||||
try {
|
||||
tenantFrameworkService.validTenant(tenantId);
|
||||
} catch (Throwable ex) {
|
||||
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
|
||||
ServletUtils.writeJSON(response, result);
|
||||
return;
|
||||
}
|
||||
} else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错
|
||||
if (tenantId == null) {
|
||||
TenantContextHolder.setIgnore(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 继续过滤
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean isIgnoreUrl(HttpServletRequest request) {
|
||||
// 快速匹配,保证性能
|
||||
if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) {
|
||||
return true;
|
||||
}
|
||||
// 逐个 Ant 路径匹配
|
||||
for (String url : tenantProperties.getIgnoreUrls()) {
|
||||
if (pathMatcher.match(url, request.getRequestURI())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tenant 框架 Service 接口,定义获取租户信息
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public interface TenantFrameworkService {
|
||||
|
||||
/**
|
||||
* 获得所有租户
|
||||
*
|
||||
* @return 租户编号数组
|
||||
*/
|
||||
List<Long> getTenantIds();
|
||||
|
||||
/**
|
||||
* 校验租户是否合法
|
||||
*
|
||||
* @param id 租户编号
|
||||
*/
|
||||
void validTenant(Long id);
|
||||
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.service;
|
||||
|
||||
import co.yixiang.yshop.framework.common.exception.ServiceException;
|
||||
import co.yixiang.yshop.framework.common.util.cache.CacheUtils;
|
||||
import co.yixiang.yshop.module.system.api.tenant.TenantApi;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tenant 框架 Service 实现类
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class TenantFrameworkServiceImpl implements TenantFrameworkService {
|
||||
|
||||
private static final ServiceException SERVICE_EXCEPTION_NULL = new ServiceException();
|
||||
|
||||
private final TenantApi tenantApi;
|
||||
|
||||
/**
|
||||
* 针对 {@link #getTenantIds()} 的缓存
|
||||
*/
|
||||
private final LoadingCache<Object, List<Long>> getTenantIdsCache = CacheUtils.buildAsyncReloadingCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
new CacheLoader<Object, List<Long>>() {
|
||||
|
||||
@Override
|
||||
public List<Long> load(Object key) {
|
||||
return tenantApi.getTenantIdList();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 针对 {@link #validTenant(Long)} 的缓存
|
||||
*/
|
||||
private final LoadingCache<Long, ServiceException> validTenantCache = CacheUtils.buildAsyncReloadingCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
new CacheLoader<Long, ServiceException>() {
|
||||
|
||||
@Override
|
||||
public ServiceException load(Long id) {
|
||||
try {
|
||||
tenantApi.validateTenant(id);
|
||||
return SERVICE_EXCEPTION_NULL;
|
||||
} catch (ServiceException ex) {
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public List<Long> getTenantIds() {
|
||||
return getTenantIdsCache.get(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validTenant(Long id) {
|
||||
ServiceException serviceException = validTenantCache.getUnchecked(id);
|
||||
if (serviceException != SERVICE_EXCEPTION_NULL) {
|
||||
throw serviceException;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.util;
|
||||
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import static co.yixiang.yshop.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
|
||||
|
||||
/**
|
||||
* 多租户 Util
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantUtils {
|
||||
|
||||
/**
|
||||
* 使用指定租户,执行对应的逻辑
|
||||
*
|
||||
* 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户
|
||||
* 当然,执行完成后,还是会恢复回去
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @param runnable 逻辑
|
||||
*/
|
||||
public static void execute(Long tenantId, Runnable runnable) {
|
||||
Long oldTenantId = TenantContextHolder.getTenantId();
|
||||
Boolean oldIgnore = TenantContextHolder.isIgnore();
|
||||
try {
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
TenantContextHolder.setIgnore(false);
|
||||
// 执行逻辑
|
||||
runnable.run();
|
||||
} finally {
|
||||
TenantContextHolder.setTenantId(oldTenantId);
|
||||
TenantContextHolder.setIgnore(oldIgnore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定租户,执行对应的逻辑
|
||||
*
|
||||
* 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户
|
||||
* 当然,执行完成后,还是会恢复回去
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @param callable 逻辑
|
||||
*/
|
||||
public static <V> V execute(Long tenantId, Callable<V> callable) {
|
||||
Long oldTenantId = TenantContextHolder.getTenantId();
|
||||
Boolean oldIgnore = TenantContextHolder.isIgnore();
|
||||
try {
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
TenantContextHolder.setIgnore(false);
|
||||
// 执行逻辑
|
||||
return callable.call();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
TenantContextHolder.setTenantId(oldTenantId);
|
||||
TenantContextHolder.setIgnore(oldIgnore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略租户,执行对应的逻辑
|
||||
*
|
||||
* @param runnable 逻辑
|
||||
*/
|
||||
public static void executeIgnore(Runnable runnable) {
|
||||
Boolean oldIgnore = TenantContextHolder.isIgnore();
|
||||
try {
|
||||
TenantContextHolder.setIgnore(true);
|
||||
// 执行逻辑
|
||||
runnable.run();
|
||||
} finally {
|
||||
TenantContextHolder.setIgnore(oldIgnore);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将多租户编号,添加到 header 中
|
||||
*
|
||||
* @param headers HTTP 请求 headers
|
||||
*/
|
||||
public static void addTenantHeader(Map<String, String> headers) {
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId != null) {
|
||||
headers.put(HEADER_TENANT_ID, tenantId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.web;
|
||||
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import co.yixiang.yshop.framework.web.core.util.WebFrameworkUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 多租户 Context Web 过滤器
|
||||
* 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。
|
||||
*
|
||||
* @author yshop
|
||||
*/
|
||||
public class TenantContextWebFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
// 设置
|
||||
Long tenantId = WebFrameworkUtils.getTenantId(request);
|
||||
if (tenantId != null) {
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
}
|
||||
try {
|
||||
chain.doFilter(request, response);
|
||||
} finally {
|
||||
// 清理
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 多租户,支持如下层面:
|
||||
* 1. DB:基于 MyBatis Plus 多租户的功能实现。
|
||||
* 2. Redis:通过在 Redis Key 上拼接租户编号的方式,进行隔离。
|
||||
* 3. Web:请求 HTTP API 时,解析 Header 的 tenant-id 租户编号,添加到租户上下文。
|
||||
* 4. Security:校验当前登陆的用户,是否越权访问其它租户的数据。
|
||||
* 5. Job:在 JobHandler 执行任务时,会按照每个租户,都独立并行执行一次。
|
||||
* 6. MQ:在 Producer 发送消息时,Header 带上 tenant-id 租户编号;在 Consumer 消费消息时,将 Header 的 tenant-id 租户编号,添加到租户上下文。
|
||||
* 7. Async:异步需要保证 ThreadLocal 的传递性,通过使用阿里开源的 TransmittableThreadLocal 实现。相关的改造点,可见:
|
||||
* 1)Spring Async:
|
||||
* {@link co.yixiang.yshop.framework.quartz.config.YshopAsyncAutoConfiguration#threadPoolTaskExecutorBeanPostProcessor()}
|
||||
* 2)Spring Security:
|
||||
* TransmittableThreadLocalSecurityContextHolderStrategy
|
||||
* 和 YshopSecurityAutoConfiguration#securityContextHolderMethodInvokingFactoryBean() 方法
|
||||
*
|
||||
*/
|
||||
package co.yixiang.yshop.framework.tenant;
|
||||
@ -0,0 +1 @@
|
||||
co.yixiang.yshop.framework.tenant.config.YshopTenantAutoConfiguration
|
||||
@ -0,0 +1,42 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.job;
|
||||
|
||||
import co.yixiang.yshop.framework.tenant.core.service.TenantFrameworkService;
|
||||
import co.yixiang.yshop.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.google.common.collect.Lists;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 验证 job 租户逻辑
|
||||
* {@link TenantJobHandlerDecorator}
|
||||
*
|
||||
* @author gaibu
|
||||
*/
|
||||
public class TenantJobTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
TenantFrameworkService tenantFrameworkService;
|
||||
|
||||
@Test
|
||||
public void test() throws Exception {
|
||||
// 准备测试租户 id
|
||||
List<Long> tenantIds = Lists.newArrayList(1L, 2L, 3L);
|
||||
// mock 数据
|
||||
Mockito.doReturn(tenantIds).when(tenantFrameworkService).getTenantIds();
|
||||
// 准备测试任务
|
||||
TestJob testJob = new TestJob();
|
||||
// 创建任务装饰器
|
||||
TenantJobHandlerDecorator tenantJobHandlerDecorator = new TenantJobHandlerDecorator(tenantFrameworkService, testJob);
|
||||
|
||||
// 执行任务
|
||||
tenantJobHandlerDecorator.execute(null);
|
||||
|
||||
// 断言返回值
|
||||
assertEquals(testJob.getTenantIds(), tenantIds);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.job;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import co.yixiang.yshop.framework.quartz.core.handler.JobHandler;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
@Component
|
||||
@TenantJob // 标记多租户
|
||||
public class TestJob implements JobHandler {
|
||||
|
||||
private final List<Long> tenantIds = new CopyOnWriteArrayList<>();
|
||||
|
||||
@Override
|
||||
public String execute(String param) throws Exception {
|
||||
tenantIds.add(TenantContextHolder.getTenantId());
|
||||
return "success";
|
||||
}
|
||||
|
||||
public List<Long> getTenantIds() {
|
||||
CollUtil.sort(tenantIds, Long::compareTo);
|
||||
return tenantIds;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package co.yixiang.yshop.framework.tenant.core.redis;
|
||||
|
||||
import co.yixiang.yshop.framework.redis.core.RedisKeyDefine;
|
||||
import co.yixiang.yshop.framework.tenant.core.context.TenantContextHolder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class TenantRedisKeyDefineTest {
|
||||
|
||||
@Test
|
||||
public void testFormatKey() {
|
||||
Long tenantId = 30L;
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
// 准备参数
|
||||
TenantRedisKeyDefine define = new TenantRedisKeyDefine("", "user:%d:%d", RedisKeyDefine.KeyTypeEnum.HASH,
|
||||
Object.class, RedisKeyDefine.TimeoutTypeEnum.FIXED);
|
||||
Long userId = 10L;
|
||||
Integer userType = 1;
|
||||
|
||||
// 调用
|
||||
String key = define.formatKey(userId, userType);
|
||||
// 断言
|
||||
assertEquals("user:10:1:30", key);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user