完成前后端紫鸟部分开发
This commit is contained in:
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,18 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DdV1XQSM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-LVYOFm.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-Cfr3mUjs.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-p4QBqp2b.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-Cfr3mUjs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DdV1XQSM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-LVYOFm.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-CS18ls2z.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-p4QBqp2b.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-CS18ls2z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DdV1XQSM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-LVYOFm.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BgPrpPsj.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-p4QBqp2b.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BM2UTMI3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DdV1XQSM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-LVYOFm.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-mwLUJT1K.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-p4QBqp2b.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-MkjZQlBu.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-mwLUJT1K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"version": "1.0.17"}
|
||||
{"version": "1.0.13"}
|
||||
BIN
backend-java/aiimage-backend.log.2026-03-27.0.gz
Normal file
BIN
backend-java/aiimage-backend.log.2026-03-27.0.gz
Normal file
Binary file not shown.
@@ -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());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setStatus(failedCount == 0 ? "SUCCESS" : "FAILED");
|
||||
if (parsedSuccessCount <= 0) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(parsedFailedCount > 0 ? "删除品牌文件解析失败" : null);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
if (!parsedPayloadBySourceFilename.isEmpty()) {
|
||||
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadBySourceFilename);
|
||||
if (!parsedPayloadByFileIdentity.isEmpty()) {
|
||||
deleteBrandTaskCacheService.saveParsedPayload(task.getId(), parsedPayloadByFileIdentity);
|
||||
}
|
||||
|
||||
|
||||
DeleteBrandRunVo vo = new DeleteBrandRunVo();
|
||||
vo.setTotal(request.getFiles().size());
|
||||
vo.setSuccessCount(successCount);
|
||||
vo.setFailedCount(failedCount);
|
||||
vo.setSuccessCount(parsedSuccessCount);
|
||||
vo.setFailedCount(parsedFailedCount);
|
||||
vo.setItems(items);
|
||||
return vo;
|
||||
}
|
||||
@@ -182,8 +215,13 @@ public class DeleteBrandRunService {
|
||||
.map(entity -> {
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
item.setFileKey(entity.getSourceFileUrl());
|
||||
item.setSourceFilename(entity.getSourceFilename());
|
||||
item.setShopName(entity.getResultFilename());
|
||||
item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename()));
|
||||
item.setOutputFilename(entity.getResultFilename());
|
||||
item.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl()));
|
||||
item.setTotalRows(entity.getRowCount());
|
||||
item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
item.setError(entity.getErrorMessage());
|
||||
return item;
|
||||
@@ -291,6 +329,13 @@ public class DeleteBrandRunService {
|
||||
return value.replace(" ", "").trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeShopName(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\u3000", " ").trim();
|
||||
}
|
||||
|
||||
private List<CountryColumnPair> resolveCountryPairs(Row titleRow, Row headerRow, DataFormatter formatter) {
|
||||
List<CountryColumnPair> pairs = new ArrayList<>();
|
||||
int lastCellNum = Math.max(titleRow.getLastCellNum(), headerRow.getLastCellNum());
|
||||
@@ -334,6 +379,139 @@ public class DeleteBrandRunService {
|
||||
private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) {
|
||||
}
|
||||
|
||||
private String resolveFileIdentity(DeleteBrandSourceFileDto sourceFile) {
|
||||
if (sourceFile != null && sourceFile.getFileKey() != null && !sourceFile.getFileKey().isBlank()) {
|
||||
return sourceFile.getFileKey().trim();
|
||||
}
|
||||
return sourceFile == null ? "" : String.valueOf(sourceFile.getOriginalFilename());
|
||||
}
|
||||
|
||||
public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) {
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
||||
taskVo.setId(task.getId());
|
||||
taskVo.setTaskNo(task.getTaskNo());
|
||||
taskVo.setStatus(task.getStatus());
|
||||
taskVo.setSourceFileCount(task.getSourceFileCount());
|
||||
taskVo.setSuccessFileCount(task.getSuccessFileCount());
|
||||
taskVo.setFailedFileCount(task.getFailedFileCount());
|
||||
taskVo.setErrorMessage(task.getErrorMessage());
|
||||
taskVo.setCreatedAt(task.getCreatedAt());
|
||||
taskVo.setUpdatedAt(task.getUpdatedAt());
|
||||
taskVo.setFinishedAt(task.getFinishedAt());
|
||||
taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId()));
|
||||
taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId()));
|
||||
|
||||
DeleteBrandLineProgressVo progressVo = buildLineProgress(taskId);
|
||||
|
||||
DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo();
|
||||
detail.setTask(taskVo);
|
||||
detail.setLine_progress(progressVo);
|
||||
return detail;
|
||||
}
|
||||
|
||||
private DeleteBrandLineProgressVo buildLineProgress(Long taskId) {
|
||||
Map<Object, Object> progress = deleteBrandTaskCacheService.getProgress(taskId);
|
||||
DeleteBrandLineProgressVo vo = new DeleteBrandLineProgressVo();
|
||||
if (progress == null || progress.isEmpty()) {
|
||||
vo.setHas_progress(false);
|
||||
vo.setInfo(null);
|
||||
return vo;
|
||||
}
|
||||
DeleteBrandLineProgressInfoVo info = new DeleteBrandLineProgressInfoVo();
|
||||
info.setFile_index(parseInt(progress.get("file_index")));
|
||||
info.setFile_total(parseInt(progress.get("file_total")));
|
||||
info.setFile_name(stringValue(progress.get("file_name")));
|
||||
info.setCurrent_line(parseInt(progress.get("current_line")));
|
||||
info.setTotal_lines(parseInt(progress.get("total_lines")));
|
||||
info.setCurrent_country(stringValue(progress.get("current_country")));
|
||||
info.setCurrent_asin(stringValue(progress.get("current_asin")));
|
||||
info.setFinished_files(parseInt(progress.get("finished_files")));
|
||||
info.setPhase(stringValue(progress.get("phase")));
|
||||
vo.setHas_progress(true);
|
||||
vo.setInfo(info);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Integer parseInt(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = String.valueOf(value);
|
||||
return raw.isBlank() ? null : raw;
|
||||
}
|
||||
|
||||
private String resolveFileIdentity(DeleteBrandResultFileDto fileDto) {
|
||||
if (fileDto != null && fileDto.getFileKey() != null && !fileDto.getFileKey().isBlank()) {
|
||||
return fileDto.getFileKey().trim();
|
||||
}
|
||||
return fileDto == null ? "" : blankToEmpty(fileDto.getSourceFilename());
|
||||
}
|
||||
|
||||
private void validateChunk(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
if (fileDto.getChunkIndex() == null || fileDto.getChunkTotal() == null
|
||||
|| fileDto.getChunkIndex() <= 0 || fileDto.getChunkTotal() <= 0
|
||||
|| fileDto.getChunkIndex() > fileDto.getChunkTotal()) {
|
||||
throw new BusinessException("分片参数不合法");
|
||||
}
|
||||
if (fileDto.getTotalRows() != null && parsedFile.getTotalRows() != null && fileDto.getTotalRows() > parsedFile.getTotalRows()) {
|
||||
throw new BusinessException("totalRows 不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFileCompleted(DeleteBrandResultFileDto fileDto) {
|
||||
return fileDto.getChunkIndex() != null && fileDto.getChunkTotal() != null && fileDto.getChunkIndex().equals(fileDto.getChunkTotal());
|
||||
}
|
||||
|
||||
private int defaultInt(Integer value, int defaultValue) {
|
||||
return value == null ? defaultValue : value;
|
||||
}
|
||||
|
||||
private String blankToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private void validateResultFileAgainstParsed(DeleteBrandResultFileDto fileDto, DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
Map<String, DeleteBrandCountryGroupVo> byCountry = new LinkedHashMap<>();
|
||||
for (DeleteBrandCountryGroupVo group : parsedFile.getCountries()) {
|
||||
byCountry.put(group.getCountry(), group);
|
||||
}
|
||||
|
||||
for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) {
|
||||
DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry());
|
||||
if (group == null) {
|
||||
throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry());
|
||||
}
|
||||
|
||||
Map<String, DeleteBrandCountryAsinVo> allowed = new LinkedHashMap<>();
|
||||
for (DeleteBrandCountryAsinVo asinVo : group.getItems()) {
|
||||
allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo);
|
||||
}
|
||||
|
||||
for (DeleteBrandCountryResultItemDto itemDto : countryDto.getItems()) {
|
||||
String key = normalizeAsinKey(itemDto.getAsin());
|
||||
if (key.isBlank() || !allowed.containsKey(key)) {
|
||||
throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
@@ -347,52 +525,366 @@ public class DeleteBrandRunService {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
Map<String, ParsedDeleteBrandFile> parsedPayload = deleteBrandTaskCacheService.getParsedPayload(taskId,
|
||||
new TypeReference<Map<String, ParsedDeleteBrandFile>>() {
|
||||
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()) {
|
||||
if (fileDto.getSourceFilename() == null || fileDto.getSourceFilename().isBlank()) {
|
||||
throw new BusinessException("sourceFilename 不能为空");
|
||||
String fileIdentity = resolveFileIdentity(fileDto);
|
||||
if (fileIdentity.isBlank()) {
|
||||
throw new BusinessException("fileKey/sourceFilename 不能为空");
|
||||
}
|
||||
ParsedDeleteBrandFile parsedFile = parsedPayload.get(fileDto.getSourceFilename());
|
||||
DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity);
|
||||
if (parsedFile == null) {
|
||||
throw new BusinessException("sourceFilename 不匹配: " + fileDto.getSourceFilename());
|
||||
throw new BusinessException("文件标识不匹配: " + fileIdentity);
|
||||
}
|
||||
|
||||
Map<String, DeleteBrandCountryGroupVo> byCountry = new LinkedHashMap<>();
|
||||
for (DeleteBrandCountryGroupVo group : parsedFile.countries()) {
|
||||
byCountry.put(group.getCountry(), group);
|
||||
validateChunk(fileDto, parsedFile);
|
||||
validateResultFileAgainstParsed(fileDto, parsedFile);
|
||||
deleteBrandTaskCacheService.mergeResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto);
|
||||
|
||||
Map<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);
|
||||
}
|
||||
}
|
||||
|
||||
for (DeleteBrandProcessedCountryDto countryDto : fileDto.getCountries()) {
|
||||
DeleteBrandCountryGroupVo group = byCountry.get(countryDto.getCountry());
|
||||
if (group == null) {
|
||||
throw new BusinessException("country 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry());
|
||||
int successCount = 0;
|
||||
List<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);
|
||||
|
||||
Map<String, DeleteBrandCountryAsinVo> allowed = new LinkedHashMap<>();
|
||||
for (DeleteBrandCountryAsinVo asinVo : group.getItems()) {
|
||||
allowed.put(normalizeAsinKey(asinVo.getAsin()), asinVo);
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", DeleteBrandTaskCacheService.PHASE_UPLOADING,
|
||||
"file_name", blankToEmpty(parsedFile.getSourceFilename()),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
|
||||
String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE);
|
||||
FileResultEntity resultEntity = resultEntityByIdentity.get(fileIdentity);
|
||||
if (resultEntity == null) {
|
||||
throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
resultEntity.setResultFilename(outputFile.getName());
|
||||
resultEntity.setResultFileUrl(objectKey);
|
||||
resultEntity.setResultFileSize(outputFile.length());
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsedFile.getTotalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(resultEntity.getId());
|
||||
item.setTaskId(task.getId());
|
||||
item.setFileKey(parsedFile.getFileKey());
|
||||
item.setSourceFilename(parsedFile.getSourceFilename());
|
||||
item.setShopName(parsedFile.getShopName());
|
||||
item.setOutputFilename(outputFile.getName());
|
||||
item.setDownloadUrl(ossStorageService.getPublicUrl(objectKey));
|
||||
item.setTotalRows(parsedFile.getTotalRows());
|
||||
item.setCountryCount(parsedFile.getCountries() == null ? 0 : parsedFile.getCountries().size());
|
||||
item.setCountries(parsedFile.getCountries());
|
||||
item.setPreviewRows(parsedFile.getPreviewRows());
|
||||
item.setTruncated(false);
|
||||
item.setSuccess(true);
|
||||
finalItems.add(item);
|
||||
successCount++;
|
||||
}
|
||||
|
||||
task.setStatus("SUCCESS");
|
||||
task.setSuccessFileCount(successCount);
|
||||
task.setErrorMessage(null);
|
||||
task.setResultJson(JSONUtil.toJsonStr(finalItems));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", "success",
|
||||
"file_index", String.valueOf(parsedPayload.size()),
|
||||
"file_total", String.valueOf(parsedPayload.size()),
|
||||
"finished_files", String.valueOf(successCount),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
} catch (Exception ex) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(ex.getMessage());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of(
|
||||
"phase", DeleteBrandTaskCacheService.PHASE_FAILED,
|
||||
"finished_files", String.valueOf(task.getSuccessFileCount() == null ? 0 : task.getSuccessFileCount()),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
if (ex instanceof BusinessException businessException) {
|
||||
throw businessException;
|
||||
}
|
||||
throw new BusinessException("删除品牌结果组装失败");
|
||||
}
|
||||
}
|
||||
|
||||
private MergedDeleteBrandFile mergeChunks(DeleteBrandParsedFileCacheDto parsedFile, List<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()) {
|
||||
String key = normalizeAsinKey(itemDto.getAsin());
|
||||
if (key.isBlank() || !allowed.containsKey(key)) {
|
||||
throw new BusinessException("asin 不匹配: " + fileDto.getSourceFilename() + " / " + countryDto.getCountry() + " / " + itemDto.getAsin());
|
||||
}
|
||||
byAsin.put(normalizeAsinKey(itemDto.getAsin()), itemDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException("结果回传处理尚未实现:请在此处接入 xlsx 重组与 OSS 上传逻辑");
|
||||
List<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,38 +47,64 @@ public class ZiniaoAuthService {
|
||||
return new StoreMatchResult(false, null, null, null, null, null);
|
||||
}
|
||||
|
||||
Long companyId = resolveCompanyId();
|
||||
List<ZiniaoStaffItemVo> staff = ziniaoClient.listStaff(companyId);
|
||||
|
||||
List<Long> userIds = new java.util.ArrayList<>();
|
||||
if (preferUserId != null && preferUserId > 0) {
|
||||
userIds.add(preferUserId);
|
||||
}
|
||||
for (ZiniaoStaffItemVo item : staff) {
|
||||
if (item != null && item.getUserId() != null && item.getUserId() > 0 && (userIds.isEmpty() || !userIds.contains(item.getUserId()))) {
|
||||
userIds.add(item.getUserId());
|
||||
List<String> apiKeys = getScanApiKeys();
|
||||
long startedAt = System.currentTimeMillis();
|
||||
int scannedKeys = 0;
|
||||
for (String apiKey : apiKeys) {
|
||||
if (isScanLimitExceeded(startedAt, scannedKeys)) {
|
||||
throw new BusinessException("紫鸟 key 轮询超出扫描限制,请缩小范围或提高扫描上限");
|
||||
}
|
||||
}
|
||||
scannedKeys++;
|
||||
|
||||
for (Long staffUserId : userIds) {
|
||||
if (staffUserId == null || staffUserId <= 0) {
|
||||
continue;
|
||||
}
|
||||
List<ZiniaoShopCacheDto> stores;
|
||||
Long companyId;
|
||||
try {
|
||||
stores = ziniaoClient.listUserStores(companyId, staffUserId);
|
||||
companyId = resolveCompanyId(apiKey);
|
||||
} catch (BusinessException ex) {
|
||||
if (isSkippableUserStoresError(ex)) {
|
||||
// key 无效/不可用时继续尝试下一个 key
|
||||
if (isInvalidApiKeyError(ex)) {
|
||||
log.info("[ziniao-match] skip invalid apiKey while resolving companyId, keyHash={}", shortKeyHash(apiKey));
|
||||
continue;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
|
||||
for (ZiniaoShopCacheDto store : stores) {
|
||||
String storeName = normalizeShopName(store == null ? null : store.getShopName());
|
||||
if (!storeName.isBlank() && storeName.equals(normalizedTarget)) {
|
||||
String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId, ziniaoClient.getUserLoginToken(companyId, staffUserId));
|
||||
return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl);
|
||||
StoreMatchResult cachedMatch = getCachedShopMatch(apiKey, companyId, normalizedTarget);
|
||||
if (cachedMatch != null) {
|
||||
log.info("[ziniao-match] hit shop-match cache, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}",
|
||||
shortKeyHash(apiKey), companyId, normalizedTarget, cachedMatch.shopId(), cachedMatch.matchedUserId());
|
||||
String openStoreUrl = buildOpenStoreUrl(cachedMatch.shopId(), cachedMatch.matchedUserId(),
|
||||
ziniaoClient.getUserLoginToken(apiKey, companyId, cachedMatch.matchedUserId()));
|
||||
return new StoreMatchResult(true, cachedMatch.shopId(), cachedMatch.shopName(), cachedMatch.platform(), cachedMatch.matchedUserId(), openStoreUrl);
|
||||
}
|
||||
|
||||
List<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 = getOrLoadUserStores(apiKey, companyId, staffUserId);
|
||||
} catch (BusinessException ex) {
|
||||
if (isSkippableUserStoresError(ex)) {
|
||||
continue;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
|
||||
for (ZiniaoShopCacheDto store : stores) {
|
||||
String storeName = normalizeShopName(store == null ? null : store.getShopName());
|
||||
if (!storeName.isBlank() && storeName.equals(normalizedTarget)) {
|
||||
log.info("[ziniao-match] matched via upstream scan, keyHash={}, companyId={}, shopName={}, shopId={}, userId={}",
|
||||
shortKeyHash(apiKey), companyId, normalizedTarget, store.getShopId(), staffUserId);
|
||||
cacheShopMatch(apiKey, companyId, normalizedTarget,
|
||||
new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, null),
|
||||
SHOP_MATCH_CACHE_TTL);
|
||||
String openStoreUrl = buildOpenStoreUrl(store.getShopId(), staffUserId,
|
||||
ziniaoClient.getUserLoginToken(apiKey, companyId, staffUserId));
|
||||
return new StoreMatchResult(true, store.getShopId(), store.getShopName(), store.getPlatform(), staffUserId, openStoreUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +138,8 @@ public class ZiniaoAuthService {
|
||||
private final ZiniaoProperties ziniaoProperties;
|
||||
private final ZiniaoClient ziniaoClient;
|
||||
private final ZiniaoSessionCacheService ziniaoSessionCacheService;
|
||||
private final ZiniaoMemoryStoreService ziniaoMemoryStoreService;
|
||||
private final ZiniaoApiKeyProvider ziniaoApiKeyProvider;
|
||||
|
||||
public ZiniaoSessionVo getSession(String sessionId, Long userId) {
|
||||
ensureEnabled();
|
||||
@@ -122,15 +164,18 @@ public class ZiniaoAuthService {
|
||||
public ZiniaoStaffListVo listStaff() {
|
||||
ensureEnabled();
|
||||
ZiniaoStaffListVo vo = new ZiniaoStaffListVo();
|
||||
Long companyId = resolveCompanyIdForStaff();
|
||||
vo.getItems().addAll(ziniaoClient.listStaff(companyId));
|
||||
String apiKey = resolveAvailableApiKey();
|
||||
Long companyId = resolveCompanyIdForStaff(apiKey);
|
||||
vo.getItems().addAll(ziniaoClient.listStaff(apiKey, companyId));
|
||||
return vo;
|
||||
}
|
||||
|
||||
public ZiniaoShopListVo listShops(String sessionId, Long userId) {
|
||||
ZiniaoSessionCacheDto session = requireOrInitSession(sessionId, userId);
|
||||
Long currentUserId = requireUserId(session.getCurrentUserId());
|
||||
List<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,52 +91,139 @@
|
||||
<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">
|
||||
<li
|
||||
v-for="item in resultItems"
|
||||
:key="`${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" class="files">国家数:{{ item.countryCount }}</div>
|
||||
<div v-if="item.totalRows !== undefined" class="time">去重后 {{ item.totalRows }} 条</div>
|
||||
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
|
||||
{{ formatPreview(item.previewRows) }}
|
||||
</div>
|
||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||
</div>
|
||||
<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 currentSectionItems"
|
||||
:key="`current-${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)"
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
打开紫鸟
|
||||
</button>
|
||||
<button
|
||||
v-if="item.resultId"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
@click="deleteHistoryRecord(item.resultId)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
@@ -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 || []
|
||||
ElMessage.success('删除品牌解析完成')
|
||||
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