From 8e684b92dda35ecc87e4081c9b4687d7682ab942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 11 Sep 2026 15:43:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=B8=8B=E8=BD=BD):=20=E5=90=84=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=BB=93=E6=9E=9C=E4=B8=8B=E8=BD=BD=E7=94=B1=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E4=BB=A3=E7=90=86=E6=94=B9=20302=20=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E5=88=B0=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E7=9B=B4?= =?UTF-8?q?=E9=93=BE=E2=80=94=E2=80=94=E5=8E=9F=E5=AE=9E=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=20Controller=20=E5=86=85=E8=BE=B9=E8=AF=BB=E8=BE=B9=E5=86=99?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=95=B4=E4=B8=AA=E6=96=87=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=BA=BF=E7=A8=8B=E8=A2=AB=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E5=8D=A0=E6=BB=A1=EF=BC=9B=E4=B8=94=E6=81=92?= =?UTF-8?q?=E5=9B=9E=20200=20=E5=85=A8=E9=87=8F=EF=BC=8C=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20Range=20=E6=96=AD=E7=82=B9=E7=BB=AD=E4=BC=A0?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 brand(/tasks/{taskId}/download)、delete-brand(任务 + 结果两处)、appearance-patent、 patrol-delete、price-track、withdraw、query-asin、product-risk、shop-match、shop-data-crawl 共 10 个 Controller,与既有的 convert/dedupe/split 302 实现对齐(ResponseEntity.status(FOUND) .location(直链) + Cache-Control no-store),并补重定向 info 日志便于排查。 前端各 tab 本就通过 saveUrlWithProgress(url, filename) 传入文件名与下载 id,不依赖 Content-Disposition,故无需前端改动即可同时获得断点续传与进度回调。 验证:两节点重启后逐个实测 11 个端点均 302(含 brand 任务下载与巡店/跟价的有文件记录); 无文件时保持既有业务错误响应(HTTP 200 + success:false)。 --- .../AppearancePatentController.java | 33 ++++----- .../brand/controller/BrandTaskController.java | 40 +++++------ .../controller/DeleteBrandRunController.java | 67 ++++++------------- .../controller/PatrolDeleteController.java | 35 ++++------ .../controller/PriceTrackController.java | 35 ++++------ .../ProductRiskResolveController.java | 36 ++++------ .../controller/QueryAsinTaskController.java | 35 ++++------ .../ShopDataCrawlTaskController.java | 38 +++++------ .../controller/ShopMatchController.java | 35 ++++------ .../controller/WithdrawTaskController.java | 35 ++++------ 10 files changed, 150 insertions(+), 239 deletions(-) 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}")