diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java new file mode 100644 index 00000000..bcba968a --- /dev/null +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java @@ -0,0 +1,29 @@ +package com.viewsh.framework.common.biz.system.project; + +import com.viewsh.framework.common.enums.RpcConstants; +import com.viewsh.framework.common.pojo.CommonResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@FeignClient(name = RpcConstants.SYSTEM_NAME) +@Tag(name = "RPC 服务 - 项目") +public interface ProjectCommonApi { + + String PREFIX = RpcConstants.SYSTEM_PREFIX + "/project"; + + @GetMapping(PREFIX + "/id-list") + @Operation(summary = "获得当前租户下所有项目编号") + CommonResult> getProjectIdList(); + + @GetMapping(PREFIX + "/valid") + @Operation(summary = "校验项目是否合法") + @Parameter(name = "id", description = "项目编号", required = true, example = "1024") + CommonResult validProject(@RequestParam("id") Long id); + +} diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/enums/WebFilterOrderEnum.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/enums/WebFilterOrderEnum.java index c680472a..580a1038 100644 --- a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/enums/WebFilterOrderEnum.java +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/enums/WebFilterOrderEnum.java @@ -1,38 +1,42 @@ -package com.viewsh.framework.common.enums; - -/** - * Web 过滤器顺序的枚举类,保证过滤器按照符合我们的预期 - * - * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 enum 包下 - * - * @author 芋道源码 - */ -public interface WebFilterOrderEnum { - - int CORS_FILTER = Integer.MIN_VALUE; - - int TRACE_FILTER = CORS_FILTER + 1; - - int ENV_TAG_FILTER = TRACE_FILTER + 1; - - int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; - - int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1; - - // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等 - - int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面 - - int API_ACCESS_LOG_FILTER = -103; // 需要保证在 RequestBodyCacheFilter 后面 - - int XSS_FILTER = -102; // 需要保证在 RequestBodyCacheFilter 后面 - - // Spring Security Filter 默认为 -100,可见 org.springframework.boot.autoconfigure.security.SecurityProperties 配置属性类 - - int TENANT_SECURITY_FILTER = -99; // 需要保证在 Spring Security 过滤器后面 - - int FLOWABLE_FILTER = -98; // 需要保证在 Spring Security 过滤后面 - - int DEMO_FILTER = Integer.MAX_VALUE; - -} +package com.viewsh.framework.common.enums; + +/** + * Web 过滤器顺序的枚举类,保证过滤器按照符合我们的预期 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 enum 包下 + * + * @author 芋道源码 + */ +public interface WebFilterOrderEnum { + + int CORS_FILTER = Integer.MIN_VALUE; + + int TRACE_FILTER = CORS_FILTER + 1; + + int ENV_TAG_FILTER = TRACE_FILTER + 1; + + int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; + + int API_ENCRYPT_FILTER = REQUEST_BODY_CACHE_FILTER + 1; + + // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等 + + int TENANT_CONTEXT_FILTER = -104; // 需要保证在 ApiAccessLogFilter 前面 + + int PROJECT_CONTEXT_FILTER = -103; // 需要保证在 ApiAccessLogFilter 前面,紧跟租户上下文之后 + + int API_ACCESS_LOG_FILTER = -102; // 需要保证在 RequestBodyCacheFilter 后面 + + int XSS_FILTER = -101; // 需要保证在 RequestBodyCacheFilter 后面 + + // Spring Security Filter 默认为 -100,可见 org.springframework.boot.autoconfigure.security.SecurityProperties 配置属性类 + + int TENANT_SECURITY_FILTER = -99; // 需要保证在 Spring Security 过滤器后面 + + int PROJECT_SECURITY_FILTER = -98; // 需要保证在 Spring Security 过滤器后面,紧跟租户安全过滤之后 + + int FLOWABLE_FILTER = -97; // 需要保证在 Spring Security 过滤后面 + + int DEMO_FILTER = Integer.MAX_VALUE; + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/TenantProperties.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/TenantProperties.java index 00f17f8b..42f18468 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/TenantProperties.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/TenantProperties.java @@ -1,57 +1,78 @@ -package com.viewsh.framework.tenant.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -/** - * 多租户配置 - * - * @author 芋道源码 - */ -@ConfigurationProperties(prefix = "viewsh.tenant") -@Data -public class TenantProperties { - - /** - * 租户是否开启 - */ - private static final Boolean ENABLE_DEFAULT = true; - - /** - * 是否开启 - */ - private Boolean enable = ENABLE_DEFAULT; - - /** - * 需要忽略多租户的请求 - * - * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API! - */ - private Set ignoreUrls = new HashSet<>(); - - /** - * 需要忽略跨(切换)租户访问的请求 - * - * 原因是:某些接口,访问的是个人信息,在跨租户是获取不到的! - */ - private Set ignoreVisitUrls = Collections.emptySet(); - - /** - * 需要忽略多租户的表 - * - * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 - */ - private Set ignoreTables = Collections.emptySet(); - - /** - * 需要忽略多租户的 Spring Cache 缓存 - * - * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 - */ - private Set ignoreCaches = Collections.emptySet(); - -} +package com.viewsh.framework.tenant.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * 多租户配置 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "viewsh.tenant") +@Data +public class TenantProperties { + + /** + * 租户是否开启 + */ + private static final Boolean ENABLE_DEFAULT = true; + + /** + * 是否开启 + */ + private Boolean enable = ENABLE_DEFAULT; + + /** + * 需要忽略多租户的请求 + * + * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API! + */ + private Set ignoreUrls = new HashSet<>(); + + /** + * 需要忽略跨(切换)租户访问的请求 + * + * 原因是:某些接口,访问的是个人信息,在跨租户是获取不到的! + */ + private Set ignoreVisitUrls = Collections.emptySet(); + + /** + * 需要忽略多租户的表 + * + * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set ignoreTables = Collections.emptySet(); + + /** + * 需要忽略多租户的 Spring Cache 缓存 + * + * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set ignoreCaches = Collections.emptySet(); + + // ========== 项目隔离配置 ========== + + /** + * 需要忽略项目隔离的请求 + * + * 默认情况下,每个请求需要带上 project-id 的请求头。但是,部分请求无需带上,例如说项目管理本身的接口 + */ + private Set ignoreProjectUrls = new HashSet<>(); + + /** + * 需要忽略项目隔离的表 + * + * 即默认继承 ProjectBaseDO 的表都开启项目隔离,此处可额外配置忽略 + */ + private Set ignoreProjectTables = Collections.emptySet(); + + /** + * 需要忽略项目隔离的 Spring Cache 缓存 + */ + private Set ignoreProjectCaches = Collections.emptySet(); + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantAutoConfiguration.java index fea84d4b..2bf48fe3 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/config/ViewshTenantAutoConfiguration.java @@ -1,222 +1,273 @@ -package com.viewsh.framework.tenant.config; - -import cn.hutool.extra.spring.SpringUtil; -import com.viewsh.framework.common.biz.system.tenant.TenantCommonApi; -import com.viewsh.framework.common.enums.WebFilterOrderEnum; -import com.viewsh.framework.mybatis.core.util.MyBatisUtils; -import com.viewsh.framework.redis.config.ViewshCacheProperties; -import com.viewsh.framework.security.core.service.SecurityFrameworkService; -import com.viewsh.framework.tenant.core.aop.TenantIgnore; -import com.viewsh.framework.tenant.core.aop.TenantIgnoreAspect; -import com.viewsh.framework.tenant.core.db.TenantDatabaseInterceptor; -import com.viewsh.framework.tenant.core.job.TenantJobAspect; -import com.viewsh.framework.tenant.core.mq.rabbitmq.TenantRabbitMQInitializer; -import com.viewsh.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor; -import com.viewsh.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer; -import com.viewsh.framework.tenant.core.redis.TenantRedisCacheManager; -import com.viewsh.framework.tenant.core.security.TenantSecurityWebFilter; -import com.viewsh.framework.tenant.core.service.TenantFrameworkService; -import com.viewsh.framework.tenant.core.service.TenantFrameworkServiceImpl; -import com.viewsh.framework.tenant.core.web.TenantContextWebFilter; -import com.viewsh.framework.tenant.core.web.TenantVisitContextInterceptor; -import com.viewsh.framework.web.config.WebProperties; -import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; -import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; -import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; -import jakarta.annotation.Resource; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -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.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.data.redis.cache.BatchStrategies; -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 org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import org.springframework.web.servlet.mvc.method.RequestMappingInfo; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import org.springframework.web.util.pattern.PathPattern; - -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; - -@AutoConfiguration -@ConditionalOnProperty(prefix = "viewsh.tenant", value = "enable", matchIfMissing = true) // 允许使用 viewsh.tenant.enable=false 禁用多租户 -@EnableConfigurationProperties(TenantProperties.class) -public class ViewshTenantAutoConfiguration { - - @Resource - private ApplicationContext applicationContext; - - @Bean - public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) { - // 参见 https://gitee.com/zhijiantianya/viewsh-cloud/issues/IC6YZF - try { - TenantCommonApi tenantApiImpl = SpringUtil.getBean("tenantApiImpl", TenantCommonApi.class); - if (tenantApiImpl != null) { - tenantApi = tenantApiImpl; - } - } catch (Exception ignored) {} - 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() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new TenantContextWebFilter()); - registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); - return registrationBean; - } - - @Bean - public TenantVisitContextInterceptor tenantVisitContextInterceptor(TenantProperties tenantProperties, - SecurityFrameworkService securityFrameworkService) { - return new TenantVisitContextInterceptor(tenantProperties, securityFrameworkService); - } - - @Bean - public WebMvcConfigurer tenantWebMvcConfigurer(TenantProperties tenantProperties, - TenantVisitContextInterceptor tenantVisitContextInterceptor) { - return new WebMvcConfigurer() { - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(tenantVisitContextInterceptor) - .excludePathPatterns(tenantProperties.getIgnoreVisitUrls().toArray(new String[0])); - } - }; - } - - // ========== Security ========== - - @Bean - public FilterRegistrationBean tenantSecurityWebFilter(TenantProperties tenantProperties, - WebProperties webProperties, - GlobalExceptionHandler globalExceptionHandler, - TenantFrameworkService tenantFrameworkService) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(), - globalExceptionHandler, tenantFrameworkService)); - registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); - return registrationBean; - } - - /** - * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中 - * - * @return 忽略租户的 URL 集合 - */ - private Set getTenantIgnoreUrls() { - Set ignoreUrls = new HashSet<>(); - // 获得接口对应的 HandlerMethod 集合 - RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) - applicationContext.getBean("requestMappingHandlerMapping"); - Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); - // 获得有 @TenantIgnore 注解的接口 - for (Map.Entry entry : handlerMethodMap.entrySet()) { - HandlerMethod handlerMethod = entry.getValue(); - if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class) // 方法级 - && !handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) { // 接口级 - continue; - } - // 添加到忽略的 URL 中 - if (entry.getKey().getPatternsCondition() != null) { - ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns()); - } - if (entry.getKey().getPathPatternsCondition() != null) { - ignoreUrls.addAll( - convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); - } - } - return ignoreUrls; - } - - // ========== MQ ========== - - /** - * 多租户 Redis 消息队列的配置类 - * - * 为什么要单独一个配置类呢?如果直接把 TenantRedisMessageInterceptor Bean 的初始化放外面,会报 RedisMessageInterceptor 类不存在的错误 - */ - @Configuration - @ConditionalOnClass(name = "com.viewsh.framework.mq.redis.core.RedisMQTemplate") - public static class TenantRedisMQAutoConfiguration { - - @Bean - public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() { - return new TenantRedisMessageInterceptor(); - } - - } - - @Bean - @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") - public TenantRabbitMQInitializer tenantRabbitMQInitializer() { - return new TenantRabbitMQInitializer(); - } - - @Bean - @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") - public TenantRocketMQInitializer tenantRocketMQInitializer() { - return new TenantRocketMQInitializer(); - } - - // ========== Job ========== - - @Bean - @ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob") - public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) { - return new TenantJobAspect(tenantFrameworkService); - } - - // ========== Redis ========== - - @Bean - @Primary // 引入租户时,tenantRedisCacheManager 为主 Bean - public RedisCacheManager tenantRedisCacheManager(RedisTemplate redisTemplate, - RedisCacheConfiguration redisCacheConfiguration, - ViewshCacheProperties viewshCacheProperties, - TenantProperties tenantProperties) { - // 创建 RedisCacheWriter 对象 - RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); - RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, - BatchStrategies.scan(viewshCacheProperties.getRedisScanBatchSize())); - // 创建 TenantRedisCacheManager 对象 - return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches()); - } - -} +package com.viewsh.framework.tenant.config; + +import cn.hutool.extra.spring.SpringUtil; +import com.viewsh.framework.common.biz.system.project.ProjectCommonApi; +import com.viewsh.framework.common.biz.system.tenant.TenantCommonApi; +import com.viewsh.framework.common.enums.WebFilterOrderEnum; +import com.viewsh.framework.mybatis.core.util.MyBatisUtils; +import com.viewsh.framework.redis.config.ViewshCacheProperties; +import com.viewsh.framework.security.core.service.SecurityFrameworkService; +import com.viewsh.framework.tenant.core.aop.ProjectIgnoreAspect; +import com.viewsh.framework.tenant.core.aop.TenantIgnore; +import com.viewsh.framework.tenant.core.aop.TenantIgnoreAspect; +import com.viewsh.framework.tenant.core.db.ProjectDatabaseInterceptor; +import com.viewsh.framework.tenant.core.db.TenantDatabaseInterceptor; +import com.viewsh.framework.tenant.core.job.ProjectJobAspect; +import com.viewsh.framework.tenant.core.job.TenantJobAspect; +import com.viewsh.framework.tenant.core.mq.rabbitmq.TenantRabbitMQInitializer; +import com.viewsh.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor; +import com.viewsh.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer; +import com.viewsh.framework.tenant.core.redis.TenantRedisCacheManager; +import com.viewsh.framework.tenant.core.security.TenantSecurityWebFilter; +import com.viewsh.framework.tenant.core.service.ProjectFrameworkService; +import com.viewsh.framework.tenant.core.service.ProjectFrameworkServiceImpl; +import com.viewsh.framework.tenant.core.service.TenantFrameworkService; +import com.viewsh.framework.tenant.core.service.TenantFrameworkServiceImpl; +import com.viewsh.framework.tenant.core.web.ProjectContextWebFilter; +import com.viewsh.framework.tenant.core.web.TenantContextWebFilter; +import com.viewsh.framework.tenant.core.web.TenantVisitContextInterceptor; +import com.viewsh.framework.web.config.WebProperties; +import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.BatchStrategies; +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 org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "viewsh.tenant", value = "enable", matchIfMissing = true) // 允许使用 viewsh.tenant.enable=false 禁用多租户 +@EnableConfigurationProperties(TenantProperties.class) +public class ViewshTenantAutoConfiguration { + + @Resource + private ApplicationContext applicationContext; + + @Bean + public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) { + // 参见 https://gitee.com/zhijiantianya/viewsh-cloud/issues/IC6YZF + try { + TenantCommonApi tenantApiImpl = SpringUtil.getBean("tenantApiImpl", TenantCommonApi.class); + if (tenantApiImpl != null) { + tenantApi = tenantApiImpl; + } + } catch (Exception ignored) {} + return new TenantFrameworkServiceImpl(tenantApi); + } + + @Bean + public ProjectFrameworkService projectFrameworkService(ProjectCommonApi projectApi) { + try { + ProjectCommonApi projectApiImpl = SpringUtil.getBean("projectApiImpl", ProjectCommonApi.class); + if (projectApiImpl != null) { + projectApi = projectApiImpl; + } + } catch (Exception ignored) {} + return new ProjectFrameworkServiceImpl(projectApi); + } + + // ========== AOP ========== + + @Bean + public TenantIgnoreAspect tenantIgnoreAspect() { + return new TenantIgnoreAspect(); + } + + @Bean + public ProjectIgnoreAspect projectIgnoreAspect() { + return new ProjectIgnoreAspect(); + } + + // ========== 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; + } + + @Bean + public TenantLineInnerInterceptor projectLineInnerInterceptor( + TenantProperties properties, + MybatisPlusInterceptor interceptor, + @Qualifier("tenantLineInnerInterceptor") TenantLineInnerInterceptor tenantInterceptor) { + // 依赖 tenantInterceptor 确保租户拦截器先注册到链中 + // 然后项目拦截器插入 index 0,租户拦截器自动后移到 index 1 + // 拦截器链顺序:[0] Project → [1] Tenant → [N] Pagination + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new ProjectDatabaseInterceptor(properties)); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + // ========== WEB ========== + + @Bean + public FilterRegistrationBean tenantContextWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantContextWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); + return registrationBean; + } + + @Bean + public FilterRegistrationBean projectContextWebFilter() { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ProjectContextWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.PROJECT_CONTEXT_FILTER); + return registrationBean; + } + + @Bean + public TenantVisitContextInterceptor tenantVisitContextInterceptor(TenantProperties tenantProperties, + SecurityFrameworkService securityFrameworkService) { + return new TenantVisitContextInterceptor(tenantProperties, securityFrameworkService); + } + + @Bean + public WebMvcConfigurer tenantWebMvcConfigurer(TenantProperties tenantProperties, + TenantVisitContextInterceptor tenantVisitContextInterceptor) { + return new WebMvcConfigurer() { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(tenantVisitContextInterceptor) + .excludePathPatterns(tenantProperties.getIgnoreVisitUrls().toArray(new String[0])); + } + }; + } + + // ========== Security ========== + + @Bean + public FilterRegistrationBean tenantSecurityWebFilter(TenantProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + TenantFrameworkService tenantFrameworkService) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantSecurityWebFilter(webProperties, tenantProperties, getTenantIgnoreUrls(), + globalExceptionHandler, tenantFrameworkService)); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); + return registrationBean; + } + + /** + * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中 + * + * @return 忽略租户的 URL 集合 + */ + private Set getTenantIgnoreUrls() { + Set ignoreUrls = new HashSet<>(); + // 获得接口对应的 HandlerMethod 集合 + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + // 获得有 @TenantIgnore 注解的接口 + for (Map.Entry entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(TenantIgnore.class) // 方法级 + && !handlerMethod.getBeanType().isAnnotationPresent(TenantIgnore.class)) { // 接口级 + continue; + } + // 添加到忽略的 URL 中 + if (entry.getKey().getPatternsCondition() != null) { + ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns()); + } + if (entry.getKey().getPathPatternsCondition() != null) { + ignoreUrls.addAll( + convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); + } + } + return ignoreUrls; + } + + // ========== MQ ========== + + /** + * 多租户 Redis 消息队列的配置类 + * + * 为什么要单独一个配置类呢?如果直接把 TenantRedisMessageInterceptor Bean 的初始化放外面,会报 RedisMessageInterceptor 类不存在的错误 + */ + @Configuration + @ConditionalOnClass(name = "com.viewsh.framework.mq.redis.core.RedisMQTemplate") + public static class TenantRedisMQAutoConfiguration { + + @Bean + public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() { + return new TenantRedisMessageInterceptor(); + } + + } + + @Bean + @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") + public TenantRabbitMQInitializer tenantRabbitMQInitializer() { + return new TenantRabbitMQInitializer(); + } + + @Bean + @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") + public TenantRocketMQInitializer tenantRocketMQInitializer() { + return new TenantRocketMQInitializer(); + } + + // ========== Job ========== + + @Bean + @ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob") + public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) { + return new TenantJobAspect(tenantFrameworkService); + } + + @Bean + @ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob") + public ProjectJobAspect projectJobAspect(ProjectFrameworkService projectFrameworkService) { + return new ProjectJobAspect(projectFrameworkService); + } + + // ========== Redis ========== + + @Bean + @Primary // 引入租户时,tenantRedisCacheManager 为主 Bean + public RedisCacheManager tenantRedisCacheManager(RedisTemplate redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + ViewshCacheProperties viewshCacheProperties, + TenantProperties tenantProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(viewshCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches()); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnore.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnore.java new file mode 100644 index 00000000..ea78b137 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnore.java @@ -0,0 +1,26 @@ +package com.viewsh.framework.tenant.core.aop; + +import java.lang.annotation.*; + +/** + * 忽略项目隔离,标记指定方法不进行项目的自动过滤 + * + * 特殊: + * 1、如果添加到 Controller 类上,则该 URL 自动添加到项目忽略 URL 列表中 + * 2、如果添加到 DO 实体类上,则它对应的表名不进行项目隔离 + * + * @author lzh + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface ProjectIgnore { + + /** + * 是否开启忽略项目隔离,默认为 true 开启 + * + * 支持 Spring EL 表达式,如果返回 true 则满足条件,进行项目隔离的忽略 + */ + String enable() default "true"; + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnoreAspect.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnoreAspect.java new file mode 100644 index 00000000..39cc5a1b --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/aop/ProjectIgnoreAspect.java @@ -0,0 +1,35 @@ +package com.viewsh.framework.tenant.core.aop; + +import com.viewsh.framework.common.util.spring.SpringExpressionUtils; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +/** + * 忽略项目隔离的 Aspect,基于 {@link ProjectIgnore} 注解实现 + * + * @author lzh + */ +@Aspect +@Slf4j +public class ProjectIgnoreAspect { + + @Around("@annotation(projectIgnore)") + public Object around(ProceedingJoinPoint joinPoint, ProjectIgnore projectIgnore) throws Throwable { + Boolean oldIgnore = ProjectContextHolder.isIgnore(); + try { + // 计算条件,满足的情况下,才进行忽略 + Object enable = SpringExpressionUtils.parseExpression(projectIgnore.enable()); + if (Boolean.TRUE.equals(enable)) { + ProjectContextHolder.setIgnore(true); + } + // 执行逻辑 + return joinPoint.proceed(); + } finally { + ProjectContextHolder.setIgnore(oldIgnore); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/context/ProjectContextHolder.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/context/ProjectContextHolder.java new file mode 100644 index 00000000..5f500e9c --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/context/ProjectContextHolder.java @@ -0,0 +1,66 @@ +package com.viewsh.framework.tenant.core.context; + +import com.alibaba.ttl.TransmittableThreadLocal; + +/** + * 项目上下文 Holder + * + * @author lzh + */ +public class ProjectContextHolder { + + /** + * 当前项目编号 + */ + private static final ThreadLocal PROJECT_ID = new TransmittableThreadLocal<>(); + + /** + * 是否忽略项目隔离 + */ + private static final ThreadLocal IGNORE = new TransmittableThreadLocal<>(); + + /** + * 获得项目编号 + * + * @return 项目编号 + */ + public static Long getProjectId() { + return PROJECT_ID.get(); + } + + /** + * 获得项目编号。如果不存在,则抛出 NullPointerException 异常 + * + * @return 项目编号 + */ + public static Long getRequiredProjectId() { + Long projectId = getProjectId(); + if (projectId == null) { + throw new NullPointerException("ProjectContextHolder 不存在项目编号!"); + } + return projectId; + } + + public static void setProjectId(Long projectId) { + PROJECT_ID.set(projectId); + } + + public static void setIgnore(Boolean ignore) { + IGNORE.set(ignore); + } + + /** + * 当前是否忽略项目隔离 + * + * @return 是否忽略 + */ + public static boolean isIgnore() { + return Boolean.TRUE.equals(IGNORE.get()); + } + + public static void clear() { + PROJECT_ID.remove(); + IGNORE.remove(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectBaseDO.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectBaseDO.java new file mode 100644 index 00000000..a26aecd1 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectBaseDO.java @@ -0,0 +1,23 @@ +package com.viewsh.framework.tenant.core.db; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 拓展项目隔离的 BaseDO 基类 + * + * 继承 {@link TenantBaseDO},在租户隔离基础上新增项目隔离。 + * 业务数据 DO(设备、工单、区域等)继承此类,MyBatis Plus 拦截器自动注入 WHERE project_id = ? + * + * @author lzh + */ +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class ProjectBaseDO extends TenantBaseDO { + + /** + * 项目编号 + */ + private Long projectId; + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectDatabaseInterceptor.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectDatabaseInterceptor.java new file mode 100644 index 00000000..27b5117c --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/db/ProjectDatabaseInterceptor.java @@ -0,0 +1,97 @@ +package com.viewsh.framework.tenant.core.db; + +import com.viewsh.framework.tenant.config.TenantProperties; +import com.viewsh.framework.tenant.core.aop.ProjectIgnore; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.toolkit.SqlParserUtils; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; + +import java.util.HashMap; +import java.util.Map; + +/** + * 基于 MyBatis Plus 多租户的功能,实现 DB 层面的项目隔离 + * + * 复用 {@link TenantLineHandler} 接口,但返回 project_id 列, + * 只对继承 {@link ProjectBaseDO} 的实体表注入 WHERE project_id = ? + * + * @author lzh + */ +public class ProjectDatabaseInterceptor implements TenantLineHandler { + + /** + * 忽略的表 + * + * KEY:表名 + * VALUE:是否忽略 + */ + private final Map ignoreTables = new HashMap<>(); + + public ProjectDatabaseInterceptor(TenantProperties properties) { + // 从配置中加载忽略项目隔离的表 + if (properties.getIgnoreProjectTables() != null) { + properties.getIgnoreProjectTables().forEach(table -> { + addIgnoreTable(table, true); + }); + } + // 在 OracleKeyGenerator 中,生成主键时,会查询这个表 + addIgnoreTable("DUAL", true); + } + + @Override + public Expression getTenantId() { + return new LongValue(ProjectContextHolder.getRequiredProjectId()); + } + + @Override + public String getTenantIdColumn() { + return "project_id"; + } + + @Override + public boolean ignoreTable(String tableName) { + // 情况一,全局忽略项目隔离 + if (ProjectContextHolder.isIgnore()) { + return true; + } + // 情况二,忽略项目隔离的表 + tableName = SqlParserUtils.removeWrapperSymbol(tableName); + Boolean ignore = ignoreTables.get(tableName.toLowerCase()); + if (ignore == null) { + ignore = computeIgnoreTable(tableName); + synchronized (ignoreTables) { + addIgnoreTable(tableName, ignore); + } + } + return ignore; + } + + private void addIgnoreTable(String tableName, boolean ignore) { + ignoreTables.put(tableName.toLowerCase(), ignore); + ignoreTables.put(tableName.toUpperCase(), ignore); + } + + private boolean computeIgnoreTable(String tableName) { + // 找不到的表,说明不是 viewsh 项目里的,不进行拦截(忽略项目) + TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName); + if (tableInfo == null) { + return true; + } + // 如果添加了 @ProjectIgnore 注解,即使继承了 ProjectBaseDO 也忽略项目隔离 + ProjectIgnore projectIgnore = tableInfo.getEntityType().getAnnotation(ProjectIgnore.class); + if (projectIgnore != null) { + return true; + } + // 如果继承了 ProjectBaseDO 基类,需要注入 project_id + if (ProjectBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) { + return false; + } + // 默认忽略(不继承 ProjectBaseDO 的表不需要项目隔离) + return true; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJob.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJob.java new file mode 100644 index 00000000..cd596025 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJob.java @@ -0,0 +1,16 @@ +package com.viewsh.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 注解,标记的方法将遍历当前租户下所有项目执行 + * + * @author lzh + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ProjectJob { +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJobAspect.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJobAspect.java new file mode 100644 index 00000000..ad08ed8d --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/ProjectJobAspect.java @@ -0,0 +1,82 @@ +package com.viewsh.framework.tenant.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import com.viewsh.framework.tenant.core.service.ProjectFrameworkService; +import com.viewsh.framework.tenant.core.util.ProjectUtils; +import com.xxl.job.core.context.XxlJobContext; +import com.xxl.job.core.context.XxlJobHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 多项目 JobHandler AOP + * 任务执行时,会按照当前租户下的项目逐个执行 Job 的逻辑 + * + * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个项目执行失败重试时,之前执行成功的项目也会再次执行。 + * + * @author lzh + */ +@Aspect +@Order(2) +@RequiredArgsConstructor +@Slf4j +public class ProjectJobAspect { + + private final ProjectFrameworkService projectFrameworkService; + + @Around("@annotation(projectJob)") + public void around(ProceedingJoinPoint joinPoint, ProjectJob projectJob) { + // 防御:@ProjectJob 必须配合 @TenantJob 使用,确保有租户上下文 + if (TenantContextHolder.getTenantId() == null) { + log.warn("[around][方法({}) @ProjectJob 必须配合 @TenantJob 使用,当前无租户上下文]", joinPoint.getSignature()); + return; + } + // 获得项目列表 + List projectIds = projectFrameworkService.getProjectIds(); + if (CollUtil.isEmpty(projectIds)) { + return; + } + + // 逐个项目,执行 Job + Map results = new ConcurrentHashMap<>(); + AtomicBoolean success = new AtomicBoolean(true); // 标记,是否存在失败的情况 + XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext(); // XXL-Job 上下文 + projectIds.parallelStream().forEach(projectId -> { + ProjectUtils.execute(projectId, () -> { + try { + XxlJobContext.setXxlJobContext(xxlJobContext); + // 执行 Job + Object result = joinPoint.proceed(); + results.put(projectId, StrUtil.toStringOrEmpty(result)); + } catch (Throwable e) { + results.put(projectId, ExceptionUtil.getRootCauseMessage(e)); + success.set(false); + // 打印异常 + XxlJobHelper.log(StrUtil.format("[多项目({}) 执行任务({}),发生异常:{}]", + projectId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e))); + } + }); + }); + // 记录执行结果 + if (success.get()) { + XxlJobHelper.handleSuccess(JsonUtils.toJsonString(results)); + } else { + XxlJobHelper.handleFail(JsonUtils.toJsonString(results)); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/TenantJobAspect.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/TenantJobAspect.java index 758b8a58..e1983877 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/TenantJobAspect.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/job/TenantJobAspect.java @@ -1,75 +1,77 @@ -package com.viewsh.framework.tenant.core.job; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.exceptions.ExceptionUtil; -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.common.util.json.JsonUtils; -import com.viewsh.framework.tenant.core.service.TenantFrameworkService; -import com.viewsh.framework.tenant.core.util.TenantUtils; -import com.xxl.job.core.context.XxlJobContext; -import com.xxl.job.core.context.XxlJobHelper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * 多租户 JobHandler AOP - * 任务执行时,会按照租户逐个执行 Job 的逻辑 - * - * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。 - * - * @author 芋道源码 - */ -@Aspect -@RequiredArgsConstructor -@Slf4j -public class TenantJobAspect { - - private final TenantFrameworkService tenantFrameworkService; - - @Around("@annotation(tenantJob)") - public void around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) { - // 获得租户列表 - List tenantIds = tenantFrameworkService.getTenantIds(); - if (CollUtil.isEmpty(tenantIds)) { - return; - } - - // 逐个租户,执行 Job - Map results = new ConcurrentHashMap<>(); - AtomicBoolean success = new AtomicBoolean(true); // 标记,是否存在失败的情况 - XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext(); // XXL-Job 上下文 - tenantIds.parallelStream().forEach(tenantId -> { - // TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况 - TenantUtils.execute(tenantId, () -> { - try { - XxlJobContext.setXxlJobContext(xxlJobContext); - // 执行 Job - Object result = joinPoint.proceed(); - results.put(tenantId, StrUtil.toStringOrEmpty(result)); - } catch (Throwable e) { - results.put(tenantId, ExceptionUtil.getRootCauseMessage(e)); - success.set(false); - // 打印异常 - XxlJobHelper.log(StrUtil.format("[多租户({}) 执行任务({}),发生异常:{}]", - tenantId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e))); - } - }); - }); - // 记录执行结果 - if (success.get()) { - XxlJobHelper.handleSuccess(JsonUtils.toJsonString(results)); - } else { - XxlJobHelper.handleFail(JsonUtils.toJsonString(results)); - } - } - -} +package com.viewsh.framework.tenant.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.framework.tenant.core.service.TenantFrameworkService; +import com.viewsh.framework.tenant.core.util.TenantUtils; +import com.xxl.job.core.context.XxlJobContext; +import com.xxl.job.core.context.XxlJobHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 多租户 JobHandler AOP + * 任务执行时,会按照租户逐个执行 Job 的逻辑 + * + * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。 + * + * @author 芋道源码 + */ +@Aspect +@Order(1) +@RequiredArgsConstructor +@Slf4j +public class TenantJobAspect { + + private final TenantFrameworkService tenantFrameworkService; + + @Around("@annotation(tenantJob)") + public void around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) { + // 获得租户列表 + List tenantIds = tenantFrameworkService.getTenantIds(); + if (CollUtil.isEmpty(tenantIds)) { + return; + } + + // 逐个租户,执行 Job + Map results = new ConcurrentHashMap<>(); + AtomicBoolean success = new AtomicBoolean(true); // 标记,是否存在失败的情况 + XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext(); // XXL-Job 上下文 + tenantIds.parallelStream().forEach(tenantId -> { + // TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况 + TenantUtils.execute(tenantId, () -> { + try { + XxlJobContext.setXxlJobContext(xxlJobContext); + // 执行 Job + Object result = joinPoint.proceed(); + results.put(tenantId, StrUtil.toStringOrEmpty(result)); + } catch (Throwable e) { + results.put(tenantId, ExceptionUtil.getRootCauseMessage(e)); + success.set(false); + // 打印异常 + XxlJobHelper.log(StrUtil.format("[多租户({}) 执行任务({}),发生异常:{}]", + tenantId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e))); + } + }); + }); + // 记录执行结果 + if (success.get()) { + XxlJobHelper.handleSuccess(JsonUtils.toJsonString(results)); + } else { + XxlJobHelper.handleFail(JsonUtils.toJsonString(results)); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java index 9ec14080..5aad1718 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java @@ -1,47 +1,54 @@ -package com.viewsh.framework.tenant.core.mq.kafka; - -import cn.hutool.core.util.ReflectUtil; -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import org.apache.kafka.clients.producer.ProducerInterceptor; -import org.apache.kafka.clients.producer.ProducerRecord; -import org.apache.kafka.clients.producer.RecordMetadata; -import org.apache.kafka.common.header.Headers; -import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; - -import java.util.Map; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * Kafka 消息队列的多租户 {@link ProducerInterceptor} 实现类 - * - * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 - * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 - * - * @author 芋道源码 - */ -public class TenantKafkaProducerInterceptor implements ProducerInterceptor { - - @Override - public ProducerRecord onSend(ProducerRecord record) { - Long tenantId = TenantContextHolder.getTenantId(); - if (tenantId != null) { - Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射 - headers.add(HEADER_TENANT_ID, tenantId.toString().getBytes()); - } - return record; - } - - @Override - public void onAcknowledgement(RecordMetadata metadata, Exception exception) { - } - - @Override - public void close() { - } - - @Override - public void configure(Map configs) { - } - -} +package com.viewsh.framework.tenant.core.mq.kafka; + +import cn.hutool.core.util.ReflectUtil; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.header.Headers; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.Map; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Kafka 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantKafkaProducerInterceptor implements ProducerInterceptor { + + @Override + public ProducerRecord onSend(ProducerRecord record) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射 + headers.add(HEADER_TENANT_ID, tenantId.toString().getBytes()); + } + Long projectId = ProjectContextHolder.getProjectId(); + if (projectId != null) { + Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); + headers.add(HEADER_PROJECT_ID, projectId.toString().getBytes()); + } + return record; + } + + @Override + public void onAcknowledgement(RecordMetadata metadata, Exception exception) { + } + + @Override + public void close() { + } + + @Override + public void configure(Map configs) { + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java index 46de627b..86db174b 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java @@ -1,31 +1,37 @@ -package com.viewsh.framework.tenant.core.mq.rabbitmq; - -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import org.apache.kafka.clients.producer.ProducerInterceptor; -import org.springframework.amqp.AmqpException; -import org.springframework.amqp.core.Message; -import org.springframework.amqp.core.MessagePostProcessor; -import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类 - * - * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 - * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 - * - * @author 芋道源码 - */ -public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor { - - @Override - public Message postProcessMessage(Message message) throws AmqpException { - Long tenantId = TenantContextHolder.getTenantId(); - if (tenantId != null) { - message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId); - } - return message; - } - -} +package com.viewsh.framework.tenant.core.mq.rabbitmq; + +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor { + + @Override + public Message postProcessMessage(Message message) throws AmqpException { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId); + } + Long projectId = ProjectContextHolder.getProjectId(); + if (projectId != null) { + message.getMessageProperties().getHeaders().put(HEADER_PROJECT_ID, projectId); + } + return message; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java index 94b2a4f9..dfaebb7b 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java @@ -1,42 +1,53 @@ -package com.viewsh.framework.tenant.core.mq.redis; - -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.mq.redis.core.interceptor.RedisMessageInterceptor; -import com.viewsh.framework.mq.redis.core.message.AbstractRedisMessage; -import com.viewsh.framework.tenant.core.context.TenantContextHolder; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * 多租户 {@link AbstractRedisMessage} 拦截器 - * - * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 - * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中 - * - * @author 芋道源码 - */ -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(); - } - -} +package com.viewsh.framework.tenant.core.mq.redis; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.viewsh.framework.mq.redis.core.message.AbstractRedisMessage; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * 多租户 {@link AbstractRedisMessage} 拦截器 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中 + * + * @author 芋道源码 + */ +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()); + } + Long projectId = ProjectContextHolder.getProjectId(); + if (projectId != null) { + message.addHeader(HEADER_PROJECT_ID, projectId.toString()); + } + } + + @Override + public void consumeMessageBefore(AbstractRedisMessage message) { + String tenantIdStr = message.getHeader(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantIdStr)) { + TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr)); + } + String projectIdStr = message.getHeader(HEADER_PROJECT_ID); + if (StrUtil.isNotEmpty(projectIdStr)) { + ProjectContextHolder.setProjectId(Long.valueOf(projectIdStr)); + } + } + + @Override + public void consumeMessageAfter(AbstractRedisMessage message) { + // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况 + TenantContextHolder.clear(); + ProjectContextHolder.clear(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java index a6abba55..3c1f58bc 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java @@ -1,46 +1,54 @@ -package com.viewsh.framework.tenant.core.mq.rocketmq; - -import cn.hutool.core.lang.Assert; -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import org.apache.rocketmq.client.hook.ConsumeMessageContext; -import org.apache.rocketmq.client.hook.ConsumeMessageHook; -import org.apache.rocketmq.common.message.MessageExt; -import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; - -import java.util.List; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * RocketMQ 消息队列的多租户 {@link ConsumeMessageHook} 实现类 - * - * Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 - * - * @author 芋道源码 - */ -public class TenantRocketMQConsumeMessageHook implements ConsumeMessageHook { - - @Override - public String hookName() { - return getClass().getSimpleName(); - } - - @Override - public void consumeMessageBefore(ConsumeMessageContext context) { - // 校验,消息必须是单条,不然设置租户可能不正确 - List messages = context.getMsgList(); - Assert.isTrue(messages.size() == 1, "消息条数({})不正确", messages.size()); - // 设置租户编号 - String tenantId = messages.get(0).getUserProperty(HEADER_TENANT_ID); - if (StrUtil.isNotEmpty(tenantId)) { - TenantContextHolder.setTenantId(Long.parseLong(tenantId)); - } - } - - @Override - public void consumeMessageAfter(ConsumeMessageContext context) { - TenantContextHolder.clear(); - } - -} +package com.viewsh.framework.tenant.core.mq.rocketmq; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import org.apache.rocketmq.client.hook.ConsumeMessageContext; +import org.apache.rocketmq.client.hook.ConsumeMessageHook; +import org.apache.rocketmq.common.message.MessageExt; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.List; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link ConsumeMessageHook} 实现类 + * + * Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author 芋道源码 + */ +public class TenantRocketMQConsumeMessageHook implements ConsumeMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void consumeMessageBefore(ConsumeMessageContext context) { + // 校验,消息必须是单条,不然设置租户可能不正确 + List messages = context.getMsgList(); + Assert.isTrue(messages.size() == 1, "消息条数({})不正确", messages.size()); + // 设置租户编号 + String tenantId = messages.get(0).getUserProperty(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantId)) { + TenantContextHolder.setTenantId(Long.parseLong(tenantId)); + } + // 设置项目编号 + String projectId = messages.get(0).getUserProperty(HEADER_PROJECT_ID); + if (StrUtil.isNotEmpty(projectId)) { + ProjectContextHolder.setProjectId(Long.parseLong(projectId)); + } + } + + @Override + public void consumeMessageAfter(ConsumeMessageContext context) { + TenantContextHolder.clear(); + ProjectContextHolder.clear(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java index e9e44d04..f89de240 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java @@ -1,36 +1,41 @@ -package com.viewsh.framework.tenant.core.mq.rocketmq; - -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import org.apache.rocketmq.client.hook.SendMessageContext; -import org.apache.rocketmq.client.hook.SendMessageHook; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * RocketMQ 消息队列的多租户 {@link SendMessageHook} 实现类 - * - * Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 - * - * @author 芋道源码 - */ -public class TenantRocketMQSendMessageHook implements SendMessageHook { - - @Override - public String hookName() { - return getClass().getSimpleName(); - } - - @Override - public void sendMessageBefore(SendMessageContext sendMessageContext) { - Long tenantId = TenantContextHolder.getTenantId(); - if (tenantId == null) { - return; - } - sendMessageContext.getMessage().putUserProperty(HEADER_TENANT_ID, tenantId.toString()); - } - - @Override - public void sendMessageAfter(SendMessageContext sendMessageContext) { - } - -} +package com.viewsh.framework.tenant.core.mq.rocketmq; + +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import org.apache.rocketmq.client.hook.SendMessageContext; +import org.apache.rocketmq.client.hook.SendMessageHook; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link SendMessageHook} 实现类 + * + * Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * + * @author 芋道源码 + */ +public class TenantRocketMQSendMessageHook implements SendMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void sendMessageBefore(SendMessageContext sendMessageContext) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + sendMessageContext.getMessage().putUserProperty(HEADER_TENANT_ID, tenantId.toString()); + } + Long projectId = ProjectContextHolder.getProjectId(); + if (projectId != null) { + sendMessageContext.getMessage().putUserProperty(HEADER_PROJECT_ID, projectId.toString()); + } + } + + @Override + public void sendMessageAfter(SendMessageContext sendMessageContext) { + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/rpc/TenantRequestInterceptor.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/rpc/TenantRequestInterceptor.java index eacb28d7..d12fa26c 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/rpc/TenantRequestInterceptor.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/rpc/TenantRequestInterceptor.java @@ -1,25 +1,31 @@ -package com.viewsh.framework.tenant.core.rpc; - -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import com.viewsh.framework.web.core.util.WebFrameworkUtils; -import feign.RequestInterceptor; -import feign.RequestTemplate; - -import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; - -/** - * Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link TenantContextHolder} 设置到 header 中,继续透传给被调用的服务 - * - * @author 芋道源码 - */ -public class TenantRequestInterceptor implements RequestInterceptor { - - @Override - public void apply(RequestTemplate requestTemplate) { - Long tenantId = TenantContextHolder.getTenantId(); - if (tenantId != null) { - requestTemplate.header(HEADER_TENANT_ID, String.valueOf(tenantId)); - } - } - -} +package com.viewsh.framework.tenant.core.rpc; + +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link TenantContextHolder} 设置到 header 中,继续透传给被调用的服务 + * + * @author 芋道源码 + */ +public class TenantRequestInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate requestTemplate) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + requestTemplate.header(HEADER_TENANT_ID, String.valueOf(tenantId)); + } + Long projectId = ProjectContextHolder.getProjectId(); + if (projectId != null) { + requestTemplate.header(HEADER_PROJECT_ID, String.valueOf(projectId)); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java new file mode 100644 index 00000000..537d522f --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java @@ -0,0 +1,26 @@ +package com.viewsh.framework.tenant.core.service; + +import java.util.List; + +/** + * 项目框架 Service 接口 + * + * @author lzh + */ +public interface ProjectFrameworkService { + + /** + * 获得当前租户的所有项目编号 + * + * @return 项目编号列表 + */ + List getProjectIds(); + + /** + * 校验项目是否合法 + * + * @param id 项目编号 + */ + void validProject(Long id); + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java new file mode 100644 index 00000000..04aec254 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java @@ -0,0 +1,65 @@ +package com.viewsh.framework.tenant.core.service; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.biz.system.project.ProjectCommonApi; +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; + +import static com.viewsh.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; + +/** + * Project 框架 Service 实现类 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class ProjectFrameworkServiceImpl implements ProjectFrameworkService { + + private final ProjectCommonApi projectApi; + + /** + * 针对 {@link #getProjectIds()} 的缓存 + */ + private final LoadingCache> getProjectIdsCache = buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public List load(Object key) { + return projectApi.getProjectIdList().getCheckedData(); + } + + }); + + /** + * 针对 {@link #validProject(Long)} 的缓存 + */ + private final LoadingCache> validProjectCache = buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader>() { + + @Override + public CommonResult load(Long id) { + return projectApi.validProject(id); + } + + }); + + @Override + @SneakyThrows + public List getProjectIds() { + return getProjectIdsCache.get(Boolean.TRUE); + } + + @Override + @SneakyThrows + public void validProject(Long id) { + validProjectCache.get(id).checkError(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/util/ProjectUtils.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/util/ProjectUtils.java new file mode 100644 index 00000000..8984196d --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/util/ProjectUtils.java @@ -0,0 +1,113 @@ +package com.viewsh.framework.tenant.core.util; + +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; + +import java.util.Map; +import java.util.concurrent.Callable; + +import static com.viewsh.framework.web.core.util.WebFrameworkUtils.HEADER_PROJECT_ID; + +/** + * 多项目 Util + * + * @author lzh + */ +public class ProjectUtils { + + /** + * 使用指定项目,执行对应的逻辑 + * + * 注意,如果当前是忽略项目的情况下,会被强制设置成不忽略项目 + * 当然,执行完成后,还是会恢复回去 + * + * @param projectId 项目编号 + * @param runnable 逻辑 + */ + public static void execute(Long projectId, Runnable runnable) { + Long oldProjectId = ProjectContextHolder.getProjectId(); + Boolean oldIgnore = ProjectContextHolder.isIgnore(); + try { + ProjectContextHolder.setProjectId(projectId); + ProjectContextHolder.setIgnore(false); + // 执行逻辑 + runnable.run(); + } finally { + ProjectContextHolder.setProjectId(oldProjectId); + ProjectContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 使用指定项目,执行对应的逻辑 + * + * 注意,如果当前是忽略项目的情况下,会被强制设置成不忽略项目 + * 当然,执行完成后,还是会恢复回去 + * + * @param projectId 项目编号 + * @param callable 逻辑 + * @return 结果 + */ + public static V execute(Long projectId, Callable callable) { + Long oldProjectId = ProjectContextHolder.getProjectId(); + Boolean oldIgnore = ProjectContextHolder.isIgnore(); + try { + ProjectContextHolder.setProjectId(projectId); + ProjectContextHolder.setIgnore(false); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + ProjectContextHolder.setProjectId(oldProjectId); + ProjectContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略项目,执行对应的逻辑 + * + * @param runnable 逻辑 + */ + public static void executeIgnore(Runnable runnable) { + Boolean oldIgnore = ProjectContextHolder.isIgnore(); + try { + ProjectContextHolder.setIgnore(true); + // 执行逻辑 + runnable.run(); + } finally { + ProjectContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略项目,执行对应的逻辑 + * + * @param callable 逻辑 + * @return 结果 + */ + public static V executeIgnore(Callable callable) { + Boolean oldIgnore = ProjectContextHolder.isIgnore(); + try { + ProjectContextHolder.setIgnore(true); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + ProjectContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 将项目编号,添加到 header 中 + * + * @param headers HTTP 请求 headers + * @param projectId 项目编号 + */ + public static void addProjectHeader(Map headers, Long projectId) { + if (projectId != null) { + headers.put(HEADER_PROJECT_ID, projectId.toString()); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/web/ProjectContextWebFilter.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/web/ProjectContextWebFilter.java new file mode 100644 index 00000000..0f500dfb --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/web/ProjectContextWebFilter.java @@ -0,0 +1,37 @@ +package com.viewsh.framework.tenant.core.web; + +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 多项目 Context Web 过滤器 + * 将请求 Header 中的 project-id 解析出来,添加到 {@link ProjectContextHolder} 中,这样后续的 DB 等操作,可以获得到项目编号。 + * + * @author lzh + */ +public class ProjectContextWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 设置 + Long projectId = WebFrameworkUtils.getProjectId(request); + if (projectId != null) { + ProjectContextHolder.setProjectId(projectId); + } + try { + chain.doFilter(request, response); + } finally { + // 清理 + ProjectContextHolder.clear(); + } + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/test/java/com/viewsh/framework/tenant/core/db/DualInterceptorTest.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/test/java/com/viewsh/framework/tenant/core/db/DualInterceptorTest.java new file mode 100644 index 00000000..48e10f91 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/test/java/com/viewsh/framework/tenant/core/db/DualInterceptorTest.java @@ -0,0 +1,192 @@ +package com.viewsh.framework.tenant.core.db; + +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 双拦截器叠加测试 + * + * 验证 ProjectLineInnerInterceptor (project_id) + TenantLineInnerInterceptor (tenant_id) + * 两层拦截器同时注入 SQL 条件。 + * + * 使用 {@link TenantLineInnerInterceptor#parserSingle(String, Object)} 直接测试 SQL 改写。 + * + * @author lzh + */ +class DualInterceptorTest { + + private TenantLineInnerInterceptor projectInterceptor; + private TenantLineInnerInterceptor tenantInterceptor; + + @BeforeEach + void setUp() { + // 项目拦截器:注入 project_id = 100 + projectInterceptor = new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override + public Expression getTenantId() { + return new LongValue(100); + } + + @Override + public String getTenantIdColumn() { + return "project_id"; + } + + @Override + public boolean ignoreTable(String tableName) { + // 模拟:只有 ops_order 和 iot_device 表需要项目隔离 + return !"ops_order".equalsIgnoreCase(tableName) + && !"iot_device".equalsIgnoreCase(tableName); + } + }); + + // 租户拦截器:注入 tenant_id = 1 + tenantInterceptor = new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override + public Expression getTenantId() { + return new LongValue(1); + } + + @Override + public boolean ignoreTable(String tableName) { + // 模拟:ops_order, iot_device, system_user 都需要租户隔离 + return !"ops_order".equalsIgnoreCase(tableName) + && !"iot_device".equalsIgnoreCase(tableName) + && !"system_user".equalsIgnoreCase(tableName); + } + }); + } + + /** + * 模拟双拦截器链:先 project 后 tenant(与实际拦截器链顺序一致) + */ + private String dualParse(String sql) { + String afterProject = projectInterceptor.parserSingle(sql, null); + return tenantInterceptor.parserSingle(afterProject, null); + } + + // ========== SELECT 测试 ========== + + @Test + @DisplayName("SELECT 简单查询:project_id + tenant_id 同时注入") + void testSelectSimple() { + String result = dualParse("SELECT * FROM ops_order WHERE status = 1"); + + assertTrue(result.contains("project_id = 100"), "应注入 project_id 条件: " + result); + assertTrue(result.contains("tenant_id = 1"), "应注入 tenant_id 条件: " + result); + assertTrue(result.contains("status = 1"), "原始条件应保留: " + result); + } + + @Test + @DisplayName("SELECT 无WHERE:自动生成双条件WHERE") + void testSelectNoWhere() { + String result = dualParse("SELECT * FROM ops_order"); + + assertTrue(result.contains("project_id = 100"), "应注入 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "应注入 tenant_id: " + result); + } + + @Test + @DisplayName("SELECT 只需租户隔离的表(system_user):只注入tenant_id") + void testSelectTenantOnly() { + String result = dualParse("SELECT * FROM system_user WHERE name = 'test'"); + + assertFalse(result.contains("project_id"), "system_user 不应注入 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "应注入 tenant_id: " + result); + } + + @Test + @DisplayName("SELECT JOIN:多表关联同时注入") + void testSelectJoin() { + String result = dualParse( + "SELECT o.*, d.device_name FROM ops_order o " + + "LEFT JOIN iot_device d ON o.device_id = d.id WHERE o.status = 1"); + + // 两张表都应有 project_id 和 tenant_id + assertTrue(result.contains("project_id = 100"), "JOIN 应注入 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "JOIN 应注入 tenant_id: " + result); + } + + @Test + @DisplayName("SELECT 子查询:内外层都注入") + void testSelectSubquery() { + String result = dualParse( + "SELECT * FROM ops_order WHERE device_id IN " + + "(SELECT id FROM iot_device WHERE product_key = 'abc')"); + + assertTrue(result.contains("project_id = 100"), "子查询应注入 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "子查询应注入 tenant_id: " + result); + } + + @Test + @DisplayName("SELECT 混合表JOIN:项目表+非项目表") + void testSelectMixedJoin() { + String result = dualParse( + "SELECT o.*, u.nickname FROM ops_order o " + + "LEFT JOIN system_user u ON o.creator = u.id WHERE o.status = 1"); + + // ops_order 需要 project_id 和 tenant_id + // system_user 只需要 tenant_id + assertTrue(result.contains("project_id = 100"), "ops_order 应注入 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "两张表都应注入 tenant_id: " + result); + } + + // ========== INSERT 测试 ========== + + @Test + @DisplayName("INSERT 项目隔离表:追加 project_id 和 tenant_id 列") + void testInsert() { + String result = dualParse("INSERT INTO ops_order (status, title) VALUES (1, 'test')"); + + assertTrue(result.toLowerCase().contains("project_id"), "INSERT 应追加 project_id 列: " + result); + assertTrue(result.toLowerCase().contains("tenant_id"), "INSERT 应追加 tenant_id 列: " + result); + } + + @Test + @DisplayName("INSERT 仅租户隔离表:只追加 tenant_id") + void testInsertTenantOnly() { + String result = dualParse("INSERT INTO system_user (name, mobile) VALUES ('test', '13800138000')"); + + assertFalse(result.toLowerCase().contains("project_id"), "system_user INSERT 不应追加 project_id: " + result); + assertTrue(result.toLowerCase().contains("tenant_id"), "应追加 tenant_id: " + result); + } + + // ========== UPDATE 测试 ========== + + @Test + @DisplayName("UPDATE:WHERE 同时追加 project_id 和 tenant_id") + void testUpdate() { + String result = dualParse("UPDATE ops_order SET status = 2 WHERE id = 1"); + + assertTrue(result.contains("project_id = 100"), "UPDATE 应追加 project_id 条件: " + result); + assertTrue(result.contains("tenant_id = 1"), "UPDATE 应追加 tenant_id 条件: " + result); + } + + @Test + @DisplayName("UPDATE 仅租户隔离表:只追加 tenant_id") + void testUpdateTenantOnly() { + String result = dualParse("UPDATE system_user SET name = 'new' WHERE id = 1"); + + assertFalse(result.contains("project_id"), "system_user UPDATE 不应追加 project_id: " + result); + assertTrue(result.contains("tenant_id = 1"), "应追加 tenant_id: " + result); + } + + // ========== DELETE 测试 ========== + + @Test + @DisplayName("DELETE:WHERE 同时追加 project_id 和 tenant_id") + void testDelete() { + String result = dualParse("DELETE FROM ops_order WHERE id = 1"); + + assertTrue(result.contains("project_id = 100"), "DELETE 应追加 project_id 条件: " + result); + assertTrue(result.contains("tenant_id = 1"), "DELETE 应追加 tenant_id 条件: " + result); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java index 096bf9eb..58102568 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/util/WebFrameworkUtils.java @@ -1,183 +1,196 @@ -package com.viewsh.framework.web.core.util; - -import cn.hutool.core.util.NumberUtil; -import cn.hutool.extra.servlet.ServletUtil; -import com.viewsh.framework.common.enums.RpcConstants; -import com.viewsh.framework.common.enums.TerminalEnum; -import com.viewsh.framework.common.enums.UserTypeEnum; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.util.servlet.ServletUtils; -import com.viewsh.framework.web.config.WebProperties; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import jakarta.servlet.ServletRequest; -import jakarta.servlet.http.HttpServletRequest; - -/** - * 专属于 web 包的工具类 - * - * @author 芋道源码 - */ -public class WebFrameworkUtils { - - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; - private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; - - private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; - - public static final String HEADER_TENANT_ID = "tenant-id"; - public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; - - /** - * 终端的 Header - * - * @see com.viewsh.framework.common.enums.TerminalEnum - */ - public static final String HEADER_TERMINAL = "terminal"; - - private static WebProperties properties; - - public WebFrameworkUtils(WebProperties webProperties) { - WebFrameworkUtils.properties = webProperties; - } - - /** - * 获得租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_TENANT_ID); - return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; - } - - /** - * 获得访问的租户编号,从 header 中 - * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 - * - * @param request 请求 - * @return 租户编号 - */ - public static Long getVisitTenantId(HttpServletRequest request) { - String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); - return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; - } - - public static void setLoginUserId(ServletRequest request, Long userId) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); - } - - /** - * 设置用户类型 - * - * @param request 请求 - * @param userType 用户类型 - */ - public static void setLoginUserType(ServletRequest request, Integer userType) { - request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); - } - - /** - * 获得当前用户的编号,从请求中 - * 注意:该方法仅限于 framework 框架使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Long getLoginUserId(HttpServletRequest request) { - if (request == null) { - return null; - } - return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); - } - - /** - * 获得当前用户的类型 - * 注意:该方法仅限于 web 相关的 framework 组件使用!!! - * - * @param request 请求 - * @return 用户编号 - */ - public static Integer getLoginUserType(HttpServletRequest request) { - if (request == null) { - return null; - } - // 1. 优先,从 Attribute 中获取 - Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); - if (userType != null) { - return userType; - } - // 2. 其次,基于 URL 前缀的约定 - if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { - return UserTypeEnum.ADMIN.getValue(); - } - if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { - return UserTypeEnum.MEMBER.getValue(); - } - return null; - } - - public static Integer getLoginUserType() { - HttpServletRequest request = getRequest(); - return getLoginUserType(request); - } - - public static Long getLoginUserId() { - HttpServletRequest request = getRequest(); - return getLoginUserId(request); - } - - public static Integer getTerminal() { - HttpServletRequest request = getRequest(); - if (request == null) { - return TerminalEnum.UNKNOWN.getTerminal(); - } - String terminalValue = request.getHeader(HEADER_TERMINAL); - return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); - } - - public static void setCommonResult(ServletRequest request, CommonResult result) { - request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); - } - - public static CommonResult getCommonResult(ServletRequest request) { - return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); - } - - @SuppressWarnings("PatternVariableCanBeUsed") - public static HttpServletRequest getRequest() { - RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); - if (!(requestAttributes instanceof ServletRequestAttributes)) { - return null; - } - ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; - return servletRequestAttributes.getRequest(); - } - - /** - * 判断是否为 RPC 请求 - * - * @param request 请求 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(HttpServletRequest request) { - return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); - } - - /** - * 判断是否为 RPC 请求 - * - * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 - * - * @param className 类名 - * @return 是否为 RPC 请求 - */ - public static boolean isRpcRequest(String className) { - return className.endsWith("Api"); - } - -} +package com.viewsh.framework.web.core.util; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.viewsh.framework.common.enums.RpcConstants; +import com.viewsh.framework.common.enums.TerminalEnum; +import com.viewsh.framework.common.enums.UserTypeEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.web.config.WebProperties; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; + +/** + * 专属于 web 包的工具类 + * + * @author 芋道源码 + */ +public class WebFrameworkUtils { + + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; + + private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; + + public static final String HEADER_TENANT_ID = "tenant-id"; + public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id"; + public static final String HEADER_PROJECT_ID = "project-id"; + + /** + * 终端的 Header + * + * @see com.viewsh.framework.common.enums.TerminalEnum + */ + public static final String HEADER_TERMINAL = "terminal"; + + private static WebProperties properties; + + public WebFrameworkUtils(WebProperties webProperties) { + WebFrameworkUtils.properties = webProperties; + } + + /** + * 获得租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_TENANT_ID); + return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; + } + + /** + * 获得项目编号,从 header 中 + * 考虑到其它 framework 组件也会使用到项目编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 项目编号 + */ + public static Long getProjectId(HttpServletRequest request) { + String projectId = request.getHeader(HEADER_PROJECT_ID); + return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null; + } + + /** + * 获得访问的租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getVisitTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID); + return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null; + } + + public static void setLoginUserId(ServletRequest request, Long userId) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); + } + + /** + * 设置用户类型 + * + * @param request 请求 + * @param userType 用户类型 + */ + public static void setLoginUserType(ServletRequest request, Integer userType) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); + } + + /** + * 获得当前用户的编号,从请求中 + * 注意:该方法仅限于 framework 框架使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Long getLoginUserId(HttpServletRequest request) { + if (request == null) { + return null; + } + return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); + } + + /** + * 获得当前用户的类型 + * 注意:该方法仅限于 web 相关的 framework 组件使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Integer getLoginUserType(HttpServletRequest request) { + if (request == null) { + return null; + } + // 1. 优先,从 Attribute 中获取 + Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); + if (userType != null) { + return userType; + } + // 2. 其次,基于 URL 前缀的约定 + if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { + return UserTypeEnum.ADMIN.getValue(); + } + if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { + return UserTypeEnum.MEMBER.getValue(); + } + return null; + } + + public static Integer getLoginUserType() { + HttpServletRequest request = getRequest(); + return getLoginUserType(request); + } + + public static Long getLoginUserId() { + HttpServletRequest request = getRequest(); + return getLoginUserId(request); + } + + public static Integer getTerminal() { + HttpServletRequest request = getRequest(); + if (request == null) { + return TerminalEnum.UNKNOWN.getTerminal(); + } + String terminalValue = request.getHeader(HEADER_TERMINAL); + return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); + } + + public static void setCommonResult(ServletRequest request, CommonResult result) { + request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); + } + + public static CommonResult getCommonResult(ServletRequest request) { + return (CommonResult) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); + } + + @SuppressWarnings("PatternVariableCanBeUsed") + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; + return servletRequestAttributes.getRequest(); + } + + /** + * 判断是否为 RPC 请求 + * + * @param request 请求 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); + } + + /** + * 判断是否为 RPC 请求 + * + * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 + * + * @param className 类名 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(String className) { + return className.endsWith("Api"); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java b/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java index f820f9ef..3ba60773 100644 --- a/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java +++ b/viewsh-module-system/viewsh-module-system-api/src/main/java/com/viewsh/module/system/enums/ErrorCodeConstants.java @@ -171,4 +171,11 @@ public interface ErrorCodeConstants { // ========== 站内信发送 1-002-028-000 ========== ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失"); + // ========== 项目 1-002-030-000 ========== + ErrorCode PROJECT_NOT_EXISTS = new ErrorCode(1_002_030_000, "项目不存在"); + ErrorCode PROJECT_DISABLE = new ErrorCode(1_002_030_001, "名字为【{}】的项目已被禁用"); + ErrorCode PROJECT_NAME_DUPLICATE = new ErrorCode(1_002_030_002, "名字为【{}】的项目已存在"); + ErrorCode PROJECT_CODE_DUPLICATE = new ErrorCode(1_002_030_003, "编码为【{}】的项目已存在"); + ErrorCode PROJECT_NOT_AUTHORIZED = new ErrorCode(1_002_030_004, "您未被授权访问该项目"); + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java new file mode 100644 index 00000000..cfc4047d --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java @@ -0,0 +1,34 @@ +package com.viewsh.module.system.api.project; + +import com.viewsh.framework.common.biz.system.project.ProjectCommonApi; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.system.service.project.ProjectService; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +public class ProjectApiImpl implements ProjectCommonApi { + + @Resource + private ProjectService projectService; + + @Override + public CommonResult> getProjectIdList() { + // 依赖框架的 TenantContextWebFilter 已设置的租户上下文 + List projectIds = projectService.getProjectIdsByTenantId(null); + return success(projectIds); + } + + @Override + public CommonResult validProject(Long id) { + projectService.validProject(id); + return success(true); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/ProjectController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/ProjectController.java new file mode 100644 index 00000000..2f0989a4 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/ProjectController.java @@ -0,0 +1,83 @@ +package com.viewsh.module.system.controller.admin.project; + +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.system.controller.admin.project.vo.ProjectPageReqVO; +import com.viewsh.module.system.controller.admin.project.vo.ProjectRespVO; +import com.viewsh.module.system.controller.admin.project.vo.ProjectSaveReqVO; +import com.viewsh.module.system.dal.dataobject.project.ProjectDO; +import com.viewsh.module.system.service.project.ProjectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - 项目管理") +@RestController +@RequestMapping("/system/project") +public class ProjectController { + + @Resource + private ProjectService projectService; + + @PostMapping("/create") + @Operation(summary = "创建项目") + @PreAuthorize("@ss.hasPermission('system:project:create')") + public CommonResult createProject(@Valid @RequestBody ProjectSaveReqVO createReqVO) { + return success(projectService.createProject(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新项目") + @PreAuthorize("@ss.hasPermission('system:project:update')") + public CommonResult updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) { + projectService.updateProject(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除项目") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('system:project:delete')") + public CommonResult deleteProject(@RequestParam("id") Long id) { + projectService.deleteProject(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得项目") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('system:project:query')") + public CommonResult getProject(@RequestParam("id") Long id) { + ProjectDO project = projectService.getProject(id); + return success(BeanUtils.toBean(project, ProjectRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得项目分页") + @PreAuthorize("@ss.hasPermission('system:project:query')") + public CommonResult> getProjectPage(@Valid ProjectPageReqVO pageVO) { + PageResult pageResult = projectService.getProjectPage(pageVO); + return success(BeanUtils.toBean(pageResult, ProjectRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获取项目精简信息列表", description = "只包含被开启的项目,用于下拉选择") + @PreAuthorize("@ss.hasPermission('system:project:query')") + public CommonResult> getProjectSimpleList() { + List list = projectService.getProjectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, project -> + new ProjectRespVO().setId(project.getId()).setName(project.getName()).setCode(project.getCode()))); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectPageReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectPageReqVO.java new file mode 100644 index 00000000..eb84b624 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectPageReqVO.java @@ -0,0 +1,24 @@ +package com.viewsh.module.system.controller.admin.project.vo; + +import com.viewsh.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - 项目分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class ProjectPageReqVO extends PageParam { + + @Schema(description = "项目名称", example = "某某大厦") + private String name; + + @Schema(description = "项目编码", example = "PROJ_001") + private String code; + + @Schema(description = "项目状态(0正常 1停用)", example = "0") + private Integer status; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectRespVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectRespVO.java new file mode 100644 index 00000000..07678928 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectRespVO.java @@ -0,0 +1,39 @@ +package com.viewsh.module.system.controller.admin.project.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 项目 Response VO") +@Data +public class ProjectRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "某某大厦") + private String name; + + @Schema(description = "项目编码", example = "PROJ_001") + private String code; + + @Schema(description = "项目状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "联系人姓名", example = "张三") + private String contactName; + + @Schema(description = "联系手机", example = "15601691300") + private String contactMobile; + + @Schema(description = "项目地址", example = "北京市朝阳区某某街道") + private String address; + + @Schema(description = "备注", example = "这是备注信息") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectSaveReqVO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectSaveReqVO.java new file mode 100644 index 00000000..01cd9b15 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/project/vo/ProjectSaveReqVO.java @@ -0,0 +1,38 @@ +package com.viewsh.module.system.controller.admin.project.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 项目创建/修改 Request VO") +@Data +public class ProjectSaveReqVO { + + @Schema(description = "项目编号", example = "1024") + private Long id; + + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "某某大厦") + @NotBlank(message = "项目名称不能为空") + private String name; + + @Schema(description = "项目编码", example = "PROJ_001") + private String code; + + @Schema(description = "项目状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "项目状态不能为空") + private Integer status; + + @Schema(description = "联系人姓名", example = "张三") + private String contactName; + + @Schema(description = "联系手机", example = "15601691300") + private String contactMobile; + + @Schema(description = "项目地址", example = "北京市朝阳区某某街道") + private String address; + + @Schema(description = "备注", example = "这是备注信息") + private String remark; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/ProjectDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/ProjectDO.java new file mode 100644 index 00000000..9fdf471d --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/ProjectDO.java @@ -0,0 +1,61 @@ +package com.viewsh.module.system.dal.dataobject.project; + +import com.viewsh.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 项目 DO + * + * @author 芋道源码 + */ +@TableName("system_project") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProjectDO extends TenantBaseDO { + + /** + * 默认项目编码 + */ + public static final String CODE_DEFAULT = "DEFAULT"; + + /** + * 项目编号,自增 + */ + private Long id; + /** + * 项目名称 + */ + private String name; + /** + * 项目编码,租户内唯一 + */ + private String code; + /** + * 项目状态 + * + * 枚举 {@link com.viewsh.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + /** + * 联系人姓名 + */ + private String contactName; + /** + * 联系手机 + */ + private String contactMobile; + /** + * 项目地址 + */ + private String address; + /** + * 备注 + */ + private String remark; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/UserProjectDO.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/UserProjectDO.java new file mode 100644 index 00000000..df433e1e --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/dataobject/project/UserProjectDO.java @@ -0,0 +1,38 @@ +package com.viewsh.module.system.dal.dataobject.project; + +import com.viewsh.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 用户项目关联 DO + * + * @author 芋道源码 + */ +@TableName("system_user_project") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class UserProjectDO extends TenantBaseDO { + + /** + * 编号,自增 + */ + private Long id; + /** + * 用户编号 + * + * 关联 {@link com.viewsh.module.system.dal.dataobject.user.AdminUserDO#getId()} + */ + private Long userId; + /** + * 项目编号 + * + * 关联 {@link ProjectDO#getId()} + */ + private Long projectId; + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/ProjectMapper.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/ProjectMapper.java new file mode 100644 index 00000000..d1a3636f --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/ProjectMapper.java @@ -0,0 +1,35 @@ +package com.viewsh.module.system.dal.mysql.project; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.system.controller.admin.project.vo.ProjectPageReqVO; +import com.viewsh.module.system.dal.dataobject.project.ProjectDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface ProjectMapper extends BaseMapperX { + + default PageResult selectPage(ProjectPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ProjectDO::getName, reqVO.getName()) + .likeIfPresent(ProjectDO::getCode, reqVO.getCode()) + .eqIfPresent(ProjectDO::getStatus, reqVO.getStatus()) + .orderByDesc(ProjectDO::getId)); + } + + default ProjectDO selectByName(String name) { + return selectOne(ProjectDO::getName, name); + } + + default ProjectDO selectByCode(String code) { + return selectOne(ProjectDO::getCode, code); + } + + default List selectListByStatus(Integer status) { + return selectList(ProjectDO::getStatus, status); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/UserProjectMapper.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/UserProjectMapper.java new file mode 100644 index 00000000..7e228946 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/dal/mysql/project/UserProjectMapper.java @@ -0,0 +1,27 @@ +package com.viewsh.module.system.dal.mysql.project; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.system.dal.dataobject.project.UserProjectDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface UserProjectMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(UserProjectDO::getUserId, userId); + } + + default List selectListByProjectId(Long projectId) { + return selectList(UserProjectDO::getProjectId, projectId); + } + + default void deleteByUserIdAndProjectId(Long userId, Long projectId) { + delete(new LambdaQueryWrapperX() + .eq(UserProjectDO::getUserId, userId) + .eq(UserProjectDO::getProjectId, projectId)); + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java new file mode 100644 index 00000000..377d48e8 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java @@ -0,0 +1,96 @@ +package com.viewsh.module.system.service.project; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.system.controller.admin.project.vo.ProjectPageReqVO; +import com.viewsh.module.system.controller.admin.project.vo.ProjectSaveReqVO; +import com.viewsh.module.system.dal.dataobject.project.ProjectDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 项目 Service 接口 + * + * @author 芋道源码 + */ +public interface ProjectService { + + /** + * 创建项目 + * + * @param createReqVO 创建信息 + * @return 项目编号 + */ + Long createProject(@Valid ProjectSaveReqVO createReqVO); + + /** + * 更新项目 + * + * @param updateReqVO 更新信息 + */ + void updateProject(@Valid ProjectSaveReqVO updateReqVO); + + /** + * 删除项目 + * + * @param id 编号 + */ + void deleteProject(Long id); + + /** + * 获得项目 + * + * @param id 编号 + * @return 项目 + */ + ProjectDO getProject(Long id); + + /** + * 获得项目分页 + * + * @param pageReqVO 分页查询 + * @return 项目分页 + */ + PageResult getProjectPage(ProjectPageReqVO pageReqVO); + + /** + * 获得指定状态的项目列表 + * + * @param status 状态 + * @return 项目列表 + */ + List getProjectListByStatus(Integer status); + + /** + * 根据编码获得项目 + * + * @param code 项目编码 + * @return 项目 + */ + ProjectDO getProjectByCode(String code); + + /** + * 校验项目是否合法(存在且启用) + * + * @param id 项目编号 + */ + void validProject(Long id); + + /** + * 获得租户下所有项目编号 + * + * @param tenantId 租户编号 + * @return 项目编号列表 + */ + List getProjectIdsByTenantId(Long tenantId); + + /** + * 创建默认项目(租户创建时自动调用) + * + * @param tenantId 租户编号 + * @param tenantName 租户名称(用于默认项目名称) + * @return 项目编号 + */ + Long createDefaultProject(Long tenantId, String tenantName); + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java new file mode 100644 index 00000000..3494fe11 --- /dev/null +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java @@ -0,0 +1,173 @@ +package com.viewsh.module.system.service.project; + +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.collection.CollectionUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.system.controller.admin.project.vo.ProjectPageReqVO; +import com.viewsh.module.system.controller.admin.project.vo.ProjectSaveReqVO; +import com.viewsh.module.system.dal.dataobject.project.ProjectDO; +import com.viewsh.module.system.dal.dataobject.project.UserProjectDO; +import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; +import com.viewsh.module.system.dal.mysql.project.ProjectMapper; +import com.viewsh.module.system.dal.mysql.project.UserProjectMapper; +import com.viewsh.module.system.service.user.AdminUserService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.system.enums.ErrorCodeConstants.*; + +/** + * 项目 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ProjectServiceImpl implements ProjectService { + + @Resource + private ProjectMapper projectMapper; + + @Resource + private UserProjectMapper userProjectMapper; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private AdminUserService userService; + + @Override + public Long createProject(ProjectSaveReqVO createReqVO) { + // 校验项目名称是否重复 + validProjectNameDuplicate(createReqVO.getName(), null); + // 校验项目编码是否重复 + if (createReqVO.getCode() != null) { + validProjectCodeDuplicate(createReqVO.getCode(), null); + } + // 插入 + ProjectDO project = BeanUtils.toBean(createReqVO, ProjectDO.class); + projectMapper.insert(project); + return project.getId(); + } + + @Override + public void updateProject(ProjectSaveReqVO updateReqVO) { + // 校验存在 + validateProjectExists(updateReqVO.getId()); + // 校验项目名称是否重复 + validProjectNameDuplicate(updateReqVO.getName(), updateReqVO.getId()); + // 校验项目编码是否重复 + if (updateReqVO.getCode() != null) { + validProjectCodeDuplicate(updateReqVO.getCode(), updateReqVO.getId()); + } + // 更新 + ProjectDO updateObj = BeanUtils.toBean(updateReqVO, ProjectDO.class); + projectMapper.updateById(updateObj); + } + + @Override + public void deleteProject(Long id) { + // 校验存在 + validateProjectExists(id); + // 删除 + projectMapper.deleteById(id); + } + + @Override + public ProjectDO getProject(Long id) { + return projectMapper.selectById(id); + } + + @Override + public PageResult getProjectPage(ProjectPageReqVO pageReqVO) { + return projectMapper.selectPage(pageReqVO); + } + + @Override + public List getProjectListByStatus(Integer status) { + return projectMapper.selectListByStatus(status); + } + + @Override + public ProjectDO getProjectByCode(String code) { + return projectMapper.selectByCode(code); + } + + @Override + public void validProject(Long id) { + ProjectDO project = getProject(id); + if (project == null) { + throw exception(PROJECT_NOT_EXISTS); + } + if (CommonStatusEnum.isDisable(project.getStatus())) { + throw exception(PROJECT_DISABLE, project.getName()); + } + } + + @Override + public List getProjectIdsByTenantId(Long tenantId) { + // 依赖框架已设置的租户上下文,直接查询当前租户下所有项目 + return CollectionUtils.convertList(projectMapper.selectList(), ProjectDO::getId); + } + + @Override + public Long createDefaultProject(Long tenantId, String tenantName) { + // 注意:调用方(TenantServiceImpl.createTenant)已在 TenantUtils.execute 块内, + // 此处无需再次切换租户上下文 + // 创建默认项目 + ProjectDO project = new ProjectDO(); + project.setName(tenantName); + project.setCode(ProjectDO.CODE_DEFAULT); + project.setStatus(CommonStatusEnum.ENABLE.getStatus()); + project.setRemark("系统自动生成默认项目"); + projectMapper.insert(project); + + // 将租户下所有用户授权到默认项目 + List users = userService.getUserListByStatus(CommonStatusEnum.ENABLE.getStatus()); + users.forEach(user -> { + UserProjectDO userProject = new UserProjectDO(); + userProject.setUserId(user.getId()); + userProject.setProjectId(project.getId()); + userProjectMapper.insert(userProject); + }); + return project.getId(); + } + + private void validateProjectExists(Long id) { + if (projectMapper.selectById(id) == null) { + throw exception(PROJECT_NOT_EXISTS); + } + } + + private void validProjectNameDuplicate(String name, Long id) { + ProjectDO project = projectMapper.selectByName(name); + if (project == null) { + return; + } + if (id == null) { + throw exception(PROJECT_NAME_DUPLICATE, name); + } + if (!project.getId().equals(id)) { + throw exception(PROJECT_NAME_DUPLICATE, name); + } + } + + private void validProjectCodeDuplicate(String code, Long id) { + ProjectDO project = projectMapper.selectByCode(code); + if (project == null) { + return; + } + if (id == null) { + throw exception(PROJECT_CODE_DUPLICATE, code); + } + if (!project.getId().equals(id)) { + throw exception(PROJECT_CODE_DUPLICATE, code); + } + } + +} diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/tenant/TenantServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/tenant/TenantServiceImpl.java index ebd99457..50628de1 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/tenant/TenantServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/tenant/TenantServiceImpl.java @@ -1,320 +1,326 @@ -package com.viewsh.module.system.service.tenant; - -import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.lang.Assert; -import cn.hutool.core.util.ObjectUtil; -import com.viewsh.framework.common.enums.CommonStatusEnum; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.common.util.collection.CollectionUtils; -import com.viewsh.framework.common.util.date.DateUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.framework.datapermission.core.annotation.DataPermission; -import com.viewsh.framework.tenant.config.TenantProperties; -import com.viewsh.framework.tenant.core.context.TenantContextHolder; -import com.viewsh.framework.tenant.core.util.TenantUtils; -import com.viewsh.module.system.controller.admin.permission.vo.role.RoleSaveReqVO; -import com.viewsh.module.system.controller.admin.tenant.vo.tenant.TenantPageReqVO; -import com.viewsh.module.system.controller.admin.tenant.vo.tenant.TenantSaveReqVO; -import com.viewsh.module.system.convert.tenant.TenantConvert; -import com.viewsh.module.system.dal.dataobject.permission.MenuDO; -import com.viewsh.module.system.dal.dataobject.permission.RoleDO; -import com.viewsh.module.system.dal.dataobject.tenant.TenantDO; -import com.viewsh.module.system.dal.dataobject.tenant.TenantPackageDO; -import com.viewsh.module.system.dal.mysql.tenant.TenantMapper; -import com.viewsh.module.system.enums.permission.RoleCodeEnum; -import com.viewsh.module.system.enums.permission.RoleTypeEnum; -import com.viewsh.module.system.service.permission.MenuService; -import com.viewsh.module.system.service.permission.PermissionService; -import com.viewsh.module.system.service.permission.RoleService; -import com.viewsh.module.system.service.tenant.handler.TenantInfoHandler; -import com.viewsh.module.system.service.tenant.handler.TenantMenuHandler; -import com.viewsh.module.system.service.user.AdminUserService; -import com.baomidou.dynamic.datasource.annotation.DSTransactional; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; -import org.springframework.validation.annotation.Validated; - -import java.util.List; -import java.util.Objects; -import java.util.Set; - -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.module.system.enums.ErrorCodeConstants.*; -import static java.util.Collections.singleton; - -/** - * 租户 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class TenantServiceImpl implements TenantService { - - @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") - @Autowired(required = false) // 由于 viewsh.tenant.enable 配置项,可以关闭多租户的功能,所以这里只能不强制注入 - private TenantProperties tenantProperties; - - @Resource - private TenantMapper tenantMapper; - - @Resource - private TenantPackageService tenantPackageService; - @Resource - @Lazy // 延迟,避免循环依赖报错 - private AdminUserService userService; - @Resource - private RoleService roleService; - @Resource - private MenuService menuService; - @Resource - private PermissionService permissionService; - - @Override - public List getTenantIdList() { - List tenants = tenantMapper.selectList(); - return CollectionUtils.convertList(tenants, TenantDO::getId); - } - - @Override - public void validTenant(Long id) { - TenantDO tenant = getTenant(id); - if (tenant == null) { - throw exception(TENANT_NOT_EXISTS); - } - if (tenant.getStatus().equals(CommonStatusEnum.DISABLE.getStatus())) { - throw exception(TENANT_DISABLE, tenant.getName()); - } - if (DateUtils.isExpired(tenant.getExpireTime())) { - throw exception(TENANT_EXPIRE, tenant.getName()); - } - } - - @Override - @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换 - @DataPermission(enable = false) // 参见 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1154 说明 - public Long createTenant(TenantSaveReqVO createReqVO) { - // 校验租户名称是否重复 - validTenantNameDuplicate(createReqVO.getName(), null); - // 校验租户域名是否重复 - validTenantWebsiteDuplicate(createReqVO.getWebsites(), null); - // 校验套餐被禁用 - TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId()); - - // 创建租户 - TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class); - tenantMapper.insert(tenant); - // 创建租户的管理员 - TenantUtils.execute(tenant.getId(), () -> { - // 创建角色 - Long roleId = createRole(tenantPackage); - // 创建用户,并分配角色 - Long userId = createUser(roleId, createReqVO); - // 修改租户的管理员 - tenantMapper.updateById(new TenantDO().setId(tenant.getId()).setContactUserId(userId)); - }); - return tenant.getId(); - } - - private Long createUser(Long roleId, TenantSaveReqVO createReqVO) { - // 创建用户 - Long userId = userService.createUser(TenantConvert.INSTANCE.convert02(createReqVO)); - // 分配角色 - permissionService.assignUserRole(userId, singleton(roleId)); - return userId; - } - - private Long createRole(TenantPackageDO tenantPackage) { - // 创建角色 - RoleSaveReqVO reqVO = new RoleSaveReqVO(); - reqVO.setName(RoleCodeEnum.TENANT_ADMIN.getName()).setCode(RoleCodeEnum.TENANT_ADMIN.getCode()) - .setSort(0).setRemark("系统自动生成"); - Long roleId = roleService.createRole(reqVO, RoleTypeEnum.SYSTEM.getType()); - // 分配权限 - permissionService.assignRoleMenu(roleId, tenantPackage.getMenuIds()); - return roleId; - } - - @Override - @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换 - public void updateTenant(TenantSaveReqVO updateReqVO) { - // 校验存在 - TenantDO tenant = validateUpdateTenant(updateReqVO.getId()); - // 校验租户名称是否重复 - validTenantNameDuplicate(updateReqVO.getName(), updateReqVO.getId()); - // 校验租户域名是否重复 - validTenantWebsiteDuplicate(updateReqVO.getWebsites(), updateReqVO.getId()); - // 校验套餐被禁用 - TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(updateReqVO.getPackageId()); - - // 更新租户 - TenantDO updateObj = BeanUtils.toBean(updateReqVO, TenantDO.class); - tenantMapper.updateById(updateObj); - // 如果套餐发生变化,则修改其角色的权限 - if (ObjectUtil.notEqual(tenant.getPackageId(), updateReqVO.getPackageId())) { - updateTenantRoleMenu(tenant.getId(), tenantPackage.getMenuIds()); - } - } - - private void validTenantNameDuplicate(String name, Long id) { - TenantDO tenant = tenantMapper.selectByName(name); - if (tenant == null) { - return; - } - // 如果 id 为空,说明不用比较是否为相同名字的租户 - if (id == null) { - throw exception(TENANT_NAME_DUPLICATE, name); - } - if (!tenant.getId().equals(id)) { - throw exception(TENANT_NAME_DUPLICATE, name); - } - } - - private void validTenantWebsiteDuplicate(List websites, Long excludeId) { - if (CollUtil.isEmpty(websites)) { - return; - } - websites.forEach(website -> { - List tenants = tenantMapper.selectListByWebsite(website); - if (excludeId != null) { - tenants.removeIf(tenant -> tenant.getId().equals(excludeId)); - } - if (CollUtil.isNotEmpty(tenants)) { - throw exception(TENANT_WEBSITE_DUPLICATE, website); - } - }); - } - - @Override - @DSTransactional - public void updateTenantRoleMenu(Long tenantId, Set menuIds) { - TenantUtils.execute(tenantId, () -> { - // 获得所有角色 - List roles = roleService.getRoleList(); - roles.forEach(role -> Assert.isTrue(tenantId.equals(role.getTenantId()), "角色({}/{}) 租户不匹配", - role.getId(), role.getTenantId(), tenantId)); // 兜底校验 - // 重新分配每个角色的权限 - roles.forEach(role -> { - // 如果是租户管理员,重新分配其权限为租户套餐的权限 - if (Objects.equals(role.getCode(), RoleCodeEnum.TENANT_ADMIN.getCode())) { - permissionService.assignRoleMenu(role.getId(), menuIds); - log.info("[updateTenantRoleMenu][租户管理员({}/{}) 的权限修改为({})]", role.getId(), role.getTenantId(), menuIds); - return; - } - // 如果是其他角色,则去掉超过套餐的权限 - Set roleMenuIds = permissionService.getRoleMenuListByRoleId(role.getId()); - roleMenuIds = CollUtil.intersectionDistinct(roleMenuIds, menuIds); - permissionService.assignRoleMenu(role.getId(), roleMenuIds); - log.info("[updateTenantRoleMenu][角色({}/{}) 的权限修改为({})]", role.getId(), role.getTenantId(), roleMenuIds); - }); - }); - } - - @Override - public void deleteTenant(Long id) { - // 校验存在 - validateUpdateTenant(id); - // 删除 - tenantMapper.deleteById(id); - } - - @Override - public void deleteTenantList(List ids) { - // 1. 校验存在 - ids.forEach(this::validateUpdateTenant); - - // 2. 批量删除 - tenantMapper.deleteByIds(ids); - } - - private TenantDO validateUpdateTenant(Long id) { - TenantDO tenant = tenantMapper.selectById(id); - if (tenant == null) { - throw exception(TENANT_NOT_EXISTS); - } - // 内置租户,不允许删除 - if (isSystemTenant(tenant)) { - throw exception(TENANT_CAN_NOT_UPDATE_SYSTEM); - } - return tenant; - } - - @Override - public TenantDO getTenant(Long id) { - return tenantMapper.selectById(id); - } - - @Override - public PageResult getTenantPage(TenantPageReqVO pageReqVO) { - return tenantMapper.selectPage(pageReqVO); - } - - @Override - public TenantDO getTenantByName(String name) { - return tenantMapper.selectByName(name); - } - - @Override - public TenantDO getTenantByWebsite(String website) { - List tenants = tenantMapper.selectListByWebsite(website); - return CollUtil.getFirst(tenants); - } - - @Override - public Long getTenantCountByPackageId(Long packageId) { - return tenantMapper.selectCountByPackageId(packageId); - } - - @Override - public List getTenantListByPackageId(Long packageId) { - return tenantMapper.selectListByPackageId(packageId); - } - - @Override - public List getTenantListByStatus(Integer status) { - return tenantMapper.selectListByStatus(status); - } - - @Override - public void handleTenantInfo(TenantInfoHandler handler) { - // 如果禁用,则不执行逻辑 - if (isTenantDisable()) { - return; - } - // 获得租户 - TenantDO tenant = getTenant(TenantContextHolder.getRequiredTenantId()); - // 执行处理器 - handler.handle(tenant); - } - - @Override - public void handleTenantMenu(TenantMenuHandler handler) { - // 如果禁用,则不执行逻辑 - if (isTenantDisable()) { - return; - } - // 获得租户,然后获得菜单 - TenantDO tenant = getTenant(TenantContextHolder.getRequiredTenantId()); - Set menuIds; - if (isSystemTenant(tenant)) { // 系统租户,菜单是全量的 - menuIds = CollectionUtils.convertSet(menuService.getMenuList(), MenuDO::getId); - } else { - menuIds = tenantPackageService.getTenantPackage(tenant.getPackageId()).getMenuIds(); - } - // 执行处理器 - handler.handle(menuIds); - } - - private static boolean isSystemTenant(TenantDO tenant) { - return Objects.equals(tenant.getPackageId(), TenantDO.PACKAGE_ID_SYSTEM); - } - - private boolean isTenantDisable() { - return tenantProperties == null || Boolean.FALSE.equals(tenantProperties.getEnable()); - } - -} +package com.viewsh.module.system.service.tenant; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjectUtil; +import com.viewsh.framework.common.enums.CommonStatusEnum; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.collection.CollectionUtils; +import com.viewsh.framework.common.util.date.DateUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.framework.datapermission.core.annotation.DataPermission; +import com.viewsh.framework.tenant.config.TenantProperties; +import com.viewsh.framework.tenant.core.context.TenantContextHolder; +import com.viewsh.framework.tenant.core.util.TenantUtils; +import com.viewsh.module.system.controller.admin.permission.vo.role.RoleSaveReqVO; +import com.viewsh.module.system.controller.admin.tenant.vo.tenant.TenantPageReqVO; +import com.viewsh.module.system.controller.admin.tenant.vo.tenant.TenantSaveReqVO; +import com.viewsh.module.system.convert.tenant.TenantConvert; +import com.viewsh.module.system.dal.dataobject.permission.MenuDO; +import com.viewsh.module.system.dal.dataobject.permission.RoleDO; +import com.viewsh.module.system.dal.dataobject.tenant.TenantDO; +import com.viewsh.module.system.dal.dataobject.tenant.TenantPackageDO; +import com.viewsh.module.system.dal.mysql.tenant.TenantMapper; +import com.viewsh.module.system.enums.permission.RoleCodeEnum; +import com.viewsh.module.system.enums.permission.RoleTypeEnum; +import com.viewsh.module.system.service.permission.MenuService; +import com.viewsh.module.system.service.permission.PermissionService; +import com.viewsh.module.system.service.permission.RoleService; +import com.viewsh.module.system.service.project.ProjectService; +import com.viewsh.module.system.service.tenant.handler.TenantInfoHandler; +import com.viewsh.module.system.service.tenant.handler.TenantMenuHandler; +import com.viewsh.module.system.service.user.AdminUserService; +import com.baomidou.dynamic.datasource.annotation.DSTransactional; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.system.enums.ErrorCodeConstants.*; +import static java.util.Collections.singleton; + +/** + * 租户 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class TenantServiceImpl implements TenantService { + + @SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection") + @Autowired(required = false) // 由于 viewsh.tenant.enable 配置项,可以关闭多租户的功能,所以这里只能不强制注入 + private TenantProperties tenantProperties; + + @Resource + private TenantMapper tenantMapper; + + @Resource + private TenantPackageService tenantPackageService; + @Resource + @Lazy // 延迟,避免循环依赖报错 + private AdminUserService userService; + @Resource + private RoleService roleService; + @Resource + private MenuService menuService; + @Resource + private PermissionService permissionService; + @Resource + @Lazy // 延迟,避免循环依赖报错 + private ProjectService projectService; + + @Override + public List getTenantIdList() { + List tenants = tenantMapper.selectList(); + return CollectionUtils.convertList(tenants, TenantDO::getId); + } + + @Override + public void validTenant(Long id) { + TenantDO tenant = getTenant(id); + if (tenant == null) { + throw exception(TENANT_NOT_EXISTS); + } + if (tenant.getStatus().equals(CommonStatusEnum.DISABLE.getStatus())) { + throw exception(TENANT_DISABLE, tenant.getName()); + } + if (DateUtils.isExpired(tenant.getExpireTime())) { + throw exception(TENANT_EXPIRE, tenant.getName()); + } + } + + @Override + @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换 + @DataPermission(enable = false) // 参见 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1154 说明 + public Long createTenant(TenantSaveReqVO createReqVO) { + // 校验租户名称是否重复 + validTenantNameDuplicate(createReqVO.getName(), null); + // 校验租户域名是否重复 + validTenantWebsiteDuplicate(createReqVO.getWebsites(), null); + // 校验套餐被禁用 + TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId()); + + // 创建租户 + TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class); + tenantMapper.insert(tenant); + // 创建租户的管理员 + TenantUtils.execute(tenant.getId(), () -> { + // 创建角色 + Long roleId = createRole(tenantPackage); + // 创建用户,并分配角色 + Long userId = createUser(roleId, createReqVO); + // 修改租户的管理员 + tenantMapper.updateById(new TenantDO().setId(tenant.getId()).setContactUserId(userId)); + // 创建默认项目 + projectService.createDefaultProject(tenant.getId(), tenant.getName()); + }); + return tenant.getId(); + } + + private Long createUser(Long roleId, TenantSaveReqVO createReqVO) { + // 创建用户 + Long userId = userService.createUser(TenantConvert.INSTANCE.convert02(createReqVO)); + // 分配角色 + permissionService.assignUserRole(userId, singleton(roleId)); + return userId; + } + + private Long createRole(TenantPackageDO tenantPackage) { + // 创建角色 + RoleSaveReqVO reqVO = new RoleSaveReqVO(); + reqVO.setName(RoleCodeEnum.TENANT_ADMIN.getName()).setCode(RoleCodeEnum.TENANT_ADMIN.getCode()) + .setSort(0).setRemark("系统自动生成"); + Long roleId = roleService.createRole(reqVO, RoleTypeEnum.SYSTEM.getType()); + // 分配权限 + permissionService.assignRoleMenu(roleId, tenantPackage.getMenuIds()); + return roleId; + } + + @Override + @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换 + public void updateTenant(TenantSaveReqVO updateReqVO) { + // 校验存在 + TenantDO tenant = validateUpdateTenant(updateReqVO.getId()); + // 校验租户名称是否重复 + validTenantNameDuplicate(updateReqVO.getName(), updateReqVO.getId()); + // 校验租户域名是否重复 + validTenantWebsiteDuplicate(updateReqVO.getWebsites(), updateReqVO.getId()); + // 校验套餐被禁用 + TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(updateReqVO.getPackageId()); + + // 更新租户 + TenantDO updateObj = BeanUtils.toBean(updateReqVO, TenantDO.class); + tenantMapper.updateById(updateObj); + // 如果套餐发生变化,则修改其角色的权限 + if (ObjectUtil.notEqual(tenant.getPackageId(), updateReqVO.getPackageId())) { + updateTenantRoleMenu(tenant.getId(), tenantPackage.getMenuIds()); + } + } + + private void validTenantNameDuplicate(String name, Long id) { + TenantDO tenant = tenantMapper.selectByName(name); + if (tenant == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同名字的租户 + if (id == null) { + throw exception(TENANT_NAME_DUPLICATE, name); + } + if (!tenant.getId().equals(id)) { + throw exception(TENANT_NAME_DUPLICATE, name); + } + } + + private void validTenantWebsiteDuplicate(List websites, Long excludeId) { + if (CollUtil.isEmpty(websites)) { + return; + } + websites.forEach(website -> { + List tenants = tenantMapper.selectListByWebsite(website); + if (excludeId != null) { + tenants.removeIf(tenant -> tenant.getId().equals(excludeId)); + } + if (CollUtil.isNotEmpty(tenants)) { + throw exception(TENANT_WEBSITE_DUPLICATE, website); + } + }); + } + + @Override + @DSTransactional + public void updateTenantRoleMenu(Long tenantId, Set menuIds) { + TenantUtils.execute(tenantId, () -> { + // 获得所有角色 + List roles = roleService.getRoleList(); + roles.forEach(role -> Assert.isTrue(tenantId.equals(role.getTenantId()), "角色({}/{}) 租户不匹配", + role.getId(), role.getTenantId(), tenantId)); // 兜底校验 + // 重新分配每个角色的权限 + roles.forEach(role -> { + // 如果是租户管理员,重新分配其权限为租户套餐的权限 + if (Objects.equals(role.getCode(), RoleCodeEnum.TENANT_ADMIN.getCode())) { + permissionService.assignRoleMenu(role.getId(), menuIds); + log.info("[updateTenantRoleMenu][租户管理员({}/{}) 的权限修改为({})]", role.getId(), role.getTenantId(), menuIds); + return; + } + // 如果是其他角色,则去掉超过套餐的权限 + Set roleMenuIds = permissionService.getRoleMenuListByRoleId(role.getId()); + roleMenuIds = CollUtil.intersectionDistinct(roleMenuIds, menuIds); + permissionService.assignRoleMenu(role.getId(), roleMenuIds); + log.info("[updateTenantRoleMenu][角色({}/{}) 的权限修改为({})]", role.getId(), role.getTenantId(), roleMenuIds); + }); + }); + } + + @Override + public void deleteTenant(Long id) { + // 校验存在 + validateUpdateTenant(id); + // 删除 + tenantMapper.deleteById(id); + } + + @Override + public void deleteTenantList(List ids) { + // 1. 校验存在 + ids.forEach(this::validateUpdateTenant); + + // 2. 批量删除 + tenantMapper.deleteByIds(ids); + } + + private TenantDO validateUpdateTenant(Long id) { + TenantDO tenant = tenantMapper.selectById(id); + if (tenant == null) { + throw exception(TENANT_NOT_EXISTS); + } + // 内置租户,不允许删除 + if (isSystemTenant(tenant)) { + throw exception(TENANT_CAN_NOT_UPDATE_SYSTEM); + } + return tenant; + } + + @Override + public TenantDO getTenant(Long id) { + return tenantMapper.selectById(id); + } + + @Override + public PageResult getTenantPage(TenantPageReqVO pageReqVO) { + return tenantMapper.selectPage(pageReqVO); + } + + @Override + public TenantDO getTenantByName(String name) { + return tenantMapper.selectByName(name); + } + + @Override + public TenantDO getTenantByWebsite(String website) { + List tenants = tenantMapper.selectListByWebsite(website); + return CollUtil.getFirst(tenants); + } + + @Override + public Long getTenantCountByPackageId(Long packageId) { + return tenantMapper.selectCountByPackageId(packageId); + } + + @Override + public List getTenantListByPackageId(Long packageId) { + return tenantMapper.selectListByPackageId(packageId); + } + + @Override + public List getTenantListByStatus(Integer status) { + return tenantMapper.selectListByStatus(status); + } + + @Override + public void handleTenantInfo(TenantInfoHandler handler) { + // 如果禁用,则不执行逻辑 + if (isTenantDisable()) { + return; + } + // 获得租户 + TenantDO tenant = getTenant(TenantContextHolder.getRequiredTenantId()); + // 执行处理器 + handler.handle(tenant); + } + + @Override + public void handleTenantMenu(TenantMenuHandler handler) { + // 如果禁用,则不执行逻辑 + if (isTenantDisable()) { + return; + } + // 获得租户,然后获得菜单 + TenantDO tenant = getTenant(TenantContextHolder.getRequiredTenantId()); + Set menuIds; + if (isSystemTenant(tenant)) { // 系统租户,菜单是全量的 + menuIds = CollectionUtils.convertSet(menuService.getMenuList(), MenuDO::getId); + } else { + menuIds = tenantPackageService.getTenantPackage(tenant.getPackageId()).getMenuIds(); + } + // 执行处理器 + handler.handle(menuIds); + } + + private static boolean isSystemTenant(TenantDO tenant) { + return Objects.equals(tenant.getPackageId(), TenantDO.PACKAGE_ID_SYSTEM); + } + + private boolean isTenantDisable() { + return tenantProperties == null || Boolean.FALSE.equals(tenantProperties.getEnable()); + } + +}