fix(下载): 各模块结果下载由流式代理改 302 重定向到对象存储直链——原实现在 Controller 内边读边写代理整个文件,请求线程被下载时长占满;且恒回 200 全量,客户端 Range 断点续传实际失效

覆盖 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)。
This commit is contained in:
2026-09-11 15:43:46 +08:00
parent d54dbf72c9
commit 8e684b92dd
10 changed files with 150 additions and 239 deletions
@@ -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<Void> 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();
}
}
@@ -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_paths302 重定向到对象存储直链"
)
@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<Void> 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();
}
}
@@ -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<Void> 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<Void> 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();
}
}
@@ -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<Void> 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}")
@@ -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<Void> 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();
}
}
@@ -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<Void> 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();
}
}
@@ -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<Void> 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}")
@@ -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<Void> 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}")
@@ -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<Void> 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();
}
}
@@ -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<Void> 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}")