处理下载为公共模块
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 生成预签名下载 URL(1小时有效)。
|
||||
* 根据 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,兼容两种格式:
|
||||
* - 旧格式:完整预签名 URL(https://bucket.endpoint/objectKey?Expires=...)
|
||||
* - 旧格式:完整 URL(https://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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user