diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 4b73e69..db8fad9 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/version.txt b/app/version.txt index 8127c49..52a718a 100644 --- a/app/version.txt +++ b/app/version.txt @@ -1 +1 @@ -{"version": "1.0.17"} \ No newline at end of file +{"version": "1.0.13"} \ No newline at end of file diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java index 9243bf3..a055d93 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ZiniaoProperties.java @@ -28,4 +28,16 @@ public class ZiniaoProperties { private long shopsCacheMinutes; private int connectTimeoutSeconds; private int readTimeoutSeconds; + + /** + * 店铺匹配时最多尝试多少个 apiKey; + * null/空/<=0 表示不限制(不推荐)。 + */ + private Integer keyScanMaxKeys; + + /** + * 店铺匹配时最多扫描多少秒; + * null/空/<=0 表示不限制(不推荐)。 + */ + private Integer keyScanMaxSeconds; } 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 45e7971..34b6600 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 @@ -15,6 +15,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,6 +25,13 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + @RestController @RequiredArgsConstructor @@ -59,12 +68,50 @@ public class DeleteBrandRunController { return ApiResponse.success(null); } + @GetMapping("/tasks/{taskId}") + @Operation(summary = "获取删除品牌任务详情", description = "根据任务ID返回任务基础信息 + Redis 中的实时行级进度(文件/国家/ASIN)。") + public ApiResponse getTask( + @PathVariable Long taskId) { + return ApiResponse.success(deleteBrandRunService.getTaskDetail(taskId)); + } + @PostMapping("/tasks/{taskId}/result") - @Operation(summary = "提交删除品牌处理结果", description = "前端插件处理完成后回传结果;后端将基于 run 时缓存的原始数据重组结果文件并上传(该逻辑后续实现)。") + @Operation(summary = "提交删除品牌处理结果", description = "插件/前端分片回传处理结果;后端累计分片并推进进度,全部完成后再执行最终组装与落库。") public ApiResponse submitResult( @PathVariable Long taskId, @Valid @RequestBody DeleteBrandSubmitResultRequest request) { deleteBrandRunService.submitResult(taskId, request); return ApiResponse.success(null); } + + @GetMapping("/tasks/{taskId}/download") + @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url 直接保存。") + public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) { + String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId); + String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "任务无可下载结果"); + } + if (filename == null || filename.isBlank()) { + String rawPath = URI.create(url).getPath(); + filename = rawPath == null ? "delete-brand_" + taskId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1); + } + + try { + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename); + try (InputStream in = URI.create(url).toURL().openStream()) { + byte[] buffer = new byte[65536]; + int read; + while ((read = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, read); + } + response.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java new file mode 100644 index 0000000..bbe82db --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/cache/DeleteBrandParsedFileCacheDto.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.deletebrand.model.cache; + +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class DeleteBrandParsedFileCacheDto { + private String fileKey; + private String sourceFilename; + private String shopName; + private Integer totalRows; + private List countries = new ArrayList<>(); + private List previewRows = 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 index 0134846..dd99831 100644 --- 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 @@ -11,10 +11,37 @@ import java.util.List; @Data @Schema(description = "删除品牌单文件结果回传") public class DeleteBrandResultFileDto { + @Schema(description = "稳定文件标识,优先使用 run 阶段返回的 fileKey") + private String fileKey; + @NotBlank(message = "sourceFilename 不能为空") @Schema(description = "源文件名") private String sourceFilename; + @Schema(description = "当前是第几个文件,从 1 开始") + private Integer fileIndex; + + @Schema(description = "总文件数") + private Integer fileTotal; + + @Schema(description = "当前分片序号,从 1 开始") + private Integer chunkIndex; + + @Schema(description = "当前文件总分片数") + private Integer chunkTotal; + + @Schema(description = "当前文件已处理行数") + private Integer processedRows; + + @Schema(description = "当前文件总行数") + private Integer totalRows; + + @Schema(description = "当前国家") + private String currentCountry; + + @Schema(description = "当前 ASIN") + private String currentAsin; + @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 index a41c87e..354e274 100644 --- 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 @@ -11,6 +11,9 @@ import java.util.List; @Data @Schema(description = "删除品牌结果提交请求") public class DeleteBrandSubmitResultRequest { + @Schema(description = "可选的提交批次ID,用于幂等追踪") + private String submissionId; + @Valid @NotEmpty(message = "files 不能为空") @Schema(description = "按源文件维度提交结果") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java new file mode 100644 index 0000000..c6a346b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressInfoVo.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务实时进度详情") +public class DeleteBrandLineProgressInfoVo { + @Schema(description = "当前处理到第几个文件,从 1 开始") + private Integer file_index; + + @Schema(description = "本次任务总文件数") + private Integer file_total; + + @Schema(description = "当前处理文件名") + private String file_name; + + @Schema(description = "当前文件已处理行数") + private Integer current_line; + + @Schema(description = "当前文件总行数") + private Integer total_lines; + + @Schema(description = "当前国家") + private String current_country; + + @Schema(description = "当前 ASIN") + private String current_asin; + + @Schema(description = "已完成文件数") + private Integer finished_files; + + @Schema(description = "当前阶段:crawling/assembling/uploading/failed") + private String phase; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java new file mode 100644 index 0000000..83fd05b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandLineProgressVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务实时行级进度") +public class DeleteBrandLineProgressVo { + @Schema(description = "当前是否存在实时进度") + private boolean has_progress; + + @Schema(description = "实时进度详情") + private DeleteBrandLineProgressInfoVo info; +} 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 32576ef..74af8a7 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 @@ -12,6 +12,9 @@ public class DeleteBrandResultItemVo { @Schema(description = "结果记录ID") private Long resultId; + @Schema(description = "上传文件 key / 稳定文件标识") + private String fileKey; + @Schema(description = "源文件名") private String sourceFilename; @@ -33,6 +36,12 @@ public class DeleteBrandResultItemVo { @Schema(description = "总行数") private Integer totalRows; + @Schema(description = "输出文件名") + private String outputFilename; + + @Schema(description = "下载地址") + private String downloadUrl; + @Schema(description = "任务ID") private Long taskId; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java new file mode 100644 index 0000000..091fc18 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskDetailVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "删除品牌任务详情响应") +public class DeleteBrandTaskDetailVo { + @Schema(description = "任务基础信息") + private DeleteBrandTaskItemVo task; + + @Schema(description = "实时行级进度信息,来自 Redis") + private DeleteBrandLineProgressVo line_progress; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java new file mode 100644 index 0000000..88b9b98 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandTaskItemVo.java @@ -0,0 +1,46 @@ +package com.nanri.aiimage.modules.deletebrand.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "删除品牌任务基础信息") +public class DeleteBrandTaskItemVo { + @Schema(description = "任务ID") + private Long id; + + @Schema(description = "任务编号") + private String taskNo; + + @Schema(description = "任务状态") + private String status; + + @Schema(description = "源文件数") + private Integer sourceFileCount; + + @Schema(description = "成功文件数") + private Integer successFileCount; + + @Schema(description = "失败文件数") + private Integer failedFileCount; + + @Schema(description = "错误信息") + private String errorMessage; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; + + @Schema(description = "完成时间") + private LocalDateTime finishedAt; + + @Schema(description = "下载地址(任务级)") + private String downloadUrl; + + @Schema(description = "下载文件名(任务级)") + private String downloadFilename; +} 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 61c5eb6..29205c4 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 @@ -4,8 +4,10 @@ import cn.hutool.core.io.FileUtil; 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.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto; 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; @@ -15,40 +17,55 @@ import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRe 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.DeleteBrandLineProgressInfoVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDetailVo; +import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskItemVo; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; 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 lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Service; import java.io.File; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; @Service @RequiredArgsConstructor public class DeleteBrandRunService { private static final String MODULE_TYPE = "DELETE_BRAND"; + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; private final LocalFileStorageService localFileStorageService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService; private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; public DeleteBrandRunVo run(DeleteBrandRunRequest request) { if (request.getUserId() == null || request.getUserId() <= 0) { @@ -68,15 +85,16 @@ public class DeleteBrandRunService { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.insert(task); - // 先不与紫鸟联动:仅解析 Excel 并返回解析 payload(shop 匹配和 openStoreUrl 先不生成) List items = new ArrayList<>(); - int successCount = 0; - int failedCount = 0; - - Map parsedPayloadBySourceFilename = new LinkedHashMap<>(); + int parsedSuccessCount = 0; + int parsedFailedCount = 0; + Map parsedPayloadByFileIdentity = new LinkedHashMap<>(); + Map storeMatchByShopName = new LinkedHashMap<>(); for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); + item.setTaskId(task.getId()); + item.setFileKey(sourceFile.getFileKey()); item.setSourceFilename(sourceFile.getOriginalFilename()); item.setShopName(resolveShopName(sourceFile)); try { @@ -85,7 +103,6 @@ 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()); @@ -93,7 +110,11 @@ public class DeleteBrandRunService { item.setPreviewRows(parsed.previewRows()); try { - com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null); + String normalizedShopName = normalizeShopName(item.getShopName()); + com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = normalizedShopName.isBlank() + ? new com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult(false, null, null, null, null, null) + : storeMatchByShopName.computeIfAbsent(normalizedShopName, + ignored -> ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null)); item.setMatched(matchResult.matched()); if (matchResult.matched()) { item.setShopId(matchResult.shopId()); @@ -112,21 +133,30 @@ public class DeleteBrandRunService { } } - parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed); + String fileIdentity = resolveFileIdentity(sourceFile); + DeleteBrandParsedFileCacheDto cacheDto = new DeleteBrandParsedFileCacheDto(); + cacheDto.setFileKey(sourceFile.getFileKey()); + cacheDto.setSourceFilename(sourceFile.getOriginalFilename()); + cacheDto.setShopName(item.getShopName()); + cacheDto.setTotalRows(parsed.totalRows()); + cacheDto.setCountries(parsed.countries()); + cacheDto.setPreviewRows(parsed.previewRows()); + parsedPayloadByFileIdentity.put(fileIdentity, cacheDto); item.setSuccess(true); - successCount++; + parsedSuccessCount++; FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(item.getSourceFilename()); - resultEntity.setResultFilename(item.getShopName()); + resultEntity.setSourceFileUrl(sourceFile.getFileKey()); + resultEntity.setResultFilename(null); resultEntity.setResultFileUrl(null); resultEntity.setResultFileSize(0L); - resultEntity.setResultContentType("application/json"); + resultEntity.setResultContentType(CONTENT_TYPE_XLSX); resultEntity.setRowCount(parsed.totalRows()); - resultEntity.setSuccess(1); + resultEntity.setSuccess(0); resultEntity.setUserId(request.getUserId()); resultEntity.setCreatedAt(LocalDateTime.now()); fileResultMapper.insert(resultEntity); @@ -134,12 +164,13 @@ public class DeleteBrandRunService { } catch (Exception ex) { item.setSuccess(false); item.setError(ex.getMessage()); - failedCount++; + parsedFailedCount++; FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType(MODULE_TYPE); resultEntity.setSourceFilename(sourceFile.getOriginalFilename()); + resultEntity.setSourceFileUrl(sourceFile.getFileKey()); resultEntity.setSuccess(0); resultEntity.setErrorMessage(ex.getMessage()); resultEntity.setUserId(request.getUserId()); @@ -150,23 +181,25 @@ public class DeleteBrandRunService { items.add(item); } - task.setSuccessFileCount(successCount); - task.setFailedFileCount(failedCount); + task.setSuccessFileCount(0); + task.setFailedFileCount(parsedFailedCount); task.setResultJson(JSONUtil.toJsonStr(items)); task.setUpdatedAt(LocalDateTime.now()); - task.setFinishedAt(LocalDateTime.now()); - task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED"); + if (parsedSuccessCount <= 0) { + task.setStatus("FAILED"); + task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null); + task.setFinishedAt(LocalDateTime.now()); + } fileTaskMapper.updateById(task); - if (!parsedPayloadBySourceFilename.isEmpty()) { - deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename); + if (!parsedPayloadByFileIdentity.isEmpty()) { + deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity); } - DeleteBrandRunVo vo = new DeleteBrandRunVo(); vo.setTotal(request.getFiles().size()); - vo.setSuccessCount(successCount); - vo.setFailedCount(failedCount); + vo.setSuccessCount(parsedSuccessCount); + vo.setFailedCount(parsedFailedCount); vo.setItems(items); return vo; } @@ -182,8 +215,13 @@ public class DeleteBrandRunService { .map(entity -> { DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); item.setResultId(entity.getId()); + item.setTaskId(entity.getTaskId()); + item.setFileKey(entity.getSourceFileUrl()); item.setSourceFilename(entity.getSourceFilename()); - item.setShopName(entity.getResultFilename()); + item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename())); + item.setOutputFilename(entity.getResultFilename()); + item.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl())); + item.setTotalRows(entity.getRowCount()); item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); item.setError(entity.getErrorMessage()); return item; @@ -291,6 +329,13 @@ public class DeleteBrandRunService { return value.replace(" ", "").trim().toUpperCase(Locale.ROOT); } + private String normalizeShopName(String value) { + if (value == null) { + return ""; + } + return value.replace("\u3000", " ").trim(); + } + private List resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) { List pairs = new ArrayList<>(); int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum()); @@ -334,6 +379,139 @@ public class DeleteBrandRunService { private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) { } + private String resolveFileIdentity(DeleteBrandSourceFileDto sourceFile) { + if (sourceFile != null && sourceFile.getFileKey() != null && !sourceFile.getFileKey().isBlank()) { + return sourceFile.getFileKey().trim(); + } + return sourceFile == null ? "" : String.valueOf(sourceFile.getOriginalFilename()); + } + + public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + + DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo(); + taskVo.setId(task.getId()); + taskVo.setTaskNo(task.getTaskNo()); + taskVo.setStatus(task.getStatus()); + taskVo.setSourceFileCount(task.getSourceFileCount()); + taskVo.setSuccessFileCount(task.getSuccessFileCount()); + taskVo.setFailedFileCount(task.getFailedFileCount()); + taskVo.setErrorMessage(task.getErrorMessage()); + taskVo.setCreatedAt(task.getCreatedAt()); + taskVo.setUpdatedAt(task.getUpdatedAt()); + taskVo.setFinishedAt(task.getFinishedAt()); + taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId())); + taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId())); + + DeleteBrandLineProgressVo progressVo = buildLineProgress(taskId); + + DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo(); + detail.setTask(taskVo); + detail.setLine_progress(progressVo); + return detail; + } + + private DeleteBrandLineProgressVo buildLineProgress(Long taskId) { + Map progress = deleteBrandTaskCacheService.getProgress(taskId); + DeleteBrandLineProgressVo vo = new DeleteBrandLineProgressVo(); + if (progress == null || progress.isEmpty()) { + vo.setHas_progress(false); + vo.setInfo(null); + return vo; + } + DeleteBrandLineProgressInfoVo info = new DeleteBrandLineProgressInfoVo(); + info.setFile_index(parseInt(progress.get("file_index"))); + info.setFile_total(parseInt(progress.get("file_total"))); + info.setFile_name(stringValue(progress.get("file_name"))); + info.setCurrent_line(parseInt(progress.get("current_line"))); + info.setTotal_lines(parseInt(progress.get("total_lines"))); + info.setCurrent_country(stringValue(progress.get("current_country"))); + info.setCurrent_asin(stringValue(progress.get("current_asin"))); + info.setFinished_files(parseInt(progress.get("finished_files"))); + info.setPhase(stringValue(progress.get("phase"))); + vo.setHas_progress(true); + vo.setInfo(info); + return vo; + } + + private Integer parseInt(Object value) { + if (value == null) { + return null; + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (Exception ex) { + return null; + } + } + + private String stringValue(Object value) { + if (value == null) { + return null; + } + String raw = String.valueOf(value); + return raw.isBlank() ? null : raw; + } + + private String resolveFileIdentity(DeleteBrandResultFileDto fileDto) { + if (fileDto != null && fileDto.getFileKey() != null && !fileDto.getFileKey().isBlank()) { + return fileDto.getFileKey().trim(); + } + return fileDto == null ? "" : blankToEmpty(fileDto.getSourceFilename()); + } + + private void validateChunk(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) { + if (fileDto.getChunkIndex() == null || fileDto.getChunkTotal() == null + || fileDto.getChunkIndex() <= 0 || fileDto.getChunkTotal() <= 0 + || fileDto.getChunkIndex() > fileDto.getChunkTotal()) { + throw new BusinessException("分片参数不合法"); + } + if (fileDto.getTotalRows() != null && parsedFile.getTotalRows() != null && fileDto.getTotalRows() > parsedFile.getTotalRows()) { + throw new BusinessException("totalRows 不合法"); + } + } + + private boolean isFileCompleted(DeleteBrandResultFileDto fileDto) { + return fileDto.getChunkIndex() != null && fileDto.getChunkTotal() != null && fileDto.getChunkIndex().equals(fileDto.getChunkTotal()); + } + + private int defaultInt(Integer value, int defaultValue) { + return value == null ? defaultValue : value; + } + + private String blankToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private void validateResultFileAgainstParsed(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) { + Map byCountry = new LinkedHashMap<>(); + for (DeleteBrandCountryGroupVo group : parsedFile.getCountries()) { + 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()); + } + } + } + } + public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -347,52 +525,366 @@ public class DeleteBrandRunService { throw new BusinessException("任务不存在"); } - Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, - new TypeReference>() { + Map parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId, + new TypeReference>() { }); if (parsedPayload == null || parsedPayload.isEmpty()) { throw new BusinessException("任务原始数据已过期,请重新执行解析"); } + task.setStatus("RUNNING"); + task.setErrorMessage(null); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(null); + fileTaskMapper.updateById(task); + for (DeleteBrandResultFileDto fileDto : request.getFiles()) { - if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) { - throw new BusinessException("sourceFilename 不能为空"); + String fileIdentity = resolveFileIdentity(fileDto); + if (fileIdentity.isBlank()) { + throw new BusinessException("fileKey/sourceFilename 不能为空"); } - ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename()); + DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity); if (parsedFile == null) { - throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename()); + throw new BusinessException("文件标识不匹配: " + fileIdentity); } - Map byCountry = new LinkedHashMap<>(); - for (DeleteBrandCountryGroupVo group : parsedFile.countries()) { - byCountry.put(group.getCountry(), group); + validateChunk(fileDto, parsedFile); + validateResultFileAgainstParsed(fileDto, parsedFile); + deleteBrandTaskCacheService.mergeResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto); + + Map progress = new LinkedHashMap<>(); + progress.put("phase", DeleteBrandTaskCacheService.PHASE_CRAWLING); + progress.put("file_index", String.valueOf(defaultInt(fileDto.getFileIndex(), 0))); + progress.put("file_total", String.valueOf(defaultInt(fileDto.getFileTotal(), parsedPayload.size()))); + progress.put("file_name", blankToEmpty(fileDto.getSourceFilename())); + progress.put("current_line", String.valueOf(defaultInt(fileDto.getProcessedRows(), 0))); + progress.put("total_lines", String.valueOf(defaultInt(fileDto.getTotalRows(), parsedFile.getTotalRows() == null ? 0 : parsedFile.getTotalRows()))); + progress.put("current_country", blankToEmpty(fileDto.getCurrentCountry())); + progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin())); + progress.put("updated_at", String.valueOf(System.currentTimeMillis())); + progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis())); + deleteBrandTaskCacheService.saveProgress(taskId, progress); + } + + Map> mergedByFile = loadMergedChunks(taskId); + int finishedFiles = countCompletedFiles(mergedByFile); + + Map progress = new LinkedHashMap<>(); + progress.put("finished_files", String.valueOf(finishedFiles)); + progress.put("updated_at", String.valueOf(System.currentTimeMillis())); + deleteBrandTaskCacheService.saveProgress(taskId, progress); + + task.setSuccessFileCount(finishedFiles); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + if (finishedFiles < parsedPayload.size()) { + return; + } + + finalizeTask(task, parsedPayload, mergedByFile); + } + + private Map> loadMergedChunks(Long taskId) { + Map> groupedJson = deleteBrandTaskCacheService.groupResultChunkJsonByFile(taskId); + Map> merged = new LinkedHashMap<>(); + for (Map.Entry> entry : groupedJson.entrySet()) { + List chunks = new ArrayList<>(); + for (String raw : entry.getValue()) { + try { + chunks.add(objectMapper.readValue(raw, DeleteBrandResultFileDto.class)); + } catch (Exception ex) { + throw new BusinessException("读取删除品牌结果分片失败"); + } + } + chunks.sort(Comparator.comparing(DeleteBrandResultFileDto::getChunkIndex, Comparator.nullsLast(Integer::compareTo))); + merged.put(entry.getKey(), chunks); + } + return merged; + } + + private int countCompletedFiles(Map> mergedByFile) { + int finishedFiles = 0; + for (List chunks : mergedByFile.values()) { + if (isMergedFileCompleted(chunks)) { + finishedFiles++; + } + } + return finishedFiles; + } + + private boolean isMergedFileCompleted(List chunks) { + if (chunks == null || chunks.isEmpty()) { + return false; + } + Integer chunkTotal = chunks.get(0).getChunkTotal(); + if (chunkTotal == null || chunkTotal <= 0 || chunks.size() < chunkTotal) { + return false; + } + Set indexes = new LinkedHashSet<>(); + for (DeleteBrandResultFileDto chunk : chunks) { + if (chunk.getChunkTotal() == null || !chunkTotal.equals(chunk.getChunkTotal()) || chunk.getChunkIndex() == null) { + return false; + } + indexes.add(chunk.getChunkIndex()); + } + for (int i = 1; i <= chunkTotal; i++) { + if (!indexes.contains(i)) { + return false; + } + } + return true; + } + + private void finalizeTask(FileTaskEntity task, + Map parsedPayload, + Map> mergedByFile) { + try { + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_ASSEMBLING, + "finished_files", String.valueOf(parsedPayload.size()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + + List resultEntities = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getTaskId, task.getId()) + .orderByAsc(FileResultEntity::getId)); + Map resultEntityByIdentity = new LinkedHashMap<>(); + for (FileResultEntity entity : resultEntities) { + String identity = entity.getSourceFileUrl(); + if (identity == null || identity.isBlank()) { + identity = blankToEmpty(entity.getSourceFilename()); + } + if (!identity.isBlank()) { + resultEntityByIdentity.putIfAbsent(identity, entity); + } } - for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) { - DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry()); - if (group == null) { - throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry()); + int successCount = 0; + List finalItems = new ArrayList<>(); + for (Map.Entry entry : parsedPayload.entrySet()) { + String fileIdentity = entry.getKey(); + DeleteBrandParsedFileCacheDto parsedFile = entry.getValue(); + List chunks = mergedByFile.get(fileIdentity); + if (!isMergedFileCompleted(chunks)) { + throw new BusinessException("存在未完成的结果分片: " + parsedFile.getSourceFilename()); } + MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks); + File outputFile = buildResultWorkbook(task.getId(), parsedFile, mergedFile); - Map allowed = new LinkedHashMap<>(); - for (DeleteBrandCountryAsinVo asinVo : group.getItems()) { - allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo); + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_UPLOADING, + "file_name", blankToEmpty(parsedFile.getSourceFilename()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + + String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE); + FileResultEntity resultEntity = resultEntityByIdentity.get(fileIdentity); + if (resultEntity == null) { + throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename()); } + resultEntity.setResultFilename(outputFile.getName()); + resultEntity.setResultFileUrl(objectKey); + resultEntity.setResultFileSize(outputFile.length()); + resultEntity.setResultContentType(CONTENT_TYPE_XLSX); + resultEntity.setRowCount(parsedFile.getTotalRows()); + resultEntity.setSuccess(1); + resultEntity.setErrorMessage(null); + fileResultMapper.updateById(resultEntity); + DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); + item.setResultId(resultEntity.getId()); + item.setTaskId(task.getId()); + item.setFileKey(parsedFile.getFileKey()); + item.setSourceFilename(parsedFile.getSourceFilename()); + item.setShopName(parsedFile.getShopName()); + item.setOutputFilename(outputFile.getName()); + item.setDownloadUrl(ossStorageService.getPublicUrl(objectKey)); + item.setTotalRows(parsedFile.getTotalRows()); + item.setCountryCount(parsedFile.getCountries() == null ? 0 : parsedFile.getCountries().size()); + item.setCountries(parsedFile.getCountries()); + item.setPreviewRows(parsedFile.getPreviewRows()); + item.setTruncated(false); + item.setSuccess(true); + finalItems.add(item); + successCount++; + } + + task.setStatus("SUCCESS"); + task.setSuccessFileCount(successCount); + task.setErrorMessage(null); + task.setResultJson(JSONUtil.toJsonStr(finalItems)); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", "success", + "file_index", String.valueOf(parsedPayload.size()), + "file_total", String.valueOf(parsedPayload.size()), + "finished_files", String.valueOf(successCount), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + } catch (Exception ex) { + task.setStatus("FAILED"); + task.setErrorMessage(ex.getMessage()); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_FAILED, + "finished_files", String.valueOf(task.getSuccessFileCount() == null ? 0 : task.getSuccessFileCount()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + if (ex instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException("删除品牌结果组装失败"); + } + } + + private MergedDeleteBrandFile mergeChunks(DeleteBrandParsedFileCacheDto parsedFile, List chunks) { + Integer chunkTotal = chunks.get(0).getChunkTotal(); + for (DeleteBrandResultFileDto chunk : chunks) { + if (!chunkTotal.equals(chunk.getChunkTotal())) { + throw new BusinessException("chunkTotal 不一致: " + parsedFile.getSourceFilename()); + } + } + + Map> processedByCountry = new LinkedHashMap<>(); + int processedRows = 0; + for (DeleteBrandResultFileDto chunk : chunks) { + processedRows = Math.max(processedRows, defaultInt(chunk.getProcessedRows(), 0)); + for (DeleteBrandProcessedCountryDto countryDto : chunk.getCountries()) { + Map byAsin = processedByCountry.computeIfAbsent(countryDto.getCountry(), ignored -> new LinkedHashMap<>()); 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()); - } + byAsin.put(normalizeAsinKey(itemDto.getAsin()), itemDto); } } } - throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑"); + List mergedCountries = new ArrayList<>(); + for (DeleteBrandCountryGroupVo parsedCountry : parsedFile.getCountries()) { + Map processedItems = processedByCountry.get(parsedCountry.getCountry()); + if (processedItems == null) { + throw new BusinessException("缺少国家结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry()); + } + DeleteBrandProcessedCountryDto mergedCountry = new DeleteBrandProcessedCountryDto(); + mergedCountry.setCountry(parsedCountry.getCountry()); + for (DeleteBrandCountryAsinVo parsedAsin : parsedCountry.getItems()) { + DeleteBrandCountryResultItemDto processed = processedItems.get(normalizeAsinKey(parsedAsin.getAsin())); + if (processed == null) { + throw new BusinessException("缺少 ASIN 结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry() + " / " + parsedAsin.getAsin()); + } + DeleteBrandCountryResultItemDto row = new DeleteBrandCountryResultItemDto(); + row.setAsin(parsedAsin.getAsin()); + row.setStatus(processed.getStatus()); + mergedCountry.getItems().add(row); + } + mergedCountries.add(mergedCountry); + } + + return new MergedDeleteBrandFile( + blankToEmpty(chunks.get(chunks.size() - 1).getCurrentCountry()), + blankToEmpty(chunks.get(chunks.size() - 1).getCurrentAsin()), + processedRows, + mergedCountries + ); + } + + private File buildResultWorkbook(Long taskId, + DeleteBrandParsedFileCacheDto parsedFile, + MergedDeleteBrandFile mergedFile) { + File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId))); + File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx")); + + try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) { + Sheet sheet = workbook.createSheet("删除品牌结果"); + Row headerRow = sheet.createRow(0); + headerRow.createCell(0).setCellValue("国家"); + headerRow.createCell(1).setCellValue("删除ASIN"); + headerRow.createCell(2).setCellValue("状态"); + + int rowIndex = 1; + for (DeleteBrandProcessedCountryDto country : mergedFile.countries()) { + for (DeleteBrandCountryResultItemDto item : country.getItems()) { + Row row = sheet.createRow(rowIndex++); + createTextCell(row, 0, country.getCountry()); + createTextCell(row, 1, item.getAsin()); + createTextCell(row, 2, item.getStatus()); + } + } + for (int i = 0; i < 3; i++) { + sheet.autoSizeColumn(i); + } + workbook.write(outputStream); + return outputFile; + } catch (Exception ex) { + throw new BusinessException("生成删除品牌结果文件失败: " + parsedFile.getSourceFilename()); + } + } + + private void createTextCell(Row row, int index, String value) { + Cell cell = row.createCell(index); + cell.setCellValue(value == null ? "" : value); + } + + private File buildNamedOutputFile(File outputDir, String filename) { + File candidate = FileUtil.file(outputDir, filename); + if (!candidate.exists()) { + return candidate; + } + String mainName = FileUtil.mainName(filename); + String extName = FileUtil.extName(filename); + int index = 2; + while (true) { + String nextFilename = extName == null || extName.isBlank() + ? mainName + "_" + index + : mainName + "_" + index + "." + extName; + File nextCandidate = FileUtil.file(outputDir, nextFilename); + if (!nextCandidate.exists()) { + return nextCandidate; + } + index++; + } + } + + public String resolveTaskDownloadUrl(Long taskId) { + FileResultEntity entity = findLatestSuccessfulResult(taskId); + return entity == null || entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl()); + } + + public String resolveTaskDownloadFilename(Long taskId) { + FileResultEntity entity = findLatestSuccessfulResult(taskId); + return entity == null ? null : entity.getResultFilename(); + } + + private FileResultEntity findLatestSuccessfulResult(Long taskId) { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getSuccess, 1) + .orderByDesc(FileResultEntity::getId) + .last("limit 1")) + .stream() + .findFirst() + .orElse(null); + } + + private String blankToDefault(String value, String defaultValue) { + String normalized = blankToEmpty(value); + return normalized.isBlank() ? defaultValue : normalized; } private record ParsedDeleteBrandFile(int totalRows, List countries, List previewRows) { } + + private record MergedDeleteBrandFile(String currentCountry, + String currentAsin, + int processedRows, + List countries) { + } } 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 index e914def..d1fb47f 100644 --- 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 @@ -13,6 +13,11 @@ import java.time.Duration; @RequiredArgsConstructor public class DeleteBrandTaskCacheService { + public static final String PHASE_CRAWLING = "crawling"; + public static final String PHASE_ASSEMBLING = "assembling"; + public static final String PHASE_UPLOADING = "uploading"; + public static final String PHASE_FAILED = "failed"; + private static final long PAYLOAD_TTL_HOURS = 24; private final StringRedisTemplate stringRedisTemplate; @@ -38,11 +43,65 @@ public class DeleteBrandTaskCacheService { } } + public void saveProgress(Long taskId, java.util.Map values) { + String key = buildProgressKey(taskId); + stringRedisTemplate.opsForHash().putAll(key, values); + stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS)); + } + + public java.util.Map getProgress(Long taskId) { + return stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId)); + } + + public void mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) { + String resultKey = buildResultChunksKey(taskId); + if (chunkIndex == null || chunkIndex <= 0) { + throw new BusinessException("chunkIndex 不合法"); + } + if (fileIdentity == null || fileIdentity.isBlank()) { + throw new BusinessException("fileIdentity 不能为空"); + } + String field = fileIdentity.trim() + "#" + chunkIndex; + try { + stringRedisTemplate.opsForHash().put(resultKey, field, objectMapper.writeValueAsString(chunk)); + } catch (Exception ex) { + throw new BusinessException("暂存删除品牌结果分片失败"); + } + stringRedisTemplate.expire(resultKey, Duration.ofHours(PAYLOAD_TTL_HOURS)); + } + + public java.util.Map> groupResultChunkJsonByFile(Long taskId) { + java.util.Map stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId)); + java.util.Map> grouped = new java.util.LinkedHashMap<>(); + for (java.util.Map.Entry entry : stored.entrySet()) { + if (!(entry.getKey() instanceof String field) || !(entry.getValue() instanceof String raw) || raw.isBlank()) { + continue; + } + int sep = field.lastIndexOf('#'); + if (sep <= 0) { + continue; + } + String fileIdentity = field.substring(0, sep); + grouped.computeIfAbsent(fileIdentity, ignored -> new java.util.ArrayList<>()).add(raw); + } + return grouped; + } + public void delete(Long taskId) { stringRedisTemplate.delete(buildPayloadKey(taskId)); + stringRedisTemplate.delete(buildProgressKey(taskId)); + stringRedisTemplate.delete(buildResultChunksKey(taskId)); } private String buildPayloadKey(Long taskId) { return "delete-brand:task:parsed-payload:" + taskId; } + + private String buildProgressKey(Long taskId) { + return "delete-brand:task:progress:" + taskId; + } + + private String buildResultChunksKey(Long taskId) { + return "delete-brand:task:result-chunks:" + taskId; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java new file mode 100644 index 0000000..7470418 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/ShopKeyController.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.shopkey.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; +import com.nanri.aiimage.modules.shopkey.service.ShopKeyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/shop-keys") +@Tag(name = "店铺密钥管理", description = "维护店铺密钥,支持增删改查。") +public class ShopKeyController { + + private final ShopKeyService shopKeyService; + + @GetMapping + @Operation(summary = "分页查询店铺密钥", description = "分页查询店铺密钥列表。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopKeyPageVo.class))) + }) + public ApiResponse page( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) { + return ApiResponse.success(shopKeyService.page(page, pageSize)); + } + + @PostMapping + @Operation(summary = "新增店铺密钥", description = "新增一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法") + }) + public ApiResponse create(@Valid @RequestBody ShopKeyCreateRequest request) { + return ApiResponse.success("创建成功", shopKeyService.create(request)); + } + + @PutMapping("/{id}") + @Operation(summary = "更新店铺密钥", description = "按 ID 更新一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") + }) + public ApiResponse update( + @Parameter(description = "主键ID", required = true) @PathVariable Long id, + @Valid @RequestBody ShopKeyUpdateRequest request) { + return ApiResponse.success("更新成功", shopKeyService.update(id, request)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除店铺密钥", description = "按 ID 删除一条店铺密钥记录。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在") + }) + public ApiResponse delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) { + shopKeyService.delete(id); + return ApiResponse.success("删除成功", null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java new file mode 100644 index 0000000..6d94adc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopKeyMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.shopkey.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ShopKeyMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java new file mode 100644 index 0000000..c86f727 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.shopkey.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "店铺密钥新增请求") +public class ShopKeyCreateRequest { + + @NotBlank(message = "紫鸟账号名称不能为空") + @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") + @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoAccountName; + + @NotBlank(message = "紫鸟令牌不能为空") + @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") + @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoToken; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java new file mode 100644 index 0000000..15a744b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.shopkey.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +@Schema(description = "店铺密钥更新请求") +public class ShopKeyUpdateRequest { + + @NotBlank(message = "紫鸟账号名称不能为空") + @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") + @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoAccountName; + + @NotBlank(message = "紫鸟令牌不能为空") + @Size(max = 512, message = "紫鸟令牌长度不能超过512个字符") + @Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED) + private String ziniaoToken; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java new file mode 100644 index 0000000..44c26d8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.shopkey.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_shop_key") +public class ShopKeyEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String ziniaoAccountName; + private String ziniaoToken; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java new file mode 100644 index 0000000..382993c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java @@ -0,0 +1,26 @@ +package com.nanri.aiimage.modules.shopkey.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "店铺密钥项") +public class ShopKeyItemVo { + + @Schema(description = "主键ID") + private Long id; + + @Schema(description = "紫鸟账号名称") + private String ziniaoAccountName; + + @Schema(description = "紫鸟令牌") + private String ziniaoToken; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "修改时间") + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java new file mode 100644 index 0000000..e84b9dc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyPageVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.shopkey.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "店铺密钥分页结果") +public class ShopKeyPageVo { + + @Schema(description = "列表") + private List items; + + @Schema(description = "总数") + private Long total; + + @Schema(description = "页码") + private Long page; + + @Schema(description = "每页数量") + private Long pageSize; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java new file mode 100644 index 0000000..36ae1b6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -0,0 +1,94 @@ +package com.nanri.aiimage.modules.shopkey.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest; +import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo; +import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ShopKeyService { + + private final ShopKeyMapper shopKeyMapper; + + public ShopKeyPageVo page(long page, long pageSize) { + long safePage = Math.max(page, 1); + long safePageSize = Math.min(Math.max(pageSize, 1), 100); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .orderByDesc(ShopKeyEntity::getId); + Long total = shopKeyMapper.selectCount(query); + List items = shopKeyMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)) + .stream() + .map(this::toItemVo) + .toList(); + ShopKeyPageVo vo = new ShopKeyPageVo(); + vo.setItems(items); + vo.setTotal(total); + vo.setPage(safePage); + vo.setPageSize(safePageSize); + return vo; + } + + @Transactional + public ShopKeyItemVo create(ShopKeyCreateRequest request) { + String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); + String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + ShopKeyEntity entity = new ShopKeyEntity(); + entity.setZiniaoAccountName(ziniaoAccountName); + entity.setZiniaoToken(ziniaoToken); + shopKeyMapper.insert(entity); + return toItemVo(getById(entity.getId())); + } + + @Transactional + public ShopKeyItemVo update(Long id, ShopKeyUpdateRequest request) { + ShopKeyEntity entity = getById(id); + String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); + String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + entity.setZiniaoAccountName(ziniaoAccountName); + entity.setZiniaoToken(ziniaoToken); + shopKeyMapper.updateById(entity); + return toItemVo(getById(id)); + } + + @Transactional + public void delete(Long id) { + ShopKeyEntity entity = getById(id); + shopKeyMapper.deleteById(entity.getId()); + } + + private ShopKeyEntity getById(Long id) { + ShopKeyEntity entity = shopKeyMapper.selectById(id); + if (entity == null) { + throw new BusinessException("店铺密钥不存在"); + } + return entity; + } + + private String normalizeRequired(String value, String message) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new BusinessException(message); + } + return normalized; + } + + private ShopKeyItemVo toItemVo(ShopKeyEntity entity) { + ShopKeyItemVo vo = new ShopKeyItemVo(); + vo.setId(entity.getId()); + vo.setZiniaoAccountName(entity.getZiniaoAccountName()); + vo.setZiniaoToken(entity.getZiniaoToken()); + vo.setCreatedAt(entity.getCreatedAt()); + vo.setUpdatedAt(entity.getUpdatedAt()); + return vo; + } +} 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 154b189..0bf22e0 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,11 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import java.util.List; public interface ZiniaoClient { - Long getCompanyIdByApiKey(); + Long getCompanyIdByApiKey(String apiKey); - List listStaff(Long companyId); + List listStaff(String apiKey, Long companyId); - List listUserStores(Long companyId, Long userId); + List listUserStores(String apiKey, Long companyId, Long userId); - String getUserLoginToken(Long companyId, Long userId); + String getUserLoginToken(String apiKey, 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 7c0d89e..837e2a3 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 @@ -26,8 +26,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { private final ObjectMapper objectMapper; @Override - public Long getCompanyIdByApiKey() { - String raw = getWithApiKey("/app/builtin/company", "获取 companyId"); + public Long getCompanyIdByApiKey(String apiKey) { + String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId"); try { JsonNode root = objectMapper.readTree(raw); JsonNode data = firstNonNull(root.get("data"), root.get("result"), root); @@ -44,8 +44,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public List listStaff(Long companyId) { - String raw = postWithApiKey(ziniaoProperties.getStaffListPath(), Map.of( + public List listStaff(String apiKey, Long companyId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getStaffListPath(), Map.of( "companyId", String.valueOf(companyId), "level", "", "isAccurate", "", @@ -75,8 +75,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public List listUserStores(Long companyId, Long userId) { - String raw = postWithApiKey(ziniaoProperties.getUserStoresPath(), Map.of( + public List listUserStores(String apiKey, Long companyId, Long userId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getUserStoresPath(), Map.of( "companyId", String.valueOf(companyId), "isAccurate", "", "limit", "100", @@ -88,8 +88,8 @@ public class ZiniaoClientImpl implements ZiniaoClient { } @Override - public String getUserLoginToken(Long companyId, Long userId) { - String raw = postWithApiKey(ziniaoProperties.getUserLoginTokenPath(), Map.of( + public String getUserLoginToken(String apiKey, Long companyId, Long userId) { + String raw = postWithApiKey(apiKey, ziniaoProperties.getUserLoginTokenPath(), Map.of( "companyId", String.valueOf(companyId), "userId", String.valueOf(userId) ), "获取员工登录 token"); @@ -125,11 +125,10 @@ public class ZiniaoClientImpl implements ZiniaoClient { } - private String getWithApiKey(String path, String action) { - String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + private String getWithApiKey(String apiKey, String path, String action) { String raw = getRestClient().get() .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) - .headers(headers -> headers.setBearerAuth(apiKey)) + .headers(headers -> applyAuthorization(headers, apiKey)) .retrieve() .onStatus(HttpStatusCode::isError, (req, res) -> { // 保留响应体,交给后续 validateSuccess 统一解析 @@ -139,12 +138,11 @@ public class ZiniaoClientImpl implements ZiniaoClient { return raw; } - private String postWithApiKey(String path, Map body, String action) { - String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + private String postWithApiKey(String apiKey, String path, Map body, String action) { RestClient.RequestBodySpec request = getRestClient().post() .uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .headers(headers -> { - headers.setBearerAuth(apiKey); + applyAuthorization(headers, apiKey); headers.setContentType(MediaType.APPLICATION_JSON); }); if (body != null) { @@ -182,6 +180,15 @@ public class ZiniaoClientImpl implements ZiniaoClient { return raw; } + private void applyAuthorization(org.springframework.http.HttpHeaders headers, String apiKey) { + String token = requireText(apiKey, "紫鸟 apiKey 未配置"); + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + headers.set("Authorization", token); + } else { + headers.setBearerAuth(token); + } + } + private void validateSuccess(String raw, String action, String path) { try { JsonNode root = objectMapper.readTree(raw); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java new file mode 100644 index 0000000..3466a31 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/mapper/ZiniaoMemoryStoreMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.ziniao.memory.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ZiniaoMemoryStoreMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java new file mode 100644 index 0000000..cc4ef88 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/model/entity/ZiniaoMemoryStoreEntity.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.ziniao.memory.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_ziniao_memory_store") +public class ZiniaoMemoryStoreEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String cacheType; + private String cacheKey; + private String payloadJson; + private LocalDateTime expiresAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java new file mode 100644 index 0000000..84c9f67 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java @@ -0,0 +1,143 @@ +package com.nanri.aiimage.modules.ziniao.memory.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.ziniao.memory.mapper.ZiniaoMemoryStoreMapper; +import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ZiniaoMemoryStoreService { + + private final ZiniaoMemoryStoreMapper ziniaoMemoryStoreMapper; + private final ObjectMapper objectMapper; + + public Optional get(String cacheType, String cacheKey, Class valueType) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity == null) { + return Optional.empty(); + } + if (isExpired(entity)) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + return Optional.empty(); + } + try { + return Optional.ofNullable(objectMapper.readValue(entity.getPayloadJson(), valueType)); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟记忆存储失败"); + } + } + + public Optional get(String cacheType, String cacheKey, JavaType javaType) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity == null) { + return Optional.empty(); + } + if (isExpired(entity)) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + return Optional.empty(); + } + try { + @SuppressWarnings("unchecked") + T value = (T) objectMapper.readValue(entity.getPayloadJson(), javaType); + return Optional.ofNullable(value); + } catch (Exception ex) { + throw new BusinessException("读取紫鸟记忆存储失败"); + } + } + + public Optional> getList(String cacheType, String cacheKey, Class elementType) { + JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, elementType); + return get(cacheType, cacheKey, type); + } + + @Transactional + public void put(String cacheType, String cacheKey, Object payload, Duration ttl) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空"); + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + throw new BusinessException("ttl 不合法"); + } + String payloadJson; + try { + payloadJson = objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + throw new BusinessException("写入紫鸟记忆存储失败"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiresAt = now.plusSeconds(ttl.getSeconds()); + ZiniaoMemoryStoreEntity entity = findOne(normalizedType, normalizedKey); + if (entity == null) { + entity = new ZiniaoMemoryStoreEntity(); + entity.setCacheType(normalizedType); + entity.setCacheKey(normalizedKey); + entity.setPayloadJson(payloadJson); + entity.setExpiresAt(expiresAt); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + ziniaoMemoryStoreMapper.insert(entity); + return; + } + entity.setPayloadJson(payloadJson); + entity.setExpiresAt(expiresAt); + entity.setUpdatedAt(now); + ziniaoMemoryStoreMapper.updateById(entity); + } + + @Transactional + public void delete(String cacheType, String cacheKey) { + ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey); + if (entity != null) { + ziniaoMemoryStoreMapper.deleteById(entity.getId()); + } + } + + @Transactional + public int deleteExpired(int limit) { + int safeLimit = Math.max(limit, 1); + List expired = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper() + .lt(ZiniaoMemoryStoreEntity::getExpiresAt, LocalDateTime.now()) + .orderByAsc(ZiniaoMemoryStoreEntity::getExpiresAt) + .last("LIMIT " + safeLimit)); + if (expired.isEmpty()) { + return 0; + } + int deleted = 0; + for (ZiniaoMemoryStoreEntity entity : expired) { + deleted += ziniaoMemoryStoreMapper.deleteById(entity.getId()); + } + return deleted; + } + + private ZiniaoMemoryStoreEntity findOne(String cacheType, String cacheKey) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空"); + return ziniaoMemoryStoreMapper.selectOne(new LambdaQueryWrapper() + .eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType) + .eq(ZiniaoMemoryStoreEntity::getCacheKey, normalizedKey) + .last("LIMIT 1")); + } + + private boolean isExpired(ZiniaoMemoryStoreEntity entity) { + return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now()); + } + + private String normalizeRequired(String value, String message) { + String normalized = Objects.toString(value, "").trim(); + if (normalized.isEmpty()) { + throw new BusinessException(message); + } + return normalized; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java index df01a7e..b81463b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/model/cache/ZiniaoSessionCacheDto.java @@ -5,6 +5,7 @@ import lombok.Data; @Data public class ZiniaoSessionCacheDto { private String sessionId; + private String apiKey; private String accessToken; private String refreshToken; private String tokenType; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java new file mode 100644 index 0000000..b72b2e4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoApiKeyProvider.java @@ -0,0 +1,43 @@ +package com.nanri.aiimage.modules.ziniao.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper; +import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ZiniaoApiKeyProvider { + + private final ShopKeyMapper shopKeyMapper; + + public List listApiKeys() { + return shopKeyMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(ShopKeyEntity::getId)) + .stream() + .map(ShopKeyEntity::getZiniaoToken) + .filter(token -> token != null && !token.isBlank()) + .map(String::trim) + .map(token -> token.regionMatches(true, 0, "Bearer ", 0, 7) ? token.substring(7).trim() : token) + .filter(token -> !token.isBlank()) + .distinct() + .toList(); + } + + public String getRequiredApiKey() { + List keys = listApiKeys(); + if (keys.isEmpty()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } + return keys.get(0); + } + + public boolean hasApiKey() { + Long total = shopKeyMapper.selectCount(new LambdaQueryWrapper()); + return total != null && total > 0; + } +} 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 5df8fdb..f4a3366 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 @@ -3,8 +3,10 @@ package com.nanri.aiimage.modules.ziniao.service; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.client.ZiniaoClient; +import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoApiKeyProvider; import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo; @@ -13,10 +15,12 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.util.UriComponentsBuilder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.List; @@ -24,8 +28,18 @@ import java.util.UUID; @Service @RequiredArgsConstructor +@Slf4j public class ZiniaoAuthService { + private static final String CACHE_TYPE_COMPANY_ID = "COMPANY_ID"; + private static final String CACHE_TYPE_STAFF_LIST = "STAFF_LIST"; + private static final String CACHE_TYPE_USER_STORES = "USER_STORES"; + private static final String CACHE_TYPE_SHOP_MATCH = "SHOP_MATCH"; + private static final Duration COMPANY_ID_CACHE_TTL = Duration.ofHours(12); + private static final Duration STAFF_LIST_CACHE_TTL = Duration.ofMinutes(30); + private static final Duration USER_STORES_CACHE_TTL = Duration.ofMinutes(30); + private static final Duration SHOP_MATCH_CACHE_TTL = Duration.ofMinutes(30); + public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) { ensureEnabled(); String normalizedTarget = normalizeShopName(targetShopName); @@ -33,38 +47,64 @@ public class ZiniaoAuthService { 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()); + List apiKeys = getScanApiKeys(); + long startedAt = System.currentTimeMillis(); + int scannedKeys = 0; + for (String apiKey : apiKeys) { + if (isScanLimitExceeded(startedAt, scannedKeys)) { + throw new BusinessException("紫鸟 key 轮询超出扫描限制,请缩小范围或提高扫描上限"); } - } + scannedKeys++; - for (Long staffUserId : userIds) { - if (staffUserId == null || staffUserId <= 0) { - continue; - } - List stores; + Long companyId; try { - stores = ziniaoClient.listUserStores(companyId, staffUserId); + companyId = resolveCompanyId(apiKey); } catch (BusinessException ex) { - if (isSkippableUserStoresError(ex)) { + // key 无效/不可用时继续尝试下一个 key + if (isInvalidApiKeyError(ex)) { + log.info("[ziniao-match] skip invalid apiKey while resolving companyId, keyHash={}", shortKeyHash(apiKey)); 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); + StoreMatchResult cachedMatch = getCachedShopMatch(apiKey, companyId, normalizedTarget); + if (cachedMatch != null) { + log.info("[ziniao-match] hit shop-match cache, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}", + shortKeyHash(apiKey), companyId, normalizedTarget, cachedMatch.shopId(), cachedMatch.matchedUserId()); + String openStoreUrl = buildOpenStoreUrl(cachedMatch.shopId(), cachedMatch.matchedUserId(), + ziniaoClient.getUserLoginToken(apiKey, companyId, cachedMatch.matchedUserId())); + return new StoreMatchResult(true, cachedMatch.shopId(), cachedMatch.shopName(), cachedMatch.platform(), cachedMatch.matchedUserId(), openStoreUrl); + } + + List staff = getOrLoadStaff(apiKey, companyId); + List userIds = buildUserIds(staff, preferUserId); + for (Long staffUserId : userIds) { + if (staffUserId == null || staffUserId <= 0) { + continue; + } + List stores; + try { + stores = getOrLoadUserStores(apiKey, 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)) { + log.info("[ziniao-match] matched via upstream scan, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}", + shortKeyHash(apiKey), companyId, normalizedTarget, store.getShopId(), staffUserId); + cacheShopMatch(apiKey, companyId, normalizedTarget, + new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, null), + SHOP_MATCH_CACHE_TTL); + String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, + ziniaoClient.getUserLoginToken(apiKey, companyId, staffUserId)); + return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl); + } } } } @@ -98,6 +138,8 @@ public class ZiniaoAuthService { private final ZiniaoProperties ziniaoProperties; private final ZiniaoClient ziniaoClient; private final ZiniaoSessionCacheService ziniaoSessionCacheService; + private final ZiniaoMemoryStoreService ziniaoMemoryStoreService; + private final ZiniaoApiKeyProvider ziniaoApiKeyProvider; public ZiniaoSessionVo getSession(String sessionId, Long userId) { ensureEnabled(); @@ -122,15 +164,18 @@ public class ZiniaoAuthService { public ZiniaoStaffListVo listStaff() { ensureEnabled(); ZiniaoStaffListVo vo = new ZiniaoStaffListVo(); - Long companyId = resolveCompanyIdForStaff(); - vo.getItems().addAll(ziniaoClient.listStaff(companyId)); + String apiKey = resolveAvailableApiKey(); + Long companyId = resolveCompanyIdForStaff(apiKey); + vo.getItems().addAll(ziniaoClient.listStaff(apiKey, companyId)); return vo; } public ZiniaoShopListVo listShops(String sessionId, Long userId) { ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId); Long currentUserId = requireUserId(session.getCurrentUserId()); - List shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); + String apiKey = resolveSessionApiKey(session); + Long companyId = resolveCompanyId(apiKey); + List shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) { session.setDefaultShopId(shops.get(0).getShopId()); @@ -151,10 +196,12 @@ public class ZiniaoAuthService { public ZiniaoOpenShopVo openShop(ZiniaoOpenShopRequest request) { ZiniaoSessionCacheDto session = requireOrInitSession(request.getSessionId(), request.getUserId()); Long currentUserId = requireUserId(request.getUserId() != null ? request.getUserId() : session.getCurrentUserId()); - String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); + String apiKey = resolveSessionApiKey(session); + Long companyId = resolveCompanyId(apiKey); + String userToken = ziniaoClient.getUserLoginToken(apiKey, companyId, currentUserId); List shops = ziniaoSessionCacheService.getShops(session.getSessionId()); if (shops.isEmpty()) { - shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId); + shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); } ZiniaoShopCacheDto targetShop = shops.stream() @@ -178,7 +225,8 @@ public class ZiniaoAuthService { public List listShopsForConfiguredUser() { ensureEnabled(); Long userId = parseConfiguredOpenStoreUserId(); - return ziniaoClient.listUserStores(resolveCompanyId(), userId); + String apiKey = resolveAvailableApiKey(); + return ziniaoClient.listUserStores(apiKey, resolveCompanyId(apiKey), userId); } public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) { @@ -187,7 +235,8 @@ public class ZiniaoAuthService { throw new BusinessException("店铺不存在"); } Long userId = parseConfiguredOpenStoreUserId(); - String loginToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId); + String apiKey = resolveAvailableApiKey(); + String loginToken = ziniaoClient.getUserLoginToken(apiKey, resolveCompanyId(apiKey), userId); return buildOpenStoreUrl(shop.getShopId(), userId, loginToken); } @@ -196,7 +245,9 @@ public class ZiniaoAuthService { ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto(); session.setSessionId(sessionId); session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); - session.setCompanyId(resolveCompanyId()); + String apiKey = resolveAvailableApiKey(); + session.setApiKey(apiKey); + session.setCompanyId(resolveCompanyId(apiKey)); session.setCurrentUserId(userId); session.setZiniaoUserId(String.valueOf(userId == null ? 0L : userId)); session.setNickname("API Key模式"); @@ -215,8 +266,12 @@ public class ZiniaoAuthService { if (userId != null && userId > 0) { session.setCurrentUserId(userId); session.setZiniaoUserId(String.valueOf(userId)); - ziniaoSessionCacheService.saveSession(session); } + // 旧 session 可能没有保存 apiKey,补齐后续请求所需 + if (session.getApiKey() == null || session.getApiKey().isBlank()) { + session.setApiKey(resolveAvailableApiKey()); + } + ziniaoSessionCacheService.saveSession(session); return session; } @@ -257,11 +312,13 @@ public class ZiniaoAuthService { if (!ziniaoProperties.isEnabled()) { throw new BusinessException("紫鸟集成未启用,请先配置环境变量"); } - requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置"); + if (!ziniaoApiKeyProvider.hasApiKey()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } } - private Long resolveCompanyIdForStaff() { - return resolveCompanyId(); + private Long resolveCompanyIdForStaff(String apiKey) { + return resolveCompanyId(apiKey); } private Long requireUserId(Long userId) { @@ -271,15 +328,163 @@ public class ZiniaoAuthService { return userId; } - private Long resolveCompanyId() { + private Long resolveCompanyId(String apiKey) { if (ziniaoProperties.getCompanyId() != null && ziniaoProperties.getCompanyId() > 0) { return ziniaoProperties.getCompanyId(); } - return ziniaoClient.getCompanyIdByApiKey(); + + String normalizedApiKey = requireText(apiKey, "紫鸟 apiKey 未配置"); + String cacheKey = buildApiKeyHash(normalizedApiKey); + Long cached = ziniaoMemoryStoreService.get(CACHE_TYPE_COMPANY_ID, cacheKey, Long.class).orElse(null); + if (cached != null && cached > 0) { + log.info("[ziniao-company] hit cache, keyHash={}, companyId={}", shortKeyHash(apiKey), cached); + return cached; + } + log.info("[ziniao-company] cache miss, requesting upstream, keyHash={}", shortKeyHash(apiKey)); + Long companyId = ziniaoClient.getCompanyIdByApiKey(normalizedApiKey); + ziniaoMemoryStoreService.put(CACHE_TYPE_COMPANY_ID, cacheKey, companyId, COMPANY_ID_CACHE_TTL); + log.info("[ziniao-company] cached upstream result, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId); + return companyId; } - private Long requireCompanyId() { - return resolveCompanyId(); + + private List getOrLoadStaff(String apiKey, Long companyId) { + String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId; + List cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_STAFF_LIST, cacheKey, ZiniaoStaffItemVo.class) + .orElse(null); + if (cached != null) { + log.info("[ziniao-staff] hit cache, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, cached.size()); + return cached; + } + log.info("[ziniao-staff] cache miss, requesting upstream, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId); + List staff = ziniaoClient.listStaff(apiKey, companyId); + ziniaoMemoryStoreService.put(CACHE_TYPE_STAFF_LIST, cacheKey, staff, STAFF_LIST_CACHE_TTL); + log.info("[ziniao-staff] cached upstream result, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, staff.size()); + return staff; + } + + private List getOrLoadUserStores(String apiKey, Long companyId, Long userId) { + String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId + ":" + userId; + List cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_USER_STORES, cacheKey, ZiniaoShopCacheDto.class) + .orElse(null); + if (cached != null) { + log.info("[ziniao-stores] hit cache, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, cached.size()); + return cached; + } + log.info("[ziniao-stores] cache miss, requesting upstream, keyHash={}, companyId={}, userId={}", shortKeyHash(apiKey), companyId, userId); + List stores = ziniaoClient.listUserStores(apiKey, companyId, userId); + ziniaoMemoryStoreService.put(CACHE_TYPE_USER_STORES, cacheKey, stores, USER_STORES_CACHE_TTL); + log.info("[ziniao-stores] cached upstream result, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, stores.size()); + return stores; + } + + private StoreMatchResult getCachedShopMatch(String apiKey, Long companyId, String normalizedShopName) { + StoreMatchResult cached = ziniaoMemoryStoreService.get(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), StoreMatchResult.class) + .orElse(null); + if (cached == null || !cached.matched() || cached.shopId() == null || cached.matchedUserId() == null) { + return null; + } + return cached; + } + + private void cacheShopMatch(String apiKey, Long companyId, String normalizedShopName, StoreMatchResult result, Duration ttl) { + ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), result, ttl); + } + + private String buildShopMatchCacheKey(String apiKey, Long companyId, String normalizedShopName) { + return buildApiKeyHash(apiKey) + ":" + companyId + ":" + normalizedShopName; + } + + private String buildApiKeyHash(String apiKey) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new BusinessException("生成紫鸟 apiKey 缓存键失败"); + } + } + + private String shortKeyHash(String apiKey) { + String hash = buildApiKeyHash(apiKey); + return hash.length() <= 12 ? hash : hash.substring(0, 12); + } + + private List buildUserIds(List staff, Long preferUserId) { + 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()); + } + } + return userIds; + } + + private List getScanApiKeys() { + List keys = ziniaoApiKeyProvider.listApiKeys(); + if (keys.isEmpty()) { + throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌"); + } + Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys(); + if (maxKeys == null || maxKeys <= 0 || keys.size() <= maxKeys) { + return keys; + } + return keys.subList(0, maxKeys); + } + + private String resolveAvailableApiKey() { + BusinessException lastError = null; + List apiKeys = getScanApiKeys(); + for (String apiKey : apiKeys) { + try { + resolveCompanyId(apiKey); + return apiKey; + } catch (BusinessException ex) { + if (isInvalidApiKeyError(ex)) { + lastError = ex; + continue; + } + throw ex; + } + } + if (lastError != null) { + throw lastError; + } + throw new BusinessException("未找到可用的紫鸟 apiKey"); + } + + private String resolveSessionApiKey(ZiniaoSessionCacheDto session) { + if (session != null && session.getApiKey() != null && !session.getApiKey().isBlank()) { + return session.getApiKey().trim(); + } + return resolveAvailableApiKey(); + } + + private boolean isInvalidApiKeyError(BusinessException ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return false; + } + return message.contains("isv.invalid-api-key") + || message.contains("无效的apiKey参数") + || message.contains("非法的参数"); + } + + private boolean isScanLimitExceeded(long startedAt, int scannedKeys) { + Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys(); + if (maxKeys != null && maxKeys > 0 && scannedKeys >= maxKeys) { + return true; + } + Integer maxSeconds = ziniaoProperties.getKeyScanMaxSeconds(); + return maxSeconds != null && maxSeconds > 0 + && System.currentTimeMillis() - startedAt >= maxSeconds * 1000L; } private Long parseConfiguredOpenStoreUserId() { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 250819e..d5446c1 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -99,3 +99,5 @@ aiimage: shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30} connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5} read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15} + key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:50} + key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:15} diff --git a/backend-java/src/main/resources/db/V5__create_shop_keys.sql b/backend-java/src/main/resources/db/V5__create_shop_keys.sql new file mode 100644 index 0000000..71941b0 --- /dev/null +++ b/backend-java/src/main/resources/db/V5__create_shop_keys.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS biz_shop_key ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + ziniao_account_name VARCHAR(128) NOT NULL COMMENT '紫鸟账号名称', + ziniao_token VARCHAR(512) NOT NULL COMMENT '紫鸟令牌', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_created_at (created_at) +) COMMENT='店铺密钥管理表'; diff --git a/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql b/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql new file mode 100644 index 0000000..2c636d4 --- /dev/null +++ b/backend-java/src/main/resources/db/V6__drop_shop_key_machine_no.sql @@ -0,0 +1,27 @@ +SET @drop_shop_key_machine_no_index_sql := ( + SELECT CASE + WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP INDEX uk_machine_no' + ELSE 'SELECT 1' + END + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_shop_key' + AND INDEX_NAME = 'uk_machine_no' +); +PREPARE stmt FROM @drop_shop_key_machine_no_index_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @drop_shop_key_machine_no_column_sql := ( + SELECT CASE + WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP COLUMN machine_no' + ELSE 'SELECT 1' + END + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_shop_key' + AND COLUMN_NAME = 'machine_no' +); +PREPARE stmt FROM @drop_shop_key_machine_no_column_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql b/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql new file mode 100644 index 0000000..9a2c60f --- /dev/null +++ b/backend-java/src/main/resources/db/V7__create_ziniao_memory_store.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS biz_ziniao_memory_store ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + cache_type VARCHAR(64) NOT NULL COMMENT '缓存类型', + cache_key VARCHAR(255) NOT NULL COMMENT '缓存key', + payload_json LONGTEXT NOT NULL COMMENT '缓存JSON', + expires_at DATETIME NOT NULL COMMENT '过期时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_type_key (cache_type, cache_key), + KEY idx_expires_at (expires_at) +) COMMENT='紫鸟记忆存储(跨会话SQL缓存)'; diff --git a/backend/__pycache__/config.cpython-312.pyc b/backend/__pycache__/config.cpython-312.pyc index 71fb25c..3967d60 100644 Binary files a/backend/__pycache__/config.cpython-312.pyc and b/backend/__pycache__/config.cpython-312.pyc differ diff --git a/backend/blueprints/__pycache__/admin_api.cpython-312.pyc b/backend/blueprints/__pycache__/admin_api.cpython-312.pyc index f0b8657..e4b7967 100644 Binary files a/backend/blueprints/__pycache__/admin_api.cpython-312.pyc and b/backend/blueprints/__pycache__/admin_api.cpython-312.pyc differ diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index bc7c75f..5e7e477 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -617,6 +617,113 @@ def upload_version(): return jsonify({'success': False, 'error': str(e)}) +# ---------- 店铺密钥管理 ---------- + +@admin_api.route('/shop-keys') +@admin_required +def list_shop_keys(): + page = max(1, int(request.args.get('page', 1))) + page_size = min(100, max(1, int(request.args.get('page_size', 15)))) + result, error_response, status = _proxy_backend_java( + 'GET', + '/api/admin/shop-keys', + params={'page': page, 'pageSize': page_size}, + ) + if error_response is not None: + return error_response, status + payload = result.get('data') or {} + items = [ + { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + } + for item in (payload.get('items') or []) + ] + return jsonify({ + 'success': True, + 'items': items, + 'total': payload.get('total') or 0, + 'page': payload.get('page') or page, + 'page_size': payload.get('pageSize') or page_size, + }) + + +@admin_api.route('/shop-key', methods=['POST']) +@admin_required +def create_shop_key(): + data = request.get_json() or {} + payload = { + 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), + 'ziniaoToken': (data.get('ziniao_token') or '').strip(), + } + result, error_response, status = _proxy_backend_java( + 'POST', + '/api/admin/shop-keys', + json_data=payload, + ) + if error_response is not None: + return error_response, status + item = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': result.get('message') or '创建成功', + 'item': { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + }, + }) + + +@admin_api.route('/shop-key/', methods=['PUT']) +@admin_required +def update_shop_key(item_id): + data = request.get_json() or {} + payload = { + 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), + 'ziniaoToken': (data.get('ziniao_token') or '').strip(), + } + result, error_response, status = _proxy_backend_java( + 'PUT', + f'/api/admin/shop-keys/{item_id}', + json_data=payload, + ) + if error_response is not None: + return error_response, status + item = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': result.get('message') or '更新成功', + 'item': { + 'id': item.get('id'), + 'ziniao_account_name': item.get('ziniaoAccountName') or '', + 'ziniao_token': item.get('ziniaoToken') or '', + 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], + 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], + }, + }) + + +@admin_api.route('/shop-key/', methods=['DELETE']) +@admin_required +def delete_shop_key(item_id): + result, error_response, status = _proxy_backend_java( + 'DELETE', + f'/api/admin/shop-keys/{item_id}', + ) + if error_response is not None: + return error_response, status + return jsonify({ + 'success': True, + 'msg': result.get('message') or '删除成功', + }) + + # ---------- 数据去重总数据 ---------- @admin_api.route('/dedupe-total-data') diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 4a27176..78d1c34 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -112,6 +112,7 @@
用户管理
栏目权限配置
数据去重总数据
+
店铺密钥管理
查看生成记录
版本管理
@@ -308,6 +309,42 @@ + +
+
+

新增店铺密钥

+
+
+ + +
+
+ + +
+ +
+

+
+
+

店铺密钥列表

+ + + + + + + + + + + + +
序号紫鸟账号名称紫鸟令牌创建时间修改时间操作
+ +
+
+
@@ -423,6 +460,27 @@
+ + + diff --git a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue index 082482d..3f0fdfc 100644 --- a/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDeleteBrandTab.vue @@ -29,7 +29,7 @@
文件名作为店铺名 -
例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;当前先不联动紫鸟
+
例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;并自动联动紫鸟进行店铺匹配
@@ -58,6 +58,14 @@ {{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }} + +
+
Python 队列推送结果
+
+ {{ queuePushResult }} +
+
{{ queuePayloadText }}
+
@@ -83,52 +91,139 @@ 删除品牌结果列表 -
+
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
-
    -
  • -
    - {{ item.sourceFilename || '-' }} -
    店铺名:{{ item.shopName || '-' }}
    -
    匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}
    -
    平台:{{ item.platform }}
    -
    国家数:{{ item.countryCount }}
    -
    去重后 {{ item.totalRows }} 条
    -
    - {{ formatPreview(item.previewRows) }} -
    -
    错误信息:{{ item.error }}
    -
    +
@@ -137,17 +232,20 @@