完成前后端紫鸟部分开发
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
{"version": "1.0.17"}
|
||||
{"version": "1.0.13"}
|
||||
@@ -28,4 +28,16 @@ public class ZiniaoProperties {
|
||||
private long shopsCacheMinutes;
|
||||
private int connectTimeoutSeconds;
|
||||
private int readTimeoutSeconds;
|
||||
|
||||
/**
|
||||
* 店铺匹配时最多尝试多少个 apiKey;
|
||||
* null/空/<=0 表示不限制(不推荐)。
|
||||
*/
|
||||
private Integer keyScanMaxKeys;
|
||||
|
||||
/**
|
||||
* 店铺匹配时最多扫描多少秒;
|
||||
* null/空/<=0 表示不限制(不推荐)。
|
||||
*/
|
||||
private Integer keyScanMaxSeconds;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -23,6 +25,13 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -59,12 +68,50 @@ public class DeleteBrandRunController {
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "获取删除品牌任务详情", description = "根据任务ID返回任务基础信息 + Redis 中的实时行级进度(文件/国家/ASIN)。")
|
||||
public ApiResponse<com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDetailVo> getTask(
|
||||
@PathVariable Long taskId) {
|
||||
return ApiResponse.success(deleteBrandRunService.getTaskDetail(taskId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "提交删除品牌处理结果", description = "前端插件处理完成后回传结果;后端将基于 run 时缓存的原始数据重组结果文件并上传(该逻辑后续实现)。")
|
||||
@Operation(summary = "提交删除品牌处理结果", description = "插件/前端分片回传处理结果;后端累计分片并推进进度,全部完成后再执行最终组装与落库。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody DeleteBrandSubmitResultRequest request) {
|
||||
deleteBrandRunService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/download")
|
||||
@Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url 直接保存。")
|
||||
public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId);
|
||||
String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "任务无可下载结果");
|
||||
}
|
||||
if (filename == null || filename.isBlank()) {
|
||||
String rawPath = URI.create(url).getPath();
|
||||
filename = rawPath == null ? "delete-brand_" + taskId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
response.getOutputStream().write(buffer, 0, read);
|
||||
}
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.deletebrand.model.cache;
|
||||
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class DeleteBrandParsedFileCacheDto {
|
||||
private String fileKey;
|
||||
private String sourceFilename;
|
||||
private String shopName;
|
||||
private Integer totalRows;
|
||||
private List<DeleteBrandCountryGroupVo> countries = new ArrayList<>();
|
||||
private List<DeleteBrandPreviewRowVo> previewRows = new ArrayList<>();
|
||||
}
|
||||
@@ -11,10 +11,37 @@ import java.util.List;
|
||||
@Data
|
||||
@Schema(description = "删除品牌单文件结果回传")
|
||||
public class DeleteBrandResultFileDto {
|
||||
@Schema(description = "稳定文件标识,优先使用 run 阶段返回的 fileKey")
|
||||
private String fileKey;
|
||||
|
||||
@NotBlank(message = "sourceFilename 不能为空")
|
||||
@Schema(description = "源文件名")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "当前是第几个文件,从 1 开始")
|
||||
private Integer fileIndex;
|
||||
|
||||
@Schema(description = "总文件数")
|
||||
private Integer fileTotal;
|
||||
|
||||
@Schema(description = "当前分片序号,从 1 开始")
|
||||
private Integer chunkIndex;
|
||||
|
||||
@Schema(description = "当前文件总分片数")
|
||||
private Integer chunkTotal;
|
||||
|
||||
@Schema(description = "当前文件已处理行数")
|
||||
private Integer processedRows;
|
||||
|
||||
@Schema(description = "当前文件总行数")
|
||||
private Integer totalRows;
|
||||
|
||||
@Schema(description = "当前国家")
|
||||
private String currentCountry;
|
||||
|
||||
@Schema(description = "当前 ASIN")
|
||||
private String currentAsin;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "按国家分组的处理结果")
|
||||
private List<DeleteBrandProcessedCountryDto> countries = new ArrayList<>();
|
||||
|
||||
@@ -11,6 +11,9 @@ import java.util.List;
|
||||
@Data
|
||||
@Schema(description = "删除品牌结果提交请求")
|
||||
public class DeleteBrandSubmitResultRequest {
|
||||
@Schema(description = "可选的提交批次ID,用于幂等追踪")
|
||||
private String submissionId;
|
||||
|
||||
@Valid
|
||||
@NotEmpty(message = "files 不能为空")
|
||||
@Schema(description = "按源文件维度提交结果")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.nanri.aiimage.modules.deletebrand.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "删除品牌任务实时进度详情")
|
||||
public class DeleteBrandLineProgressInfoVo {
|
||||
@Schema(description = "当前处理到第几个文件,从 1 开始")
|
||||
private Integer file_index;
|
||||
|
||||
@Schema(description = "本次任务总文件数")
|
||||
private Integer file_total;
|
||||
|
||||
@Schema(description = "当前处理文件名")
|
||||
private String file_name;
|
||||
|
||||
@Schema(description = "当前文件已处理行数")
|
||||
private Integer current_line;
|
||||
|
||||
@Schema(description = "当前文件总行数")
|
||||
private Integer total_lines;
|
||||
|
||||
@Schema(description = "当前国家")
|
||||
private String current_country;
|
||||
|
||||
@Schema(description = "当前 ASIN")
|
||||
private String current_asin;
|
||||
|
||||
@Schema(description = "已完成文件数")
|
||||
private Integer finished_files;
|
||||
|
||||
@Schema(description = "当前阶段:crawling/assembling/uploading/failed")
|
||||
private String phase;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.deletebrand.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "删除品牌任务实时行级进度")
|
||||
public class DeleteBrandLineProgressVo {
|
||||
@Schema(description = "当前是否存在实时进度")
|
||||
private boolean has_progress;
|
||||
|
||||
@Schema(description = "实时进度详情")
|
||||
private DeleteBrandLineProgressInfoVo info;
|
||||
}
|
||||
@@ -12,6 +12,9 @@ public class DeleteBrandResultItemVo {
|
||||
@Schema(description = "结果记录ID")
|
||||
private Long resultId;
|
||||
|
||||
@Schema(description = "上传文件 key / 稳定文件标识")
|
||||
private String fileKey;
|
||||
|
||||
@Schema(description = "源文件名")
|
||||
private String sourceFilename;
|
||||
|
||||
@@ -33,6 +36,12 @@ public class DeleteBrandResultItemVo {
|
||||
@Schema(description = "总行数")
|
||||
private Integer totalRows;
|
||||
|
||||
@Schema(description = "输出文件名")
|
||||
private String outputFilename;
|
||||
|
||||
@Schema(description = "下载地址")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "任务ID")
|
||||
private Long taskId;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.deletebrand.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "删除品牌任务详情响应")
|
||||
public class DeleteBrandTaskDetailVo {
|
||||
@Schema(description = "任务基础信息")
|
||||
private DeleteBrandTaskItemVo task;
|
||||
|
||||
@Schema(description = "实时行级进度信息,来自 Redis")
|
||||
private DeleteBrandLineProgressVo line_progress;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.nanri.aiimage.modules.deletebrand.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "删除品牌任务基础信息")
|
||||
public class DeleteBrandTaskItemVo {
|
||||
@Schema(description = "任务ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "任务状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "源文件数")
|
||||
private Integer sourceFileCount;
|
||||
|
||||
@Schema(description = "成功文件数")
|
||||
private Integer successFileCount;
|
||||
|
||||
@Schema(description = "失败文件数")
|
||||
private Integer failedFileCount;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Schema(description = "完成时间")
|
||||
private LocalDateTime finishedAt;
|
||||
|
||||
@Schema(description = "下载地址(任务级)")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "下载文件名(任务级)")
|
||||
private String downloadFilename;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto;
|
||||
@@ -15,40 +17,55 @@ import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRe
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryAsinVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandCountryGroupVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandHistoryVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressInfoVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandLineProgressVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandPreviewRowVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandResultItemVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandRunVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskDetailVo;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskItemVo;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeleteBrandRunService {
|
||||
|
||||
private static final String MODULE_TYPE = "DELETE_BRAND";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||
private final com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService ziniaoAuthService;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||
@@ -68,15 +85,16 @@ public class DeleteBrandRunService {
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
// 先不与紫鸟联动:仅解析 Excel 并返回解析 payload(shop 匹配和 openStoreUrl 先不生成)
|
||||
List<DeleteBrandResultItemVo> items = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
|
||||
Map<String, ParsedDeleteBrandFile> parsedPayloadBySourceFilename = new LinkedHashMap<>();
|
||||
int parsedSuccessCount = 0;
|
||||
int parsedFailedCount = 0;
|
||||
Map<String, DeleteBrandParsedFileCacheDto> parsedPayloadByFileIdentity = new LinkedHashMap<>();
|
||||
Map<String, com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult> storeMatchByShopName = new LinkedHashMap<>();
|
||||
|
||||
for (DeleteBrandSourceFileDto sourceFile : request.getFiles()) {
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setTaskId(task.getId());
|
||||
item.setFileKey(sourceFile.getFileKey());
|
||||
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
item.setShopName(resolveShopName(sourceFile));
|
||||
try {
|
||||
@@ -85,7 +103,6 @@ public class DeleteBrandRunService {
|
||||
throw new BusinessException("上传文件不存在,请重新上传");
|
||||
}
|
||||
ParsedDeleteBrandFile parsed = parseDeleteBrandFile(inputFile);
|
||||
item.setTaskId(task.getId());
|
||||
item.setCountryCount(parsed.countries().size());
|
||||
item.setCountries(parsed.countries());
|
||||
item.setTotalRows(parsed.totalRows());
|
||||
@@ -93,7 +110,11 @@ public class DeleteBrandRunService {
|
||||
item.setPreviewRows(parsed.previewRows());
|
||||
|
||||
try {
|
||||
com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null);
|
||||
String normalizedShopName = normalizeShopName(item.getShopName());
|
||||
com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult matchResult = normalizedShopName.isBlank()
|
||||
? new com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService.StoreMatchResult(false, null, null, null, null, null)
|
||||
: storeMatchByShopName.computeIfAbsent(normalizedShopName,
|
||||
ignored -> ziniaoAuthService.matchStoreByNameAcrossStaff(item.getShopName(), null));
|
||||
item.setMatched(matchResult.matched());
|
||||
if (matchResult.matched()) {
|
||||
item.setShopId(matchResult.shopId());
|
||||
@@ -112,21 +133,30 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
}
|
||||
|
||||
parsedPayloadBySourceFilename.put(sourceFile.getOriginalFilename(), parsed);
|
||||
String fileIdentity = resolveFileIdentity(sourceFile);
|
||||
DeleteBrandParsedFileCacheDto cacheDto = new DeleteBrandParsedFileCacheDto();
|
||||
cacheDto.setFileKey(sourceFile.getFileKey());
|
||||
cacheDto.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
cacheDto.setShopName(item.getShopName());
|
||||
cacheDto.setTotalRows(parsed.totalRows());
|
||||
cacheDto.setCountries(parsed.countries());
|
||||
cacheDto.setPreviewRows(parsed.previewRows());
|
||||
parsedPayloadByFileIdentity.put(fileIdentity, cacheDto);
|
||||
|
||||
item.setSuccess(true);
|
||||
successCount++;
|
||||
parsedSuccessCount++;
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(item.getSourceFilename());
|
||||
resultEntity.setResultFilename(item.getShopName());
|
||||
resultEntity.setSourceFileUrl(sourceFile.getFileKey());
|
||||
resultEntity.setResultFilename(null);
|
||||
resultEntity.setResultFileUrl(null);
|
||||
resultEntity.setResultFileSize(0L);
|
||||
resultEntity.setResultContentType("application/json");
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsed.totalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setSuccess(0);
|
||||
resultEntity.setUserId(request.getUserId());
|
||||
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(resultEntity);
|
||||
@@ -134,12 +164,13 @@ public class DeleteBrandRunService {
|
||||
} catch (Exception ex) {
|
||||
item.setSuccess(false);
|
||||
item.setError(ex.getMessage());
|
||||
failedCount++;
|
||||
parsedFailedCount++;
|
||||
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||
resultEntity.setSourceFileUrl(sourceFile.getFileKey());
|
||||
resultEntity.setSuccess(0);
|
||||
resultEntity.setErrorMessage(ex.getMessage());
|
||||
resultEntity.setUserId(request.getUserId());
|
||||
@@ -150,23 +181,25 @@ public class DeleteBrandRunService {
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
task.setSuccessFileCount(successCount);
|
||||
task.setFailedFileCount(failedCount);
|
||||
task.setSuccessFileCount(0);
|
||||
task.setFailedFileCount(parsedFailedCount);
|
||||
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (parsedSuccessCount <= 0) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED");
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
if (!parsedPayloadBySourceFilename.isEmpty()) {
|
||||
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename);
|
||||
if (!parsedPayloadByFileIdentity.isEmpty()) {
|
||||
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity);
|
||||
}
|
||||
|
||||
|
||||
DeleteBrandRunVo vo = new DeleteBrandRunVo();
|
||||
vo.setTotal(request.getFiles().size());
|
||||
vo.setSuccessCount(successCount);
|
||||
vo.setFailedCount(failedCount);
|
||||
vo.setSuccessCount(parsedSuccessCount);
|
||||
vo.setFailedCount(parsedFailedCount);
|
||||
vo.setItems(items);
|
||||
return vo;
|
||||
}
|
||||
@@ -182,8 +215,13 @@ public class DeleteBrandRunService {
|
||||
.map(entity -> {
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
item.setFileKey(entity.getSourceFileUrl());
|
||||
item.setSourceFilename(entity.getSourceFilename());
|
||||
item.setShopName(entity.getResultFilename());
|
||||
item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename()));
|
||||
item.setOutputFilename(entity.getResultFilename());
|
||||
item.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl()));
|
||||
item.setTotalRows(entity.getRowCount());
|
||||
item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
item.setError(entity.getErrorMessage());
|
||||
return item;
|
||||
@@ -291,6 +329,13 @@ public class DeleteBrandRunService {
|
||||
return value.replace(" ", "").trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeShopName(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\u3000", " ").trim();
|
||||
}
|
||||
|
||||
private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) {
|
||||
List<CountryColumnPair> pairs = new ArrayList<>();
|
||||
int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum());
|
||||
@@ -334,37 +379,116 @@ public class DeleteBrandRunService {
|
||||
private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) {
|
||||
}
|
||||
|
||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
private String resolveFileIdentity(DeleteBrandSourceFileDto sourceFile) {
|
||||
if (sourceFile != null && sourceFile.getFileKey() != null && !sourceFile.getFileKey().isBlank()) {
|
||||
return sourceFile.getFileKey().trim();
|
||||
}
|
||||
if (request == null || request.getFiles() == null || request.getFiles().isEmpty()) {
|
||||
throw new BusinessException("files 不能为空");
|
||||
return sourceFile == null ? "" : String.valueOf(sourceFile.getOriginalFilename());
|
||||
}
|
||||
|
||||
public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
Map<String, ParsedDeleteBrandFile> parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId,
|
||||
new TypeReference<Map<String, ParsedDeleteBrandFile>>() {
|
||||
});
|
||||
if (parsedPayload == null || parsedPayload.isEmpty()) {
|
||||
throw new BusinessException("任务原始数据已过期,请重新执行解析");
|
||||
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
||||
taskVo.setId(task.getId());
|
||||
taskVo.setTaskNo(task.getTaskNo());
|
||||
taskVo.setStatus(task.getStatus());
|
||||
taskVo.setSourceFileCount(task.getSourceFileCount());
|
||||
taskVo.setSuccessFileCount(task.getSuccessFileCount());
|
||||
taskVo.setFailedFileCount(task.getFailedFileCount());
|
||||
taskVo.setErrorMessage(task.getErrorMessage());
|
||||
taskVo.setCreatedAt(task.getCreatedAt());
|
||||
taskVo.setUpdatedAt(task.getUpdatedAt());
|
||||
taskVo.setFinishedAt(task.getFinishedAt());
|
||||
taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId()));
|
||||
taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId()));
|
||||
|
||||
DeleteBrandLineProgressVo progressVo = buildLineProgress(taskId);
|
||||
|
||||
DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo();
|
||||
detail.setTask(taskVo);
|
||||
detail.setLine_progress(progressVo);
|
||||
return detail;
|
||||
}
|
||||
|
||||
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
|
||||
if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) {
|
||||
throw new BusinessException("sourceFilename 不能为空");
|
||||
private DeleteBrandLineProgressVo buildLineProgress(Long taskId) {
|
||||
Map<Object, Object> progress = deleteBrandTaskCacheService.getProgress(taskId);
|
||||
DeleteBrandLineProgressVo vo = new DeleteBrandLineProgressVo();
|
||||
if (progress == null || progress.isEmpty()) {
|
||||
vo.setHas_progress(false);
|
||||
vo.setInfo(null);
|
||||
return vo;
|
||||
}
|
||||
ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename());
|
||||
if (parsedFile == null) {
|
||||
throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename());
|
||||
DeleteBrandLineProgressInfoVo info = new DeleteBrandLineProgressInfoVo();
|
||||
info.setFile_index(parseInt(progress.get("file_index")));
|
||||
info.setFile_total(parseInt(progress.get("file_total")));
|
||||
info.setFile_name(stringValue(progress.get("file_name")));
|
||||
info.setCurrent_line(parseInt(progress.get("current_line")));
|
||||
info.setTotal_lines(parseInt(progress.get("total_lines")));
|
||||
info.setCurrent_country(stringValue(progress.get("current_country")));
|
||||
info.setCurrent_asin(stringValue(progress.get("current_asin")));
|
||||
info.setFinished_files(parseInt(progress.get("finished_files")));
|
||||
info.setPhase(stringValue(progress.get("phase")));
|
||||
vo.setHas_progress(true);
|
||||
vo.setInfo(info);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Integer parseInt(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = String.valueOf(value);
|
||||
return raw.isBlank() ? null : raw;
|
||||
}
|
||||
|
||||
private String resolveFileIdentity(DeleteBrandResultFileDto fileDto) {
|
||||
if (fileDto != null && fileDto.getFileKey() != null && !fileDto.getFileKey().isBlank()) {
|
||||
return fileDto.getFileKey().trim();
|
||||
}
|
||||
return fileDto == null ? "" : blankToEmpty(fileDto.getSourceFilename());
|
||||
}
|
||||
|
||||
private void validateChunk(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
if (fileDto.getChunkIndex() == null || fileDto.getChunkTotal() == null
|
||||
|| fileDto.getChunkIndex() <= 0 || fileDto.getChunkTotal() <= 0
|
||||
|| fileDto.getChunkIndex() > fileDto.getChunkTotal()) {
|
||||
throw new BusinessException("分片参数不合法");
|
||||
}
|
||||
if (fileDto.getTotalRows() != null && parsedFile.getTotalRows() != null && fileDto.getTotalRows() > parsedFile.getTotalRows()) {
|
||||
throw new BusinessException("totalRows 不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFileCompleted(DeleteBrandResultFileDto fileDto) {
|
||||
return fileDto.getChunkIndex() != null && fileDto.getChunkTotal() != null && fileDto.getChunkIndex().equals(fileDto.getChunkTotal());
|
||||
}
|
||||
|
||||
private int defaultInt(Integer value, int defaultValue) {
|
||||
return value == null ? defaultValue : value;
|
||||
}
|
||||
|
||||
private String blankToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private void validateResultFileAgainstParsed(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
Map<String, DeleteBrandCountryGroupVo> byCountry = new LinkedHashMap<>();
|
||||
for (DeleteBrandCountryGroupVo group : parsedFile.countries()) {
|
||||
for (DeleteBrandCountryGroupVo group : parsedFile.getCountries()) {
|
||||
byCountry.put(group.getCountry(), group);
|
||||
}
|
||||
|
||||
@@ -388,11 +512,379 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑");
|
||||
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, DeleteBrandParsedFileCacheDto> parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId,
|
||||
new TypeReference<Map<String, DeleteBrandParsedFileCacheDto>>() {
|
||||
});
|
||||
if (parsedPayload == null || parsedPayload.isEmpty()) {
|
||||
throw new BusinessException("任务原始数据已过期,请重新执行解析");
|
||||
}
|
||||
|
||||
task.setStatus("RUNNING");
|
||||
task.setErrorMessage(null);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(null);
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
|
||||
String fileIdentity = resolveFileIdentity(fileDto);
|
||||
if (fileIdentity.isBlank()) {
|
||||
throw new BusinessException("fileKey/sourceFilename 不能为空");
|
||||
}
|
||||
DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity);
|
||||
if (parsedFile == null) {
|
||||
throw new BusinessException("文件标识不匹配: " + fileIdentity);
|
||||
}
|
||||
|
||||
validateChunk(fileDto, parsedFile);
|
||||
validateResultFileAgainstParsed(fileDto, parsedFile);
|
||||
deleteBrandTaskCacheService.mergeResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto);
|
||||
|
||||
Map<String, String> progress = new LinkedHashMap<>();
|
||||
progress.put("phase", DeleteBrandTaskCacheService.PHASE_CRAWLING);
|
||||
progress.put("file_index", String.valueOf(defaultInt(fileDto.getFileIndex(), 0)));
|
||||
progress.put("file_total", String.valueOf(defaultInt(fileDto.getFileTotal(), parsedPayload.size())));
|
||||
progress.put("file_name", blankToEmpty(fileDto.getSourceFilename()));
|
||||
progress.put("current_line", String.valueOf(defaultInt(fileDto.getProcessedRows(), 0)));
|
||||
progress.put("total_lines", String.valueOf(defaultInt(fileDto.getTotalRows(), parsedFile.getTotalRows() == null ? 0 : parsedFile.getTotalRows())));
|
||||
progress.put("current_country", blankToEmpty(fileDto.getCurrentCountry()));
|
||||
progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin()));
|
||||
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
|
||||
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
|
||||
deleteBrandTaskCacheService.saveProgress(taskId, progress);
|
||||
}
|
||||
|
||||
Map<String, List<DeleteBrandResultFileDto>> mergedByFile = loadMergedChunks(taskId);
|
||||
int finishedFiles = countCompletedFiles(mergedByFile);
|
||||
|
||||
Map<String, String> progress = new LinkedHashMap<>();
|
||||
progress.put("finished_files", String.valueOf(finishedFiles));
|
||||
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
|
||||
deleteBrandTaskCacheService.saveProgress(taskId, progress);
|
||||
|
||||
task.setSuccessFileCount(finishedFiles);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
if (finishedFiles < parsedPayload.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizeTask(task, parsedPayload, mergedByFile);
|
||||
}
|
||||
|
||||
private Map<String, List<DeleteBrandResultFileDto>> loadMergedChunks(Long taskId) {
|
||||
Map<String, List<String>> groupedJson = deleteBrandTaskCacheService.groupResultChunkJsonByFile(taskId);
|
||||
Map<String, List<DeleteBrandResultFileDto>> merged = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> entry : groupedJson.entrySet()) {
|
||||
List<DeleteBrandResultFileDto> chunks = new ArrayList<>();
|
||||
for (String raw : entry.getValue()) {
|
||||
try {
|
||||
chunks.add(objectMapper.readValue(raw, DeleteBrandResultFileDto.class));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("读取删除品牌结果分片失败");
|
||||
}
|
||||
}
|
||||
chunks.sort(Comparator.comparing(DeleteBrandResultFileDto::getChunkIndex, Comparator.nullsLast(Integer::compareTo)));
|
||||
merged.put(entry.getKey(), chunks);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private int countCompletedFiles(Map<String, List<DeleteBrandResultFileDto>> mergedByFile) {
|
||||
int finishedFiles = 0;
|
||||
for (List<DeleteBrandResultFileDto> chunks : mergedByFile.values()) {
|
||||
if (isMergedFileCompleted(chunks)) {
|
||||
finishedFiles++;
|
||||
}
|
||||
}
|
||||
return finishedFiles;
|
||||
}
|
||||
|
||||
private boolean isMergedFileCompleted(List<DeleteBrandResultFileDto> chunks) {
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Integer chunkTotal = chunks.get(0).getChunkTotal();
|
||||
if (chunkTotal == null || chunkTotal <= 0 || chunks.size() < chunkTotal) {
|
||||
return false;
|
||||
}
|
||||
Set<Integer> indexes = new LinkedHashSet<>();
|
||||
for (DeleteBrandResultFileDto chunk : chunks) {
|
||||
if (chunk.getChunkTotal() == null || !chunkTotal.equals(chunk.getChunkTotal()) || chunk.getChunkIndex() == null) {
|
||||
return false;
|
||||
}
|
||||
indexes.add(chunk.getChunkIndex());
|
||||
}
|
||||
for (int i = 1; i <= chunkTotal; i++) {
|
||||
if (!indexes.contains(i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void finalizeTask(FileTaskEntity task,
|
||||
Map<String, DeleteBrandParsedFileCacheDto> parsedPayload,
|
||||
Map<String, List<DeleteBrandResultFileDto>> mergedByFile) {
|
||||
try {
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", DeleteBrandTaskCacheService.PHASE_ASSEMBLING,
|
||||
"finished_files", String.valueOf(parsedPayload.size()),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
|
||||
List<FileResultEntity> resultEntities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getTaskId, task.getId())
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
Map<String, FileResultEntity> resultEntityByIdentity = new LinkedHashMap<>();
|
||||
for (FileResultEntity entity : resultEntities) {
|
||||
String identity = entity.getSourceFileUrl();
|
||||
if (identity == null || identity.isBlank()) {
|
||||
identity = blankToEmpty(entity.getSourceFilename());
|
||||
}
|
||||
if (!identity.isBlank()) {
|
||||
resultEntityByIdentity.putIfAbsent(identity, entity);
|
||||
}
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
List<DeleteBrandResultItemVo> finalItems = new ArrayList<>();
|
||||
for (Map.Entry<String, DeleteBrandParsedFileCacheDto> entry : parsedPayload.entrySet()) {
|
||||
String fileIdentity = entry.getKey();
|
||||
DeleteBrandParsedFileCacheDto parsedFile = entry.getValue();
|
||||
List<DeleteBrandResultFileDto> chunks = mergedByFile.get(fileIdentity);
|
||||
if (!isMergedFileCompleted(chunks)) {
|
||||
throw new BusinessException("存在未完成的结果分片: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks);
|
||||
File outputFile = buildResultWorkbook(task.getId(), parsedFile, mergedFile);
|
||||
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", DeleteBrandTaskCacheService.PHASE_UPLOADING,
|
||||
"file_name", blankToEmpty(parsedFile.getSourceFilename()),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
|
||||
String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE);
|
||||
FileResultEntity resultEntity = resultEntityByIdentity.get(fileIdentity);
|
||||
if (resultEntity == null) {
|
||||
throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
resultEntity.setResultFilename(outputFile.getName());
|
||||
resultEntity.setResultFileUrl(objectKey);
|
||||
resultEntity.setResultFileSize(outputFile.length());
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsedFile.getTotalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(resultEntity.getId());
|
||||
item.setTaskId(task.getId());
|
||||
item.setFileKey(parsedFile.getFileKey());
|
||||
item.setSourceFilename(parsedFile.getSourceFilename());
|
||||
item.setShopName(parsedFile.getShopName());
|
||||
item.setOutputFilename(outputFile.getName());
|
||||
item.setDownloadUrl(ossStorageService.getPublicUrl(objectKey));
|
||||
item.setTotalRows(parsedFile.getTotalRows());
|
||||
item.setCountryCount(parsedFile.getCountries() == null ? 0 : parsedFile.getCountries().size());
|
||||
item.setCountries(parsedFile.getCountries());
|
||||
item.setPreviewRows(parsedFile.getPreviewRows());
|
||||
item.setTruncated(false);
|
||||
item.setSuccess(true);
|
||||
finalItems.add(item);
|
||||
successCount++;
|
||||
}
|
||||
|
||||
task.setStatus("SUCCESS");
|
||||
task.setSuccessFileCount(successCount);
|
||||
task.setErrorMessage(null);
|
||||
task.setResultJson(JSONUtil.toJsonStr(finalItems));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", "success",
|
||||
"file_index", String.valueOf(parsedPayload.size()),
|
||||
"file_total", String.valueOf(parsedPayload.size()),
|
||||
"finished_files", String.valueOf(successCount),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
} catch (Exception ex) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(ex.getMessage());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", DeleteBrandTaskCacheService.PHASE_FAILED,
|
||||
"finished_files", String.valueOf(task.getSuccessFileCount() == null ? 0 : task.getSuccessFileCount()),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
if (ex instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("删除品牌结果组装失败");
|
||||
}
|
||||
}
|
||||
|
||||
private MergedDeleteBrandFile mergeChunks(DeleteBrandParsedFileCacheDto parsedFile, List<DeleteBrandResultFileDto> chunks) {
|
||||
Integer chunkTotal = chunks.get(0).getChunkTotal();
|
||||
for (DeleteBrandResultFileDto chunk : chunks) {
|
||||
if (!chunkTotal.equals(chunk.getChunkTotal())) {
|
||||
throw new BusinessException("chunkTotal 不一致: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Map<String, DeleteBrandCountryResultItemDto>> processedByCountry = new LinkedHashMap<>();
|
||||
int processedRows = 0;
|
||||
for (DeleteBrandResultFileDto chunk : chunks) {
|
||||
processedRows = Math.max(processedRows, defaultInt(chunk.getProcessedRows(), 0));
|
||||
for (DeleteBrandProcessedCountryDto countryDto : chunk.getCountries()) {
|
||||
Map<String, DeleteBrandCountryResultItemDto> byAsin = processedByCountry.computeIfAbsent(countryDto.getCountry(), ignored -> new LinkedHashMap<>());
|
||||
for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) {
|
||||
byAsin.put(normalizeAsinKey(itemDto.getAsin()), itemDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DeleteBrandProcessedCountryDto> mergedCountries = new ArrayList<>();
|
||||
for (DeleteBrandCountryGroupVo parsedCountry : parsedFile.getCountries()) {
|
||||
Map<String, DeleteBrandCountryResultItemDto> processedItems = processedByCountry.get(parsedCountry.getCountry());
|
||||
if (processedItems == null) {
|
||||
throw new BusinessException("缺少国家结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry());
|
||||
}
|
||||
DeleteBrandProcessedCountryDto mergedCountry = new DeleteBrandProcessedCountryDto();
|
||||
mergedCountry.setCountry(parsedCountry.getCountry());
|
||||
for (DeleteBrandCountryAsinVo parsedAsin : parsedCountry.getItems()) {
|
||||
DeleteBrandCountryResultItemDto processed = processedItems.get(normalizeAsinKey(parsedAsin.getAsin()));
|
||||
if (processed == null) {
|
||||
throw new BusinessException("缺少 ASIN 结果: " + parsedFile.getSourceFilename() + " / " + parsedCountry.getCountry() + " / " + parsedAsin.getAsin());
|
||||
}
|
||||
DeleteBrandCountryResultItemDto row = new DeleteBrandCountryResultItemDto();
|
||||
row.setAsin(parsedAsin.getAsin());
|
||||
row.setStatus(processed.getStatus());
|
||||
mergedCountry.getItems().add(row);
|
||||
}
|
||||
mergedCountries.add(mergedCountry);
|
||||
}
|
||||
|
||||
return new MergedDeleteBrandFile(
|
||||
blankToEmpty(chunks.get(chunks.size() - 1).getCurrentCountry()),
|
||||
blankToEmpty(chunks.get(chunks.size() - 1).getCurrentAsin()),
|
||||
processedRows,
|
||||
mergedCountries
|
||||
);
|
||||
}
|
||||
|
||||
private File buildResultWorkbook(Long taskId,
|
||||
DeleteBrandParsedFileCacheDto parsedFile,
|
||||
MergedDeleteBrandFile mergedFile) {
|
||||
File outputDir = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "delete-brand-result", String.valueOf(taskId)));
|
||||
File outputFile = buildNamedOutputFile(outputDir, blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx"));
|
||||
|
||||
try (Workbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(outputFile)) {
|
||||
Sheet sheet = workbook.createSheet("删除品牌结果");
|
||||
Row headerRow = sheet.createRow(0);
|
||||
headerRow.createCell(0).setCellValue("国家");
|
||||
headerRow.createCell(1).setCellValue("删除ASIN");
|
||||
headerRow.createCell(2).setCellValue("状态");
|
||||
|
||||
int rowIndex = 1;
|
||||
for (DeleteBrandProcessedCountryDto country : mergedFile.countries()) {
|
||||
for (DeleteBrandCountryResultItemDto item : country.getItems()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
createTextCell(row, 0, country.getCountry());
|
||||
createTextCell(row, 1, item.getAsin());
|
||||
createTextCell(row, 2, item.getStatus());
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < 3; i++) {
|
||||
sheet.autoSizeColumn(i);
|
||||
}
|
||||
workbook.write(outputStream);
|
||||
return outputFile;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("生成删除品牌结果文件失败: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
}
|
||||
|
||||
private void createTextCell(Row row, int index, String value) {
|
||||
Cell cell = row.createCell(index);
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||
File candidate = FileUtil.file(outputDir, filename);
|
||||
if (!candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
String mainName = FileUtil.mainName(filename);
|
||||
String extName = FileUtil.extName(filename);
|
||||
int index = 2;
|
||||
while (true) {
|
||||
String nextFilename = extName == null || extName.isBlank()
|
||||
? mainName + "_" + index
|
||||
: mainName + "_" + index + "." + extName;
|
||||
File nextCandidate = FileUtil.file(outputDir, nextFilename);
|
||||
if (!nextCandidate.exists()) {
|
||||
return nextCandidate;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveTaskDownloadUrl(Long taskId) {
|
||||
FileResultEntity entity = findLatestSuccessfulResult(taskId);
|
||||
return entity == null || entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
public String resolveTaskDownloadFilename(Long taskId) {
|
||||
FileResultEntity entity = findLatestSuccessfulResult(taskId);
|
||||
return entity == null ? null : entity.getResultFilename();
|
||||
}
|
||||
|
||||
private FileResultEntity findLatestSuccessfulResult(Long taskId) {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getSuccess, 1)
|
||||
.orderByDesc(FileResultEntity::getId)
|
||||
.last("limit 1"))
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String blankToDefault(String value, String defaultValue) {
|
||||
String normalized = blankToEmpty(value);
|
||||
return normalized.isBlank() ? defaultValue : normalized;
|
||||
}
|
||||
|
||||
private record ParsedDeleteBrandFile(int totalRows,
|
||||
List<DeleteBrandCountryGroupVo> countries,
|
||||
List<DeleteBrandPreviewRowVo> previewRows) {
|
||||
}
|
||||
|
||||
private record MergedDeleteBrandFile(String currentCountry,
|
||||
String currentAsin,
|
||||
int processedRows,
|
||||
List<DeleteBrandProcessedCountryDto> countries) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import java.time.Duration;
|
||||
@RequiredArgsConstructor
|
||||
public class DeleteBrandTaskCacheService {
|
||||
|
||||
public static final String PHASE_CRAWLING = "crawling";
|
||||
public static final String PHASE_ASSEMBLING = "assembling";
|
||||
public static final String PHASE_UPLOADING = "uploading";
|
||||
public static final String PHASE_FAILED = "failed";
|
||||
|
||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
@@ -38,11 +43,65 @@ public class DeleteBrandTaskCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
public void saveProgress(Long taskId, java.util.Map<String, String> values) {
|
||||
String key = buildProgressKey(taskId);
|
||||
stringRedisTemplate.opsForHash().putAll(key, values);
|
||||
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
}
|
||||
|
||||
public java.util.Map<Object, Object> getProgress(Long taskId) {
|
||||
return stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId));
|
||||
}
|
||||
|
||||
public void mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
|
||||
String resultKey = buildResultChunksKey(taskId);
|
||||
if (chunkIndex == null || chunkIndex <= 0) {
|
||||
throw new BusinessException("chunkIndex 不合法");
|
||||
}
|
||||
if (fileIdentity == null || fileIdentity.isBlank()) {
|
||||
throw new BusinessException("fileIdentity 不能为空");
|
||||
}
|
||||
String field = fileIdentity.trim() + "#" + chunkIndex;
|
||||
try {
|
||||
stringRedisTemplate.opsForHash().put(resultKey, field, objectMapper.writeValueAsString(chunk));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("暂存删除品牌结果分片失败");
|
||||
}
|
||||
stringRedisTemplate.expire(resultKey, Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
}
|
||||
|
||||
public java.util.Map<String, java.util.List<String>> groupResultChunkJsonByFile(Long taskId) {
|
||||
java.util.Map<Object, Object> stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId));
|
||||
java.util.Map<String, java.util.List<String>> grouped = new java.util.LinkedHashMap<>();
|
||||
for (java.util.Map.Entry<Object, Object> entry : stored.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String field) || !(entry.getValue() instanceof String raw) || raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
int sep = field.lastIndexOf('#');
|
||||
if (sep <= 0) {
|
||||
continue;
|
||||
}
|
||||
String fileIdentity = field.substring(0, sep);
|
||||
grouped.computeIfAbsent(fileIdentity, ignored -> new java.util.ArrayList<>()).add(raw);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
public void delete(Long taskId) {
|
||||
stringRedisTemplate.delete(buildPayloadKey(taskId));
|
||||
stringRedisTemplate.delete(buildProgressKey(taskId));
|
||||
stringRedisTemplate.delete(buildResultChunksKey(taskId));
|
||||
}
|
||||
|
||||
private String buildPayloadKey(Long taskId) {
|
||||
return "delete-brand:task:parsed-payload:" + taskId;
|
||||
}
|
||||
|
||||
private String buildProgressKey(Long taskId) {
|
||||
return "delete-brand:task:progress:" + taskId;
|
||||
}
|
||||
|
||||
private String buildResultChunksKey(Long taskId) {
|
||||
return "delete-brand:task:result-chunks:" + taskId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.shopkey.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.ShopKeyService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/shop-keys")
|
||||
@Tag(name = "店铺密钥管理", description = "维护店铺密钥,支持增删改查。")
|
||||
public class ShopKeyController {
|
||||
|
||||
private final ShopKeyService shopKeyService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询店铺密钥", description = "分页查询店铺密钥列表。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopKeyPageVo.class)))
|
||||
})
|
||||
public ApiResponse<ShopKeyPageVo> page(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize) {
|
||||
return ApiResponse.success(shopKeyService.page(page, pageSize));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增店铺密钥", description = "新增一条店铺密钥记录。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法")
|
||||
})
|
||||
public ApiResponse<ShopKeyItemVo> create(@Valid @RequestBody ShopKeyCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", shopKeyService.create(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新店铺密钥", description = "按 ID 更新一条店铺密钥记录。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = ShopKeyItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在")
|
||||
})
|
||||
public ApiResponse<ShopKeyItemVo> update(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Valid @RequestBody ShopKeyUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", shopKeyService.update(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除店铺密钥", description = "按 ID 删除一条店铺密钥记录。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "记录不存在")
|
||||
})
|
||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
shopKeyService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.shopkey.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ShopKeyMapper extends BaseMapper<ShopKeyEntity> {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "店铺密钥新增请求")
|
||||
public class ShopKeyCreateRequest {
|
||||
|
||||
@NotBlank(message = "紫鸟账号名称不能为空")
|
||||
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
|
||||
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoAccountName;
|
||||
|
||||
@NotBlank(message = "紫鸟令牌不能为空")
|
||||
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
|
||||
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoToken;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "店铺密钥更新请求")
|
||||
public class ShopKeyUpdateRequest {
|
||||
|
||||
@NotBlank(message = "紫鸟账号名称不能为空")
|
||||
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
|
||||
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoAccountName;
|
||||
|
||||
@NotBlank(message = "紫鸟令牌不能为空")
|
||||
@Size(max = 512, message = "紫鸟令牌长度不能超过512个字符")
|
||||
@Schema(description = "紫鸟令牌", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String ziniaoToken;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_shop_key")
|
||||
public class ShopKeyEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String ziniaoAccountName;
|
||||
private String ziniaoToken;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "店铺密钥项")
|
||||
public class ShopKeyItemVo {
|
||||
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "紫鸟账号名称")
|
||||
private String ziniaoAccountName;
|
||||
|
||||
@Schema(description = "紫鸟令牌")
|
||||
private String ziniaoToken;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "修改时间")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "店铺密钥分页结果")
|
||||
public class ShopKeyPageVo {
|
||||
|
||||
@Schema(description = "列表")
|
||||
private List<ShopKeyItemVo> items;
|
||||
|
||||
@Schema(description = "总数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "页码")
|
||||
private Long page;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.nanri.aiimage.modules.shopkey.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopKeyUpdateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ShopKeyService {
|
||||
|
||||
private final ShopKeyMapper shopKeyMapper;
|
||||
|
||||
public ShopKeyPageVo page(long page, long pageSize) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
LambdaQueryWrapper<ShopKeyEntity> query = new LambdaQueryWrapper<ShopKeyEntity>()
|
||||
.orderByDesc(ShopKeyEntity::getId);
|
||||
Long total = shopKeyMapper.selectCount(query);
|
||||
List<ShopKeyItemVo> items = shopKeyMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
||||
.stream()
|
||||
.map(this::toItemVo)
|
||||
.toList();
|
||||
ShopKeyPageVo vo = new ShopKeyPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopKeyItemVo create(ShopKeyCreateRequest request) {
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
ShopKeyEntity entity = new ShopKeyEntity();
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
shopKeyMapper.insert(entity);
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ShopKeyItemVo update(Long id, ShopKeyUpdateRequest request) {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
shopKeyMapper.updateById(entity);
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
shopKeyMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
private ShopKeyEntity getById(Long id) {
|
||||
ShopKeyEntity entity = shopKeyMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new BusinessException("店铺密钥不存在");
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String message) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private ShopKeyItemVo toItemVo(ShopKeyEntity entity) {
|
||||
ShopKeyItemVo vo = new ShopKeyItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setZiniaoAccountName(entity.getZiniaoAccountName());
|
||||
vo.setZiniaoToken(entity.getZiniaoToken());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
|
||||
import java.util.List;
|
||||
|
||||
public interface ZiniaoClient {
|
||||
Long getCompanyIdByApiKey();
|
||||
Long getCompanyIdByApiKey(String apiKey);
|
||||
|
||||
List<ZiniaoStaffItemVo> listStaff(Long companyId);
|
||||
List<ZiniaoStaffItemVo> listStaff(String apiKey, Long companyId);
|
||||
|
||||
List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId);
|
||||
List<ZiniaoShopCacheDto> listUserStores(String apiKey, Long companyId, Long userId);
|
||||
|
||||
String getUserLoginToken(Long companyId, Long userId);
|
||||
String getUserLoginToken(String apiKey, Long companyId, Long userId);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Long getCompanyIdByApiKey() {
|
||||
String raw = getWithApiKey("/app/builtin/company", "获取 companyId");
|
||||
public Long getCompanyIdByApiKey(String apiKey) {
|
||||
String raw = getWithApiKey(apiKey, "/app/builtin/company", "获取 companyId");
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(raw);
|
||||
JsonNode data = firstNonNull(root.get("data"), root.get("result"), root);
|
||||
@@ -44,8 +44,8 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ZiniaoStaffItemVo> listStaff(Long companyId) {
|
||||
String raw = postWithApiKey(ziniaoProperties.getStaffListPath(), Map.of(
|
||||
public List<ZiniaoStaffItemVo> listStaff(String apiKey, Long companyId) {
|
||||
String raw = postWithApiKey(apiKey, ziniaoProperties.getStaffListPath(), Map.of(
|
||||
"companyId", String.valueOf(companyId),
|
||||
"level", "",
|
||||
"isAccurate", "",
|
||||
@@ -75,8 +75,8 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ZiniaoShopCacheDto> listUserStores(Long companyId, Long userId) {
|
||||
String raw = postWithApiKey(ziniaoProperties.getUserStoresPath(), Map.of(
|
||||
public List<ZiniaoShopCacheDto> listUserStores(String apiKey, Long companyId, Long userId) {
|
||||
String raw = postWithApiKey(apiKey, ziniaoProperties.getUserStoresPath(), Map.of(
|
||||
"companyId", String.valueOf(companyId),
|
||||
"isAccurate", "",
|
||||
"limit", "100",
|
||||
@@ -88,8 +88,8 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUserLoginToken(Long companyId, Long userId) {
|
||||
String raw = postWithApiKey(ziniaoProperties.getUserLoginTokenPath(), Map.of(
|
||||
public String getUserLoginToken(String apiKey, Long companyId, Long userId) {
|
||||
String raw = postWithApiKey(apiKey, ziniaoProperties.getUserLoginTokenPath(), Map.of(
|
||||
"companyId", String.valueOf(companyId),
|
||||
"userId", String.valueOf(userId)
|
||||
), "获取员工登录 token");
|
||||
@@ -125,11 +125,10 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
|
||||
|
||||
private String getWithApiKey(String path, String action) {
|
||||
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
|
||||
private String getWithApiKey(String apiKey, String path, String action) {
|
||||
String raw = getRestClient().get()
|
||||
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
|
||||
.headers(headers -> headers.setBearerAuth(apiKey))
|
||||
.headers(headers -> applyAuthorization(headers, apiKey))
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (req, res) -> {
|
||||
// 保留响应体,交给后续 validateSuccess 统一解析
|
||||
@@ -139,12 +138,11 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
return raw;
|
||||
}
|
||||
|
||||
private String postWithApiKey(String path, Map<String, Object> body, String action) {
|
||||
String apiKey = requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
|
||||
private String postWithApiKey(String apiKey, String path, Map<String, Object> body, String action) {
|
||||
RestClient.RequestBodySpec request = getRestClient().post()
|
||||
.uri(joinUrl(ziniaoProperties.getBaseUrl(), path))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(apiKey);
|
||||
applyAuthorization(headers, apiKey);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
});
|
||||
if (body != null) {
|
||||
@@ -182,6 +180,15 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
return raw;
|
||||
}
|
||||
|
||||
private void applyAuthorization(org.springframework.http.HttpHeaders headers, String apiKey) {
|
||||
String token = requireText(apiKey, "紫鸟 apiKey 未配置");
|
||||
if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
headers.set("Authorization", token);
|
||||
} else {
|
||||
headers.setBearerAuth(token);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSuccess(String raw, String action, String path) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(raw);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.ziniao.memory.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ZiniaoMemoryStoreMapper extends BaseMapper<ZiniaoMemoryStoreEntity> {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.nanri.aiimage.modules.ziniao.memory.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_ziniao_memory_store")
|
||||
public class ZiniaoMemoryStoreEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String cacheType;
|
||||
private String cacheKey;
|
||||
private String payloadJson;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.nanri.aiimage.modules.ziniao.memory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.mapper.ZiniaoMemoryStoreMapper;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.model.entity.ZiniaoMemoryStoreEntity;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ZiniaoMemoryStoreService {
|
||||
|
||||
private final ZiniaoMemoryStoreMapper ziniaoMemoryStoreMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public <T> Optional<T> get(String cacheType, String cacheKey, Class<T> valueType) {
|
||||
ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey);
|
||||
if (entity == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (isExpired(entity)) {
|
||||
ziniaoMemoryStoreMapper.deleteById(entity.getId());
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.ofNullable(objectMapper.readValue(entity.getPayloadJson(), valueType));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("读取紫鸟记忆存储失败");
|
||||
}
|
||||
}
|
||||
|
||||
public <T> Optional<T> get(String cacheType, String cacheKey, JavaType javaType) {
|
||||
ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey);
|
||||
if (entity == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (isExpired(entity)) {
|
||||
ziniaoMemoryStoreMapper.deleteById(entity.getId());
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
T value = (T) objectMapper.readValue(entity.getPayloadJson(), javaType);
|
||||
return Optional.ofNullable(value);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("读取紫鸟记忆存储失败");
|
||||
}
|
||||
}
|
||||
|
||||
public <E> Optional<List<E>> getList(String cacheType, String cacheKey, Class<E> elementType) {
|
||||
JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
return get(cacheType, cacheKey, type);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void put(String cacheType, String cacheKey, Object payload, Duration ttl) {
|
||||
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
|
||||
String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空");
|
||||
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
|
||||
throw new BusinessException("ttl 不合法");
|
||||
}
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("写入紫鸟记忆存储失败");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime expiresAt = now.plusSeconds(ttl.getSeconds());
|
||||
ZiniaoMemoryStoreEntity entity = findOne(normalizedType, normalizedKey);
|
||||
if (entity == null) {
|
||||
entity = new ZiniaoMemoryStoreEntity();
|
||||
entity.setCacheType(normalizedType);
|
||||
entity.setCacheKey(normalizedKey);
|
||||
entity.setPayloadJson(payloadJson);
|
||||
entity.setExpiresAt(expiresAt);
|
||||
entity.setCreatedAt(now);
|
||||
entity.setUpdatedAt(now);
|
||||
ziniaoMemoryStoreMapper.insert(entity);
|
||||
return;
|
||||
}
|
||||
entity.setPayloadJson(payloadJson);
|
||||
entity.setExpiresAt(expiresAt);
|
||||
entity.setUpdatedAt(now);
|
||||
ziniaoMemoryStoreMapper.updateById(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String cacheType, String cacheKey) {
|
||||
ZiniaoMemoryStoreEntity entity = findOne(cacheType, cacheKey);
|
||||
if (entity != null) {
|
||||
ziniaoMemoryStoreMapper.deleteById(entity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteExpired(int limit) {
|
||||
int safeLimit = Math.max(limit, 1);
|
||||
List<ZiniaoMemoryStoreEntity> expired = ziniaoMemoryStoreMapper.selectList(new LambdaQueryWrapper<ZiniaoMemoryStoreEntity>()
|
||||
.lt(ZiniaoMemoryStoreEntity::getExpiresAt, LocalDateTime.now())
|
||||
.orderByAsc(ZiniaoMemoryStoreEntity::getExpiresAt)
|
||||
.last("LIMIT " + safeLimit));
|
||||
if (expired.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int deleted = 0;
|
||||
for (ZiniaoMemoryStoreEntity entity : expired) {
|
||||
deleted += ziniaoMemoryStoreMapper.deleteById(entity.getId());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private ZiniaoMemoryStoreEntity findOne(String cacheType, String cacheKey) {
|
||||
String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空");
|
||||
String normalizedKey = normalizeRequired(cacheKey, "cacheKey 不能为空");
|
||||
return ziniaoMemoryStoreMapper.selectOne(new LambdaQueryWrapper<ZiniaoMemoryStoreEntity>()
|
||||
.eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType)
|
||||
.eq(ZiniaoMemoryStoreEntity::getCacheKey, normalizedKey)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private boolean isExpired(ZiniaoMemoryStoreEntity entity) {
|
||||
return entity.getExpiresAt() == null || !entity.getExpiresAt().isAfter(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String message) {
|
||||
String normalized = Objects.toString(value, "").trim();
|
||||
if (normalized.isEmpty()) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class ZiniaoSessionCacheDto {
|
||||
private String sessionId;
|
||||
private String apiKey;
|
||||
private String accessToken;
|
||||
private String refreshToken;
|
||||
private String tokenType;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.nanri.aiimage.modules.ziniao.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ZiniaoApiKeyProvider {
|
||||
|
||||
private final ShopKeyMapper shopKeyMapper;
|
||||
|
||||
public List<String> listApiKeys() {
|
||||
return shopKeyMapper.selectList(new LambdaQueryWrapper<ShopKeyEntity>()
|
||||
.orderByDesc(ShopKeyEntity::getId))
|
||||
.stream()
|
||||
.map(ShopKeyEntity::getZiniaoToken)
|
||||
.filter(token -> token != null && !token.isBlank())
|
||||
.map(String::trim)
|
||||
.map(token -> token.regionMatches(true, 0, "Bearer ", 0, 7) ? token.substring(7).trim() : token)
|
||||
.filter(token -> !token.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getRequiredApiKey() {
|
||||
List<String> keys = listApiKeys();
|
||||
if (keys.isEmpty()) {
|
||||
throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌");
|
||||
}
|
||||
return keys.get(0);
|
||||
}
|
||||
|
||||
public boolean hasApiKey() {
|
||||
Long total = shopKeyMapper.selectCount(new LambdaQueryWrapper<ShopKeyEntity>());
|
||||
return total != null && total > 0;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package com.nanri.aiimage.modules.ziniao.service;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.ZiniaoProperties;
|
||||
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClient;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoApiKeyProvider;
|
||||
import com.nanri.aiimage.modules.ziniao.model.dto.ZiniaoOpenShopRequest;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoOpenShopVo;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoSessionVo;
|
||||
@@ -13,10 +15,12 @@ import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopListVo;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffListVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
@@ -24,8 +28,18 @@ import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ZiniaoAuthService {
|
||||
|
||||
private static final String CACHE_TYPE_COMPANY_ID = "COMPANY_ID";
|
||||
private static final String CACHE_TYPE_STAFF_LIST = "STAFF_LIST";
|
||||
private static final String CACHE_TYPE_USER_STORES = "USER_STORES";
|
||||
private static final String CACHE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
||||
private static final Duration COMPANY_ID_CACHE_TTL = Duration.ofHours(12);
|
||||
private static final Duration STAFF_LIST_CACHE_TTL = Duration.ofMinutes(30);
|
||||
private static final Duration USER_STORES_CACHE_TTL = Duration.ofMinutes(30);
|
||||
private static final Duration SHOP_MATCH_CACHE_TTL = Duration.ofMinutes(30);
|
||||
|
||||
public StoreMatchResult matchStoreByNameAcrossStaff(String targetShopName, Long preferUserId) {
|
||||
ensureEnabled();
|
||||
String normalizedTarget = normalizeShopName(targetShopName);
|
||||
@@ -33,26 +47,45 @@ public class ZiniaoAuthService {
|
||||
return new StoreMatchResult(false, null, null, null, null, null);
|
||||
}
|
||||
|
||||
Long companyId = resolveCompanyId();
|
||||
List<ZiniaoStaffItemVo> staff = ziniaoClient.listStaff(companyId);
|
||||
List<String> apiKeys = getScanApiKeys();
|
||||
long startedAt = System.currentTimeMillis();
|
||||
int scannedKeys = 0;
|
||||
for (String apiKey : apiKeys) {
|
||||
if (isScanLimitExceeded(startedAt, scannedKeys)) {
|
||||
throw new BusinessException("紫鸟 key 轮询超出扫描限制,请缩小范围或提高扫描上限");
|
||||
}
|
||||
scannedKeys++;
|
||||
|
||||
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());
|
||||
Long companyId;
|
||||
try {
|
||||
companyId = resolveCompanyId(apiKey);
|
||||
} catch (BusinessException ex) {
|
||||
// key 无效/不可用时继续尝试下一个 key
|
||||
if (isInvalidApiKeyError(ex)) {
|
||||
log.info("[ziniao-match] skip invalid apiKey while resolving companyId, keyHash={}", shortKeyHash(apiKey));
|
||||
continue;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
|
||||
StoreMatchResult cachedMatch = getCachedShopMatch(apiKey, companyId, normalizedTarget);
|
||||
if (cachedMatch != null) {
|
||||
log.info("[ziniao-match] hit shop-match cache, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}",
|
||||
shortKeyHash(apiKey), companyId, normalizedTarget, cachedMatch.shopId(), cachedMatch.matchedUserId());
|
||||
String openStoreUrl = buildOpenStoreUrl(cachedMatch.shopId(), cachedMatch.matchedUserId(),
|
||||
ziniaoClient.getUserLoginToken(apiKey, companyId, cachedMatch.matchedUserId()));
|
||||
return new StoreMatchResult(true, cachedMatch.shopId(), cachedMatch.shopName(), cachedMatch.platform(), cachedMatch.matchedUserId(), openStoreUrl);
|
||||
}
|
||||
|
||||
List<ZiniaoStaffItemVo> staff = getOrLoadStaff(apiKey, companyId);
|
||||
List<Long> userIds = buildUserIds(staff, preferUserId);
|
||||
for (Long staffUserId : userIds) {
|
||||
if (staffUserId == null || staffUserId <= 0) {
|
||||
continue;
|
||||
}
|
||||
List<ZiniaoShopCacheDto> stores;
|
||||
try {
|
||||
stores = ziniaoClient.listUserStores(companyId, staffUserId);
|
||||
stores = getOrLoadUserStores(apiKey, companyId, staffUserId);
|
||||
} catch (BusinessException ex) {
|
||||
if (isSkippableUserStoresError(ex)) {
|
||||
continue;
|
||||
@@ -63,11 +96,18 @@ public class ZiniaoAuthService {
|
||||
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));
|
||||
log.info("[ziniao-match] matched via upstream scan, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}",
|
||||
shortKeyHash(apiKey), companyId, normalizedTarget, store.getShopId(), staffUserId);
|
||||
cacheShopMatch(apiKey, companyId, normalizedTarget,
|
||||
new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, null),
|
||||
SHOP_MATCH_CACHE_TTL);
|
||||
String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId,
|
||||
ziniaoClient.getUserLoginToken(apiKey, companyId, staffUserId));
|
||||
return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new StoreMatchResult(false, null, null, null, null, null);
|
||||
}
|
||||
@@ -98,6 +138,8 @@ public class ZiniaoAuthService {
|
||||
private final ZiniaoProperties ziniaoProperties;
|
||||
private final ZiniaoClient ziniaoClient;
|
||||
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
|
||||
private final ZiniaoMemoryStoreService ziniaoMemoryStoreService;
|
||||
private final ZiniaoApiKeyProvider ziniaoApiKeyProvider;
|
||||
|
||||
public ZiniaoSessionVo getSession(String sessionId, Long userId) {
|
||||
ensureEnabled();
|
||||
@@ -122,15 +164,18 @@ public class ZiniaoAuthService {
|
||||
public ZiniaoStaffListVo listStaff() {
|
||||
ensureEnabled();
|
||||
ZiniaoStaffListVo vo = new ZiniaoStaffListVo();
|
||||
Long companyId = resolveCompanyIdForStaff();
|
||||
vo.getItems().addAll(ziniaoClient.listStaff(companyId));
|
||||
String apiKey = resolveAvailableApiKey();
|
||||
Long companyId = resolveCompanyIdForStaff(apiKey);
|
||||
vo.getItems().addAll(ziniaoClient.listStaff(apiKey, companyId));
|
||||
return vo;
|
||||
}
|
||||
|
||||
public ZiniaoShopListVo listShops(String sessionId, Long userId) {
|
||||
ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId);
|
||||
Long currentUserId = requireUserId(session.getCurrentUserId());
|
||||
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
|
||||
String apiKey = resolveSessionApiKey(session);
|
||||
Long companyId = resolveCompanyId(apiKey);
|
||||
List<ZiniaoShopCacheDto> shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId);
|
||||
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
|
||||
if (!shops.isEmpty() && (session.getDefaultShopId() == null || session.getDefaultShopId().isBlank())) {
|
||||
session.setDefaultShopId(shops.get(0).getShopId());
|
||||
@@ -151,10 +196,12 @@ public class ZiniaoAuthService {
|
||||
public ZiniaoOpenShopVo openShop(ZiniaoOpenShopRequest request) {
|
||||
ZiniaoSessionCacheDto session = requireOrInitSession(request.getSessionId(), request.getUserId());
|
||||
Long currentUserId = requireUserId(request.getUserId() != null ? request.getUserId() : session.getCurrentUserId());
|
||||
String userToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), currentUserId);
|
||||
String apiKey = resolveSessionApiKey(session);
|
||||
Long companyId = resolveCompanyId(apiKey);
|
||||
String userToken = ziniaoClient.getUserLoginToken(apiKey, companyId, currentUserId);
|
||||
List<ZiniaoShopCacheDto> shops = ziniaoSessionCacheService.getShops(session.getSessionId());
|
||||
if (shops.isEmpty()) {
|
||||
shops = ziniaoClient.listUserStores(resolveCompanyId(), currentUserId);
|
||||
shops = ziniaoClient.listUserStores(apiKey, companyId, currentUserId);
|
||||
ziniaoSessionCacheService.saveShops(session.getSessionId(), shops);
|
||||
}
|
||||
ZiniaoShopCacheDto targetShop = shops.stream()
|
||||
@@ -178,7 +225,8 @@ public class ZiniaoAuthService {
|
||||
public List<ZiniaoShopCacheDto> listShopsForConfiguredUser() {
|
||||
ensureEnabled();
|
||||
Long userId = parseConfiguredOpenStoreUserId();
|
||||
return ziniaoClient.listUserStores(resolveCompanyId(), userId);
|
||||
String apiKey = resolveAvailableApiKey();
|
||||
return ziniaoClient.listUserStores(apiKey, resolveCompanyId(apiKey), userId);
|
||||
}
|
||||
|
||||
public String buildOpenStoreUrlForShop(ZiniaoShopCacheDto shop) {
|
||||
@@ -187,7 +235,8 @@ public class ZiniaoAuthService {
|
||||
throw new BusinessException("店铺不存在");
|
||||
}
|
||||
Long userId = parseConfiguredOpenStoreUserId();
|
||||
String loginToken = ziniaoClient.getUserLoginToken(resolveCompanyId(), userId);
|
||||
String apiKey = resolveAvailableApiKey();
|
||||
String loginToken = ziniaoClient.getUserLoginToken(apiKey, resolveCompanyId(apiKey), userId);
|
||||
return buildOpenStoreUrl(shop.getShopId(), userId, loginToken);
|
||||
}
|
||||
|
||||
@@ -196,7 +245,9 @@ public class ZiniaoAuthService {
|
||||
ZiniaoSessionCacheDto session = new ZiniaoSessionCacheDto();
|
||||
session.setSessionId(sessionId);
|
||||
session.setExpireAt(Instant.now().plusSeconds(ziniaoProperties.getSessionTtlHours() * 3600).toEpochMilli());
|
||||
session.setCompanyId(resolveCompanyId());
|
||||
String apiKey = resolveAvailableApiKey();
|
||||
session.setApiKey(apiKey);
|
||||
session.setCompanyId(resolveCompanyId(apiKey));
|
||||
session.setCurrentUserId(userId);
|
||||
session.setZiniaoUserId(String.valueOf(userId == null ? 0L : userId));
|
||||
session.setNickname("API Key模式");
|
||||
@@ -215,8 +266,12 @@ public class ZiniaoAuthService {
|
||||
if (userId != null && userId > 0) {
|
||||
session.setCurrentUserId(userId);
|
||||
session.setZiniaoUserId(String.valueOf(userId));
|
||||
ziniaoSessionCacheService.saveSession(session);
|
||||
}
|
||||
// 旧 session 可能没有保存 apiKey,补齐后续请求所需
|
||||
if (session.getApiKey() == null || session.getApiKey().isBlank()) {
|
||||
session.setApiKey(resolveAvailableApiKey());
|
||||
}
|
||||
ziniaoSessionCacheService.saveSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -257,11 +312,13 @@ public class ZiniaoAuthService {
|
||||
if (!ziniaoProperties.isEnabled()) {
|
||||
throw new BusinessException("紫鸟集成未启用,请先配置环境变量");
|
||||
}
|
||||
requireText(ziniaoProperties.getApiKey(), "紫鸟 apiKey 未配置");
|
||||
if (!ziniaoApiKeyProvider.hasApiKey()) {
|
||||
throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌");
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveCompanyIdForStaff() {
|
||||
return resolveCompanyId();
|
||||
private Long resolveCompanyIdForStaff(String apiKey) {
|
||||
return resolveCompanyId(apiKey);
|
||||
}
|
||||
|
||||
private Long requireUserId(Long userId) {
|
||||
@@ -271,15 +328,163 @@ public class ZiniaoAuthService {
|
||||
return userId;
|
||||
}
|
||||
|
||||
private Long resolveCompanyId() {
|
||||
private Long resolveCompanyId(String apiKey) {
|
||||
if (ziniaoProperties.getCompanyId() != null && ziniaoProperties.getCompanyId() > 0) {
|
||||
return ziniaoProperties.getCompanyId();
|
||||
}
|
||||
return ziniaoClient.getCompanyIdByApiKey();
|
||||
|
||||
String normalizedApiKey = requireText(apiKey, "紫鸟 apiKey 未配置");
|
||||
String cacheKey = buildApiKeyHash(normalizedApiKey);
|
||||
Long cached = ziniaoMemoryStoreService.get(CACHE_TYPE_COMPANY_ID, cacheKey, Long.class).orElse(null);
|
||||
if (cached != null && cached > 0) {
|
||||
log.info("[ziniao-company] hit cache, keyHash={}, companyId={}", shortKeyHash(apiKey), cached);
|
||||
return cached;
|
||||
}
|
||||
log.info("[ziniao-company] cache miss, requesting upstream, keyHash={}", shortKeyHash(apiKey));
|
||||
Long companyId = ziniaoClient.getCompanyIdByApiKey(normalizedApiKey);
|
||||
ziniaoMemoryStoreService.put(CACHE_TYPE_COMPANY_ID, cacheKey, companyId, COMPANY_ID_CACHE_TTL);
|
||||
log.info("[ziniao-company] cached upstream result, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId);
|
||||
return companyId;
|
||||
}
|
||||
|
||||
private Long requireCompanyId() {
|
||||
return resolveCompanyId();
|
||||
|
||||
private List<ZiniaoStaffItemVo> getOrLoadStaff(String apiKey, Long companyId) {
|
||||
String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId;
|
||||
List<ZiniaoStaffItemVo> cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_STAFF_LIST, cacheKey, ZiniaoStaffItemVo.class)
|
||||
.orElse(null);
|
||||
if (cached != null) {
|
||||
log.info("[ziniao-staff] hit cache, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, cached.size());
|
||||
return cached;
|
||||
}
|
||||
log.info("[ziniao-staff] cache miss, requesting upstream, keyHash={}, companyId={}", shortKeyHash(apiKey), companyId);
|
||||
List<ZiniaoStaffItemVo> staff = ziniaoClient.listStaff(apiKey, companyId);
|
||||
ziniaoMemoryStoreService.put(CACHE_TYPE_STAFF_LIST, cacheKey, staff, STAFF_LIST_CACHE_TTL);
|
||||
log.info("[ziniao-staff] cached upstream result, keyHash={}, companyId={}, size={}", shortKeyHash(apiKey), companyId, staff.size());
|
||||
return staff;
|
||||
}
|
||||
|
||||
private List<ZiniaoShopCacheDto> getOrLoadUserStores(String apiKey, Long companyId, Long userId) {
|
||||
String cacheKey = buildApiKeyHash(apiKey) + ":" + companyId + ":" + userId;
|
||||
List<ZiniaoShopCacheDto> cached = ziniaoMemoryStoreService.getList(CACHE_TYPE_USER_STORES, cacheKey, ZiniaoShopCacheDto.class)
|
||||
.orElse(null);
|
||||
if (cached != null) {
|
||||
log.info("[ziniao-stores] hit cache, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, cached.size());
|
||||
return cached;
|
||||
}
|
||||
log.info("[ziniao-stores] cache miss, requesting upstream, keyHash={}, companyId={}, userId={}", shortKeyHash(apiKey), companyId, userId);
|
||||
List<ZiniaoShopCacheDto> stores = ziniaoClient.listUserStores(apiKey, companyId, userId);
|
||||
ziniaoMemoryStoreService.put(CACHE_TYPE_USER_STORES, cacheKey, stores, USER_STORES_CACHE_TTL);
|
||||
log.info("[ziniao-stores] cached upstream result, keyHash={}, companyId={}, userId={}, size={}", shortKeyHash(apiKey), companyId, userId, stores.size());
|
||||
return stores;
|
||||
}
|
||||
|
||||
private StoreMatchResult getCachedShopMatch(String apiKey, Long companyId, String normalizedShopName) {
|
||||
StoreMatchResult cached = ziniaoMemoryStoreService.get(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), StoreMatchResult.class)
|
||||
.orElse(null);
|
||||
if (cached == null || !cached.matched() || cached.shopId() == null || cached.matchedUserId() == null) {
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
private void cacheShopMatch(String apiKey, Long companyId, String normalizedShopName, StoreMatchResult result, Duration ttl) {
|
||||
ziniaoMemoryStoreService.put(CACHE_TYPE_SHOP_MATCH, buildShopMatchCacheKey(apiKey, companyId, normalizedShopName), result, ttl);
|
||||
}
|
||||
|
||||
private String buildShopMatchCacheKey(String apiKey, Long companyId, String normalizedShopName) {
|
||||
return buildApiKeyHash(apiKey) + ":" + companyId + ":" + normalizedShopName;
|
||||
}
|
||||
|
||||
private String buildApiKeyHash(String apiKey) {
|
||||
try {
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("生成紫鸟 apiKey 缓存键失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String shortKeyHash(String apiKey) {
|
||||
String hash = buildApiKeyHash(apiKey);
|
||||
return hash.length() <= 12 ? hash : hash.substring(0, 12);
|
||||
}
|
||||
|
||||
private List<Long> buildUserIds(List<ZiniaoStaffItemVo> staff, Long preferUserId) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
private List<String> getScanApiKeys() {
|
||||
List<String> keys = ziniaoApiKeyProvider.listApiKeys();
|
||||
if (keys.isEmpty()) {
|
||||
throw new BusinessException("紫鸟 apiKey 未配置,请先在店铺密钥管理中维护可用令牌");
|
||||
}
|
||||
Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys();
|
||||
if (maxKeys == null || maxKeys <= 0 || keys.size() <= maxKeys) {
|
||||
return keys;
|
||||
}
|
||||
return keys.subList(0, maxKeys);
|
||||
}
|
||||
|
||||
private String resolveAvailableApiKey() {
|
||||
BusinessException lastError = null;
|
||||
List<String> apiKeys = getScanApiKeys();
|
||||
for (String apiKey : apiKeys) {
|
||||
try {
|
||||
resolveCompanyId(apiKey);
|
||||
return apiKey;
|
||||
} catch (BusinessException ex) {
|
||||
if (isInvalidApiKeyError(ex)) {
|
||||
lastError = ex;
|
||||
continue;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
if (lastError != null) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new BusinessException("未找到可用的紫鸟 apiKey");
|
||||
}
|
||||
|
||||
private String resolveSessionApiKey(ZiniaoSessionCacheDto session) {
|
||||
if (session != null && session.getApiKey() != null && !session.getApiKey().isBlank()) {
|
||||
return session.getApiKey().trim();
|
||||
}
|
||||
return resolveAvailableApiKey();
|
||||
}
|
||||
|
||||
private boolean isInvalidApiKeyError(BusinessException ex) {
|
||||
String message = ex == null ? null : ex.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return message.contains("isv.invalid-api-key")
|
||||
|| message.contains("无效的apiKey参数")
|
||||
|| message.contains("非法的参数");
|
||||
}
|
||||
|
||||
private boolean isScanLimitExceeded(long startedAt, int scannedKeys) {
|
||||
Integer maxKeys = ziniaoProperties.getKeyScanMaxKeys();
|
||||
if (maxKeys != null && maxKeys > 0 && scannedKeys >= maxKeys) {
|
||||
return true;
|
||||
}
|
||||
Integer maxSeconds = ziniaoProperties.getKeyScanMaxSeconds();
|
||||
return maxSeconds != null && maxSeconds > 0
|
||||
&& System.currentTimeMillis() - startedAt >= maxSeconds * 1000L;
|
||||
}
|
||||
|
||||
private Long parseConfiguredOpenStoreUserId() {
|
||||
|
||||
@@ -99,3 +99,5 @@ aiimage:
|
||||
shops-cache-minutes: ${AIIMAGE_ZINIAO_SHOPS_CACHE_MINUTES:30}
|
||||
connect-timeout-seconds: ${AIIMAGE_ZINIAO_CONNECT_TIMEOUT_SECONDS:5}
|
||||
read-timeout-seconds: ${AIIMAGE_ZINIAO_READ_TIMEOUT_SECONDS:15}
|
||||
key-scan-max-keys: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_KEYS:50}
|
||||
key-scan-max-seconds: ${AIIMAGE_ZINIAO_KEY_SCAN_MAX_SECONDS:15}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_shop_key (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
ziniao_account_name VARCHAR(128) NOT NULL COMMENT '紫鸟账号名称',
|
||||
ziniao_token VARCHAR(512) NOT NULL COMMENT '紫鸟令牌',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
KEY idx_created_at (created_at)
|
||||
) COMMENT='店铺密钥管理表';
|
||||
@@ -0,0 +1,27 @@
|
||||
SET @drop_shop_key_machine_no_index_sql := (
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP INDEX uk_machine_no'
|
||||
ELSE 'SELECT 1'
|
||||
END
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_shop_key'
|
||||
AND INDEX_NAME = 'uk_machine_no'
|
||||
);
|
||||
PREPARE stmt FROM @drop_shop_key_machine_no_index_sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @drop_shop_key_machine_no_column_sql := (
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) > 0 THEN 'ALTER TABLE biz_shop_key DROP COLUMN machine_no'
|
||||
ELSE 'SELECT 1'
|
||||
END
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_shop_key'
|
||||
AND COLUMN_NAME = 'machine_no'
|
||||
);
|
||||
PREPARE stmt FROM @drop_shop_key_machine_no_column_sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_ziniao_memory_store (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
cache_type VARCHAR(64) NOT NULL COMMENT '缓存类型',
|
||||
cache_key VARCHAR(255) NOT NULL COMMENT '缓存key',
|
||||
payload_json LONGTEXT NOT NULL COMMENT '缓存JSON',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_type_key (cache_type, cache_key),
|
||||
KEY idx_expires_at (expires_at)
|
||||
) COMMENT='紫鸟记忆存储(跨会话SQL缓存)';
|
||||
Binary file not shown.
Binary file not shown.
@@ -617,6 +617,113 @@ def upload_version():
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
# ---------- 店铺密钥管理 ----------
|
||||
|
||||
@admin_api.route('/shop-keys')
|
||||
@admin_required
|
||||
def list_shop_keys():
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/shop-keys',
|
||||
params={'page': page, 'pageSize': page_size},
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
payload = result.get('data') or {}
|
||||
items = [
|
||||
{
|
||||
'id': item.get('id'),
|
||||
'ziniao_account_name': item.get('ziniaoAccountName') or '',
|
||||
'ziniao_token': item.get('ziniaoToken') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
}
|
||||
for item in (payload.get('items') or [])
|
||||
]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items,
|
||||
'total': payload.get('total') or 0,
|
||||
'page': payload.get('page') or page,
|
||||
'page_size': payload.get('pageSize') or page_size,
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/shop-key', methods=['POST'])
|
||||
@admin_required
|
||||
def create_shop_key():
|
||||
data = request.get_json() or {}
|
||||
payload = {
|
||||
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
||||
'ziniaoToken': (data.get('ziniao_token') or '').strip(),
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/shop-keys',
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
item = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '创建成功',
|
||||
'item': {
|
||||
'id': item.get('id'),
|
||||
'ziniao_account_name': item.get('ziniaoAccountName') or '',
|
||||
'ziniao_token': item.get('ziniaoToken') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/shop-key/<int:item_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_shop_key(item_id):
|
||||
data = request.get_json() or {}
|
||||
payload = {
|
||||
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
||||
'ziniaoToken': (data.get('ziniao_token') or '').strip(),
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/shop-keys/{item_id}',
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
item = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '更新成功',
|
||||
'item': {
|
||||
'id': item.get('id'),
|
||||
'ziniao_account_name': item.get('ziniaoAccountName') or '',
|
||||
'ziniao_token': item.get('ziniaoToken') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/shop-key/<int:item_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_shop_key(item_id):
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'DELETE',
|
||||
f'/api/admin/shop-keys/{item_id}',
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '删除成功',
|
||||
})
|
||||
|
||||
|
||||
# ---------- 数据去重总数据 ----------
|
||||
|
||||
@admin_api.route('/dedupe-total-data')
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
<div class="tab active" data-tab="users">用户管理</div>
|
||||
<div class="tab" data-tab="columns">栏目权限配置</div>
|
||||
<div class="tab" data-tab="dedupe-total-data">数据去重总数据</div>
|
||||
<div class="tab" data-tab="shop-keys">店铺密钥管理</div>
|
||||
<div class="tab" data-tab="history">查看生成记录</div>
|
||||
<div class="tab" data-tab="version">版本管理</div>
|
||||
</div>
|
||||
@@ -308,6 +309,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 店铺密钥管理 -->
|
||||
<div id="panel-shop-keys" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">新增店铺密钥</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>紫鸟账号名称</label>
|
||||
<input type="text" id="shopKeyZiniaoAccountName" placeholder="请输入紫鸟账号名称">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:260px;">
|
||||
<label>紫鸟令牌</label>
|
||||
<input type="text" id="shopKeyZiniaoToken" placeholder="请输入紫鸟令牌">
|
||||
</div>
|
||||
<button class="btn" id="btnCreateShopKey">新增店铺密钥</button>
|
||||
</div>
|
||||
<p class="msg" id="msgShopKey"></p>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">店铺密钥列表</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>紫鸟账号名称</th>
|
||||
<th>紫鸟令牌</th>
|
||||
<th>创建时间</th>
|
||||
<th>修改时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="shopKeyListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="shopKeyPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看生成记录 -->
|
||||
<div id="panel-history" class="tab-panel">
|
||||
<div class="form-box">
|
||||
@@ -423,6 +460,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑店铺密钥弹窗 -->
|
||||
<div class="modal-mask" id="editShopKeyModal">
|
||||
<div class="modal">
|
||||
<h3>编辑店铺密钥</h3>
|
||||
<input type="hidden" id="editShopKeyId">
|
||||
<div class="form-group">
|
||||
<label>紫鸟账号名称</label>
|
||||
<input type="text" id="editShopKeyZiniaoAccountName" placeholder="紫鸟账号名称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>紫鸟令牌</label>
|
||||
<input type="text" id="editShopKeyZiniaoToken" placeholder="紫鸟令牌">
|
||||
</div>
|
||||
<p class="msg" id="msgEditShopKey"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveShopKey">保存</button>
|
||||
<button class="btn btn-secondary" id="btnCloseEditShopKey">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Tab 切换
|
||||
@@ -436,6 +494,7 @@
|
||||
if (t.dataset.tab === 'users') { loadUsers(1); loadColumnsForPermission(); }
|
||||
else if (t.dataset.tab === 'columns') loadColumns();
|
||||
else if (t.dataset.tab === 'dedupe-total-data') loadDedupeTotalData(1);
|
||||
else if (t.dataset.tab === 'shop-keys') loadShopKeys(1);
|
||||
else if (t.dataset.tab === 'history') loadHistory(1);
|
||||
else if (t.dataset.tab === 'version') loadVersions();
|
||||
};
|
||||
@@ -1134,6 +1193,142 @@
|
||||
document.getElementById('editDedupeTotalDataModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 店铺密钥管理 ==========
|
||||
var shopKeyPage = 1, shopKeyPageSize = 15;
|
||||
function buildShopKeyQuery(page) {
|
||||
return 'page=' + (page || 1) + '&page_size=' + shopKeyPageSize;
|
||||
}
|
||||
function loadShopKeys(page) {
|
||||
shopKeyPage = page || 1;
|
||||
fetch('/api/admin/shop-keys?' + buildShopKeyQuery(shopKeyPage))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('shopKeyListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无店铺密钥</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(item, index) {
|
||||
var rowNo = (shopKeyPage - 1) * shopKeyPageSize + index + 1;
|
||||
return '<tr><td>' + rowNo + '</td><td>' + (item.ziniao_account_name || '') + '</td><td>' + (item.ziniao_token || '') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-shop-key-edit="' + item.id + '" data-shop-key="' + (JSON.stringify(item).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-shop-key-delete="' + item.id + '" data-ziniao-account-name="' + (item.ziniao_account_name || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('shopKeyPagination', res.total, res.page, res.page_size, loadShopKeys);
|
||||
bindShopKeyActions();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('shopKeyListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function bindShopKeyActions() {
|
||||
document.querySelectorAll('[data-shop-key-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var item = {};
|
||||
try { item = JSON.parse((btn.dataset.shopKey || '').replace(/"/g, '"')); } catch (e) { item = {}; }
|
||||
document.getElementById('editShopKeyId').value = item.id || '';
|
||||
document.getElementById('editShopKeyZiniaoAccountName').value = item.ziniao_account_name || '';
|
||||
document.getElementById('editShopKeyZiniaoToken').value = item.ziniao_token || '';
|
||||
document.getElementById('msgEditShopKey').textContent = '';
|
||||
document.getElementById('msgEditShopKey').className = 'msg';
|
||||
document.getElementById('editShopKeyModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-shop-key-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var name = (btn.dataset.ziniaoAccountName || '').replace(/"/g, '"');
|
||||
if (!confirm('确定删除店铺密钥「' + name + '」吗?')) return;
|
||||
fetch('/api/admin/shop-key/' + btn.dataset.shopKeyDelete, { method: 'DELETE' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) { loadShopKeys(shopKeyPage); }
|
||||
else { alert(res.error || '删除失败'); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById('btnCreateShopKey').onclick = function() {
|
||||
var ziniaoAccountName = (document.getElementById('shopKeyZiniaoAccountName').value || '').trim();
|
||||
var ziniaoToken = (document.getElementById('shopKeyZiniaoToken').value || '').trim();
|
||||
var msgEl = document.getElementById('msgShopKey');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!ziniaoAccountName || !ziniaoToken) {
|
||||
msgEl.textContent = '请完整填写紫鸟账号名称、紫鸟令牌';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/shop-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ziniao_account_name: ziniaoAccountName,
|
||||
ziniao_token: ziniaoToken
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
document.getElementById('shopKeyZiniaoAccountName').value = '';
|
||||
document.getElementById('shopKeyZiniaoToken').value = '';
|
||||
msgEl.textContent = res.msg || '创建成功';
|
||||
msgEl.className = 'msg ok';
|
||||
loadShopKeys(1);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '创建失败';
|
||||
msgEl.className = 'msg err';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
});
|
||||
};
|
||||
document.getElementById('btnSaveShopKey').onclick = function() {
|
||||
var itemId = document.getElementById('editShopKeyId').value;
|
||||
var ziniaoAccountName = (document.getElementById('editShopKeyZiniaoAccountName').value || '').trim();
|
||||
var ziniaoToken = (document.getElementById('editShopKeyZiniaoToken').value || '').trim();
|
||||
var msgEl = document.getElementById('msgEditShopKey');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!ziniaoAccountName || !ziniaoToken) {
|
||||
msgEl.textContent = '请完整填写紫鸟账号名称、紫鸟令牌';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/shop-key/' + itemId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ziniao_account_name: ziniaoAccountName,
|
||||
ziniao_token: ziniaoToken
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
document.getElementById('editShopKeyModal').classList.remove('show');
|
||||
loadShopKeys(shopKeyPage);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.classList.add('err');
|
||||
});
|
||||
};
|
||||
document.getElementById('btnCloseEditShopKey').onclick = function() {
|
||||
document.getElementById('editShopKeyModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 版本管理 ==========
|
||||
function loadVersions() {
|
||||
fetch('/api/admin/versions')
|
||||
@@ -1334,6 +1529,7 @@
|
||||
loadUserOptions();
|
||||
loadColumnsForPermission();
|
||||
loadDedupeTotalData(1);
|
||||
loadShopKeys(1);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<input type="checkbox" checked disabled />
|
||||
<div>
|
||||
<span class="label">文件名作为店铺名</span>
|
||||
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;当前先不联动紫鸟</div>
|
||||
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明;并自动联动紫鸟进行店铺匹配</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">Python 队列推送结果</div>
|
||||
<div v-if="queuePushResult" class="queue-debug-line">
|
||||
{{ queuePushResult }}
|
||||
</div>
|
||||
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
@@ -83,14 +91,17 @@
|
||||
<span>删除品牌结果列表</span>
|
||||
</div>
|
||||
|
||||
<div v-if="resultItems.length === 0" class="empty-tasks">
|
||||
<div v-if="!hasVisibleItems" class="empty-tasks">
|
||||
暂无删除品牌结果,完成解析后会在这里展示按国家分组的去重结果
|
||||
</div>
|
||||
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<template v-else>
|
||||
<div v-if="currentSectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list clean-result-list">
|
||||
<li
|
||||
v-for="item in resultItems"
|
||||
:key="`${item.resultId || item.taskId || item.sourceFilename}`"
|
||||
v-for="item in currentSectionItems"
|
||||
:key="`current-${item.resultId || item.taskId || item.sourceFilename}`"
|
||||
class="task-item split-result-item"
|
||||
>
|
||||
<div class="left split-result-main">
|
||||
@@ -98,8 +109,18 @@
|
||||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
||||
<div class="files">匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
|
||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||
<div v-if="item.countryCount !== undefined" class="files">国家数:{{ item.countryCount }}</div>
|
||||
<div v-if="item.totalRows !== undefined" class="time">去重后 {{ item.totalRows }} 条</div>
|
||||
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount }}</div>
|
||||
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} 条</div>
|
||||
<div v-if="item.taskId && item.matched" class="delete-brand-progress-block">
|
||||
<div class="delete-brand-progress-header">
|
||||
<span>任务进度</span>
|
||||
<span>{{ formatProgressPercent(item.taskId) }}%</span>
|
||||
</div>
|
||||
<div class="delete-brand-progress-bar">
|
||||
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId)}%` }"></div>
|
||||
</div>
|
||||
<div class="files delete-brand-progress">{{ formatProgress(item.taskId) }}</div>
|
||||
</div>
|
||||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
||||
{{ formatPreview(item.previewRows) }}
|
||||
</div>
|
||||
@@ -118,6 +139,14 @@
|
||||
>
|
||||
打开紫鸟
|
||||
</button>
|
||||
<button
|
||||
v-if="canDownloadTaskResult(item)"
|
||||
type="button"
|
||||
class="download"
|
||||
@click="downloadTaskResult(item)"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
v-if="item.resultId"
|
||||
type="button"
|
||||
@@ -130,6 +159,72 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="historySectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">历史记录</div>
|
||||
<ul class="task-list clean-result-list">
|
||||
<li
|
||||
v-for="item in historySectionItems"
|
||||
:key="`history-${item.resultId || item.taskId || item.sourceFilename}`"
|
||||
class="task-item split-result-item"
|
||||
>
|
||||
<div class="left split-result-main">
|
||||
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
|
||||
<div class="files">店铺名:{{ item.shopName || '-' }}</div>
|
||||
<div class="files">匹配结果:{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
|
||||
<div v-if="item.platform" class="files">平台:{{ item.platform }}</div>
|
||||
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数:{{ item.countryCount }}</div>
|
||||
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} 条</div>
|
||||
<div v-if="item.taskId && item.matched" class="delete-brand-progress-block">
|
||||
<div class="delete-brand-progress-header">
|
||||
<span>任务进度</span>
|
||||
<span>{{ formatProgressPercent(item.taskId) }}%</span>
|
||||
</div>
|
||||
<div class="delete-brand-progress-bar">
|
||||
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId)}%` }"></div>
|
||||
</div>
|
||||
<div class="files delete-brand-progress">{{ formatProgress(item.taskId) }}</div>
|
||||
</div>
|
||||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
||||
{{ formatPreview(item.previewRows) }}
|
||||
</div>
|
||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="task-right split-result-actions">
|
||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
||||
{{ item.success ? '已完成' : '失败' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="item.openStoreUrl"
|
||||
type="button"
|
||||
class="download"
|
||||
@click="openShop(item.openStoreUrl)"
|
||||
>
|
||||
打开紫鸟
|
||||
</button>
|
||||
<button
|
||||
v-if="canDownloadTaskResult(item)"
|
||||
type="button"
|
||||
class="download"
|
||||
@click="downloadTaskResult(item)"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
v-if="item.resultId"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
@click="deleteHistoryRecord(item.resultId)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -137,17 +232,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import {
|
||||
deleteDeleteBrandHistory,
|
||||
getDeleteBrandHistory,
|
||||
getDeleteBrandTaskDetail,
|
||||
getDeleteBrandTaskDownloadUrl,
|
||||
runDeleteBrand,
|
||||
type DeleteBrandPreviewRow,
|
||||
type DeleteBrandResultItem,
|
||||
type DeleteBrandRunVo,
|
||||
type DeleteBrandTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
|
||||
@@ -155,8 +253,50 @@ const selectedPaths = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadedJavaFile[]>([])
|
||||
const running = ref(false)
|
||||
const resultItems = ref<DeleteBrandResultItem[]>([])
|
||||
const currentRunItems = ref<DeleteBrandResultItem[]>([])
|
||||
const historyItems = ref<DeleteBrandResultItem[]>([])
|
||||
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||
const currentSectionItems = computed(() => currentRunItems.value)
|
||||
const historySectionItems = computed(() =>
|
||||
historyItems.value.filter(
|
||||
(item) =>
|
||||
!currentRunItems.value.some(
|
||||
(current) =>
|
||||
(current.resultId || current.taskId || current.fileKey || current.sourceFilename) ===
|
||||
(item.resultId || item.taskId || item.fileKey || item.sourceFilename),
|
||||
),
|
||||
),
|
||||
)
|
||||
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
|
||||
|
||||
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
|
||||
return items.map((item) => {
|
||||
if (!item.matched) {
|
||||
return {
|
||||
...item,
|
||||
success: false,
|
||||
error: item.error || '未匹配到紫鸟店铺',
|
||||
taskId: undefined,
|
||||
openStoreUrl: undefined,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function mergeVisibleItems() {
|
||||
resultItems.value = [...currentSectionItems.value, ...historySectionItems.value]
|
||||
}
|
||||
|
||||
function syncResultState() {
|
||||
mergeVisibleItems()
|
||||
updateSummary(resultItems.value)
|
||||
}
|
||||
|
||||
function updateSummary(items: DeleteBrandResultItem[]) {
|
||||
summary.value = {
|
||||
@@ -167,6 +307,87 @@ function updateSummary(items: DeleteBrandResultItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function getItemTaskId(item: DeleteBrandResultItem) {
|
||||
return item.taskId || 0
|
||||
}
|
||||
|
||||
function formatProgress(taskId: number) {
|
||||
if (!taskId) return ''
|
||||
const detail = taskDetails.value[taskId]
|
||||
const info = detail?.line_progress?.info
|
||||
if (!detail?.line_progress?.has_progress || !info) {
|
||||
const status = detail?.task?.status
|
||||
return status ? `任务状态:${status}` : ''
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (info.phase) parts.push(`阶段:${info.phase}`)
|
||||
if (info.file_index && info.file_total) parts.push(`文件:${info.file_index}/${info.file_total}`)
|
||||
if (info.file_name) parts.push(`当前文件:${info.file_name}`)
|
||||
if (info.current_line !== undefined && info.total_lines !== undefined) parts.push(`进度:${info.current_line}/${info.total_lines}`)
|
||||
if (info.current_country) parts.push(`国家:${info.current_country}`)
|
||||
if (info.current_asin) parts.push(`ASIN:${info.current_asin}`)
|
||||
if (info.finished_files !== undefined) parts.push(`已完成文件:${info.finished_files}`)
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function formatProgressPercent(taskId: number) {
|
||||
if (!taskId) return 0
|
||||
const info = taskDetails.value[taskId]?.line_progress?.info
|
||||
if (!info) return 0
|
||||
|
||||
if (info.current_line !== undefined && info.total_lines && info.total_lines > 0) {
|
||||
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
|
||||
}
|
||||
if (info.finished_files !== undefined && info.file_total && info.file_total > 0) {
|
||||
return Math.max(0, Math.min(100, Math.round((info.finished_files / info.file_total) * 100)))
|
||||
}
|
||||
const status = taskDetails.value[taskId]?.task?.status
|
||||
return status === 'SUCCESS' ? 100 : 0
|
||||
}
|
||||
|
||||
function isTaskRunning(taskId: number) {
|
||||
const status = taskDetails.value[taskId]?.task?.status
|
||||
return status === 'RUNNING'
|
||||
}
|
||||
|
||||
function canDownloadTaskResult(item: DeleteBrandResultItem) {
|
||||
const taskId = item.taskId
|
||||
if (!taskId || !item.matched) return false
|
||||
const status = taskDetails.value[taskId]?.task?.status
|
||||
return status === 'SUCCESS'
|
||||
}
|
||||
|
||||
async function refreshTaskDetails() {
|
||||
const taskIds = Array.from(new Set(resultItems.value.map(getItemTaskId).filter((id) => id > 0)))
|
||||
for (const id of taskIds) {
|
||||
try {
|
||||
const detail = await getDeleteBrandTaskDetail(id)
|
||||
taskDetails.value[id] = detail
|
||||
} catch {
|
||||
// ignore polling failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (pollTimer.value) return
|
||||
pollTimer.value = window.setInterval(async () => {
|
||||
await refreshTaskDetails()
|
||||
const stillRunning = Object.keys(taskDetails.value).some((id) => isTaskRunning(Number(id)))
|
||||
if (!stillRunning) {
|
||||
stopPolling()
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value) {
|
||||
window.clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.upload_file_to_java) {
|
||||
@@ -239,9 +460,40 @@ async function submitRun() {
|
||||
relativePath: item.relativePath,
|
||||
})),
|
||||
})
|
||||
summary.value = result
|
||||
resultItems.value = result.items || []
|
||||
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
|
||||
currentRunItems.value = normalizedItems
|
||||
syncResultState()
|
||||
|
||||
await refreshTaskDetails()
|
||||
ensurePolling()
|
||||
|
||||
const api = getPywebviewApi()
|
||||
const hasUnmatched = normalizedItems.some((item) => !item.matched)
|
||||
if (!hasUnmatched && api?.enqueue_json) {
|
||||
const payload = {
|
||||
type: 'delete-brand-run',
|
||||
ts: Date.now(),
|
||||
data: { ...result, items: normalizedItems },
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
const pushResult = await api.enqueue_json(payload)
|
||||
if (pushResult?.success) {
|
||||
queuePushResult.value = `已推送到 Python 队列,当前队列长度:${pushResult.queue_size ?? '-'}`
|
||||
} else {
|
||||
queuePushResult.value = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
|
||||
}
|
||||
} else {
|
||||
queuePushResult.value = hasUnmatched
|
||||
? '存在未匹配到紫鸟店铺的文件,已跳过推送到 Python 队列'
|
||||
: '当前环境未启用 pywebview enqueue_json(浏览器环境不会推送)'
|
||||
queuePayloadText.value = ''
|
||||
}
|
||||
|
||||
if (hasUnmatched) {
|
||||
ElMessage.error('存在未匹配到紫鸟店铺的文件,已标记为失败')
|
||||
} else {
|
||||
ElMessage.success('删除品牌解析完成')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '执行失败')
|
||||
} finally {
|
||||
@@ -249,11 +501,37 @@ async function submitRun() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTaskResult(item: DeleteBrandResultItem) {
|
||||
const taskId = item.taskId
|
||||
if (!taskId) {
|
||||
ElMessage.warning('未找到任务ID')
|
||||
return
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
const url = getDeleteBrandTaskDownloadUrl(taskId)
|
||||
const filename =
|
||||
item.outputFilename ||
|
||||
item.sourceFilename ||
|
||||
`delete-brand_${taskId}.xlsx`
|
||||
|
||||
if (api?.save_file_from_url) {
|
||||
const result = await api.save_file_from_url(url, filename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await getDeleteBrandHistory()
|
||||
resultItems.value = response.items || []
|
||||
updateSummary(resultItems.value)
|
||||
historyItems.value = normalizeDeleteBrandItems(response.items || [])
|
||||
syncResultState()
|
||||
} catch {
|
||||
// ignore history load errors
|
||||
}
|
||||
@@ -282,9 +560,18 @@ function openShop(url: string) {
|
||||
onMounted(() => {
|
||||
loadHistory().catch(() => undefined)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.queue-debug-card { margin-top: 18px; padding: 14px; border: 1px solid #2a2a2a; border-radius: 10px; background: #202020; }
|
||||
.queue-debug-title { margin-bottom: 8px; }
|
||||
.queue-debug-line { color: #b8c1cc; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
||||
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #141414; color: #8fd3ff; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
|
||||
.module-page { min-height: 100vh; background: #1a1a1a; }
|
||||
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
|
||||
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
|
||||
@@ -324,6 +611,11 @@ onMounted(() => {
|
||||
.time { font-size: 12px; color: #666; margin-top: 2px; }
|
||||
.split-entry-list { line-height: 1.6; word-break: break-all; max-width: 100%; }
|
||||
.delete-brand-preview { white-space: normal; }
|
||||
.delete-brand-progress-block { margin-top: 8px; }
|
||||
.delete-brand-progress-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; color: #8fbfff; }
|
||||
.delete-brand-progress-bar { width: 100%; height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
|
||||
.delete-brand-progress-bar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%); transition: width 0.3s ease; }
|
||||
.delete-brand-progress { color: #69b6ff; line-height: 1.6; white-space: normal; margin-top: 6px; }
|
||||
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.split-result-actions { flex-shrink: 0; min-width: 180px; justify-content: flex-end; align-self: center; }
|
||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
|
||||
|
||||
@@ -150,22 +150,72 @@ export interface DeleteBrandProcessedCountry {
|
||||
}
|
||||
|
||||
export interface DeleteBrandResultFile {
|
||||
fileKey?: string
|
||||
sourceFilename: string
|
||||
fileIndex?: number
|
||||
fileTotal?: number
|
||||
chunkIndex?: number
|
||||
chunkTotal?: number
|
||||
processedRows?: number
|
||||
totalRows?: number
|
||||
currentCountry?: string
|
||||
currentAsin?: string
|
||||
countries: DeleteBrandProcessedCountry[]
|
||||
}
|
||||
|
||||
export interface DeleteBrandSubmitResultRequest {
|
||||
submissionId?: string
|
||||
files: DeleteBrandResultFile[]
|
||||
}
|
||||
|
||||
export interface DeleteBrandLineProgressInfo {
|
||||
file_index?: number
|
||||
file_total?: number
|
||||
file_name?: string
|
||||
current_line?: number
|
||||
total_lines?: number
|
||||
current_country?: string
|
||||
current_asin?: string
|
||||
finished_files?: number
|
||||
phase?: string
|
||||
}
|
||||
|
||||
export interface DeleteBrandLineProgress {
|
||||
has_progress: boolean
|
||||
info?: DeleteBrandLineProgressInfo
|
||||
}
|
||||
|
||||
export interface DeleteBrandTaskItem {
|
||||
id: number
|
||||
taskNo?: string
|
||||
status?: string
|
||||
sourceFileCount?: number
|
||||
successFileCount?: number
|
||||
failedFileCount?: number
|
||||
errorMessage?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
finishedAt?: string
|
||||
downloadUrl?: string
|
||||
downloadFilename?: string
|
||||
}
|
||||
|
||||
export interface DeleteBrandTaskDetailVo {
|
||||
task: DeleteBrandTaskItem
|
||||
line_progress: DeleteBrandLineProgress
|
||||
}
|
||||
|
||||
export interface DeleteBrandResultItem {
|
||||
resultId?: number
|
||||
fileKey?: string
|
||||
sourceFilename: string
|
||||
shopName?: string
|
||||
matched: boolean
|
||||
shopId?: string
|
||||
platform?: string
|
||||
openStoreUrl?: string
|
||||
outputFilename?: string
|
||||
downloadUrl?: string
|
||||
taskId?: number
|
||||
countryCount?: number
|
||||
countries?: DeleteBrandCountryGroup[]
|
||||
@@ -311,6 +361,14 @@ export function deleteDeleteBrandHistory(resultId: number) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function getDeleteBrandTaskDetail(taskId: number) {
|
||||
return unwrapJavaResponse(get<JavaApiResponse<DeleteBrandTaskDetailVo>>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}`))
|
||||
}
|
||||
|
||||
export function getDeleteBrandTaskDownloadUrl(taskId: number) {
|
||||
return getJavaDownloadUrl(`/delete-brand/tasks/${taskId}/download`)
|
||||
}
|
||||
|
||||
export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubmitResultRequest) {
|
||||
return unwrapJavaResponse(post<JavaApiResponse<null>, DeleteBrandSubmitResultRequest>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}/result`, request))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PywebviewApi {
|
||||
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
enqueue_json?: (data: unknown) => Promise<{ success: boolean; queue_size?: number; error?: string }>
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user