删除品牌

This commit is contained in:
super
2026-03-27 21:32:00 +08:00
parent 7fcdf1a761
commit 15a78abc1b
29 changed files with 24219 additions and 217 deletions

View File

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

View File

@@ -277,7 +277,11 @@ public class BrandTaskService {
String originalFilename = resolveOriginalFilename(sourceFile, sourceLocalFile);
File outputFile = buildNamedOutputFile(outputDir, buildResultFilename(originalFilename));
writeBrandWorkbook(outputFile, request.getStrategy(), cachedFile, resultFile);
outputEntries.add(new OutputEntry(sourceLocalFile, originalFilename, outputFile, outputFile.getName()));
outputEntries.add(new OutputEntry(
sourceLocalFile,
originalFilename,
outputFile,
originalFilename));
}
brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_UPLOADING, finishedCount, totalCount);
@@ -338,18 +342,24 @@ public class BrandTaskService {
if (!(raw instanceof Map<?, ?> map)) {
throw new BusinessException("无结果可下载");
}
String stored = null;
Object zipUrl = map.get("zip_url");
if (zipUrl instanceof String zip && !zip.isBlank()) {
return zip;
}
Object urls = map.get("urls");
if (urls instanceof List<?> list && !list.isEmpty()) {
Object first = list.get(0);
if (first instanceof String url && !url.isBlank()) {
return url;
stored = zip;
} else {
Object urls = map.get("urls");
if (urls instanceof List<?> list && !list.isEmpty()) {
Object first = list.get(0);
if (first instanceof String url && !url.isBlank()) {
stored = url;
}
}
}
throw new BusinessException("无结果可下载");
if (stored == null || stored.isBlank()) {
throw new BusinessException("无结果可下载");
}
// 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL
return ossStorageService.generateFreshDownloadUrl(stored);
}
private List<BrandCrawlTaskEntity> listTaskEntities(Long userId) {
@@ -852,28 +862,29 @@ public class BrandTaskService {
if (entries.isEmpty()) {
throw new BusinessException("没有可上传的结果文件");
}
List<String> urls = new ArrayList<>();
List<String> fullUrls = new ArrayList<>();
for (OutputEntry entry : entries) {
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND");
urls.add(ossStorageService.generateDownloadUrl(objectKey));
fullUrls.add(ossStorageService.getPublicUrl(objectKey));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("urls", urls);
result.put("urls", fullUrls);
File zipFile = packageAsZip(taskId, entries);
String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND");
result.put("zip_url", ossStorageService.generateDownloadUrl(zipObjectKey));
result.put("zip_url", ossStorageService.getPublicUrl(zipObjectKey));
return result;
}
private File packageAsZip(Long taskId, List<OutputEntry> entries) throws IOException {
File zipDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "brand-result", String.valueOf(taskId)));
File zipFile = buildNamedOutputFile(zipDir, "brand_task_" + taskId + ".zip");
File zipFile = buildNamedOutputFile(zipDir, buildArchiveFilename(entries));
Set<String> zipEntryNames = new LinkedHashSet<>();
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
byte[] buffer = new byte[8192];
for (OutputEntry entry : entries) {
writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.sourceFilename()), zipEntryNames);
writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.resultFilename()), zipEntryNames);
writeZipEntry(zos, buffer, entry.sourceFile(), buildSourceZipEntry(entry.zipSourceFilename()), zipEntryNames);
writeZipEntry(zos, buffer, entry.resultFile(), buildResultZipEntry(entry.zipResultFilename()), zipEntryNames);
}
}
return zipFile;
@@ -933,8 +944,15 @@ public class BrandTaskService {
}
private String buildResultFilename(String originalFilename) {
String sourceName = blankToDefault(originalFilename, "brand.xlsx");
return FileUtil.mainName(sourceName) + "_result.xlsx";
return blankToDefault(originalFilename, "brand.xlsx");
}
private String buildArchiveFilename(List<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) {
@@ -1062,6 +1080,6 @@ public class BrandTaskService {
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 ossObjectKey = ossStorageService.uploadResultFile(packagedResultFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
// 只存 objectKey
ConvertResultItemVo item = new ConvertResultItemVo();
item.setSourceFilename(inputName);
item.setSuccess(true);
item.setOutputFilename(packagedFilename);
item.setDownloadUrl(downloadUrl);
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(inputName);
resultEntity.setResultFilename(packagedFilename);
resultEntity.setResultFileUrl(downloadUrl);
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(packagedResultFile.length());
resultEntity.setResultContentType(contentType);
resultEntity.setSuccess(1);
@@ -161,20 +160,19 @@ public class ConvertRunService {
if (folderMode && !archiveEntries.isEmpty()) {
File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
// 只存 objectKey
ConvertResultItemVo item = new ConvertResultItemVo();
item.setSourceFilename(request.getArchiveName());
item.setOutputFilename(zipFile.getName());
item.setSuccess(true);
item.setDownloadUrl(downloadUrl);
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
FileResultEntity resultEntity = new FileResultEntity();
resultEntity.setTaskId(task.getId());
resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(request.getArchiveName());
resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(downloadUrl);
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType("application/zip");
resultEntity.setSuccess(1);
@@ -211,7 +209,9 @@ public class ConvertRunService {
vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null);
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1
? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())
: null);
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage());
return vo;

View File

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

View File

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

View File

@@ -5,8 +5,15 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSourceFileDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryAsinVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo;
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo;
@@ -16,8 +23,6 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
@@ -29,20 +34,21 @@ import java.io.File;
import java.io.FileInputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class DeleteBrandRunService {
private static final String MODULE_TYPE = "DELETE_BRAND";
private static final int PREVIEW_LIMIT = 200;
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final LocalFileStorageService localFileStorageService;
private final ZiniaoAuthService ziniaoAuthService;
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService;
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
@@ -53,21 +59,22 @@ public class DeleteBrandRunService {
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("IMMEDIATE");
task.setStatus("SUCCESS");
task.setStatus("RUNNING");
task.setSourceFileCount(request.getFiles().size());
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setRequestJson(JSONUtil.toJsonStr(request));
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
List<ZiniaoShopCacheDto> shops = ziniaoAuthService.listShopsForConfiguredUser();
// 先不与紫鸟联动:仅解析 Excel 并返回解析 payloadshop 匹配和 openStoreUrl 先不生成)
List<DeleteBrandResultItemVo> items = new ArrayList<>();
int successCount = 0;
int failedCount = 0;
Map<String, ParsedDeleteBrandFile> parsedPayloadBySourceFilename = new LinkedHashMap<>();
for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) {
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
item.setSourceFilename(sourceFile.getOriginalFilename());
@@ -78,20 +85,35 @@ public class DeleteBrandRunService {
throw new BusinessException("上传文件不存在,请重新上传");
}
ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile);
item.setTaskId(task.getId());
item.setCountryCount(parsed.countries().size());
item.setCountries(parsed.countries());
item.setTotalRows(parsed.totalRows());
item.setTruncated(parsed.truncated());
item.setTruncated(false);
item.setPreviewRows(parsed.previewRows());
ZiniaoShopCacheDto matchedShop = matchShop(item.getShopName(), shops);
if (matchedShop != null) {
item.setMatched(true);
item.setShopId(matchedShop.getShopId());
item.setPlatform(matchedShop.getPlatform());
item.setOpenStoreUrl(ziniaoAuthService.buildOpenStoreUrlForShop(matchedShop));
} else {
item.setMatched(false);
try {
com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null);
item.setMatched(matchResult.matched());
if (matchResult.matched()) {
item.setShopId(matchResult.shopId());
item.setPlatform(matchResult.platform());
item.setOpenStoreUrl(matchResult.openStoreUrl());
}
} catch (BusinessException ex) {
String message = ex.getMessage();
if (message != null && (message.contains("code=40004")
|| message.contains("userId存在无效的参数值")
|| message.contains("无权限")
|| message.contains("没有权限"))) {
item.setMatched(false);
} else {
throw ex;
}
}
parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed);
item.setSuccess(true);
successCount++;
@@ -103,6 +125,7 @@ public class DeleteBrandRunService {
resultEntity.setResultFileUrl(null);
resultEntity.setResultFileSize(0L);
resultEntity.setResultContentType("application/json");
resultEntity.setRowCount(parsed.totalRows());
resultEntity.setSuccess(1);
resultEntity.setUserId(request.getUserId());
resultEntity.setCreatedAt(LocalDateTime.now());
@@ -131,8 +154,15 @@ public class DeleteBrandRunService {
task.setFailedFileCount(failedCount);
task.setResultJson(JSONUtil.toJsonStr(items));
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED");
fileTaskMapper.updateById(task);
if (!parsedPayloadBySourceFilename.isEmpty()) {
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename);
}
DeleteBrandRunVo vo = new DeleteBrandRunVo();
vo.setTotal(request.getFiles().size());
vo.setSuccessCount(successCount);
@@ -185,34 +215,68 @@ public class DeleteBrandRunService {
throw new BusinessException("未识别到删除品牌表头");
}
List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>();
int totalRows = 0;
boolean truncated = false;
Map<String, Map<String, DeleteBrandCountryAsinVo>> grouped = new LinkedHashMap<>();
Map<String, String> displayCountryNames = new LinkedHashMap<>();
for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
for (CountryColumnPair pair : pairs) {
String country = pair.country();
String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex())));
String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex())));
if (asin.isBlank() && status.isBlank()) {
continue;
}
totalRows++;
if (previewRows.size() < PREVIEW_LIMIT) {
DeleteBrandPreviewRowVo item = new DeleteBrandPreviewRowVo();
item.setRowIndex(rowNum + 1);
item.setCountry(pair.country());
item.setAsin(asin);
item.setStatus(status);
previewRows.add(item);
} else {
truncated = true;
String asinKey = normalizeAsinKey(asin);
if (asinKey.isBlank()) {
continue;
}
displayCountryNames.putIfAbsent(country, country);
Map<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) {
throw ex;
} catch (Exception ex) {
@@ -220,6 +284,13 @@ public class DeleteBrandRunService {
}
}
private String normalizeAsinKey(String value) {
if (value == null) {
return "";
}
return value.replace(" ", "").trim().toUpperCase(Locale.ROOT);
}
private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) {
List<CountryColumnPair> pairs = new ArrayList<>();
int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum());
@@ -238,19 +309,6 @@ public class DeleteBrandRunService {
return pairs;
}
private ZiniaoShopCacheDto matchShop(String shopName, List<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) {
String name = sourceFile.getOriginalFilename();
if (name == null || name.isBlank()) {
@@ -259,16 +317,6 @@ public class DeleteBrandRunService {
return FileUtil.mainName(name).trim();
}
private String normalizeShopName(String value) {
if (value == null) {
return "";
}
return value.replace("\u3000", " ")
.replace(" ", "")
.trim()
.toLowerCase(Locale.ROOT);
}
private String normalizeCellText(String value) {
if (value == null) {
return "";
@@ -286,6 +334,65 @@ public class DeleteBrandRunService {
private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) {
}
private record ParsedDeleteBrandFile(int totalRows, boolean truncated, List<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 java.io.File;
import java.net.URI;
import java.net.URL;
import java.util.Date;
import java.util.UUID;
@@ -17,13 +18,13 @@ public class OssStorageService {
private final OssProperties ossProperties;
/**
* 上传结果文件到 OSS返回 objectKey非预签名 URL
* 调用方应存储 objectKey下载时通过 generateFreshDownloadUrl 按需生成链接。
*/
public String uploadResultFile(File file, String moduleType) {
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
OSS ossClient = new OSSClientBuilder().build(
"https://" + ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
OSS ossClient = buildClient();
try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file);
return objectKey;
@@ -32,12 +33,11 @@ public class OssStorageService {
}
}
/**
* 根据 objectKey 生成预签名下载 URL1小时有效
*/
public String generateDownloadUrl(String objectKey) {
OSS ossClient = new OSSClientBuilder().build(
"https://" + ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret()
);
OSS ossClient = buildClient();
try {
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
@@ -46,4 +46,52 @@ public class OssStorageService {
ossClient.shutdown();
}
}
/**
* 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey}
*/
public String getPublicUrl(String objectKey) {
if (objectKey == null || objectKey.isBlank()) {
return null;
}
return String.format("https://%s.%s/%s", ossProperties.getBucket(), ossProperties.getEndpoint(), objectKey);
}
/**
* 从存储值中解析出 objectKey兼容两种格式
* - 旧格式:完整预签名 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()) {
File zipFile = packageTaskSplitResultsAsZip(request.getArchiveName(), successSourceNames, allChunkResults);
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
// 只存 objectKey
SplitResultItemVo item = new SplitResultItemVo();
item.setSourceFilename(folderMode && request.getArchiveName() != null && !request.getArchiveName().isBlank()
? request.getArchiveName()
@@ -132,7 +131,7 @@ public class SplitRunService {
item.setOutputFilename(zipFile.getName());
item.setSuccess(true);
item.setRowCount(allChunkResults.stream().mapToInt(TaskSplitChunkResult::rowCount).sum());
item.setDownloadUrl(downloadUrl);
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
item.setEntryCount(allChunkResults.size());
item.setEntries(allChunkResults.stream().map(this::toArchiveEntry).toList());
@@ -141,7 +140,7 @@ public class SplitRunService {
resultEntity.setModuleType(MODULE_TYPE);
resultEntity.setSourceFilename(item.getSourceFilename());
resultEntity.setResultFilename(zipFile.getName());
resultEntity.setResultFileUrl(downloadUrl);
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
resultEntity.setResultFileSize(zipFile.length());
resultEntity.setResultContentType(ZIP_CONTENT_TYPE);
resultEntity.setRowCount(item.getRowCount());
@@ -179,7 +178,9 @@ public class SplitRunService {
vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 ? entity.getResultFileUrl() : null);
vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1
? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())
: null);
vo.setRowCount(entity.getRowCount());
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage());

View File

@@ -6,13 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
import java.util.List;
public interface ZiniaoClient {
String getAppToken();
Long getCompanyIdByApiKey();
List<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);
}

View File

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

View File

@@ -26,6 +26,75 @@ import java.util.UUID;
@RequiredArgsConstructor
public class ZiniaoAuthService {
public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) {
ensureEnabled();
String normalizedTarget = normalizeShopName(targetShopName);
if (normalizedTarget.isBlank()) {
return new StoreMatchResult(false, null, null, null, null, null);
}
Long companyId = resolveCompanyId();
List<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 ZiniaoClient ziniaoClient;
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
@@ -36,13 +105,13 @@ public class ZiniaoAuthService {
ZiniaoSessionVo vo = new ZiniaoSessionVo();
vo.setSessionId(session.getSessionId());
vo.setEnabled(ziniaoProperties.isEnabled());
vo.setAuthenticated(session.getAccessToken() != null && !session.getAccessToken().isBlank());
vo.setAuthenticated(Boolean.TRUE);
vo.setCompanyId(session.getCompanyId());
vo.setCurrentUserId(session.getCurrentUserId());
vo.setZiniaoUserId(session.getZiniaoUserId());
vo.setNickname(session.getNickname());
vo.setAppToken(session.getAccessToken());
vo.setTokenType(session.getTokenType());
vo.setAppToken(null);
vo.setTokenType(null);
vo.setExpireAt(session.getExpireAt());
vo.setShopCount(ziniaoSessionCacheService.getShops(session.getSessionId()).size());
vo.setCurrentShopId(session.getDefaultShopId());
@@ -61,8 +130,7 @@ public class ZiniaoAuthService {
public ZiniaoShopListVo listShops(String sessionId, Long userId) {
ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId);
Long currentUserId = requireUserId(session.getCurrentUserId());
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken);
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) {
session.setDefaultShopId(shops.get(0).getShopId());
@@ -86,7 +154,7 @@ public class ZiniaoAuthService {
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(session.getSessionId());
if (shops.isEmpty()) {
shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId, userToken);
shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
}
ZiniaoShopCacheDto targetShop = shops.stream()
@@ -110,8 +178,7 @@ public class ZiniaoAuthService {
public List<ZiniaoShopCacheDto> listShopsForConfiguredUser() {
ensureEnabled();
Long userId = parseConfiguredOpenStoreUserId();
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId);
return ziniaoClient.listUserStores(resolveCompanyId(), userId, userToken);
return ziniaoClient.listUserStores(resolveCompanyId(), userId);
}
public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) {
@@ -128,9 +195,6 @@ public class ZiniaoAuthService {
String sessionId = "ziniao_" + UUID.randomUUID().toString().replace("-", "");
ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto();
session.setSessionId(sessionId);
String appToken = ziniaoClient.getAppToken();
session.setAccessToken(maskToken(appToken));
session.setTokenType("Bearer");
session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli());
session.setCompanyId(resolveCompanyId());
session.setCurrentUserId(userId);
@@ -186,7 +250,7 @@ public class ZiniaoAuthService {
if (ziniaoProperties.getOpenStoreExtraArgs() != null && !ziniaoProperties.getOpenStoreExtraArgs().isBlank()) {
builder.queryParam("extraArgs", ziniaoProperties.getOpenStoreExtraArgs());
}
return builder.build(true).toUriString();
return builder.build().encode().toUriString();
}
private void ensureEnabled() {

View File

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