From 423bf3ec3f8a80c8c9ab32be35d68471b43b20d4 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 16 Apr 2026 23:35:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(tenant):=20=E5=AE=9E=E7=8E=B0=20ProjectSec?= =?UTF-8?q?urityWebFilter=20=E9=A1=B9=E7=9B=AE=E6=9D=83=E9=99=90=E9=9B=86?= =?UTF-8?q?=E5=90=88=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ProjectSecurityWebFilter: - 集合校验: user.authorizedProjectIds.contains(header.projectId) - 默认项目选择: DEFAULT编码 → 最小ID → 单项目自动选中 → 无授权403 - @ProjectIgnore URL 自动跳过 - 注册在 WebFilterOrderEnum.PROJECT_SECURITY_FILTER (-98) 框架层: - ProjectCommonApi: 新增 getAuthorizedProjectIds, getDefaultProjectId - ProjectFrameworkService: 新增授权查询 + Caffeine 缓存(60s/1000条) - ViewshTenantAutoConfiguration: 注册 Filter + 扫描 @ProjectIgnore 业务层: - ProjectService: 新增 getAuthorizedProjectIds, getDefaultProjectId - ProjectServiceImpl: 默认项目3级回退逻辑 - ProjectApiImpl: 实现 Feign 端点 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../biz/system/project/ProjectCommonApi.java | 9 + .../config/ViewshTenantAutoConfiguration.java | 42 +++++ .../security/ProjectSecurityWebFilter.java | 159 ++++++++++++++++++ .../core/service/ProjectFrameworkService.java | 23 +++ .../service/ProjectFrameworkServiceImpl.java | 32 ++++ .../system/api/project/ProjectApiImpl.java | 10 ++ .../service/project/ProjectService.java | 17 ++ .../service/project/ProjectServiceImpl.java | 28 +++ 8 files changed, 320 insertions(+) create mode 100644 viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java 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 index bcba968a..fa29f6b7 100644 --- 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 @@ -26,4 +26,13 @@ public interface ProjectCommonApi { @Parameter(name = "id", description = "项目编号", required = true, example = "1024") CommonResult validProject(@RequestParam("id") Long id); + @GetMapping(PREFIX + "/authorized-ids") + @Operation(summary = "获得用户授权的项目编号列表") + CommonResult> getAuthorizedProjectIds(@RequestParam("userId") Long userId); + + @GetMapping(PREFIX + "/default-id") + @Operation(summary = "获得用户的默认项目编号") + @Parameter(name = "userId", description = "用户编号", required = true) + CommonResult getDefaultProjectId(@RequestParam("userId") Long userId); + } 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 674606b4..eece03c3 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 @@ -19,6 +19,8 @@ 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.ProjectRedisCacheManager; import com.viewsh.framework.tenant.core.redis.TenantRedisCacheManager; +import com.viewsh.framework.tenant.core.aop.ProjectIgnore; +import com.viewsh.framework.tenant.core.security.ProjectSecurityWebFilter; import com.viewsh.framework.tenant.core.security.TenantSecurityWebFilter; import com.viewsh.framework.tenant.core.service.ProjectFrameworkService; import com.viewsh.framework.tenant.core.service.ProjectFrameworkServiceImpl; @@ -181,6 +183,46 @@ public class ViewshTenantAutoConfiguration { return registrationBean; } + @Bean + public FilterRegistrationBean projectSecurityWebFilter( + TenantProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + ProjectFrameworkService projectFrameworkService) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ProjectSecurityWebFilter(webProperties, tenantProperties, + getProjectIgnoreUrls(), globalExceptionHandler, projectFrameworkService)); + registrationBean.setOrder(WebFilterOrderEnum.PROJECT_SECURITY_FILTER); + return registrationBean; + } + + /** + * 如果 Controller 接口上,有 {@link ProjectIgnore} 注解,则添加到忽略项目的 URL 集合中 + * + * @return 忽略项目的 URL 集合 + */ + private Set getProjectIgnoreUrls() { + Set ignoreUrls = new HashSet<>(); + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + for (Map.Entry entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(ProjectIgnore.class) + && !handlerMethod.getBeanType().isAnnotationPresent(ProjectIgnore.class)) { + continue; + } + 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; + } + /** * 如果 Controller 接口上,有 {@link TenantIgnore} 注解,则添加到忽略租户的 URL 集合中 * diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java new file mode 100644 index 00000000..83725f0d --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java @@ -0,0 +1,159 @@ +package com.viewsh.framework.tenant.core.security; + +import cn.hutool.core.collection.CollUtil; +import com.viewsh.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.servlet.ServletUtils; +import com.viewsh.framework.security.core.LoginUser; +import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; +import com.viewsh.framework.tenant.config.TenantProperties; +import com.viewsh.framework.tenant.core.context.ProjectContextHolder; +import com.viewsh.framework.tenant.core.service.ProjectFrameworkService; +import com.viewsh.framework.web.config.WebProperties; +import com.viewsh.framework.web.core.filter.ApiRequestFilter; +import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +/** + * 项目 Security Web 过滤器 + * + * 与 TenantSecurityWebFilter 的「相等校验」不同,本过滤器执行「集合包含校验」: + * 用户授权的项目 ID 列表必须包含请求携带的项目 ID。 + * + * 主要职责: + * 1. 已登录用户未携带 project-id 时,按默认项目选择逻辑自动填充上下文。 + * 2. 已登录用户携带 project-id 时,校验用户是否拥有该项目的访问权限。 + * 3. 校验项目合法性(启用状态、是否过期等)。 + * + * @author lzh + */ +@Slf4j +public class ProjectSecurityWebFilter extends ApiRequestFilter { + + private final TenantProperties tenantProperties; + + /** + * 允许忽略项目校验的 URL 列表(来自 @ProjectIgnore 注解扫描) + */ + private final Set ignoreUrls; + + private final AntPathMatcher pathMatcher; + + private final GlobalExceptionHandler globalExceptionHandler; + private final ProjectFrameworkService projectFrameworkService; + + public ProjectSecurityWebFilter(WebProperties webProperties, + TenantProperties tenantProperties, + Set ignoreUrls, + GlobalExceptionHandler globalExceptionHandler, + ProjectFrameworkService projectFrameworkService) { + super(webProperties); + this.tenantProperties = tenantProperties; + this.ignoreUrls = ignoreUrls; + this.pathMatcher = new AntPathMatcher(); + this.globalExceptionHandler = globalExceptionHandler; + this.projectFrameworkService = projectFrameworkService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Long projectId = ProjectContextHolder.getProjectId(); + // 1. 未登录用户,跳过项目校验(租户校验由 TenantSecurityWebFilter 已处理) + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) { + chain.doFilter(request, response); + return; + } + + // 2. 忽略项目校验的 URL,直接放行;若未携带 project-id 则标记忽略,避免下游报错 + if (isIgnoreUrl(request)) { + if (projectId == null) { + ProjectContextHolder.setIgnore(true); + } + chain.doFilter(request, response); + return; + } + + // 3. 请求未携带 project-id,执行默认项目选择逻辑 + if (projectId == null) { + try { + projectId = selectDefaultProject(user.getId()); + if (projectId == null) { + // 用户无任何授权项目,拒绝访问 + log.error("[doFilterInternal][用户({}) 无任何授权项目,URL({}/{})]", + user.getId(), request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error( + GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您未被授权访问任何项目")); + return; + } + ProjectContextHolder.setProjectId(projectId); + } catch (Throwable ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } else { + // 4. 请求已携带 project-id,校验用户是否有权限访问该项目 + List authorizedProjectIds = projectFrameworkService.getAuthorizedProjectIds(user.getId()); + if (!authorizedProjectIds.contains(projectId)) { + log.error("[doFilterInternal][用户({}) 无权访问项目({}),URL({}/{})]", + user.getId(), projectId, request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error( + GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您未被授权访问该项目")); + return; + } + // 5. 校验项目是否合法(启用、未过期等) + try { + projectFrameworkService.validProject(projectId); + } catch (Throwable ex) { + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + + chain.doFilter(request, response); + } + + /** + * 默认项目选择逻辑,委托给系统模块实现: + * 1. 取用户授权项目中标记为 DEFAULT 的项目 + * 2. 若无 DEFAULT,取最小 ID 的项目 + * 3. 若无任何授权项目,返回 null + */ + private Long selectDefaultProject(Long userId) { + return projectFrameworkService.getDefaultProjectId(userId); + } + + private boolean isIgnoreUrl(HttpServletRequest request) { + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); + // 快速精确匹配,保证性能 + if (CollUtil.contains(tenantProperties.getIgnoreProjectUrls(), apiUri) + || CollUtil.contains(ignoreUrls, apiUri)) { + return true; + } + // 逐个 Ant 路径匹配 + for (String url : tenantProperties.getIgnoreProjectUrls()) { + if (pathMatcher.match(url, apiUri)) { + return true; + } + } + for (String url : ignoreUrls) { + if (pathMatcher.match(url, apiUri)) { + return true; + } + } + return false; + } + +} 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 index 537d522f..746df824 100644 --- 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 @@ -23,4 +23,27 @@ public interface ProjectFrameworkService { */ void validProject(Long id); + /** + * 获得用户授权的项目编号列表(带缓存) + * + * @param userId 用户编号 + * @return 项目编号列表 + */ + List getAuthorizedProjectIds(Long userId); + + /** + * 获得用户的默认项目编号 + * + * @param userId 用户编号 + * @return 默认项目编号,若无授权项目则返回 null + */ + Long getDefaultProjectId(Long userId); + + /** + * 清除用户的授权项目缓存(授权变更时调用) + * + * @param userId 用户编号 + */ + void invalidateAuthorizedProjectCache(Long userId); + } 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 index 04aec254..ed0f2aa5 100644 --- 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 @@ -50,6 +50,22 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService { }); + /** + * 针对 {@link #getAuthorizedProjectIds(Long)} 的缓存 + * Key: userId, Value: List<Long> projectIds + * TTL: 60秒 + */ + private final LoadingCache> authorizedProjectCache = buildAsyncReloadingCache( + Duration.ofSeconds(60L), // 过期时间 60 秒 + new CacheLoader>() { + + @Override + public List load(Long userId) { + return projectApi.getAuthorizedProjectIds(userId).getCheckedData(); + } + + }); + @Override @SneakyThrows public List getProjectIds() { @@ -62,4 +78,20 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService { validProjectCache.get(id).checkError(); } + @Override + @SneakyThrows + public List getAuthorizedProjectIds(Long userId) { + return authorizedProjectCache.get(userId); + } + + @Override + public Long getDefaultProjectId(Long userId) { + return projectApi.getDefaultProjectId(userId).getCheckedData(); + } + + @Override + public void invalidateAuthorizedProjectCache(Long userId) { + authorizedProjectCache.invalidate(userId); + } + } 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 index cfc4047d..20111d8b 100644 --- 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 @@ -31,4 +31,14 @@ public class ProjectApiImpl implements ProjectCommonApi { return success(true); } + @Override + public CommonResult> getAuthorizedProjectIds(Long userId) { + return success(projectService.getAuthorizedProjectIds(userId)); + } + + @Override + public CommonResult getDefaultProjectId(Long userId) { + return success(projectService.getDefaultProjectId(userId)); + } + } 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 index 377d48e8..9139a473 100644 --- 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 @@ -93,4 +93,21 @@ public interface ProjectService { */ Long createDefaultProject(Long tenantId, String tenantName); + /** + * 获得用户授权的项目编号列表 + * + * @param userId 用户编号 + * @return 项目编号列表 + */ + List getAuthorizedProjectIds(Long userId); + + /** + * 获得用户的默认项目编号 + * 逻辑:1.找 DEFAULT 编码 → 2.取最小 ID → 3.无授权返回 null + * + * @param userId 用户编号 + * @return 默认项目编号,无授权返回 null + */ + Long getDefaultProjectId(Long userId); + } 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 index 3494fe11..3da910f3 100644 --- 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 @@ -1,5 +1,6 @@ package com.viewsh.module.system.service.project; +import cn.hutool.core.collection.CollUtil; import com.viewsh.framework.common.enums.CommonStatusEnum; import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.common.util.collection.CollectionUtils; @@ -138,6 +139,33 @@ public class ProjectServiceImpl implements ProjectService { return project.getId(); } + @Override + public List getAuthorizedProjectIds(Long userId) { + List userProjects = userProjectMapper.selectListByUserId(userId); + return CollectionUtils.convertList(userProjects, UserProjectDO::getProjectId); + } + + @Override + public Long getDefaultProjectId(Long userId) { + List authorizedProjectIds = getAuthorizedProjectIds(userId); + if (CollUtil.isEmpty(authorizedProjectIds)) { + return null; + } + // 1. 只有一个项目,直接返回 + if (authorizedProjectIds.size() == 1) { + return authorizedProjectIds.get(0); + } + // 2. 查找 DEFAULT 项目 + for (Long projectId : authorizedProjectIds) { + ProjectDO project = projectMapper.selectById(projectId); + if (project != null && ProjectDO.CODE_DEFAULT.equals(project.getCode())) { + return project.getId(); + } + } + // 3. 取最小 ID + return authorizedProjectIds.stream().min(Long::compareTo).orElse(null); + } + private void validateProjectExists(Long id) { if (projectMapper.selectById(id) == null) { throw exception(PROJECT_NOT_EXISTS);