删除品牌

This commit is contained in:
super
2026-03-27 21:32:00 +08:00
parent ea212c8931
commit 5dd7a92cf5
22 changed files with 675 additions and 214 deletions

View File

@@ -16,6 +16,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -27,6 +28,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/brand") @RequestMapping("/api/brand")
@@ -191,17 +197,34 @@ public class BrandTaskController {
@GetMapping("/tasks/{taskId}/download") @GetMapping("/tasks/{taskId}/download")
@Operation( @Operation(
summary = "下载品牌任务结果", summary = "下载品牌任务结果",
description = "根据任务 ID 读取 brand_crawl_tasks.result_paths优先跳转 zip_url若没有 zip_url则跳转第一个可用结果 URL" description = "根据任务 ID 读取 brand_crawl_tasks.result_paths后端代理拉取 OSS 文件并直接返回给浏览器,避免跨域问题"
) )
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "302", description = "重定向到 OSS 结果文件地址"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "文件流application/zip"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在或无结果可下载") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在或无结果可下载")
}) })
public ResponseEntity<Void> download( public void download(
@Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId) { @Parameter(description = "品牌任务 ID", required = true) @PathVariable Long taskId,
String url = brandTaskService.resolveDownloadUrl(taskId); HttpServletResponse response) throws Exception {
return ResponseEntity.status(302) String ossUrl = brandTaskService.resolveDownloadUrl(taskId);
.header(HttpHeaders.LOCATION, url) // 从 OSS URL 路径中取文件名
.build(); String rawPath = URI.create(ossUrl).getPath();
String filename = rawPath.substring(rawPath.lastIndexOf('/') + 1);
if (filename.isBlank()) {
filename = "brand_task_" + taskId + ".zip";
}
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/zip");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
// 后端代理拉取 OSS 文件并流式写出
try (InputStream in = URI.create(ossUrl).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
}
response.getOutputStream().flush();
}
} }
} }

View File

@@ -277,7 +277,11 @@ public class BrandTaskService {
String originalFilename = resolveOriginalFilename(sourceFile, sourceLocalFile); String originalFilename = resolveOriginalFilename(sourceFile, sourceLocalFile);
File outputFile = buildNamedOutputFile(outputDir, buildResultFilename(originalFilename)); File outputFile = buildNamedOutputFile(outputDir, buildResultFilename(originalFilename));
writeBrandWorkbook(outputFile, request.getStrategy(), cachedFile, resultFile); writeBrandWorkbook(outputFile, request.getStrategy(), cachedFile, resultFile);
outputEntries.add(new OutputEntry(sourceLocalFile, originalFilename, outputFile, outputFile.getName())); outputEntries.add(new OutputEntry(
sourceLocalFile,
originalFilename,
outputFile,
originalFilename));
} }
brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_UPLOADING, finishedCount, totalCount); brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_UPLOADING, finishedCount, totalCount);
@@ -338,18 +342,24 @@ public class BrandTaskService {
if (!(raw instanceof Map<?, ?> map)) { if (!(raw instanceof Map<?, ?> map)) {
throw new BusinessException("无结果可下载"); throw new BusinessException("无结果可下载");
} }
String stored = null;
Object zipUrl = map.get("zip_url"); Object zipUrl = map.get("zip_url");
if (zipUrl instanceof String zip && !zip.isBlank()) { if (zipUrl instanceof String zip && !zip.isBlank()) {
return zip; stored = zip;
} } else {
Object urls = map.get("urls"); Object urls = map.get("urls");
if (urls instanceof List<?> list && !list.isEmpty()) { if (urls instanceof List<?> list && !list.isEmpty()) {
Object first = list.get(0); Object first = list.get(0);
if (first instanceof String url && !url.isBlank()) { if (first instanceof String url && !url.isBlank()) {
return url; stored = url;
}
} }
} }
throw new BusinessException("无结果可下载"); if (stored == null || stored.isBlank()) {
throw new BusinessException("无结果可下载");
}
// 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL
return ossStorageService.generateFreshDownloadUrl(stored);
} }
private List<BrandCrawlTaskEntity> listTaskEntities(Long userId) { private List<BrandCrawlTaskEntity> listTaskEntities(Long userId) {
@@ -852,28 +862,29 @@ public class BrandTaskService {
if (entries.isEmpty()) { if (entries.isEmpty()) {
throw new BusinessException("没有可上传的结果文件"); throw new BusinessException("没有可上传的结果文件");
} }
List<String> urls = new ArrayList<>(); List<String> fullUrls = new ArrayList<>();
for (OutputEntry entry : entries) { for (OutputEntry entry : entries) {
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND"); String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND");
urls.add(ossStorageService.generateDownloadUrl(objectKey)); fullUrls.add(ossStorageService.getPublicUrl(objectKey));
} }
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("urls", urls); result.put("urls", fullUrls);
File zipFile = packageAsZip(taskId, entries); File zipFile = packageAsZip(taskId, entries);
String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND"); String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND");
result.put("zip_url", ossStorageService.generateDownloadUrl(zipObjectKey)); result.put("zip_url", ossStorageService.getPublicUrl(zipObjectKey));
return result; return result;
} }
private File packageAsZip(Long taskId, List<OutputEntry> entries) throws IOException { private File packageAsZip(Long taskId, List<OutputEntry> entries) throws IOException {
File zipDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId))); File zipDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId)));
File zipFile = buildNamedOutputFile(zipDir, "brand_task_" + taskId + ".zip"); File zipFile = buildNamedOutputFile(zipDir, buildArchiveFilename(entries));
Set<String> zipEntryNames = new LinkedHashSet<>(); Set<String> zipEntryNames = new LinkedHashSet<>();
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) { try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
for (OutputEntry entry : entries) { for (OutputEntry entry : entries) {
writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.sourceFilename()), zipEntryNames); writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.zipSourceFilename()), zipEntryNames);
writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.resultFilename()), zipEntryNames); writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.zipResultFilename()), zipEntryNames);
} }
} }
return zipFile; return zipFile;
@@ -933,8 +944,15 @@ public class BrandTaskService {
} }
private String buildResultFilename(String originalFilename) { private String buildResultFilename(String originalFilename) {
String sourceName = blankToDefault(originalFilename, "brand.xlsx"); return blankToDefault(originalFilename, "brand.xlsx");
return FileUtil.mainName(sourceName) + "_result.xlsx"; }
private String buildArchiveFilename(List<OutputEntry> entries) {
if (entries == null || entries.isEmpty()) {
return "brand.zip";
}
String sourceFilename = blankToDefault(entries.get(0).zipSourceFilename(), "brand.xlsx");
return FileUtil.mainName(sourceFilename) + ".zip";
} }
private Integer defaultInteger(Integer value) { private Integer defaultInteger(Integer value) {
@@ -1062,6 +1080,6 @@ public class BrandTaskService {
List<String> uniqueBrands) { List<String> uniqueBrands) {
} }
private record OutputEntry(File sourceFile, String sourceFilename, File resultFile, String resultFilename) { private record OutputEntry(File sourceFile, String zipSourceFilename, File resultFile, String zipResultFilename) {
} }
} }

View File

@@ -114,20 +114,19 @@ public class ConvertRunService {
String contentType = useZipPackage ? "application/zip" : "text/plain"; String contentType = useZipPackage ? "application/zip" : "text/plain";
String ossObjectKey = ossStorageService.uploadResultFile(packagedResultFile, MODULE_TYPE); String ossObjectKey = ossStorageService.uploadResultFile(packagedResultFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); // 只存 objectKey
ConvertResultItemVo item = new ConvertResultItemVo(); ConvertResultItemVo item = new ConvertResultItemVo();
item.setSourceFilename(inputName); item.setSourceFilename(inputName);
item.setSuccess(true); item.setSuccess(true);
item.setOutputFilename(packagedFilename); item.setOutputFilename(packagedFilename);
item.setDownloadUrl(downloadUrl); item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId()); resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE); resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(inputName); resultEntity.setSourceFilename(inputName);
resultEntity.setResultFilename(packagedFilename); resultEntity.setResultFilename(packagedFilename);
resultEntity.setResultFileUrl(downloadUrl); resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(packagedResultFile.length()); resultEntity.setResultFileSize(packagedResultFile.length());
resultEntity.setResultContentType(contentType); resultEntity.setResultContentType(contentType);
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
@@ -161,20 +160,19 @@ public class ConvertRunService {
if (folderMode && !archiveEntries.isEmpty()) { if (folderMode && !archiveEntries.isEmpty()) {
File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries); File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); // 只存 objectKey
ConvertResultItemVo item = new ConvertResultItemVo(); ConvertResultItemVo item = new ConvertResultItemVo();
item.setSourceFilename(request.getArchiveName()); item.setSourceFilename(request.getArchiveName());
item.setOutputFilename(zipFile.getName()); item.setOutputFilename(zipFile.getName());
item.setSuccess(true); item.setSuccess(true);
item.setDownloadUrl(downloadUrl); item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId()); resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE); resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setSourceFilename(request.getArchiveName());
resultEntity.setResultFilename(zipFile.getName()); resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(downloadUrl); resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip"); resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
@@ -211,7 +209,9 @@ public class ConvertRunService {
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename()); vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename()); vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1
? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())
: null);
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
return vo; return vo;

View File

@@ -106,13 +106,12 @@ public class DedupeRunService {
} }
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE"); String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE");
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); // 只存 objectKey不存预签名 URL
String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName(); String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName();
item.setSuccess(true); item.setSuccess(true);
item.setOutputFilename(downloadFilename); item.setOutputFilename(downloadFilename);
item.setDownloadUrl(downloadUrl); item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
successCount++; successCount++;
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
@@ -120,7 +119,7 @@ public class DedupeRunService {
resultEntity.setModuleType("DEDUPE"); resultEntity.setModuleType("DEDUPE");
resultEntity.setSourceFilename(inputName); resultEntity.setSourceFilename(inputName);
resultEntity.setResultFilename(downloadFilename); resultEntity.setResultFilename(downloadFilename);
resultEntity.setResultFileUrl(downloadUrl); resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(outputFile.length()); resultEntity.setResultFileSize(outputFile.length());
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
@@ -148,20 +147,19 @@ public class DedupeRunService {
if (folderMode && !archiveEntries.isEmpty()) { if (folderMode && !archiveEntries.isEmpty()) {
File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries); File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE"); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE");
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); // 只存 objectKey
DedupeResultItemVo item = new DedupeResultItemVo(); DedupeResultItemVo item = new DedupeResultItemVo();
item.setSourceFilename(request.getArchiveName()); item.setSourceFilename(request.getArchiveName());
item.setOutputFilename(zipFile.getName()); item.setOutputFilename(zipFile.getName());
item.setSuccess(true); item.setSuccess(true);
item.setDownloadUrl(downloadUrl); item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
FileResultEntity resultEntity = new FileResultEntity(); FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId()); resultEntity.setTaskId(task.getId());
resultEntity.setModuleType("DEDUPE"); resultEntity.setModuleType("DEDUPE");
resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setSourceFilename(request.getArchiveName());
resultEntity.setResultFilename(zipFile.getName()); resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(downloadUrl); resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip"); resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
@@ -198,7 +196,9 @@ public class DedupeRunService {
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename()); vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename()); vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1
? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())
: null);
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
return vo; return vo;

View File

@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.deletebrand.controller;
import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
@@ -26,13 +27,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/delete-brand") @RequestMapping("/api/delete-brand")
@Tag(name = "删除品牌", description = "按每个上传 Excel 的文件名匹配紫鸟店铺,并解析删除品牌表格预览") @Tag(name = "删除品牌", description = "解析固定格式 Excel 的删除ASIN数据暂不联动紫鸟")
public class DeleteBrandRunController { public class DeleteBrandRunController {
private final DeleteBrandRunService deleteBrandRunService; private final DeleteBrandRunService deleteBrandRunService;
@PostMapping("/run") @PostMapping("/run")
@Operation(summary = "执行删除品牌解析", description = "读取上传的删除品牌 Excel每个 Excel 文件名匹配紫鸟店铺,并返回受限预览数据和紫鸟店铺打开链接") @Operation(summary = "执行删除品牌解析", description = "读取上传的删除品牌 Excel国家分组解析并在每个国家内按 ASIN 去重,返回完整 payload")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功", content = @Content(schema = @Schema(implementation = DeleteBrandRunVo.class))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功", content = @Content(schema = @Schema(implementation = DeleteBrandRunVo.class)))
}) })
@@ -57,4 +58,13 @@ public class DeleteBrandRunController {
deleteBrandRunService.deleteHistory(resultId, userId); deleteBrandRunService.deleteHistory(resultId, userId);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@PostMapping("/tasks/{taskId}/result")
@Operation(summary = "提交删除品牌处理结果", description = "前端插件处理完成后回传结果;后端将基于 run 时缓存的原始数据重组结果文件并上传(该逻辑后续实现)。")
public ApiResponse<Void> submitResult(
@PathVariable Long taskId,
@Valid @RequestBody DeleteBrandSubmitResultRequest request) {
deleteBrandRunService.submitResult(taskId, request);
return ApiResponse.success(null);
}
} }

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.deletebrand.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "删除品牌插件处理后的 ASIN 结果项")
public class DeleteBrandCountryResultItemDto {
@NotBlank(message = "asin 不能为空")
@Schema(description = "删除ASIN")
private String asin;
@Schema(description = "状态")
private String status;
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.deletebrand.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "删除品牌插件处理后的国家结果")
public class DeleteBrandProcessedCountryDto {
@NotBlank(message = "country 不能为空")
@Schema(description = "国家")
private String country;
@Valid
@Schema(description = "处理后的 ASIN 列表")
private List<DeleteBrandCountryResultItemDto> items = new ArrayList<>();
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.deletebrand.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "删除品牌单文件结果回传")
public class DeleteBrandResultFileDto {
@NotBlank(message = "sourceFilename 不能为空")
@Schema(description = "源文件名")
private String sourceFilename;
@Valid
@Schema(description = "按国家分组的处理结果")
private List<DeleteBrandProcessedCountryDto> countries = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.deletebrand.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "删除品牌结果提交请求")
public class DeleteBrandSubmitResultRequest {
@Valid
@NotEmpty(message = "files 不能为空")
@Schema(description = "按源文件维度提交结果")
private List<DeleteBrandResultFileDto> files = new ArrayList<>();
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.deletebrand.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "删除品牌国家维度 ASIN 项")
public class DeleteBrandCountryAsinVo {
@Schema(description = "行号")
private Integer rowIndex;
@Schema(description = "删除ASIN")
private String asin;
@Schema(description = "状态")
private String status;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.deletebrand.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "删除品牌国家分组")
public class DeleteBrandCountryGroupVo {
@Schema(description = "国家")
private String country;
@Schema(description = "去重后 ASIN 数")
private Integer asinCount;
@Schema(description = "ASIN 列表")
private List<DeleteBrandCountryAsinVo> items = new ArrayList<>();
}

View File

@@ -33,6 +33,15 @@ public class DeleteBrandResultItemVo {
@Schema(description = "总行数") @Schema(description = "总行数")
private Integer totalRows; private Integer totalRows;
@Schema(description = "任务ID")
private Long taskId;
@Schema(description = "国家数量")
private Integer countryCount;
@Schema(description = "按国家分组的数据")
private List<DeleteBrandCountryGroupVo> countries = new ArrayList<>();
@Schema(description = "是否已截断") @Schema(description = "是否已截断")
private boolean truncated; private boolean truncated;

View File

@@ -5,8 +5,15 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSourceFileDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSourceFileDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryAsinVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo; import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo; 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.DeleteBrandResultItemVo;
@@ -16,8 +23,6 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
@@ -29,20 +34,21 @@ import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class DeleteBrandRunService { public class DeleteBrandRunService {
private static final String MODULE_TYPE = "DELETE_BRAND"; private static final String MODULE_TYPE = "DELETE_BRAND";
private static final int PREVIEW_LIMIT = 200;
private final FileTaskMapper fileTaskMapper; private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper; private final FileResultMapper fileResultMapper;
private final LocalFileStorageService localFileStorageService; private final LocalFileStorageService localFileStorageService;
private final ZiniaoAuthService ziniaoAuthService; private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService;
public DeleteBrandRunVo run(DeleteBrandRunRequest request) { public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) { if (request.getUserId() == null || request.getUserId() <= 0) {
@@ -53,21 +59,22 @@ public class DeleteBrandRunService {
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr()); task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE); task.setModuleType(MODULE_TYPE);
task.setTaskMode("IMMEDIATE"); task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS"); task.setStatus("RUNNING");
task.setSourceFileCount(request.getFiles().size()); task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("user:" + request.getUserId()); task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId()); task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request)); task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now()); task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
fileTaskMapper.insert(task); fileTaskMapper.insert(task);
List<ZiniaoShopCacheDto> shops = ziniaoAuthService.listShopsForConfiguredUser(); // 先不与紫鸟联动:仅解析 Excel 并返回解析 payloadshop 匹配和 openStoreUrl 先不生成)
List<DeleteBrandResultItemVo> items = new ArrayList<>(); List<DeleteBrandResultItemVo> items = new ArrayList<>();
int successCount = 0; int successCount = 0;
int failedCount = 0; int failedCount = 0;
Map<String, ParsedDeleteBrandFile> parsedPayloadBySourceFilename = new LinkedHashMap<>();
for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) { for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) {
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
item.setSourceFilename(sourceFile.getOriginalFilename()); item.setSourceFilename(sourceFile.getOriginalFilename());
@@ -78,20 +85,35 @@ public class DeleteBrandRunService {
throw new BusinessException("上传文件不存在,请重新上传"); throw new BusinessException("上传文件不存在,请重新上传");
} }
ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile); ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile);
item.setTaskId(task.getId());
item.setCountryCount(parsed.countries().size());
item.setCountries(parsed.countries());
item.setTotalRows(parsed.totalRows()); item.setTotalRows(parsed.totalRows());
item.setTruncated(parsed.truncated()); item.setTruncated(false);
item.setPreviewRows(parsed.previewRows()); item.setPreviewRows(parsed.previewRows());
ZiniaoShopCacheDto matchedShop = matchShop(item.getShopName(), shops); try {
if (matchedShop != null) { com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null);
item.setMatched(true); item.setMatched(matchResult.matched());
item.setShopId(matchedShop.getShopId()); if (matchResult.matched()) {
item.setPlatform(matchedShop.getPlatform()); item.setShopId(matchResult.shopId());
item.setOpenStoreUrl(ziniaoAuthService.buildOpenStoreUrlForShop(matchedShop)); item.setPlatform(matchResult.platform());
} else { item.setOpenStoreUrl(matchResult.openStoreUrl());
item.setMatched(false); }
} catch (BusinessException ex) {
String message = ex.getMessage();
if (message != null && (message.contains("code=40004")
|| message.contains("userId存在无效的参数值")
|| message.contains("无权限")
|| message.contains("没有权限"))) {
item.setMatched(false);
} else {
throw ex;
}
} }
parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed);
item.setSuccess(true); item.setSuccess(true);
successCount++; successCount++;
@@ -103,6 +125,7 @@ public class DeleteBrandRunService {
resultEntity.setResultFileUrl(null); resultEntity.setResultFileUrl(null);
resultEntity.setResultFileSize(0L); resultEntity.setResultFileSize(0L);
resultEntity.setResultContentType("application/json"); resultEntity.setResultContentType("application/json");
resultEntity.setRowCount(parsed.totalRows());
resultEntity.setSuccess(1); resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId()); resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now()); resultEntity.setCreatedAt(LocalDateTime.now());
@@ -131,8 +154,15 @@ public class DeleteBrandRunService {
task.setFailedFileCount(failedCount); task.setFailedFileCount(failedCount);
task.setResultJson(JSONUtil.toJsonStr(items)); task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED");
fileTaskMapper.updateById(task); fileTaskMapper.updateById(task);
if (!parsedPayloadBySourceFilename.isEmpty()) {
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename);
}
DeleteBrandRunVo vo = new DeleteBrandRunVo(); DeleteBrandRunVo vo = new DeleteBrandRunVo();
vo.setTotal(request.getFiles().size()); vo.setTotal(request.getFiles().size());
vo.setSuccessCount(successCount); vo.setSuccessCount(successCount);
@@ -185,34 +215,68 @@ public class DeleteBrandRunService {
throw new BusinessException("未识别到删除品牌表头"); throw new BusinessException("未识别到删除品牌表头");
} }
List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>(); Map<String, Map<String, DeleteBrandCountryAsinVo>> grouped = new LinkedHashMap<>();
int totalRows = 0; Map<String, String> displayCountryNames = new LinkedHashMap<>();
boolean truncated = false;
for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) { for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum); Row row = sheet.getRow(rowNum);
if (row == null) { if (row == null) {
continue; continue;
} }
for (CountryColumnPair pair : pairs) { for (CountryColumnPair pair : pairs) {
String country = pair.country();
String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex()))); String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex())));
String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex()))); String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex())));
if (asin.isBlank() && status.isBlank()) { if (asin.isBlank() && status.isBlank()) {
continue; continue;
} }
totalRows++;
if (previewRows.size() < PREVIEW_LIMIT) { String asinKey = normalizeAsinKey(asin);
DeleteBrandPreviewRowVo item = new DeleteBrandPreviewRowVo(); if (asinKey.isBlank()) {
item.setRowIndex(rowNum + 1); continue;
item.setCountry(pair.country());
item.setAsin(asin);
item.setStatus(status);
previewRows.add(item);
} else {
truncated = true;
} }
displayCountryNames.putIfAbsent(country, country);
Map<String, DeleteBrandCountryAsinVo> countryMap = grouped.computeIfAbsent(country, ignored -> new LinkedHashMap<>());
if (countryMap.containsKey(asinKey)) {
continue;
}
DeleteBrandCountryAsinVo item = new DeleteBrandCountryAsinVo();
item.setRowIndex(rowNum + 1);
item.setAsin(asin);
item.setStatus(status);
countryMap.put(asinKey, item);
} }
} }
return new ParsedDeleteBrandFile(totalRows, truncated, previewRows);
List<DeleteBrandCountryGroupVo> countries = new ArrayList<>();
List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>();
int totalRows = 0;
for (CountryColumnPair pair : pairs) {
String country = pair.country();
Map<String, DeleteBrandCountryAsinVo> countryMap = grouped.get(country);
if (countryMap == null || countryMap.isEmpty()) {
continue;
}
DeleteBrandCountryGroupVo groupVo = new DeleteBrandCountryGroupVo();
groupVo.setCountry(displayCountryNames.getOrDefault(country, country));
groupVo.getItems().addAll(countryMap.values());
groupVo.setAsinCount(groupVo.getItems().size());
countries.add(groupVo);
for (DeleteBrandCountryAsinVo asinVo : groupVo.getItems()) {
DeleteBrandPreviewRowVo preview = new DeleteBrandPreviewRowVo();
preview.setRowIndex(asinVo.getRowIndex());
preview.setCountry(groupVo.getCountry());
preview.setAsin(asinVo.getAsin());
preview.setStatus(asinVo.getStatus());
previewRows.add(preview);
totalRows++;
}
}
return new ParsedDeleteBrandFile(totalRows, countries, previewRows);
} catch (BusinessException ex) { } catch (BusinessException ex) {
throw ex; throw ex;
} catch (Exception ex) { } catch (Exception ex) {
@@ -220,6 +284,13 @@ public class DeleteBrandRunService {
} }
} }
private String normalizeAsinKey(String value) {
if (value == null) {
return "";
}
return value.replace(" ", "").trim().toUpperCase(Locale.ROOT);
}
private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) { private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) {
List<CountryColumnPair> pairs = new ArrayList<>(); List<CountryColumnPair> pairs = new ArrayList<>();
int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum()); int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum());
@@ -238,19 +309,6 @@ public class DeleteBrandRunService {
return pairs; return pairs;
} }
private ZiniaoShopCacheDto matchShop(String shopName, List<ZiniaoShopCacheDto> shops) {
if (shopName == null || shopName.isBlank() || shops == null || shops.isEmpty()) {
return null;
}
String normalized = normalizeShopName(shopName);
for (ZiniaoShopCacheDto shop : shops) {
if (normalized.equals(normalizeShopName(shop.getShopName()))) {
return shop;
}
}
return null;
}
private String resolveShopName(DeleteBrandSourceFileDto sourceFile) { private String resolveShopName(DeleteBrandSourceFileDto sourceFile) {
String name = sourceFile.getOriginalFilename(); String name = sourceFile.getOriginalFilename();
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
@@ -259,16 +317,6 @@ public class DeleteBrandRunService {
return FileUtil.mainName(name).trim(); return FileUtil.mainName(name).trim();
} }
private String normalizeShopName(String value) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
.replace(" ", "")
.trim()
.toLowerCase(Locale.ROOT);
}
private String normalizeCellText(String value) { private String normalizeCellText(String value) {
if (value == null) { if (value == null) {
return ""; return "";
@@ -286,6 +334,65 @@ public class DeleteBrandRunService {
private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) { private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) {
} }
private record ParsedDeleteBrandFile(int totalRows, boolean truncated, List<DeleteBrandPreviewRowVo> previewRows) { public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getFiles() == null || request.getFiles().isEmpty()) {
throw new BusinessException("files 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
Map<String, ParsedDeleteBrandFile> parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId,
new TypeReference<Map<String, ParsedDeleteBrandFile>>() {
});
if (parsedPayload == null || parsedPayload.isEmpty()) {
throw new BusinessException("任务原始数据已过期,请重新执行解析");
}
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) {
throw new BusinessException("sourceFilename 不能为空");
}
ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename());
if (parsedFile == null) {
throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename());
}
Map<String, DeleteBrandCountryGroupVo> byCountry = new LinkedHashMap<>();
for (DeleteBrandCountryGroupVo group : parsedFile.countries()) {
byCountry.put(group.getCountry(), group);
}
for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) {
DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry());
if (group == null) {
throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry());
}
Map<String, DeleteBrandCountryAsinVo> allowed = new LinkedHashMap<>();
for (DeleteBrandCountryAsinVo asinVo : group.getItems()) {
allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo);
}
for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) {
String key = normalizeAsinKey(itemDto.getAsin());
if (key.isBlank() || !allowed.containsKey(key)) {
throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin());
}
}
}
}
throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑");
}
private record ParsedDeleteBrandFile(int totalRows,
List<DeleteBrandCountryGroupVo> countries,
List<DeleteBrandPreviewRowVo> previewRows) {
} }
} }

View File

@@ -0,0 +1,48 @@
package com.nanri.aiimage.modules.deletebrand.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class DeleteBrandTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public void saveParsedPayload(Long taskId, Object payload) {
try {
stringRedisTemplate.opsForValue().set(buildPayloadKey(taskId), objectMapper.writeValueAsString(payload), Duration.ofHours(PAYLOAD_TTL_HOURS));
} catch (Exception ex) {
throw new BusinessException("暂存删除品牌原始数据失败");
}
}
public <T> T getParsedPayload(Long taskId, TypeReference<T> typeReference) {
String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId));
if (raw == null || raw.isBlank()) {
return null;
}
try {
return objectMapper.readValue(raw, typeReference);
} catch (Exception ex) {
throw new BusinessException("读取删除品牌原始数据失败");
}
}
public void delete(Long taskId) {
stringRedisTemplate.delete(buildPayloadKey(taskId));
}
private String buildPayloadKey(Long taskId) {
return "delete-brand:task:parsed-payload:" + taskId;
}
}

View File

@@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
import java.net.URI;
import java.net.URL; import java.net.URL;
import java.util.Date; import java.util.Date;
import java.util.UUID; import java.util.UUID;
@@ -17,13 +18,13 @@ public class OssStorageService {
private final OssProperties ossProperties; private final OssProperties ossProperties;
/**
* 上传结果文件到 OSS返回 objectKey非预签名 URL
* 调用方应存储 objectKey下载时通过 generateFreshDownloadUrl 按需生成链接。
*/
public String uploadResultFile(File file, String moduleType) { public String uploadResultFile(File file, String moduleType) {
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
OSS ossClient = new OSSClientBuilder().build( OSS ossClient = buildClient();
"https://" + ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
try { try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file); ossClient.putObject(ossProperties.getBucket(), objectKey, file);
return objectKey; return objectKey;
@@ -32,12 +33,11 @@ public class OssStorageService {
} }
} }
/**
* 根据 objectKey 生成预签名下载 URL1小时有效
*/
public String generateDownloadUrl(String objectKey) { public String generateDownloadUrl(String objectKey) {
OSS ossClient = new OSSClientBuilder().build( OSS ossClient = buildClient();
"https://" + ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
try { try {
Date expiration = new Date(System.currentTimeMillis() + 3600_000L); Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration); URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
@@ -46,4 +46,52 @@ public class OssStorageService {
ossClient.shutdown(); ossClient.shutdown();
} }
} }
/**
* 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey}
*/
public String getPublicUrl(String objectKey) {
if (objectKey == null || objectKey.isBlank()) {
return null;
}
return String.format("https://%s.%s/%s", ossProperties.getBucket(), ossProperties.getEndpoint(), objectKey);
}
/**
* 从存储值中解析出 objectKey兼容两种格式
* - 旧格式:完整预签名 URLhttps://bucket.endpoint/objectKey?Expires=...
* - 新格式:直接是 objectKey如 result/dedupe/uuid/file.xlsx
*/
public String resolveObjectKey(String value) {
if (value == null || value.isBlank()) {
return value;
}
try {
if (value.startsWith("http://") || value.startsWith("https://")) {
String path = URI.create(value).getPath();
return path.startsWith("/") ? path.substring(1) : path;
}
} catch (Exception ignored) {
}
return value;
}
/**
* 根据存储值objectKey 或旧格式预签名 URL生成新鲜的预签名下载 URL。
* 供各模块 listHistory 使用,每次按需生成,避免旧 URL 1小时后过期。
*/
public String generateFreshDownloadUrl(String value) {
if (value == null || value.isBlank()) {
return null;
}
return generateDownloadUrl(resolveObjectKey(value));
}
private OSS buildClient() {
return new OSSClientBuilder().build(
"https://" + ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
}
} }

View File

@@ -123,8 +123,7 @@ public class SplitRunService {
if (!allChunkResults.isEmpty()) { if (!allChunkResults.isEmpty()) {
File zipFile = packageTaskSplitResultsAsZip(request.getArchiveName(), successSourceNames, allChunkResults); File zipFile = packageTaskSplitResultsAsZip(request.getArchiveName(), successSourceNames, allChunkResults);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE); String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); // 只存 objectKey
SplitResultItemVo item = new SplitResultItemVo(); SplitResultItemVo item = new SplitResultItemVo();
item.setSourceFilename(folderMode && request.getArchiveName() != null && !request.getArchiveName().isBlank() item.setSourceFilename(folderMode && request.getArchiveName() != null && !request.getArchiveName().isBlank()
? request.getArchiveName() ? request.getArchiveName()
@@ -132,7 +131,7 @@ public class SplitRunService {
item.setOutputFilename(zipFile.getName()); item.setOutputFilename(zipFile.getName());
item.setSuccess(true); item.setSuccess(true);
item.setRowCount(allChunkResults.stream().mapToInt(TaskSplitChunkResult::rowCount).sum()); item.setRowCount(allChunkResults.stream().mapToInt(TaskSplitChunkResult::rowCount).sum());
item.setDownloadUrl(downloadUrl); item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
item.setEntryCount(allChunkResults.size()); item.setEntryCount(allChunkResults.size());
item.setEntries(allChunkResults.stream().map(this::toArchiveEntry).toList()); item.setEntries(allChunkResults.stream().map(this::toArchiveEntry).toList());
@@ -141,7 +140,7 @@ public class SplitRunService {
resultEntity.setModuleType(MODULE_TYPE); resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(item.getSourceFilename()); resultEntity.setSourceFilename(item.getSourceFilename());
resultEntity.setResultFilename(zipFile.getName()); resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(downloadUrl); resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType(ZIP_CONTENT_TYPE); resultEntity.setResultContentType(ZIP_CONTENT_TYPE);
resultEntity.setRowCount(item.getRowCount()); resultEntity.setRowCount(item.getRowCount());
@@ -179,7 +178,9 @@ public class SplitRunService {
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename()); vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename()); vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null); vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1
? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())
: null);
vo.setRowCount(entity.getRowCount()); vo.setRowCount(entity.getRowCount());
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());

View File

@@ -6,13 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import java.util.List; import java.util.List;
public interface ZiniaoClient { public interface ZiniaoClient {
String getAppToken();
Long getCompanyIdByApiKey(); Long getCompanyIdByApiKey();
List<ZiniaoStaffItemVo> listStaff(Long companyId); List<ZiniaoStaffItemVo> listStaff(Long companyId);
List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId, String userToken); List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId);
String getUserLoginToken(Long companyId, Long userId); String getUserLoginToken(Long companyId, Long userId);
} }

View File

@@ -6,8 +6,8 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.config.ZiniaoProperties;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoSessionCacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -24,35 +24,6 @@ public class ZiniaoClientImpl implements ZiniaoClient {
private final ZiniaoProperties ziniaoProperties; private final ZiniaoProperties ziniaoProperties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
@Override
public String getAppToken() {
String cachedToken = ziniaoSessionCacheService.getAppToken();
if (cachedToken != null && !cachedToken.isBlank()) {
return cachedToken;
}
String raw = postWithApiKeyEmptyJsonBody(ziniaoProperties.getAppTokenPath(), "获取 appToken");
try {
JsonNode root = objectMapper.readTree(raw);
JsonNode data = firstNonNull(root.get("data"), root.get("result"), root);
String token = text(firstNonNull(
data == null ? null : data.get("appAuthToken"),
data == null ? null : data.get("appToken"),
root.get("appAuthToken"),
root.get("appToken")
));
if (token == null || token.isBlank()) {
throw new BusinessException("紫鸟 appToken 响应缺少 token");
}
ziniaoSessionCacheService.saveAppToken(token);
return token;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("解析紫鸟 appToken 响应失败");
}
}
@Override @Override
public Long getCompanyIdByApiKey() { public Long getCompanyIdByApiKey() {
@@ -104,14 +75,14 @@ public class ZiniaoClientImpl implements ZiniaoClient {
} }
@Override @Override
public List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId, String userToken) { public List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId) {
String raw = postWithAuthorization(ziniaoProperties.getUserStoresPath(), userToken, Map.of( String raw = postWithApiKey(ziniaoProperties.getUserStoresPath(), Map.of(
"companyId", companyId, "companyId", String.valueOf(companyId),
"userId", userId, "isAccurate", "",
"limit", "100",
"storeName", "", "storeName", "",
"isAccurate", 1, "page", "1",
"page", 1, "userId", String.valueOf(userId)
"limit", 10
), "获取员工店铺列表"); ), "获取员工店铺列表");
return parseUserStores(raw); return parseUserStores(raw);
} }
@@ -128,7 +99,15 @@ public class ZiniaoClientImpl implements ZiniaoClient {
throw new BusinessException("获取紫鸟员工登录 token 失败: " + Objects.toString(text(root.get("msg")), "未知错误")); throw new BusinessException("获取紫鸟员工登录 token 失败: " + Objects.toString(text(root.get("msg")), "未知错误"));
} }
JsonNode data = firstNonNull(root.get("data"), root.get("result")); JsonNode data = firstNonNull(root.get("data"), root.get("result"));
JsonNode payload = firstNonNull(
data == null ? null : data.get("data"),
data == null ? null : data.get("result"),
data,
root
);
String token = text(firstNonNull( String token = text(firstNonNull(
payload == null ? null : payload.get("token"),
payload == null ? null : payload.get("loginToken"),
data == null ? null : data.get("token"), data == null ? null : data.get("token"),
data == null ? null : data.get("loginToken"), data == null ? null : data.get("loginToken"),
root.get("token"), root.get("token"),
@@ -152,21 +131,9 @@ public class ZiniaoClientImpl implements ZiniaoClient {
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> headers.setBearerAuth(apiKey)) .headers(headers -> headers.setBearerAuth(apiKey))
.retrieve() .retrieve()
.body(String.class); .onStatus(HttpStatusCode::isError, (req, res) -> {
validateSuccess(raw, action, path); // 保留响应体,交给后续 validateSuccess 统一解析
return raw;
}
private String postWithApiKeyEmptyJsonBody(String path, String action) {
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
String raw = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> {
headers.setBearerAuth(apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
}) })
.body("")
.retrieve()
.body(String.class); .body(String.class);
validateSuccess(raw, action, path); validateSuccess(raw, action, path);
return raw; return raw;
@@ -185,6 +152,9 @@ public class ZiniaoClientImpl implements ZiniaoClient {
} }
String raw = request String raw = request
.retrieve() .retrieve()
.onStatus(HttpStatusCode::isError, (req, res) -> {
// 保留响应体,交给后续 validateSuccess 统一解析 code/sub_code/sub_msg
})
.body(String.class); .body(String.class);
validateSuccess(raw, action, path); validateSuccess(raw, action, path);
return raw; return raw;
@@ -195,7 +165,11 @@ public class ZiniaoClientImpl implements ZiniaoClient {
RestClient.RequestBodySpec request = getRestClient().post() RestClient.RequestBodySpec request = getRestClient().post()
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path)) .uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
.headers(headers -> { .headers(headers -> {
headers.set("Authorization", token); if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
headers.set("Authorization", token);
} else {
headers.setBearerAuth(token);
}
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
}); });
if (body != null) { if (body != null) {
@@ -212,7 +186,29 @@ public class ZiniaoClientImpl implements ZiniaoClient {
try { try {
JsonNode root = objectMapper.readTree(raw); JsonNode root = objectMapper.readTree(raw);
if (!success(root)) { if (!success(root)) {
throw new BusinessException("紫鸟接口返回失败(" + action + ", " + path + "): " + Objects.toString(text(root.get("msg")), "未知错误")); String code = Objects.toString(text(root.get("code")), "");
String msg = Objects.toString(text(root.get("msg")), "未知错误");
String subCode = Objects.toString(text(root.get("sub_code")), "");
String subMsg = Objects.toString(text(root.get("sub_msg")), "");
StringBuilder detail = new StringBuilder();
if (!code.isBlank()) {
detail.append("code=").append(code);
}
if (!subCode.isBlank()) {
if (!detail.isEmpty()) detail.append(", ");
detail.append("sub_code=").append(subCode);
}
if (!subMsg.isBlank()) {
if (!detail.isEmpty()) detail.append(", ");
detail.append("sub_msg=").append(subMsg);
}
if (!detail.isEmpty()) {
detail.append(", ");
}
detail.append("msg=").append(msg);
throw new BusinessException("紫鸟接口返回失败(" + action + ", " + path + "): " + detail);
} }
} catch (BusinessException ex) { } catch (BusinessException ex) {
throw ex; throw ex;
@@ -284,7 +280,13 @@ public class ZiniaoClientImpl implements ZiniaoClient {
private List<ZiniaoShopCacheDto> parseUserStores(String raw) { private List<ZiniaoShopCacheDto> parseUserStores(String raw) {
try { try {
JsonNode root = objectMapper.readTree(raw); JsonNode root = objectMapper.readTree(raw);
JsonNode itemsNode = firstNonNull(root.get("data"), root.get("result")); JsonNode wrapper = firstNonNull(root.get("data"), root.get("result"), root);
JsonNode itemsNode = firstNonNull(
wrapper == null ? null : wrapper.get("data"),
wrapper == null ? null : wrapper.get("items"),
wrapper
);
List<ZiniaoShopCacheDto> items = new ArrayList<>(); List<ZiniaoShopCacheDto> items = new ArrayList<>();
if (itemsNode != null && itemsNode.isArray()) { if (itemsNode != null && itemsNode.isArray()) {
for (JsonNode itemNode : itemsNode) { for (JsonNode itemNode : itemsNode) {

View File

@@ -26,6 +26,75 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ZiniaoAuthService { public class ZiniaoAuthService {
public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) {
ensureEnabled();
String normalizedTarget = normalizeShopName(targetShopName);
if (normalizedTarget.isBlank()) {
return new StoreMatchResult(false, null, null, null, null, null);
}
Long companyId = resolveCompanyId();
List<ZiniaoStaffItemVo> staff = ziniaoClient.listStaff(companyId);
List<Long> userIds = new java.util.ArrayList<>();
if (preferUserId != null && preferUserId > 0) {
userIds.add(preferUserId);
}
for (ZiniaoStaffItemVo item : staff) {
if (item != null && item.getUserId() != null && item.getUserId() > 0 && (userIds.isEmpty() || !userIds.contains(item.getUserId()))) {
userIds.add(item.getUserId());
}
}
for (Long staffUserId : userIds) {
if (staffUserId == null || staffUserId <= 0) {
continue;
}
List<ZiniaoShopCacheDto> stores;
try {
stores = ziniaoClient.listUserStores(companyId, staffUserId);
} catch (BusinessException ex) {
if (isSkippableUserStoresError(ex)) {
continue;
}
throw ex;
}
for (ZiniaoShopCacheDto store : stores) {
String storeName = normalizeShopName(store == null ? null : store.getShopName());
if (!storeName.isBlank() && storeName.equals(normalizedTarget)) {
String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, ziniaoClient.getUserLoginToken(companyId, staffUserId));
return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl);
}
}
}
return new StoreMatchResult(false, null, null, null, null, null);
}
private boolean isSkippableUserStoresError(BusinessException ex) {
String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) {
return false;
}
return message.contains("code=40004")
|| message.contains("userId存在无效的参数值")
|| message.contains("无权限")
|| message.contains("没有权限");
}
private String normalizeShopName(String value) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
.trim();
}
public record StoreMatchResult(boolean matched, String shopId, String shopName, String platform, Long matchedUserId,
String openStoreUrl) {
}
private final ZiniaoProperties ziniaoProperties; private final ZiniaoProperties ziniaoProperties;
private final ZiniaoClient ziniaoClient; private final ZiniaoClient ziniaoClient;
private final ZiniaoSessionCacheService ziniaoSessionCacheService; private final ZiniaoSessionCacheService ziniaoSessionCacheService;
@@ -36,13 +105,13 @@ public class ZiniaoAuthService {
ZiniaoSessionVo vo = new ZiniaoSessionVo(); ZiniaoSessionVo vo = new ZiniaoSessionVo();
vo.setSessionId(session.getSessionId()); vo.setSessionId(session.getSessionId());
vo.setEnabled(ziniaoProperties.isEnabled()); vo.setEnabled(ziniaoProperties.isEnabled());
vo.setAuthenticated(session.getAccessToken() != null && !session.getAccessToken().isBlank()); vo.setAuthenticated(Boolean.TRUE);
vo.setCompanyId(session.getCompanyId()); vo.setCompanyId(session.getCompanyId());
vo.setCurrentUserId(session.getCurrentUserId()); vo.setCurrentUserId(session.getCurrentUserId());
vo.setZiniaoUserId(session.getZiniaoUserId()); vo.setZiniaoUserId(session.getZiniaoUserId());
vo.setNickname(session.getNickname()); vo.setNickname(session.getNickname());
vo.setAppToken(session.getAccessToken()); vo.setAppToken(null);
vo.setTokenType(session.getTokenType()); vo.setTokenType(null);
vo.setExpireAt(session.getExpireAt()); vo.setExpireAt(session.getExpireAt());
vo.setShopCount(ziniaoSessionCacheService.getShops(session.getSessionId()).size()); vo.setShopCount(ziniaoSessionCacheService.getShops(session.getSessionId()).size());
vo.setCurrentShopId(session.getDefaultShopId()); vo.setCurrentShopId(session.getDefaultShopId());
@@ -61,8 +130,7 @@ public class ZiniaoAuthService {
public ZiniaoShopListVo listShops(String sessionId, Long userId) { public ZiniaoShopListVo listShops(String sessionId, Long userId) {
ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId); ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId);
Long currentUserId = requireUserId(session.getCurrentUserId()); Long currentUserId = requireUserId(session.getCurrentUserId());
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) { if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) {
session.setDefaultShopId(shops.get(0).getShopId()); session.setDefaultShopId(shops.get(0).getShopId());
@@ -86,7 +154,7 @@ public class ZiniaoAuthService {
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId); String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(session.getSessionId()); List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(session.getSessionId());
if (shops.isEmpty()) { if (shops.isEmpty()) {
shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken); shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops); ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
} }
ZiniaoShopCacheDto targetShop = shops.stream() ZiniaoShopCacheDto targetShop = shops.stream()
@@ -110,8 +178,7 @@ public class ZiniaoAuthService {
public List<ZiniaoShopCacheDto> listShopsForConfiguredUser() { public List<ZiniaoShopCacheDto> listShopsForConfiguredUser() {
ensureEnabled(); ensureEnabled();
Long userId = parseConfiguredOpenStoreUserId(); Long userId = parseConfiguredOpenStoreUserId();
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId); return ziniaoClient.listUserStores(resolveCompanyId(), userId);
return ziniaoClient.listUserStores(resolveCompanyId(), userId, userToken);
} }
public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) { public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) {
@@ -128,9 +195,6 @@ public class ZiniaoAuthService {
String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", ""); String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", "");
ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto(); ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto();
session.setSessionId(sessionId); session.setSessionId(sessionId);
String appToken = ziniaoClient.getAppToken();
session.setAccessToken(maskToken(appToken));
session.setTokenType("Bearer");
session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli()); session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli());
session.setCompanyId(resolveCompanyId()); session.setCompanyId(resolveCompanyId());
session.setCurrentUserId(userId); session.setCurrentUserId(userId);
@@ -186,7 +250,7 @@ public class ZiniaoAuthService {
if (ziniaoProperties.getOpenStoreExtraArgs() != null && !ziniaoProperties.getOpenStoreExtraArgs().isBlank()) { if (ziniaoProperties.getOpenStoreExtraArgs() != null && !ziniaoProperties.getOpenStoreExtraArgs().isBlank()) {
builder.queryParam("extraArgs", ziniaoProperties.getOpenStoreExtraArgs()); builder.queryParam("extraArgs", ziniaoProperties.getOpenStoreExtraArgs());
} }
return builder.build(true).toUriString(); return builder.build().encode().toUriString();
} }
private void ensureEnabled() { private void ensureEnabled() {

View File

@@ -17,27 +17,10 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ZiniaoSessionCacheService { public class ZiniaoSessionCacheService {
private static final String APP_TOKEN_KEY = "ziniao:app-token";
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ZiniaoProperties ziniaoProperties; private final ZiniaoProperties ziniaoProperties;
public void saveAppToken(String appToken) {
if (appToken == null || appToken.isBlank()) {
return;
}
stringRedisTemplate.opsForValue().set(APP_TOKEN_KEY, appToken.trim(), Duration.ofHours(ziniaoProperties.getSessionTtlHours()));
}
public String getAppToken() {
String value = stringRedisTemplate.opsForValue().get(APP_TOKEN_KEY);
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
public void saveSession(ZiniaoSessionCacheDto session) { public void saveSession(ZiniaoSessionCacheDto session) {
try { try {
stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours()));

View File

@@ -6,7 +6,7 @@
<aside class="left-panel"> <aside class="left-panel">
<div class="section-title">选择文件</div> <div class="section-title">选择文件</div>
<div class="upload-zone"> <div class="upload-zone">
<div class="hint">上传删除品牌 Excel无论是直接上传文件还是上传文件夹批量导入都会按每个 Excel 文件名匹配紫鸟店铺</div> <div class="hint">上传删除品牌 Excel无论是直接上传文件还是上传文件夹批量导入都会按每个 Excel 文件名作为店铺</div>
<div class="btns"> <div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button> <button type="button" class="opt-btn" @click="selectFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button> <button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
@@ -29,7 +29,7 @@
<input type="checkbox" checked disabled /> <input type="checkbox" checked disabled />
<div> <div>
<span class="label">文件名作为店铺名</span> <span class="label">文件名作为店铺名</span>
<div class="desc">例如 鲍丽明.xlsx 匹配紫鸟店铺 鲍丽明选择文件夹时也仍按每个 Excel 文件名匹配</div> <div class="desc">例如 鲍丽明.xlsx 解析为店铺 鲍丽明当前先不联动紫鸟</div>
</div> </div>
</label> </label>
@@ -44,8 +44,8 @@
<label class="radio-item active"> <label class="radio-item active">
<input type="checkbox" checked disabled /> <input type="checkbox" checked disabled />
<div> <div>
<span class="label">大表仅返回预览</span> <span class="label">按国家分组去重</span>
<div class="desc">当前最多展示前 200 避免大文件直接拖慢页面</div> <div class="desc">解析后按国家返回并在每个国家内按 ASIN 去重保留首次出现的状态</div>
</div> </div>
</label> </label>
</div> </div>
@@ -55,7 +55,7 @@
{{ running ? '解析中...' : '开始解析' }} {{ running ? '解析中...' : '开始解析' }}
</button> </button>
<span class="loading-msg"> <span class="loading-msg">
{{ running ? '正在读取删除品牌数据并匹配紫鸟店铺,请稍候…' : '解析后会在右侧展示预览数据和紫鸟店铺打开链接' }} {{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
</span> </span>
</div> </div>
</aside> </aside>
@@ -84,13 +84,13 @@
</div> </div>
<div v-if="resultItems.length === 0" class="empty-tasks"> <div v-if="resultItems.length === 0" class="empty-tasks">
暂无删除品牌结果完成解析后会在这里展示店铺匹配结果和预览数据 暂无删除品牌结果完成解析后会在这里展示按国家分组的去重结果
</div> </div>
<ul v-else class="task-list clean-result-list"> <ul v-else class="task-list clean-result-list">
<li <li
v-for="item in resultItems" v-for="item in resultItems"
:key="`${item.resultId || item.sourceFilename}-${item.shopId || ''}`" :key="`${item.resultId || item.taskId || item.sourceFilename}`"
class="task-item split-result-item" class="task-item split-result-item"
> >
<div class="left split-result-main"> <div class="left split-result-main">
@@ -98,8 +98,8 @@
<div class="files">店铺名{{ item.shopName || '-' }}</div> <div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div> <div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div> <div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.totalRows !== undefined" class="time">预览 {{ item.previewRows?.length || 0 }} / {{ item.totalRows }} </div> <div v-if="item.countryCount !== undefined" class="files">国家数{{ item.countryCount }}</div>
<div v-if="item.truncated" class="files">数据较大当前仅展示前 200 </div> <div v-if="item.totalRows !== undefined" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview"> <div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }} {{ formatPreview(item.previewRows) }}
</div> </div>
@@ -116,7 +116,7 @@
class="download" class="download"
@click="openShop(item.openStoreUrl)" @click="openShop(item.openStoreUrl)"
> >
打开店铺 打开紫鸟
</button> </button>
<button <button
v-if="item.resultId" v-if="item.resultId"
@@ -241,7 +241,6 @@ async function submitRun() {
}) })
summary.value = result summary.value = result
resultItems.value = result.items || [] resultItems.value = result.items || []
await loadHistory()
ElMessage.success('删除品牌解析完成') ElMessage.success('删除品牌解析完成')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败') ElMessage.error(error instanceof Error ? error.message : '执行失败')

View File

@@ -127,6 +127,37 @@ export interface DeleteBrandPreviewRow {
status: string status: string
} }
export interface DeleteBrandCountryAsinItem {
rowIndex: number
asin: string
status: string
}
export interface DeleteBrandCountryGroup {
country: string
asinCount: number
items: DeleteBrandCountryAsinItem[]
}
export interface DeleteBrandCountryResultItem {
asin: string
status?: string
}
export interface DeleteBrandProcessedCountry {
country: string
items: DeleteBrandCountryResultItem[]
}
export interface DeleteBrandResultFile {
sourceFilename: string
countries: DeleteBrandProcessedCountry[]
}
export interface DeleteBrandSubmitResultRequest {
files: DeleteBrandResultFile[]
}
export interface DeleteBrandResultItem { export interface DeleteBrandResultItem {
resultId?: number resultId?: number
sourceFilename: string sourceFilename: string
@@ -135,6 +166,9 @@ export interface DeleteBrandResultItem {
shopId?: string shopId?: string
platform?: string platform?: string
openStoreUrl?: string openStoreUrl?: string
taskId?: number
countryCount?: number
countries?: DeleteBrandCountryGroup[]
totalRows?: number totalRows?: number
truncated?: boolean truncated?: boolean
previewRows?: DeleteBrandPreviewRow[] previewRows?: DeleteBrandPreviewRow[]
@@ -277,6 +311,10 @@ export function deleteDeleteBrandHistory(resultId: number) {
})) }))
} }
export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubmitResultRequest) {
return unwrapJavaResponse(post<JavaApiResponse<null>, DeleteBrandSubmitResultRequest>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}/result`, request))
}
export function getJavaDownloadUrl(path: string) { export function getJavaDownloadUrl(path: string) {
const raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}` const raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
const separator = raw.includes('?') ? '&' : '?' const separator = raw.includes('?') ? '&' : '?'