diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java index b4ad774..08dcb2e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/controller/BrandTaskController.java @@ -16,6 +16,7 @@ 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 org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -27,6 +28,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; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + @RestController @RequiredArgsConstructor @RequestMapping("/api/brand") @@ -191,17 +197,34 @@ public class BrandTaskController { @GetMapping("/tasks/{taskId}/download") @Operation( summary = "下载品牌任务结果", - description = "根据任务 ID 读取 brand_crawl_tasks.result_paths,优先跳转 zip_url;若没有 zip_url,则跳转第一个可用结果 URL。" + description = "根据任务 ID 读取 brand_crawl_tasks.result_paths,后端代理拉取 OSS 文件并直接返回给浏览器,避免跨域问题。" ) @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "重定向到 OSS 结果文件地址"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "文件流(application/zip)"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在或无结果可下载") }) - public ResponseEntity download( - @Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) { - String url = brandTaskService.resolveDownloadUrl(taskId); - return ResponseEntity.status(302) - .header(HttpHeaders.LOCATION, url) - .build(); + public void download( + @Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId, + HttpServletResponse response) throws Exception { + 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"; + } + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType("application/zip"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename); + // 后端代理拉取 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(); + } } } 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 39ed9f9..491fdc6 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 @@ -277,7 +277,11 @@ public class BrandTaskService { String originalFilename = resolveOriginalFilename(sourceFile, sourceLocalFile); File outputFile = buildNamedOutputFile(outputDir, buildResultFilename(originalFilename)); writeBrandWorkbook(outputFile, request.getStrategy(), cachedFile, resultFile); - outputEntries.add(new OutputEntry(sourceLocalFile, originalFilename, outputFile, outputFile.getName())); + outputEntries.add(new OutputEntry( + sourceLocalFile, + originalFilename, + outputFile, + originalFilename)); } brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_UPLOADING, finishedCount, totalCount); @@ -338,18 +342,24 @@ public class BrandTaskService { if (!(raw instanceof Map map)) { throw new BusinessException("无结果可下载"); } + String stored = null; Object zipUrl = map.get("zip_url"); if (zipUrl instanceof String zip && !zip.isBlank()) { - return zip; - } - Object urls = map.get("urls"); - if (urls instanceof List list && !list.isEmpty()) { - Object first = list.get(0); - if (first instanceof String url && !url.isBlank()) { - return url; + stored = zip; + } else { + Object urls = map.get("urls"); + if (urls instanceof List list && !list.isEmpty()) { + Object first = list.get(0); + if (first instanceof String url && !url.isBlank()) { + stored = url; + } } } - throw new BusinessException("无结果可下载"); + if (stored == null || stored.isBlank()) { + throw new BusinessException("无结果可下载"); + } + // 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL + return ossStorageService.generateFreshDownloadUrl(stored); } private List listTaskEntities(Long userId) { @@ -852,28 +862,29 @@ public class BrandTaskService { if (entries.isEmpty()) { throw new BusinessException("没有可上传的结果文件"); } - List urls = new ArrayList<>(); + List fullUrls = new ArrayList<>(); for (OutputEntry entry : entries) { + // 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey) String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND"); - urls.add(ossStorageService.generateDownloadUrl(objectKey)); + fullUrls.add(ossStorageService.getPublicUrl(objectKey)); } Map result = new LinkedHashMap<>(); - result.put("urls", urls); + result.put("urls", fullUrls); File zipFile = packageAsZip(taskId, entries); String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND"); - result.put("zip_url", ossStorageService.generateDownloadUrl(zipObjectKey)); + result.put("zip_url", ossStorageService.getPublicUrl(zipObjectKey)); return result; } private File packageAsZip(Long taskId, List entries) throws IOException { File zipDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId))); - File zipFile = buildNamedOutputFile(zipDir, "brand_task_" + taskId + ".zip"); + File zipFile = buildNamedOutputFile(zipDir, buildArchiveFilename(entries)); Set zipEntryNames = new LinkedHashSet<>(); try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) { byte[] buffer = new byte[8192]; for (OutputEntry entry : entries) { - writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.sourceFilename()), zipEntryNames); - writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.resultFilename()), zipEntryNames); + writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.zipSourceFilename()), zipEntryNames); + writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.zipResultFilename()), zipEntryNames); } } return zipFile; @@ -933,8 +944,15 @@ public class BrandTaskService { } private String buildResultFilename(String originalFilename) { - String sourceName = blankToDefault(originalFilename, "brand.xlsx"); - return FileUtil.mainName(sourceName) + "_result.xlsx"; + return blankToDefault(originalFilename, "brand.xlsx"); + } + + private String buildArchiveFilename(List entries) { + if (entries == null || entries.isEmpty()) { + return "brand.zip"; + } + String sourceFilename = blankToDefault(entries.get(0).zipSourceFilename(), "brand.xlsx"); + return FileUtil.mainName(sourceFilename) + ".zip"; } private Integer defaultInteger(Integer value) { @@ -1062,6 +1080,6 @@ public class BrandTaskService { List uniqueBrands) { } - private record OutputEntry(File sourceFile, String sourceFilename, File resultFile, String resultFilename) { + private record OutputEntry(File sourceFile, String zipSourceFilename, File resultFile, String zipResultFilename) { } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index 97962c2..b524dde 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -114,20 +114,19 @@ public class ConvertRunService { String contentType = useZipPackage ? "application/zip" : "text/plain"; String ossObjectKey = ossStorageService.uploadResultFile(packagedResultFile, MODULE_TYPE); - String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); - + // 只存 objectKey ConvertResultItemVo item = new ConvertResultItemVo(); item.setSourceFilename(inputName); item.setSuccess(true); item.setOutputFilename(packagedFilename); - item.setDownloadUrl(downloadUrl); + item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(inputName); resultEntity.setResultFilename(packagedFilename); - resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileSize(packagedResultFile.length()); resultEntity.setResultContentType(contentType); resultEntity.setSuccess(1); @@ -161,20 +160,19 @@ public class ConvertRunService { if (folderMode && !archiveEntries.isEmpty()) { File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE); - String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); - + // 只存 objectKey ConvertResultItemVo item = new ConvertResultItemVo(); item.setSourceFilename(request.getArchiveName()); item.setOutputFilename(zipFile.getName()); item.setSuccess(true); - item.setDownloadUrl(downloadUrl); + item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setResultFilename(zipFile.getName()); - resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultContentType("application/zip"); resultEntity.setSuccess(1); @@ -211,7 +209,9 @@ public class ConvertRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); + vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 + ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) + : null); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); return vo; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index 8953a1f..c82eff7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -106,13 +106,12 @@ public class DedupeRunService { } String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE"); - String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); - + // 只存 objectKey,不存预签名 URL String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName(); item.setSuccess(true); item.setOutputFilename(downloadFilename); - item.setDownloadUrl(downloadUrl); + item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); successCount++; FileResultEntity resultEntity = new FileResultEntity(); @@ -120,7 +119,7 @@ public class DedupeRunService { resultEntity.setModuleType("DEDUPE"); resultEntity.setSourceFilename(inputName); resultEntity.setResultFilename(downloadFilename); - resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileSize(outputFile.length()); resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); resultEntity.setSuccess(1); @@ -148,20 +147,19 @@ public class DedupeRunService { if (folderMode && !archiveEntries.isEmpty()) { File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE"); - String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); - + // 只存 objectKey DedupeResultItemVo item = new DedupeResultItemVo(); item.setSourceFilename(request.getArchiveName()); item.setOutputFilename(zipFile.getName()); item.setSuccess(true); - item.setDownloadUrl(downloadUrl); + item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType("DEDUPE"); resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setResultFilename(zipFile.getName()); - resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultContentType("application/zip"); resultEntity.setSuccess(1); @@ -198,7 +196,9 @@ public class DedupeRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); + vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 + ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) + : null); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); return vo; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index a6e98a3..45e7971 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.deletebrand.controller; import com.nanri.aiimage.common.api.ApiResponse; 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.vo.DeleteBrandHistoryVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; @@ -26,13 +27,13 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api/delete-brand") -@Tag(name = "删除品牌", description = "按每个上传 Excel 的文件名匹配紫鸟店铺,并解析删除品牌表格预览。") +@Tag(name = "删除品牌", description = "解析固定格式 Excel 的删除ASIN数据(暂不联动紫鸟)。") public class DeleteBrandRunController { private final DeleteBrandRunService deleteBrandRunService; @PostMapping("/run") - @Operation(summary = "执行删除品牌解析", description = "读取上传的删除品牌 Excel,按每个 Excel 文件名匹配紫鸟店铺,并返回受限预览数据和紫鸟店铺打开链接。") + @Operation(summary = "执行删除品牌解析", description = "读取上传的删除品牌 Excel,按国家分组解析并在每个国家内按 ASIN 去重,返回完整 payload。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功", content = @Content(schema = @Schema(implementation = DeleteBrandRunVo.class))) }) @@ -57,4 +58,13 @@ public class DeleteBrandRunController { deleteBrandRunService.deleteHistory(resultId, userId); return ApiResponse.success(null); } + + @PostMapping("/tasks/{taskId}/result") + @Operation(summary = "提交删除品牌处理结果", description = "前端插件处理完成后回传结果;后端将基于 run 时缓存的原始数据重组结果文件并上传(该逻辑后续实现)。") + public ApiResponse submitResult( + @PathVariable Long taskId, + @Valid @RequestBody DeleteBrandSubmitResultRequest request) { + deleteBrandRunService.submitResult(taskId, request); + return ApiResponse.success(null); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandCountryResultItemDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandCountryResultItemDto.java new file mode 100644 index 0000000..f5aaeb7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandCountryResultItemDto.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.deletebrand.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "删除品牌插件处理后的 ASIN 结果项") +public class DeleteBrandCountryResultItemDto { + @NotBlank(message = "asin 不能为空") + @Schema(description = "删除ASIN") + private String asin; + + @Schema(description = "状态") + private String status; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandProcessedCountryDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandProcessedCountryDto.java new file mode 100644 index 0000000..c685f0e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandProcessedCountryDto.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.deletebrand.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "删除品牌插件处理后的国家结果") +public class DeleteBrandProcessedCountryDto { + @NotBlank(message = "country 不能为空") + @Schema(description = "国家") + private String country; + + @Valid + @Schema(description = "处理后的 ASIN 列表") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java new file mode 100644 index 0000000..0134846 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandResultFileDto.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.deletebrand.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "删除品牌单文件结果回传") +public class DeleteBrandResultFileDto { + @NotBlank(message = "sourceFilename 不能为空") + @Schema(description = "源文件名") + private String sourceFilename; + + @Valid + @Schema(description = "按国家分组的处理结果") + private List countries = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java new file mode 100644 index 0000000..a41c87e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/dto/DeleteBrandSubmitResultRequest.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.deletebrand.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "删除品牌结果提交请求") +public class DeleteBrandSubmitResultRequest { + @Valid + @NotEmpty(message = "files 不能为空") + @Schema(description = "按源文件维度提交结果") + private List files = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryAsinVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryAsinVo.java new file mode 100644 index 0000000..78e3170 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryAsinVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌国家维度 ASIN 项") +public class DeleteBrandCountryAsinVo { + @Schema(description = "行号") + private Integer rowIndex; + + @Schema(description = "删除ASIN") + private String asin; + + @Schema(description = "状态") + private String status; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryGroupVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryGroupVo.java new file mode 100644 index 0000000..4f4ba20 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandCountryGroupVo.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "删除品牌国家分组") +public class DeleteBrandCountryGroupVo { + @Schema(description = "国家") + private String country; + + @Schema(description = "去重后 ASIN 数") + private Integer asinCount; + + @Schema(description = "ASIN 列表") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java index 7c162cd..32576ef 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java @@ -33,6 +33,15 @@ public class DeleteBrandResultItemVo { @Schema(description = "总行数") private Integer totalRows; + @Schema(description = "任务ID") + private Long taskId; + + @Schema(description = "国家数量") + private Integer countryCount; + + @Schema(description = "按国家分组的数据") + private List countries = new ArrayList<>(); + @Schema(description = "是否已截断") private boolean truncated; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index f32f2f7..61c5eb6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -5,8 +5,15 @@ import cn.hutool.core.util.IdUtil; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.nanri.aiimage.common.exception.BusinessException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto; +import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto; +import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSourceFileDto; +import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryAsinVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo; @@ -16,8 +23,6 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; -import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; -import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService; import lombok.RequiredArgsConstructor; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; @@ -29,20 +34,21 @@ import java.io.File; import java.io.FileInputStream; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; @Service @RequiredArgsConstructor public class DeleteBrandRunService { private static final String MODULE_TYPE = "DELETE_BRAND"; - private static final int PREVIEW_LIMIT = 200; - private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; private final LocalFileStorageService localFileStorageService; - private final ZiniaoAuthService ziniaoAuthService; + private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; + private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService; public DeleteBrandRunVo run(DeleteBrandRunRequest request) { if (request.getUserId() == null || request.getUserId() <= 0) { @@ -53,21 +59,22 @@ public class DeleteBrandRunService { task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr()); task.setModuleType(MODULE_TYPE); task.setTaskMode("IMMEDIATE"); - task.setStatus("SUCCESS"); + task.setStatus("RUNNING"); task.setSourceFileCount(request.getFiles().size()); task.setCreatedBy("user:" + request.getUserId()); task.setUserId(request.getUserId()); task.setRequestJson(JSONUtil.toJsonStr(request)); task.setCreatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); - task.setFinishedAt(LocalDateTime.now()); fileTaskMapper.insert(task); - List shops = ziniaoAuthService.listShopsForConfiguredUser(); + // 先不与紫鸟联动:仅解析 Excel 并返回解析 payload(shop 匹配和 openStoreUrl 先不生成) List items = new ArrayList<>(); int successCount = 0; int failedCount = 0; + Map parsedPayloadBySourceFilename = new LinkedHashMap<>(); + for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); item.setSourceFilename(sourceFile.getOriginalFilename()); @@ -78,20 +85,35 @@ public class DeleteBrandRunService { throw new BusinessException("上传文件不存在,请重新上传"); } ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile); + item.setTaskId(task.getId()); + item.setCountryCount(parsed.countries().size()); + item.setCountries(parsed.countries()); item.setTotalRows(parsed.totalRows()); - item.setTruncated(parsed.truncated()); + item.setTruncated(false); item.setPreviewRows(parsed.previewRows()); - ZiniaoShopCacheDto matchedShop = matchShop(item.getShopName(), shops); - if (matchedShop != null) { - item.setMatched(true); - item.setShopId(matchedShop.getShopId()); - item.setPlatform(matchedShop.getPlatform()); - item.setOpenStoreUrl(ziniaoAuthService.buildOpenStoreUrlForShop(matchedShop)); - } else { - item.setMatched(false); + try { + com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null); + item.setMatched(matchResult.matched()); + if (matchResult.matched()) { + item.setShopId(matchResult.shopId()); + item.setPlatform(matchResult.platform()); + item.setOpenStoreUrl(matchResult.openStoreUrl()); + } + } catch (BusinessException ex) { + String message = ex.getMessage(); + if (message != null && (message.contains("code=40004") + || message.contains("userId存在无效的参数值") + || message.contains("无权限") + || message.contains("没有权限"))) { + item.setMatched(false); + } else { + throw ex; + } } + parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed); + item.setSuccess(true); successCount++; @@ -103,6 +125,7 @@ public class DeleteBrandRunService { resultEntity.setResultFileUrl(null); resultEntity.setResultFileSize(0L); resultEntity.setResultContentType("application/json"); + resultEntity.setRowCount(parsed.totalRows()); resultEntity.setSuccess(1); resultEntity.setUserId(request.getUserId()); resultEntity.setCreatedAt(LocalDateTime.now()); @@ -131,8 +154,15 @@ public class DeleteBrandRunService { task.setFailedFileCount(failedCount); task.setResultJson(JSONUtil.toJsonStr(items)); task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED"); fileTaskMapper.updateById(task); + if (!parsedPayloadBySourceFilename.isEmpty()) { + deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename); + } + + DeleteBrandRunVo vo = new DeleteBrandRunVo(); vo.setTotal(request.getFiles().size()); vo.setSuccessCount(successCount); @@ -185,34 +215,68 @@ public class DeleteBrandRunService { throw new BusinessException("未识别到删除品牌表头"); } - List previewRows = new ArrayList<>(); - int totalRows = 0; - boolean truncated = false; + Map> grouped = new LinkedHashMap<>(); + Map displayCountryNames = new LinkedHashMap<>(); + for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) { Row row = sheet.getRow(rowNum); if (row == null) { continue; } for (CountryColumnPair pair : pairs) { + String country = pair.country(); String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex()))); String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex()))); if (asin.isBlank() && status.isBlank()) { continue; } - totalRows++; - if (previewRows.size() < PREVIEW_LIMIT) { - DeleteBrandPreviewRowVo item = new DeleteBrandPreviewRowVo(); - item.setRowIndex(rowNum + 1); - item.setCountry(pair.country()); - item.setAsin(asin); - item.setStatus(status); - previewRows.add(item); - } else { - truncated = true; + + String asinKey = normalizeAsinKey(asin); + if (asinKey.isBlank()) { + continue; } + + displayCountryNames.putIfAbsent(country, country); + Map countryMap = grouped.computeIfAbsent(country, ignored -> new LinkedHashMap<>()); + if (countryMap.containsKey(asinKey)) { + continue; + } + + DeleteBrandCountryAsinVo item = new DeleteBrandCountryAsinVo(); + item.setRowIndex(rowNum + 1); + item.setAsin(asin); + item.setStatus(status); + countryMap.put(asinKey, item); } } - return new ParsedDeleteBrandFile(totalRows, truncated, previewRows); + + List countries = new ArrayList<>(); + List previewRows = new ArrayList<>(); + int totalRows = 0; + for (CountryColumnPair pair : pairs) { + String country = pair.country(); + Map countryMap = grouped.get(country); + if (countryMap == null || countryMap.isEmpty()) { + continue; + } + DeleteBrandCountryGroupVo groupVo = new DeleteBrandCountryGroupVo(); + groupVo.setCountry(displayCountryNames.getOrDefault(country, country)); + groupVo.getItems().addAll(countryMap.values()); + groupVo.setAsinCount(groupVo.getItems().size()); + countries.add(groupVo); + + for (DeleteBrandCountryAsinVo asinVo : groupVo.getItems()) { + DeleteBrandPreviewRowVo preview = new DeleteBrandPreviewRowVo(); + preview.setRowIndex(asinVo.getRowIndex()); + preview.setCountry(groupVo.getCountry()); + preview.setAsin(asinVo.getAsin()); + preview.setStatus(asinVo.getStatus()); + previewRows.add(preview); + totalRows++; + } + } + + return new ParsedDeleteBrandFile(totalRows, countries, previewRows); } catch (BusinessException ex) { throw ex; } catch (Exception ex) { @@ -220,6 +284,13 @@ public class DeleteBrandRunService { } } + private String normalizeAsinKey(String value) { + if (value == null) { + return ""; + } + return value.replace(" ", "").trim().toUpperCase(Locale.ROOT); + } + private List resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) { List pairs = new ArrayList<>(); int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum()); @@ -238,19 +309,6 @@ public class DeleteBrandRunService { return pairs; } - private ZiniaoShopCacheDto matchShop(String shopName, List shops) { - if (shopName == null || shopName.isBlank() || shops == null || shops.isEmpty()) { - return null; - } - String normalized = normalizeShopName(shopName); - for (ZiniaoShopCacheDto shop : shops) { - if (normalized.equals(normalizeShopName(shop.getShopName()))) { - return shop; - } - } - return null; - } - private String resolveShopName(DeleteBrandSourceFileDto sourceFile) { String name = sourceFile.getOriginalFilename(); if (name == null || name.isBlank()) { @@ -259,16 +317,6 @@ public class DeleteBrandRunService { return FileUtil.mainName(name).trim(); } - private String normalizeShopName(String value) { - if (value == null) { - return ""; - } - return value.replace("\u3000", " ") - .replace(" ", "") - .trim() - .toLowerCase(Locale.ROOT); - } - private String normalizeCellText(String value) { if (value == null) { return ""; @@ -286,6 +334,65 @@ public class DeleteBrandRunService { private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) { } - private record ParsedDeleteBrandFile(int totalRows, boolean truncated, List previewRows) { + public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId 不合法"); + } + if (request == null || request.getFiles() == null || request.getFiles().isEmpty()) { + throw new BusinessException("files 不能为空"); + } + + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + + Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, + new TypeReference>() { + }); + if (parsedPayload == null || parsedPayload.isEmpty()) { + throw new BusinessException("任务原始数据已过期,请重新执行解析"); + } + + for (DeleteBrandResultFileDto fileDto : request.getFiles()) { + if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) { + throw new BusinessException("sourceFilename 不能为空"); + } + ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename()); + if (parsedFile == null) { + throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename()); + } + + Map byCountry = new LinkedHashMap<>(); + for (DeleteBrandCountryGroupVo group : parsedFile.countries()) { + byCountry.put(group.getCountry(), group); + } + + for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) { + DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry()); + if (group == null) { + throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry()); + } + + Map allowed = new LinkedHashMap<>(); + for (DeleteBrandCountryAsinVo asinVo : group.getItems()) { + allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo); + } + + for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) { + String key = normalizeAsinKey(itemDto.getAsin()); + if (key.isBlank() || !allowed.containsKey(key)) { + throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin()); + } + } + } + } + + throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑"); + } + + private record ParsedDeleteBrandFile(int totalRows, + List countries, + List previewRows) { } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java new file mode 100644 index 0000000..e914def --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java @@ -0,0 +1,48 @@ +package com.nanri.aiimage.modules.deletebrand.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class DeleteBrandTaskCacheService { + + private static final long PAYLOAD_TTL_HOURS = 24; + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + + public void saveParsedPayload(Long taskId, Object payload) { + try { + stringRedisTemplate.opsForValue().set(buildPayloadKey(taskId), objectMapper.writeValueAsString(payload), Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + throw new BusinessException("暂存删除品牌原始数据失败"); + } + } + + public T getParsedPayload(Long taskId, TypeReference typeReference) { + String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId)); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return objectMapper.readValue(raw, typeReference); + } catch (Exception ex) { + throw new BusinessException("读取删除品牌原始数据失败"); + } + } + + public void delete(Long taskId) { + stringRedisTemplate.delete(buildPayloadKey(taskId)); + } + + private String buildPayloadKey(Long taskId) { + return "delete-brand:task:parsed-payload:" + taskId; + } +} 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 bc75374..3b767bf 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 @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.io.File; +import java.net.URI; import java.net.URL; import java.util.Date; import java.util.UUID; @@ -17,13 +18,13 @@ public class OssStorageService { private final OssProperties ossProperties; + /** + * 上传结果文件到 OSS,返回 objectKey(非预签名 URL)。 + * 调用方应存储 objectKey,下载时通过 generateFreshDownloadUrl 按需生成链接。 + */ public String uploadResultFile(File file, String moduleType) { String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); - OSS ossClient = new OSSClientBuilder().build( - "https://" + ossProperties.getEndpoint(), - ossProperties.getAccessKeyId(), - ossProperties.getAccessKeySecret() - ); + OSS ossClient = buildClient(); try { ossClient.putObject(ossProperties.getBucket(), objectKey, file); return objectKey; @@ -32,12 +33,11 @@ public class OssStorageService { } } + /** + * 根据 objectKey 生成预签名下载 URL(1小时有效)。 + */ public String generateDownloadUrl(String objectKey) { - OSS ossClient = new OSSClientBuilder().build( - "https://" + ossProperties.getEndpoint(), - ossProperties.getAccessKeyId(), - ossProperties.getAccessKeySecret() - ); + OSS ossClient = buildClient(); try { Date expiration = new Date(System.currentTimeMillis() + 3600_000L); URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration); @@ -46,4 +46,52 @@ public class OssStorageService { ossClient.shutdown(); } } + + /** + * 获取公开(无签名)URL,格式:https://{bucket}.{endpoint}/{objectKey} + */ + public String getPublicUrl(String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + return String.format("https://%s.%s/%s", ossProperties.getBucket(), ossProperties.getEndpoint(), objectKey); + } + + /** + * 从存储值中解析出 objectKey,兼容两种格式: + * - 旧格式:完整预签名 URL(https://bucket.endpoint/objectKey?Expires=...) + * - 新格式:直接是 objectKey(如 result/dedupe/uuid/file.xlsx) + */ + public String resolveObjectKey(String value) { + if (value == null || value.isBlank()) { + return value; + } + try { + if (value.startsWith("http://") || value.startsWith("https://")) { + String path = URI.create(value).getPath(); + return path.startsWith("/") ? path.substring(1) : path; + } + } catch (Exception ignored) { + } + return value; + } + + /** + * 根据存储值(objectKey 或旧格式预签名 URL)生成新鲜的预签名下载 URL。 + * 供各模块 listHistory 使用,每次按需生成,避免旧 URL 1小时后过期。 + */ + public String generateFreshDownloadUrl(String value) { + if (value == null || value.isBlank()) { + return null; + } + return generateDownloadUrl(resolveObjectKey(value)); + } + + private OSS buildClient() { + return new OSSClientBuilder().build( + "https://" + ossProperties.getEndpoint(), + ossProperties.getAccessKeyId(), + ossProperties.getAccessKeySecret() + ); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java index a05f420..524de90 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -123,8 +123,7 @@ public class SplitRunService { if (!allChunkResults.isEmpty()) { File zipFile = packageTaskSplitResultsAsZip(request.getArchiveName(), successSourceNames, allChunkResults); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE); - String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); - + // 只存 objectKey SplitResultItemVo item = new SplitResultItemVo(); item.setSourceFilename(folderMode && request.getArchiveName() != null && !request.getArchiveName().isBlank() ? request.getArchiveName() @@ -132,7 +131,7 @@ public class SplitRunService { item.setOutputFilename(zipFile.getName()); item.setSuccess(true); item.setRowCount(allChunkResults.stream().mapToInt(TaskSplitChunkResult::rowCount).sum()); - item.setDownloadUrl(downloadUrl); + item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); item.setEntryCount(allChunkResults.size()); item.setEntries(allChunkResults.stream().map(this::toArchiveEntry).toList()); @@ -141,7 +140,7 @@ public class SplitRunService { resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(item.getSourceFilename()); resultEntity.setResultFilename(zipFile.getName()); - resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultContentType(ZIP_CONTENT_TYPE); resultEntity.setRowCount(item.getRowCount()); @@ -179,7 +178,9 @@ public class SplitRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); + vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 + ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) + : null); vo.setRowCount(entity.getRowCount()); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java index 6aedaff..154b189 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClient.java @@ -6,13 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import java.util.List; public interface ZiniaoClient { - String getAppToken(); - Long getCompanyIdByApiKey(); List listStaff(Long companyId); - List listUserStores(Long companyId, Long userId, String userToken); + List listUserStores(Long companyId, Long userId); String getUserLoginToken(Long companyId, Long userId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index beb321b..7c0d89e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -6,8 +6,8 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; -import com.nanri.aiimage.modules.ziniao.service.ZiniaoSessionCacheService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; @@ -24,35 +24,6 @@ public class ZiniaoClientImpl implements ZiniaoClient { private final ZiniaoProperties ziniaoProperties; private final ObjectMapper objectMapper; - private final ZiniaoSessionCacheService ziniaoSessionCacheService; - - @Override - public String getAppToken() { - String cachedToken = ziniaoSessionCacheService.getAppToken(); - if (cachedToken != null && !cachedToken.isBlank()) { - return cachedToken; - } - String raw = postWithApiKeyEmptyJsonBody(ziniaoProperties.getAppTokenPath(), "获取 appToken"); - try { - JsonNode root = objectMapper.readTree(raw); - JsonNode data = firstNonNull(root.get("data"), root.get("result"), root); - String token = text(firstNonNull( - data == null ? null : data.get("appAuthToken"), - data == null ? null : data.get("appToken"), - root.get("appAuthToken"), - root.get("appToken") - )); - if (token == null || token.isBlank()) { - throw new BusinessException("紫鸟 appToken 响应缺少 token"); - } - ziniaoSessionCacheService.saveAppToken(token); - return token; - } catch (BusinessException ex) { - throw ex; - } catch (Exception ex) { - throw new BusinessException("解析紫鸟 appToken 响应失败"); - } - } @Override public Long getCompanyIdByApiKey() { @@ -104,14 +75,14 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public List listUserStores(Long companyId, Long userId, String userToken) { - String raw = postWithAuthorization(ziniaoProperties.getUserStoresPath(), userToken, Map.of( - "companyId", companyId, - "userId", userId, + public List listUserStores(Long companyId, Long userId) { + String raw = postWithApiKey(ziniaoProperties.getUserStoresPath(), Map.of( + "companyId", String.valueOf(companyId), + "isAccurate", "", + "limit", "100", "storeName", "", - "isAccurate", 1, - "page", 1, - "limit", 10 + "page", "1", + "userId", String.valueOf(userId) ), "获取员工店铺列表"); return parseUserStores(raw); } @@ -128,7 +99,15 @@ public class ZiniaoClientImpl implements ZiniaoClient { throw new BusinessException("获取紫鸟员工登录 token 失败: " + Objects.toString(text(root.get("msg")), "未知错误")); } JsonNode data = firstNonNull(root.get("data"), root.get("result")); + JsonNode payload = firstNonNull( + data == null ? null : data.get("data"), + data == null ? null : data.get("result"), + data, + root + ); String token = text(firstNonNull( + payload == null ? null : payload.get("token"), + payload == null ? null : payload.get("loginToken"), data == null ? null : data.get("token"), data == null ? null : data.get("loginToken"), root.get("token"), @@ -152,21 +131,9 @@ public class ZiniaoClientImpl implements ZiniaoClient { .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .headers(headers -> headers.setBearerAuth(apiKey)) .retrieve() - .body(String.class); - validateSuccess(raw, action, path); - return raw; - } - - private String postWithApiKeyEmptyJsonBody(String path, String action) { - String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); - String raw = getRestClient().post() - .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) - .headers(headers -> { - headers.setBearerAuth(apiKey); - headers.setContentType(MediaType.APPLICATION_JSON); + .onStatus(HttpStatusCode::isError, (req, res) -> { + // 保留响应体,交给后续 validateSuccess 统一解析 }) - .body("") - .retrieve() .body(String.class); validateSuccess(raw, action, path); return raw; @@ -185,6 +152,9 @@ public class ZiniaoClientImpl implements ZiniaoClient { } String raw = request .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { + // 保留响应体,交给后续 validateSuccess 统一解析 code/sub_code/sub_msg + }) .body(String.class); validateSuccess(raw, action, path); return raw; @@ -195,7 +165,11 @@ public class ZiniaoClientImpl implements ZiniaoClient { RestClient.RequestBodySpec request = getRestClient().post() .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .headers(headers -> { - headers.set("Authorization", token); + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + headers.set("Authorization", token); + } else { + headers.setBearerAuth(token); + } headers.setContentType(MediaType.APPLICATION_JSON); }); if (body != null) { @@ -212,7 +186,29 @@ public class ZiniaoClientImpl implements ZiniaoClient { try { JsonNode root = objectMapper.readTree(raw); if (!success(root)) { - throw new BusinessException("紫鸟接口返回失败(" + action + ", " + path + "): " + Objects.toString(text(root.get("msg")), "未知错误")); + String code = Objects.toString(text(root.get("code")), ""); + String msg = Objects.toString(text(root.get("msg")), "未知错误"); + String subCode = Objects.toString(text(root.get("sub_code")), ""); + String subMsg = Objects.toString(text(root.get("sub_msg")), ""); + + StringBuilder detail = new StringBuilder(); + if (!code.isBlank()) { + detail.append("code=").append(code); + } + if (!subCode.isBlank()) { + if (!detail.isEmpty()) detail.append(", "); + detail.append("sub_code=").append(subCode); + } + if (!subMsg.isBlank()) { + if (!detail.isEmpty()) detail.append(", "); + detail.append("sub_msg=").append(subMsg); + } + if (!detail.isEmpty()) { + detail.append(", "); + } + detail.append("msg=").append(msg); + + throw new BusinessException("紫鸟接口返回失败(" + action + ", " + path + "): " + detail); } } catch (BusinessException ex) { throw ex; @@ -284,7 +280,13 @@ public class ZiniaoClientImpl implements ZiniaoClient { private List parseUserStores(String raw) { try { JsonNode root = objectMapper.readTree(raw); - JsonNode itemsNode = firstNonNull(root.get("data"), root.get("result")); + JsonNode wrapper = firstNonNull(root.get("data"), root.get("result"), root); + JsonNode itemsNode = firstNonNull( + wrapper == null ? null : wrapper.get("data"), + wrapper == null ? null : wrapper.get("items"), + wrapper + ); + List items = new ArrayList<>(); if (itemsNode != null && itemsNode.isArray()) { for (JsonNode itemNode : itemsNode) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index e3e28a0..5df8fdb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -26,6 +26,75 @@ import java.util.UUID; @RequiredArgsConstructor public class ZiniaoAuthService { + public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { + ensureEnabled(); + String normalizedTarget = normalizeShopName(targetShopName); + if (normalizedTarget.isBlank()) { + return new StoreMatchResult(false, null, null, null, null, null); + } + + Long companyId = resolveCompanyId(); + List staff = ziniaoClient.listStaff(companyId); + + List userIds = new java.util.ArrayList<>(); + if (preferUserId != null && preferUserId > 0) { + userIds.add(preferUserId); + } + for (ZiniaoStaffItemVo item : staff) { + if (item != null && item.getUserId() != null && item.getUserId() > 0 && (userIds.isEmpty() || !userIds.contains(item.getUserId()))) { + userIds.add(item.getUserId()); + } + } + + for (Long staffUserId : userIds) { + if (staffUserId == null || staffUserId <= 0) { + continue; + } + List stores; + try { + stores = ziniaoClient.listUserStores(companyId, staffUserId); + } catch (BusinessException ex) { + if (isSkippableUserStoresError(ex)) { + continue; + } + throw ex; + } + + for (ZiniaoShopCacheDto store : stores) { + String storeName = normalizeShopName(store == null ? null : store.getShopName()); + if (!storeName.isBlank() && storeName.equals(normalizedTarget)) { + String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, ziniaoClient.getUserLoginToken(companyId, staffUserId)); + return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl); + } + } + } + + return new StoreMatchResult(false, null, null, null, null, null); + } + + private boolean isSkippableUserStoresError(BusinessException ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return false; + } + return message.contains("code=40004") + || message.contains("userId存在无效的参数值") + || message.contains("无权限") + || message.contains("没有权限"); + } + + private String normalizeShopName(String value) { + if (value == null) { + return ""; + } + return value.replace("\u3000", " ") + .trim(); + } + + public record StoreMatchResult(boolean matched, String shopId, String shopName, String platform, Long matchedUserId, + String openStoreUrl) { + } + private final ZiniaoProperties ziniaoProperties; private final ZiniaoClient ziniaoClient; private final ZiniaoSessionCacheService ziniaoSessionCacheService; @@ -36,13 +105,13 @@ public class ZiniaoAuthService { ZiniaoSessionVo vo = new ZiniaoSessionVo(); vo.setSessionId(session.getSessionId()); vo.setEnabled(ziniaoProperties.isEnabled()); - vo.setAuthenticated(session.getAccessToken() != null && !session.getAccessToken().isBlank()); + vo.setAuthenticated(Boolean.TRUE); vo.setCompanyId(session.getCompanyId()); vo.setCurrentUserId(session.getCurrentUserId()); vo.setZiniaoUserId(session.getZiniaoUserId()); vo.setNickname(session.getNickname()); - vo.setAppToken(session.getAccessToken()); - vo.setTokenType(session.getTokenType()); + vo.setAppToken(null); + vo.setTokenType(null); vo.setExpireAt(session.getExpireAt()); vo.setShopCount(ziniaoSessionCacheService.getShops(session.getSessionId()).size()); vo.setCurrentShopId(session.getDefaultShopId()); @@ -61,8 +130,7 @@ public class ZiniaoAuthService { public ZiniaoShopListVo listShops(String sessionId, Long userId) { ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId); Long currentUserId = requireUserId(session.getCurrentUserId()); - String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); - List shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken); + List shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) { session.setDefaultShopId(shops.get(0).getShopId()); @@ -86,7 +154,7 @@ public class ZiniaoAuthService { String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); List shops = ziniaoSessionCacheService.getShops(session.getSessionId()); if (shops.isEmpty()) { - shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken); + shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); } ZiniaoShopCacheDto targetShop = shops.stream() @@ -110,8 +178,7 @@ public class ZiniaoAuthService { public List listShopsForConfiguredUser() { ensureEnabled(); Long userId = parseConfiguredOpenStoreUserId(); - String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId); - return ziniaoClient.listUserStores(resolveCompanyId(), userId, userToken); + return ziniaoClient.listUserStores(resolveCompanyId(), userId); } public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) { @@ -128,9 +195,6 @@ public class ZiniaoAuthService { String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", ""); ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto(); session.setSessionId(sessionId); - String appToken = ziniaoClient.getAppToken(); - session.setAccessToken(maskToken(appToken)); - session.setTokenType("Bearer"); session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); session.setCompanyId(resolveCompanyId()); session.setCurrentUserId(userId); @@ -186,7 +250,7 @@ public class ZiniaoAuthService { if (ziniaoProperties.getOpenStoreExtraArgs() != null && !ziniaoProperties.getOpenStoreExtraArgs().isBlank()) { builder.queryParam("extraArgs", ziniaoProperties.getOpenStoreExtraArgs()); } - return builder.build(true).toUriString(); + return builder.build().encode().toUriString(); } private void ensureEnabled() { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java index d99a37d..61b4a51 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java @@ -17,27 +17,10 @@ import java.util.List; @RequiredArgsConstructor public class ZiniaoSessionCacheService { - private static final String APP_TOKEN_KEY = "ziniao:app-token"; - private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final ZiniaoProperties ziniaoProperties; - public void saveAppToken(String appToken) { - if (appToken == null || appToken.isBlank()) { - return; - } - stringRedisTemplate.opsForValue().set(APP_TOKEN_KEY, appToken.trim(), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); - } - - public String getAppToken() { - String value = stringRedisTemplate.opsForValue().get(APP_TOKEN_KEY); - if (value == null || value.isBlank()) { - return null; - } - return value.trim(); - } - public void saveSession(ZiniaoSessionCacheDto session) { try { stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index caea652..082482d 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -6,7 +6,7 @@ @@ -84,13 +84,13 @@
- 暂无删除品牌结果,完成解析后会在这里展示店铺匹配结果和预览数据 + 暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
  • @@ -98,8 +98,8 @@
    店铺名:{{ item.shopName || '-' }}
    匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}
    平台:{{ item.platform }}
    -
    预览 {{ item.previewRows?.length || 0 }} / {{ item.totalRows }} 行
    -
    数据较大,当前仅展示前 200 行
    +
    国家数:{{ item.countryCount }}
    +
    去重后 {{ item.totalRows }} 条
    {{ formatPreview(item.previewRows) }}
    @@ -116,7 +116,7 @@ class="download" @click="openShop(item.openStoreUrl)" > - 打开店铺 + 打开紫鸟