处理下载为公共模块

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.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@@ -70,6 +71,12 @@ public class GlobalExceptionHandler {
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)
public ApiResponse<Void> handleException(Exception ex) {
if (isClientAbort(ex)) {

View File

@@ -14,7 +14,7 @@ public class AppearancePatentHistoryItemVo {
private String sourceFilename;
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
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 Long fileJobId;
private String fileStatus;

View File

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

View File

@@ -1381,9 +1381,11 @@ public class CollectDataService {
return null;
}
try {
return ossStorageService.generateFreshDownloadUrl(resultFileUrl);
// bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL避免签名 1 小时过期后下载 403。
// resolveObjectKey 兼容历史存量的旧格式(带签名的完整 URL统一还原为纯 objectKey 再拼公开 URL。
return ossStorageService.getPublicUrl(ossStorageService.resolveObjectKey(resultFileUrl));
} 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());
return null;
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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;
@@ -243,13 +244,19 @@ public class ProductRiskResolveController {
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = productRiskTaskService.resolveResultDownloadFilename(resultId, 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/octet-stream");
response.setContentType("application/zip");
DownloadHeaderUtil.setAttachment(response, filename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];

View File

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

View File

@@ -51,7 +51,7 @@ public class ProductRiskExcelAssemblyService {
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
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, 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())) {
throw new BusinessException("record not found");
}
taskFileJobService.deleteResultJobs(latestEntity.getTaskId(), MODULE_TYPE, latestEntity.getId());
fileResultMapper.deleteById(resultId);
reconcileTaskAfterResultRemoval(taskId);
}
@@ -275,6 +276,7 @@ public class ProductRiskTaskService {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE);
fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
}

View File

@@ -14,7 +14,7 @@ public class SimilarAsinHistoryItemVo {
private String sourceFilename;
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
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 Long fileJobId;
private String fileStatus;

View File

@@ -5028,9 +5028,11 @@ public class SimilarAsinTaskService {
return null;
}
try {
return ossStorageService.generateFreshDownloadUrl(resultFileUrl);
// bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL避免签名 1 小时过期后下载 403。
// resolveObjectKey 兼容历史存量的旧格式(带签名的完整 URL统一还原为纯 objectKey 再拼公开 URL。
return ossStorageService.getPublicUrl(ossStorageService.resolveObjectKey(resultFileUrl));
} 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());
return null;
}

View File

@@ -982,7 +982,7 @@ function canDownload(item: ProductRiskHistoryItem) {
async function downloadResult(item: ProductRiskHistoryItem) {
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 result = await saveUrlWithProgress(url, filename, `product-risk:${item.resultId}`)