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:
+13
-20
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+15
-25
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
+20
-47
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-21
@@ -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}")
|
||||
|
||||
+14
-21
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+14
-22
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+14
-21
@@ -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}")
|
||||
|
||||
+18
-20
@@ -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}")
|
||||
|
||||
+14
-21
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+14
-21
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user