diff --git a/zt-framework/zt-spring-boot-starter-job/pom.xml b/zt-framework/zt-spring-boot-starter-job/pom.xml index a3eab15c..380b7870 100644 --- a/zt-framework/zt-spring-boot-starter-job/pom.xml +++ b/zt-framework/zt-spring-boot-starter-job/pom.xml @@ -44,6 +44,10 @@ jakarta.validation jakarta.validation-api + + com.zt.plat + zt-spring-boot-starter-security + diff --git a/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/XxlJobProperties.java b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/XxlJobProperties.java index 01cfcc21..19aef4dd 100644 --- a/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/XxlJobProperties.java +++ b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/XxlJobProperties.java @@ -35,6 +35,11 @@ public class XxlJobProperties { @NotNull(message = "执行器配置不能为空") private ExecutorProperties executor; + /** + * 系统用户配置 + */ + private SystemUserProperties systemUser = new SystemUserProperties(); + /** * XXL-Job 调度器配置类 */ @@ -96,4 +101,37 @@ public class XxlJobProperties { } + /** + * XXL-Job 系统用户配置类 + */ + @Data + public static class SystemUserProperties { + + /** + * 系统用户 ID + */ + private Long userId = 0L; + + /** + * 系统用户昵称 + */ + private String nickname = "job"; + + /** + * 系统租户 ID + */ + private Long tenantId = 1L; + + /** + * 系统公司 ID(可选) + */ + private Long companyId; + + /** + * 系统部门 ID(可选) + */ + private Long deptId; + + } + } diff --git a/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/ZtXxlJobAutoConfiguration.java b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/ZtXxlJobAutoConfiguration.java index 31c5699e..0e6b4bb0 100644 --- a/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/ZtXxlJobAutoConfiguration.java +++ b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/config/ZtXxlJobAutoConfiguration.java @@ -1,5 +1,6 @@ package com.zt.plat.framework.quartz.config; +import com.zt.plat.framework.quartz.core.handler.XxlJobSystemAuthenticationAspect; import com.xxl.job.core.executor.XxlJobExecutor; import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; import lombok.extern.slf4j.Slf4j; @@ -9,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.scheduling.annotation.EnableScheduling; /** @@ -21,6 +23,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @ConditionalOnProperty(prefix = "xxl.job", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties({XxlJobProperties.class}) @EnableScheduling // 开启 Spring 自带的定时任务 +@EnableAspectJAutoProxy // 开启 AOP @Slf4j public class ZtXxlJobAutoConfiguration { @@ -43,4 +46,17 @@ public class ZtXxlJobAutoConfiguration { return xxlJobExecutor; } + /** + * 配置 XXL-Job 系统认证切面 + * + * 为 @XxlJob 注解的方法提供系统用户认证上下文 + */ + @Bean + @ConditionalOnMissingBean + public XxlJobSystemAuthenticationAspect xxlJobSystemAuthenticationAspect(XxlJobProperties properties) { + log.info("[ZtXxlJobAutoConfiguration][注册 XXL-Job 系统认证切面] systemUserId=[{}], systemTenantId=[{}]", + properties.getSystemUser().getUserId(), properties.getSystemUser().getTenantId()); + return new XxlJobSystemAuthenticationAspect(properties.getSystemUser()); + } + } diff --git a/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/core/handler/XxlJobSystemAuthenticationAspect.java b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/core/handler/XxlJobSystemAuthenticationAspect.java new file mode 100644 index 00000000..061a3f13 --- /dev/null +++ b/zt-framework/zt-spring-boot-starter-job/src/main/java/com/zt/plat/framework/quartz/core/handler/XxlJobSystemAuthenticationAspect.java @@ -0,0 +1,119 @@ +package com.zt.plat.framework.quartz.core.handler; + +import com.zt.plat.framework.common.enums.UserTypeEnum; +import com.zt.plat.framework.quartz.config.XxlJobProperties; +import com.zt.plat.framework.security.core.LoginUser; +import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils; +import com.zt.plat.framework.web.core.util.WebFrameworkUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * XXL-Job 系统认证切面 + * + * 为 @XxlJob 注解的方法提供系统用户认证上下文, + * 确保 Job 方法执行时能够获取到用户信息 + * + * @author ZT + */ +@Aspect +@RequiredArgsConstructor +@Slf4j +@Order(-100) // 设置较高的优先级,确保在其他切面之前执行 +public class XxlJobSystemAuthenticationAspect { + + private final XxlJobProperties.SystemUserProperties systemUserConfig; + + @Around("@annotation(com.xxl.job.core.handler.annotation.XxlJob)") + public Object around(ProceedingJoinPoint joinPoint) throws Throwable { + // 获取当前登录用户 + LoginUser currentUser = SecurityFrameworkUtils.getLoginUser(); + try { + // 如果当前没有登录用户,则设置系统用户上下文 + if (currentUser == null) { + LoginUser systemUser = createSystemUser(); + setLoginUserForJob(systemUser); + log.debug("[XxlJobSystemAuthenticationAspect][XXL-Job 方法执行,设置系统用户上下文] method=[{}], userId=[{}]", + joinPoint.getSignature().toShortString(), systemUser.getId()); + } else { + log.debug("[XxlJobSystemAuthenticationAspect][XXL-Job 方法执行,已存在用户上下文] method=[{}], userId=[{}]", + joinPoint.getSignature().toShortString(), currentUser.getId()); + } + + // 执行目标方法 + return joinPoint.proceed(); + + } + catch (Exception e) { + log.error("[XxlJobSystemAuthenticationAspect][XXL-Job 方法执行异常] method=[{}], error=[{}]", + joinPoint.getSignature().toShortString(), e.getMessage(), e); + throw e; + } + finally { + // 如果是我们设置的系统用户,执行完毕后清理上下文 + if (currentUser == null) { + clearLoginUserForJob(); + log.debug("[XxlJobSystemAuthenticationAspect][XXL-Job 方法执行完毕,清理系统用户上下文] method=[{}]", + joinPoint.getSignature().toShortString()); + } + } + } + + /** + * 创建 XXL-Job 系统用户 + */ + private LoginUser createSystemUser() { + LoginUser systemUser = new LoginUser(); + systemUser.setId(systemUserConfig.getUserId()); + systemUser.setUserType(UserTypeEnum.ADMIN.getValue()); + systemUser.setTenantId(systemUserConfig.getTenantId()); + systemUser.setVisitTenantId(systemUserConfig.getTenantId()); + systemUser.setExpiresTime(LocalDateTime.now().plusDays(1)); + + // 设置用户信息 + Map info = new HashMap<>(); + info.put(LoginUser.INFO_KEY_NICKNAME, systemUserConfig.getNickname()); + info.put(LoginUser.INFO_KEY_TENANT_ID, String.valueOf(systemUserConfig.getTenantId())); + + systemUser.setInfo(info); + + return systemUser; + } + + /** + * 为 Job 设置登录用户到 Spring Security 上下文和 Web 上下文 + */ + private void setLoginUserForJob(LoginUser loginUser) { + // 1. 设置到 Spring Security 上下文 + Authentication authentication = new UsernamePasswordAuthenticationToken( + loginUser, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 2. 设置到 Web 请求上下文,供 DefaultDBFieldHandler 使用 + HttpServletRequest request = WebFrameworkUtils.getRequest(); + if (request != null) { + WebFrameworkUtils.setLoginUserId(request, loginUser.getId()); + WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType()); + } + } + + /** + * 清理 Job 的登录用户上下文 + */ + private void clearLoginUserForJob() { + SecurityContextHolder.getContext().setAuthentication(null); + } +} \ No newline at end of file diff --git a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/handler/DefaultDBFieldHandler.java index 174eaca3..6a1acf08 100644 --- a/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/handler/DefaultDBFieldHandler.java +++ b/zt-framework/zt-spring-boot-starter-mybatis/src/main/java/com/zt/plat/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -47,7 +47,7 @@ public class DefaultDBFieldHandler implements MetaObjectHandler { baseDO.setUpdateTime(current); } - Long userId = WebFrameworkUtils.getLoginUserId(); + Long userId = getUserId(); String userNickname = SecurityFrameworkUtils.getLoginUserNickname(); // 当前登录用户不为空,创建人为空,则当前登录用户为创建人 if (Objects.nonNull(userId) && Objects.isNull(baseDO.getCreator())) { @@ -81,7 +81,7 @@ public class DefaultDBFieldHandler implements MetaObjectHandler { // 当前登录用户不为空,更新人为空,则当前登录用户为更新人 Object modifier = getFieldValByName("updater", metaObject); - Long userId = WebFrameworkUtils.getLoginUserId(); + Long userId = getUserId(); String userNickname = SecurityFrameworkUtils.getLoginUserNickname(); if (Objects.nonNull(userId) && Objects.isNull(modifier)) { setFieldValByName("updater", userId.toString(), metaObject); @@ -96,6 +96,15 @@ public class DefaultDBFieldHandler implements MetaObjectHandler { } } + private static Long getUserId() { + Long userId = WebFrameworkUtils.getLoginUserId(); + if (userId == null) { + // 如果不是 http 请求发起的操作,获取不到用户,从认证中获取 + userId = SecurityFrameworkUtils.getLoginUserId(); + } + return userId; + } + private void autoFillUserNames(BusinessBaseDO businessBaseDO) { String userNickname = SecurityFrameworkUtils.getLoginUserNickname(); if (Objects.nonNull(userNickname)) { diff --git a/zt-framework/zt-spring-boot-starter-web/src/main/java/com/zt/plat/framework/web/config/ZtWebAutoConfiguration.java b/zt-framework/zt-spring-boot-starter-web/src/main/java/com/zt/plat/framework/web/config/ZtWebAutoConfiguration.java index 8033681e..682f6160 100644 --- a/zt-framework/zt-spring-boot-starter-web/src/main/java/com/zt/plat/framework/web/config/ZtWebAutoConfiguration.java +++ b/zt-framework/zt-spring-boot-starter-web/src/main/java/com/zt/plat/framework/web/config/ZtWebAutoConfiguration.java @@ -88,6 +88,7 @@ public class ZtWebAutoConfiguration implements WebMvcConfigurer { config.addAllowedOriginPattern("*"); // 设置访问源地址 config.addAllowedHeader("*"); // 设置访问源请求头 config.addAllowedMethod("*"); // 设置访问源请求方法 + config.addExposedHeader("content-disposition"); // 暴露 content-disposition 头,用于文件下载 // 创建 UrlBasedCorsConfigurationSource 对象 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 diff --git a/zt-gateway/src/main/java/com/zt/plat/gateway/filter/cors/CorsFilter.java b/zt-gateway/src/main/java/com/zt/plat/gateway/filter/cors/CorsFilter.java index 547b4522..7cd7af21 100644 --- a/zt-gateway/src/main/java/com/zt/plat/gateway/filter/cors/CorsFilter.java +++ b/zt-gateway/src/main/java/com/zt/plat/gateway/filter/cors/CorsFilter.java @@ -37,6 +37,7 @@ public class CorsFilter implements WebFilter { headers.add("Access-Control-Allow-Origin", ALL); headers.add("Access-Control-Allow-Methods", ALL); headers.add("Access-Control-Allow-Headers", ALL); + headers.add("Access-Control-Expose-Headers", "content-disposition"); // 暴露 content-disposition 头,用于文件下载 headers.add("Access-Control-Max-Age", MAX_AGE); if (request.getMethod() == HttpMethod.OPTIONS) { response.setStatusCode(HttpStatus.OK); diff --git a/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/test/CorsTestController.java b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/test/CorsTestController.java new file mode 100644 index 00000000..c48e75dd --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/java/com/zt/plat/module/template/controller/admin/test/CorsTestController.java @@ -0,0 +1,140 @@ +package com.zt.plat.module.template.controller.admin.test; + +import com.zt.plat.framework.common.pojo.CommonResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +/** + * CORS 测试控制器 + * + * @author ZT + */ +@Tag(name = "管理后台 - CORS 测试") +@RestController +@RequestMapping("/template/test") +@Slf4j +public class CorsTestController { + + @GetMapping("/download") + @Operation(summary = "测试文件下载响应头", description = "测试 content-disposition 响应头是否能被前端获取") + public ResponseEntity testDownload() { + log.info("[testDownload] 测试 content-disposition 响应头"); + + // 创建响应头 + HttpHeaders headers = new HttpHeaders(); + + // 设置 content-disposition 头,包含文件名 + String filename = "测试文件.txt"; + String encodedFilename = new String(filename.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + headers.add("Content-Disposition", "attachment; filename*=UTF-8''" + + java.net.URLEncoder.encode(filename, StandardCharsets.UTF_8) + + "; filename=\"" + encodedFilename + "\""); + + // 设置其他常用的文件下载响应头 + headers.add("Content-Type", "application/octet-stream"); + headers.add("Cache-Control", "no-cache"); + + String responseBody = "这是一个测试文件的内容,用于验证 CORS 配置是否正确。\n" + + "如果前端能够获取到 content-disposition 头信息,说明配置成功。\n" + + "文件名应该是:" + filename; + + return ResponseEntity.ok() + .headers(headers) + .body(responseBody); + } + + @GetMapping("/cors-info") + @Operation(summary = "获取 CORS 配置信息", description = "返回当前 CORS 配置的相关信息") + public CommonResult getCorsInfo() { + log.info("[getCorsInfo] 获取 CORS 配置信息"); + + CorsInfo corsInfo = new CorsInfo(); + corsInfo.setExposedHeaders("content-disposition"); + corsInfo.setAllowedOrigins("*"); + corsInfo.setAllowedMethods("GET, POST, PUT, DELETE, OPTIONS"); + corsInfo.setAllowedHeaders("*"); + corsInfo.setAllowCredentials(true); + corsInfo.setMaxAge(3600L); + corsInfo.setMessage("CORS 配置已启用,content-disposition 头已暴露给前端"); + + return CommonResult.success(corsInfo); + } + + /** + * CORS 配置信息 + */ + public static class CorsInfo { + private String exposedHeaders; + private String allowedOrigins; + private String allowedMethods; + private String allowedHeaders; + private Boolean allowCredentials; + private Long maxAge; + private String message; + + // Getters and Setters + public String getExposedHeaders() { + return exposedHeaders; + } + + public void setExposedHeaders(String exposedHeaders) { + this.exposedHeaders = exposedHeaders; + } + + public String getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(String allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public String getAllowedMethods() { + return allowedMethods; + } + + public void setAllowedMethods(String allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public String getAllowedHeaders() { + return allowedHeaders; + } + + public void setAllowedHeaders(String allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public Boolean getAllowCredentials() { + return allowCredentials; + } + + public void setAllowCredentials(Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + + public Long getMaxAge() { + return maxAge; + } + + public void setMaxAge(Long maxAge) { + this.maxAge = maxAge; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + } +} \ No newline at end of file diff --git a/zt-module-template/zt-module-template-server/src/main/resources/static/cors-test.html b/zt-module-template/zt-module-template-server/src/main/resources/static/cors-test.html new file mode 100644 index 00000000..13255f4a --- /dev/null +++ b/zt-module-template/zt-module-template-server/src/main/resources/static/cors-test.html @@ -0,0 +1,206 @@ + + + + + + CORS content-disposition 测试 + + + + + CORS content-disposition 头测试 + + + 服务地址: + + + + + 测试1: 获取 CORS 配置信息 + 这个测试会调用接口获取当前的 CORS 配置信息 + 测试 CORS 配置 + + + + + 测试2: 文件下载响应头测试 + 这个测试会检查是否能获取到 content-disposition 响应头 + 测试下载响应头 + 直接下载文件 + + + + + 测试结果说明 + + 成功: 能够获取到 content-disposition 头,说明 CORS 配置正确 + 失败: 无法获取响应头,可能是 CORS 配置问题 + 请确保服务已启动,并且地址配置正确 + + + + + + + \ No newline at end of file
这个测试会调用接口获取当前的 CORS 配置信息
这个测试会检查是否能获取到 content-disposition 响应头