From 8672668c337502ae2e09b9430d27874ddd22259d Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 12 Jun 2026 13:40:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E4=B8=8B=E8=BD=BD=E4=B8=BA?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 7 +++ .../vo/AppearancePatentHistoryItemVo.java | 2 +- .../brand/service/BrandTaskService.java | 4 +- .../service/CollectDataService.java | 6 ++- .../file/service/oss/OssStorageService.java | 49 ++++++++++--------- .../model/vo/PriceTrackResultItemVo.java | 2 +- .../ProductRiskResolveController.java | 13 +++-- .../model/vo/ProductRiskResultItemVo.java | 2 +- .../ProductRiskExcelAssemblyService.java | 2 +- .../service/ProductRiskTaskService.java | 2 + .../model/vo/SimilarAsinHistoryItemVo.java | 2 +- .../service/SimilarAsinTaskService.java | 6 ++- .../brand/components/BrandProductRiskTab.vue | 2 +- 13 files changed, 62 insertions(+), 37 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java index 8abbc59..123dfd4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java @@ -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> 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 handleException(Exception ex) { if (isClientAbort(ex)) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java index 4cf8ffc..25587c0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java index d65a581..056c976 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java @@ -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 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)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index fb78aec..e7d4a2f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index d7ba8bf..9c2bc8e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -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()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java index 0e3b3d7..35063af 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java index b7cce9e..b67d288 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java @@ -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]; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java index 26085b8..96213cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java index f678dc5..3beb2f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java @@ -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()); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index 0dddb2b..433f053 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -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() .eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); fileTaskMapper.deleteById(taskId); cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY"); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java index d920458..1ccd3a2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java @@ -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; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index aa391ca..93be949 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -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; } diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue index 2839568..e503e5f 100644 --- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue @@ -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}`)