处理下载为公共模块

This commit is contained in:
super
2026-06-12 13:40:29 +08:00
parent 17236265c6
commit 8672668c33
13 changed files with 62 additions and 37 deletions

View File

@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
@@ -70,6 +71,12 @@ public class GlobalExceptionHandler {
log.debug("Client disconnected before response completed: {}", ex.getMessage()); log.debug("Client disconnected before response completed: {}", ex.getMessage());
} }
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatusException(ResponseStatusException ex) {
String message = ex.getReason() == null || ex.getReason().isBlank() ? ex.getMessage() : ex.getReason();
return ResponseEntity.status(ex.getStatusCode()).body(ApiResponse.fail(message));
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) { public ApiResponse<Void> handleException(Exception ex) {
if (isClientAbort(ex)) { if (isClientAbort(ex)) {

View File

@@ -14,7 +14,7 @@ public class AppearancePatentHistoryItemVo {
private String sourceFilename; private String sourceFilename;
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx") @Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
private String resultFilename; private String resultFilename;
@Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的新鲜预签名 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/appearance_patent/xxx/17-result.xlsx?Expires=...") @Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的公开直链 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/appearance_patent/xxx/17-result.xlsx")
private String downloadUrl; private String downloadUrl;
private Long fileJobId; private Long fileJobId;
private String fileStatus; private String fileStatus;

View File

@@ -377,7 +377,7 @@ public class BrandTaskService {
if (stored == null || stored.isBlank()) { if (stored == null || stored.isBlank()) {
throw new BusinessException("无结果可下载"); throw new BusinessException("无结果可下载");
} }
// 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL // 统一通过 OssStorageService.generateFreshDownloadUrl 生成公开 OSS 直链
return ossStorageService.generateFreshDownloadUrl(stored); return ossStorageService.generateFreshDownloadUrl(stored);
} }
@@ -1037,7 +1037,7 @@ public class BrandTaskService {
} }
List<String> fullUrls = new ArrayList<>(); List<String> fullUrls = new ArrayList<>();
for (OutputEntry entry : entries) { for (OutputEntry entry : entries) {
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey // 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取 objectKey(见 OssStorageService.resolveObjectKey
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE); String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE);
fullUrls.add(ossStorageService.getPublicUrl(objectKey)); fullUrls.add(ossStorageService.getPublicUrl(objectKey));
} }

View File

@@ -1381,9 +1381,11 @@ public class CollectDataService {
return null; return null;
} }
try { try {
return ossStorageService.generateFreshDownloadUrl(resultFileUrl); // bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL避免签名 1 小时过期后下载 403。
// resolveObjectKey 兼容历史存量的旧格式(带签名的完整 URL统一还原为纯 objectKey 再拼公开 URL。
return ossStorageService.getPublicUrl(ossStorageService.resolveObjectKey(resultFileUrl));
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[collect-data] generate fresh download url failed resultId={} err={}", log.warn("[collect-data] build public download url failed resultId={} err={}",
row.getId(), ex.getMessage()); row.getId(), ex.getMessage());
return null; return null;
} }

View File

@@ -10,9 +10,8 @@ import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -23,8 +22,8 @@ public class OssStorageService {
private final OssProperties ossProperties; private final OssProperties ossProperties;
/** /**
* 上传结果文件到 OSS返回 objectKey(非预签名 URL * 上传结果文件到 OSS返回 objectKey。
* 调用方应存储 objectKey下载时通过 generateFreshDownloadUrl 按需生成链接 * 调用方应存储 objectKey下载时通过 generateFreshDownloadUrl 生成公开直链
*/ */
public String uploadResultFile(File file, String moduleType) { public String uploadResultFile(File file, String moduleType) {
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
@@ -42,9 +41,7 @@ public class OssStorageService {
OSS ossClient = buildClient(); OSS ossClient = buildClient();
try { try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file); ossClient.putObject(ossProperties.getBucket(), objectKey, file);
Date expiration = new Date(System.currentTimeMillis() + 3600_000L); return new UploadedResult(objectKey, getPublicUrl(objectKey));
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
return new UploadedResult(objectKey, url.toString());
} finally { } finally {
ossClient.shutdown(); ossClient.shutdown();
} }
@@ -113,32 +110,32 @@ public class OssStorageService {
} }
/** /**
* 根据 objectKey 生成预签名下载 URL1小时有效 * 根据 objectKey 生成公开直链下载 URL。
*/ */
public String generateDownloadUrl(String objectKey) { public String generateDownloadUrl(String objectKey) {
OSS ossClient = buildClient(); return getPublicUrl(objectKey);
try {
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
return url.toString();
} finally {
ossClient.shutdown();
}
} }
/** /**
* 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey} * 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey}
*/ */
public String getPublicUrl(String objectKey) { public String getPublicUrl(String value) {
if (objectKey == null || objectKey.isBlank()) { if (value == null || value.isBlank()) {
return null; return null;
} }
return String.format("https://%s.%s/%s", ossProperties.getBucket(), ossProperties.getEndpoint(), objectKey); String objectKey = resolveObjectKey(value);
String normalizedKey = objectKey.startsWith("/") ? objectKey.substring(1) : objectKey;
String host = String.format("%s.%s", ossProperties.getBucket(), ossProperties.getEndpoint());
try {
return new URI("https", host, "/" + normalizedKey, null).toASCIIString();
} catch (Exception ignored) {
return String.format("https://%s/%s", host, normalizedKey);
}
} }
/** /**
* 从存储值中解析出 objectKey兼容两种格式 * 从存储值中解析出 objectKey兼容两种格式
* - 旧格式:完整预签名 URLhttps://bucket.endpoint/objectKey?Expires=... * - 旧格式:完整 URLhttps://bucket.endpoint/objectKey?Expires=...
* - 新格式:直接是 objectKey如 result/dedupe/uuid/file.xlsx * - 新格式:直接是 objectKey如 result/dedupe/uuid/file.xlsx
*/ */
public String resolveObjectKey(String value) { public String resolveObjectKey(String value) {
@@ -151,13 +148,21 @@ public class OssStorageService {
return path.startsWith("/") ? path.substring(1) : path; return path.startsWith("/") ? path.substring(1) : path;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
int schemeEnd = value.indexOf("://");
int pathStart = schemeEnd < 0 ? -1 : value.indexOf('/', schemeEnd + 3);
if (pathStart >= 0 && pathStart + 1 < value.length()) {
String path = value.substring(pathStart + 1);
int queryStart = path.indexOf('?');
String objectKey = queryStart >= 0 ? path.substring(0, queryStart) : path;
return URLDecoder.decode(objectKey, StandardCharsets.UTF_8);
}
} }
return value; return value;
} }
/** /**
* 根据存储值objectKey 或旧格式预签名 URL生成新鲜的预签名下载 URL。 * 根据存储值objectKey 或旧格式完整 URL生成公开直链下载 URL。
* 供各模块 listHistory 使用,每次按需生成,避免旧 URL 1小时后过期。 * 供各模块 listHistory 和下载接口使用,避免签名过期。
*/ */
public String generateFreshDownloadUrl(String value) { public String generateFreshDownloadUrl(String value) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {

View File

@@ -50,7 +50,7 @@ public class PriceTrackResultItemVo {
@Schema(description = "生成的 xlsx 文件名") @Schema(description = "生成的 xlsx 文件名")
private String outputFilename; private String outputFilename;
@Schema(description = "预签名下载 URL列表可能带回也可通过下载接口获取") @Schema(description = "公开 OSS 直链下载 URL列表可能带回也可通过下载接口获取")
private String downloadUrl; private String downloadUrl;
private Long fileJobId; private Long fileJobId;
private String fileStatus; private String fileStatus;

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.productrisk.controller; package com.nanri.aiimage.modules.productrisk.controller;
import com.nanri.aiimage.common.api.ApiResponse; 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.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; 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.ProductRiskCountryPreferenceSaveRequest;
@@ -243,13 +244,19 @@ public class ProductRiskResolveController {
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId, @RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) { jakarta.servlet.http.HttpServletResponse response) {
String url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId); String url;
String filename = productRiskTaskService.resolveResultDownloadFilename(resultId, userId); 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()) { if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
} }
try { try {
response.setContentType("application/octet-stream"); response.setContentType("application/zip");
DownloadHeaderUtil.setAttachment(response, filename); DownloadHeaderUtil.setAttachment(response, filename);
try (InputStream in = URI.create(url).toURL().openStream()) { try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536]; byte[] buffer = new byte[65536];

View File

@@ -61,7 +61,7 @@ public class ProductRiskResultItemVo {
private String outputFilename; private String outputFilename;
@JsonProperty("downloadUrl") @JsonProperty("downloadUrl")
@Schema(description = "预签名下载 URL列表里可能带直链下载也可用 GET /results/{resultId}/download") @Schema(description = "公开 OSS 直链下载 URL列表里可能带也可用 GET /results/{resultId}/download")
private String downloadUrl; private String downloadUrl;
private Long fileJobId; private Long fileJobId;
private String fileStatus; private String fileStatus;

View File

@@ -51,7 +51,7 @@ public class ProductRiskExcelAssemblyService {
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName)); createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku()); createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
createTextCell(row, 2, dto == null ? null : dto.getStatus()); createTextCell(row, 2, dto == null ? null : dto.getStatus());
createTextCell(row, 3, dto == null || dto.getDone() == null ? null : String.valueOf(dto.getDone())); createTextCell(row, 3, dto == null || dto.getDone() == null ? null : (dto.getDone() ? "" : ""));
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin()); createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus()); createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
} }

View File

@@ -254,6 +254,7 @@ public class ProductRiskTaskService {
if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) {
throw new BusinessException("record not found"); throw new BusinessException("record not found");
} }
taskFileJobService.deleteResultJobs(latestEntity.getTaskId(), MODULE_TYPE, latestEntity.getId());
fileResultMapper.deleteById(resultId); fileResultMapper.deleteById(resultId);
reconcileTaskAfterResultRemoval(taskId); reconcileTaskAfterResultRemoval(taskId);
} }
@@ -275,6 +276,7 @@ public class ProductRiskTaskService {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>() fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)); .eq(FileResultEntity::getModuleType, MODULE_TYPE));
taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE);
fileTaskMapper.deleteById(taskId); fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY"); cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
} }

View File

@@ -14,7 +14,7 @@ public class SimilarAsinHistoryItemVo {
private String sourceFilename; private String sourceFilename;
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx") @Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
private String resultFilename; private String resultFilename;
@Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的新鲜预签名 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/similar_asin/xxx/17-result.xlsx?Expires=...") @Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的公开直链 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/similar_asin/xxx/17-result.xlsx")
private String downloadUrl; private String downloadUrl;
private Long fileJobId; private Long fileJobId;
private String fileStatus; private String fileStatus;

View File

@@ -5028,9 +5028,11 @@ public class SimilarAsinTaskService {
return null; return null;
} }
try { try {
return ossStorageService.generateFreshDownloadUrl(resultFileUrl); // bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL避免签名 1 小时过期后下载 403。
// resolveObjectKey 兼容历史存量的旧格式(带签名的完整 URL统一还原为纯 objectKey 再拼公开 URL。
return ossStorageService.getPublicUrl(ossStorageService.resolveObjectKey(resultFileUrl));
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[similar-asin] generate fresh download url failed resultId={} err={}", log.warn("[similar-asin] build public download url failed resultId={} err={}",
row.getId(), ex.getMessage()); row.getId(), ex.getMessage());
return null; return null;
} }

View File

@@ -982,7 +982,7 @@ function canDownload(item: ProductRiskHistoryItem) {
async function downloadResult(item: ProductRiskHistoryItem) { async function downloadResult(item: ProductRiskHistoryItem) {
if (!item.resultId) return if (!item.resultId) return
const url = getProductRiskResultDownloadUrl(item.resultId) const url = item.downloadUrl || getProductRiskResultDownloadUrl(item.resultId)
const filename = item.outputFilename || `${item.shopName || 'result'}.zip` const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
const result = await saveUrlWithProgress(url, filename, `product-risk:${item.resultId}`) const result = await saveUrlWithProgress(url, filename, `product-risk:${item.resultId}`)