diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index d20d12af..897c89cc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.appearancepatent.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedGroupPageDto; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto; @@ -17,7 +16,10 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,7 +30,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; @@ -36,6 +37,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/appearance-patent") @Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 LLM 检测、补齐子行、生成最终 xlsx 并上传 OSS。") public class AppearancePatentController { @@ -156,30 +158,21 @@ public class AppearancePatentController { @GetMapping("/results/{resultId}/download") @Operation(summary = "下载外观专利检测结果文件") - public void downloadResult( + public ResponseEntity downloadResult( @Parameter(description = "结果记录 ID", required = true, example = "1001") @PathVariable Long resultId, @Parameter(description = "当前用户 ID", required = true, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = service.resolveResultDownloadUrl(resultId, userId); - String filename = service.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件常见百 MB 级:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[外观专利] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java index 6e0a131e..e3f8459c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.brand.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultRequest; import com.nanri.aiimage.modules.brand.model.dto.BrandTaskCreateRequest; import com.nanri.aiimage.modules.brand.model.vo.BrandCrawlPayloadVo; @@ -17,7 +16,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -28,11 +29,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.InputStream; import java.net.URI; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/brand") @Tag(name = "品牌任务", description = "品牌任务接口。Java 端只负责下载并解析 OSS 文件、返回待爬取数据、接收爬取结果并生成 xlsx/上传 OSS/落旧表,不负责实际爬虫执行。") public class BrandTaskController { @@ -206,32 +207,21 @@ public class BrandTaskController { @GetMapping("/tasks/{taskId}/download") @Operation( summary = "下载品牌任务结果", - description = "根据任务 ID 读取 brand_crawl_tasks.result_paths,后端代理拉取 OSS 文件并直接返回给浏览器,避免跨域问题。" + description = "根据任务 ID 读取 brand_crawl_tasks.result_paths,302 重定向到对象存储直链。" ) @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "文件流(application/zip)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "重定向到结果文件直链"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在或无结果可下载") }) - public void download( - @Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId, - HttpServletResponse response) throws Exception { + public ResponseEntity download( + @Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) { String ossUrl = brandTaskService.resolveDownloadUrl(taskId); - // 从 OSS URL 路径中取文件名 - String rawPath = URI.create(ossUrl).getPath(); - String filename = rawPath.substring(rawPath.lastIndexOf('/') + 1); - if (filename.isBlank()) { - filename = "brand_task_" + taskId + ".zip"; - } - response.setContentType("application/zip"); - DownloadHeaderUtil.setAttachment(response, filename); - // 后端代理拉取 OSS 文件并流式写出 - try (InputStream in = URI.create(ossUrl).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } + // 结果 zip 可能较大:改为 302 重定向到对象存储直链, + // 避免 Tomcat 请求线程被整个下载时长占满,并让客户端 Range 断点续传生效。 + log.info("[品牌任务] 结果文件下载重定向 任务ID={}", taskId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(ossUrl)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index d2465e02..2baaea4b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.deletebrand.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.exception.BusinessException; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandTaskBatchRequest; @@ -20,7 +19,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -31,7 +33,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; @@ -40,6 +41,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/delete-brand") @Tag(name = "删除品牌", description = "解析固定格式 Excel 的删除ASIN数据(暂不联动紫鸟)。") public class DeleteBrandRunController { @@ -137,65 +139,36 @@ public class DeleteBrandRunController { } @GetMapping("/tasks/{taskId}/download") - @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url_new 直接保存。") - public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) { + @Operation(summary = "下载删除品牌任务结果", description = "302 重定向到任务结果文件的对象存储直链,便于桌面端通过 save_file_from_url_new 直接保存。") + public ResponseEntity download(@PathVariable Long taskId) { String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId); - String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "任务无可下载结果"); } - if (filename == null || filename.isBlank()) { - String rawPath = URI.create(url).getPath(); - filename = rawPath == null ? "delete-brand_" + taskId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1); - } - - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[删除品牌] 任务结果下载重定向 任务ID={}", taskId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } @GetMapping("/results/{resultId}/download") @Operation(summary = "下载单个删除品牌结果") - public void downloadResult( + public ResponseEntity downloadResult( @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId); - String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "结果无可下载"); } - if (filename == null || filename.isBlank()) { - String rawPath = URI.create(url).getPath(); - filename = rawPath == null ? "delete-brand_" + resultId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1); - } - - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + log.info("[删除品牌] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java index a75dd21d..09ea9893 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.patroldelete.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteConditionAddRequest; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest; @@ -26,7 +25,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -37,7 +39,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -46,6 +47,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/patrol-delete") @Tag( name = "巡店删除", @@ -356,32 +358,23 @@ public class PatrolDeleteController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的巡店删除 Excel 文件。") - public void downloadResult( + @Operation(summary = "下载结果文件", description = "按结果记录 302 重定向到服务端已生成并上传到 OSS 的巡店删除 Excel 直链。") + public ResponseEntity downloadResult( @Parameter(description = "结果记录主键", example = "4599") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = patrolDeleteTaskService.resolveResultDownloadUrl(resultId, userId); - String filename = patrolDeleteTaskService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[巡店删除] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } @DeleteMapping("/tasks/{taskId}") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index 6c932091..b35c4bb6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.pricetrack.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest; @@ -33,7 +32,10 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -45,7 +47,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -54,6 +55,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/price-track") @Tag( name = "跟价", @@ -298,30 +300,21 @@ public class PriceTrackController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载结果文件", description = "按结果记录下载后端生成的跟价 Excel 文件。") - public void downloadResult( + @Operation(summary = "下载结果文件", description = "按结果记录 302 重定向到后端生成的跟价 Excel 直链。") + public ResponseEntity downloadResult( @Parameter(description = "结果记录主键 biz_file_result.id", example = "1001") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = priceTrackTaskService.resolveResultDownloadUrl(resultId, userId); - String filename = priceTrackTaskService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[跟价] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java index 045b90a1..bb182c92 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java @@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.productrisk.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.exception.BusinessException; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest; @@ -28,7 +27,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -40,7 +42,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -49,6 +50,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/product-risk-resolve") @Tag( name = "商品风险解决", @@ -248,36 +250,26 @@ public class ProductRiskResolveController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载单个店铺的 zip 结果", description = "从 OSS 拉取 zip 并以附件流返回;需校验 user_id 归属。") - public void downloadResult( + @Operation(summary = "下载单个店铺的 zip 结果", description = "302 重定向到 OSS 结果 zip 直链;需校验 user_id 归属。") + public ResponseEntity downloadResult( @Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url; - String filename; try { url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId); - filename = productRiskTaskService.resolveResultDownloadFilename(resultId, userId); } catch (BusinessException ex) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage(), ex); } if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/zip"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果 zip 可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[商品风险] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java index c5b45c23..83fd7016 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.queryasin.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; @@ -24,7 +23,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -35,7 +37,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -44,6 +45,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/query-asin") @Tag( name = "查询ASIN", @@ -265,32 +267,23 @@ public class QueryAsinTaskController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询 ASIN Excel 文件。") - public void downloadResult( + @Operation(summary = "下载结果文件", description = "按结果记录 302 重定向到服务端已生成并上传到 OSS 的查询 ASIN Excel 直链。") + public ResponseEntity downloadResult( @Parameter(description = "结果记录主键", example = "4599") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = queryAsinTaskService.resolveResultDownloadUrl(resultId, userId); - String filename = queryAsinTaskService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[查询ASIN] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } @DeleteMapping("/tasks/{taskId}") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java index cf88c21c..a8cd32d2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.shopdatacrawl.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; @@ -26,11 +25,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.util.List; import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; @@ -38,6 +39,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/shop-data-crawl") @Tag( name = "店铺数据抓取", @@ -264,31 +266,27 @@ public class ShopDataCrawlTaskController { @GetMapping("/results/{resultId}/download") @Operation( summary = "下载抓取结果 Excel", - description = "校验 resultId 属于当前 user_id 后,从对象存储读取最终 XLSX 并以附件返回。结果文件异步生成,只有列表中的 fileReady=true 后才能下载。", + description = "校验 resultId 属于当前 user_id 后,302 重定向到对象存储的最终 XLSX 直链。结果文件异步生成,只有列表中的 fileReady=true 后才能下载。", responses = @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "店铺数据抓取结果 Excel 文件", - content = @Content( - mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - schema = @Schema(type = "string", format = "binary")))) - public void download( + responseCode = "302", + description = "重定向到结果文件直链")) + public ResponseEntity download( @Parameter(description = "结果记录主键", required = true, example = "1001") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前用户 ID,用于校验结果归属", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = taskService.resolveResultDownloadUrl(resultId, userId); - String filename = taskService.resolveResultDownloadFilename(resultId, userId); - try { - response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream input = URI.create(url).toURL().openStream()) { - input.transferTo(response.getOutputStream()); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[店铺数据采集] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } @DeleteMapping("/tasks/{taskId}") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java index 4fde8430..ebab808a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.shopmatch.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; @@ -25,7 +24,10 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -37,7 +39,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -46,6 +47,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/shop-match") @Tag(name = "匹配店铺", description = "店铺候选、国家顺序、批量匹配、定时任务、Python 回传结果与下载接口。") public class ShopMatchController { @@ -212,29 +214,20 @@ public class ShopMatchController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载结果文件", description = "按结果记录下载后端已生成的 Excel 文件。") - public void downloadResult( + @Operation(summary = "下载结果文件", description = "按结果记录 302 重定向到后端已生成的 Excel 直链。") + public ResponseEntity downloadResult( @Parameter(description = "结果记录主键", example = "1001") @PathVariable Long resultId, - @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId) { String url = shopMatchTaskService.resolveResultDownloadUrl(resultId, userId); - String filename = shopMatchTaskService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No downloadable result"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Download failed"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[匹配店铺] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java index 585cae1d..1f587d3c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java @@ -1,7 +1,6 @@ package com.nanri.aiimage.modules.withdraw.controller; import com.nanri.aiimage.common.api.ApiResponse; -import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; @@ -22,7 +21,10 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,7 +35,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; @@ -42,6 +43,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/api/withdraw") @Tag(name = "取款任务", description = "店铺取款任务模块:维护待取款店铺、匹配紫鸟店铺、创建任务、接收 Python 回传、查询进度和下载结果文件。") public class WithdrawTaskController { @@ -137,32 +139,23 @@ public class WithdrawTaskController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载取款结果文件", description = "按结果记录 ID 下载后端组装好的取款 Excel 文件。只有文件组装完成后才有可下载地址。") - public void downloadResult( + @Operation(summary = "下载取款结果文件", description = "按结果记录 ID 302 重定向到后端组装好的取款 Excel 直链。只有文件组装完成后才有可下载地址。") + public ResponseEntity downloadResult( @Parameter(description = "结果记录 ID,即 history/items 中的 resultId。", required = true, example = "1001") @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1") - @RequestParam("user_id") Long userId, - jakarta.servlet.http.HttpServletResponse response) { + @RequestParam("user_id") Long userId) { String url = withdrawTaskService.resolveResultDownloadUrl(resultId, userId); - String filename = withdrawTaskService.resolveResultDownloadFilename(resultId, userId); if (url == null || url.isBlank()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); } - try { - response.setContentType("application/octet-stream"); - DownloadHeaderUtil.setAttachment(response, filename); - try (InputStream in = URI.create(url).toURL().openStream()) { - byte[] buffer = new byte[65536]; - int read; - while ((read = in.read(buffer)) != -1) { - response.getOutputStream().write(buffer, 0, read); - } - response.getOutputStream().flush(); - } - } catch (Exception ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); - } + // 结果文件可能较大:改为 302 重定向到对象存储直链,避免 Tomcat 请求线程被整个下载时长占满, + // 并让客户端 Range 断点续传生效。 + log.info("[取款] 结果文件下载重定向 结果ID={} 用户ID={}", resultId, userId); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(url)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } @DeleteMapping("/tasks/{taskId}")