This commit is contained in:
铭坤
2026-04-19 00:18:02 +08:00
63 changed files with 8858 additions and 1473 deletions

View File

@@ -1,13 +1,11 @@
package com.nanri.aiimage.common.api;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统一响应体")
public class ApiResponse<T> {
@@ -20,6 +18,20 @@ public class ApiResponse<T> {
@Schema(description = "响应数据")
private T data;
@Schema(description = "业务错误码,成功时可为空")
private Integer code;
public ApiResponse(boolean success, String message, T data) {
this(success, message, data, null);
}
public ApiResponse(boolean success, String message, T data, Integer code) {
this.success = success;
this.message = message;
this.data = data;
this.code = code;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "操作成功", data);
}
@@ -31,4 +43,8 @@ public class ApiResponse<T> {
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
public static <T> ApiResponse<T> fail(Integer code, String message) {
return new ApiResponse<>(false, message, null, code);
}
}

View File

@@ -2,7 +2,18 @@ package com.nanri.aiimage.common.exception;
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(String message) {
this(null, message);
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public Integer getCode() {
return code;
}
}

View File

@@ -13,7 +13,9 @@ public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
return ApiResponse.fail(ex.getMessage());
return ex.getCode() == null
? ApiResponse.fail(ex.getMessage())
: ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)

View File

@@ -0,0 +1,126 @@
package com.nanri.aiimage.common.util;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
public final class ExcelStreamReader {
private ExcelStreamReader() {
}
public static void readFirstSheet(File file, SheetRowHandler handler) throws IOException {
try (InputStream inputStream = new FileInputStream(file)) {
readFirstSheet(inputStream, handler);
}
}
public static void readFirstSheet(InputStream inputStream, SheetRowHandler handler) throws IOException {
runRead(inputStream, false, handler);
}
public static void readAllSheets(File file, SheetRowHandler handler) throws IOException {
try (InputStream inputStream = new FileInputStream(file)) {
readAllSheets(inputStream, handler);
}
}
public static void readAllSheets(InputStream inputStream, SheetRowHandler handler) throws IOException {
runRead(inputStream, true, handler);
}
private static void runRead(InputStream inputStream, boolean readAllSheets, SheetRowHandler handler) throws IOException {
StreamingListener listener = new StreamingListener(handler);
try {
if (readAllSheets) {
EasyExcel.read(inputStream, listener).headRowNumber(1).doReadAll();
} else {
EasyExcel.read(inputStream, listener).sheet(0).headRowNumber(1).doRead();
}
} catch (WrappedRuntimeException ex) {
if (ex.getCause() instanceof IOException ioException) {
throw ioException;
}
throw ex;
}
}
public interface SheetRowHandler {
default void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
}
void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception;
}
private static final class StreamingListener extends AnalysisEventListener<Map<Integer, String>> {
private final SheetRowHandler handler;
private Map<Integer, String> currentHeaderMap = Map.of();
private StreamingListener(SheetRowHandler handler) {
this.handler = handler;
}
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
Map<Integer, String> normalizedHeadMap = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : headMap.entrySet()) {
normalizedHeadMap.put(entry.getKey(), entry.getValue());
}
currentHeaderMap = normalizedHeadMap;
try {
handler.onHeader(sheetName(context), sheetNo(context), currentHeaderMap);
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new WrappedRuntimeException(ex);
}
}
@Override
public void invoke(Map<Integer, String> data, AnalysisContext context) {
Map<Integer, String> normalizedRow = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : data.entrySet()) {
normalizedRow.put(entry.getKey(), entry.getValue());
}
try {
handler.onRow(
sheetName(context),
sheetNo(context),
context.readRowHolder().getRowIndex(),
currentHeaderMap,
normalizedRow
);
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new WrappedRuntimeException(ex);
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
}
private String sheetName(AnalysisContext context) {
return context.readSheetHolder() == null ? "" : context.readSheetHolder().getSheetName();
}
private Integer sheetNo(AnalysisContext context) {
return context.readSheetHolder() == null ? null : context.readSheetHolder().getSheetNo();
}
}
private static final class WrappedRuntimeException extends RuntimeException {
private WrappedRuntimeException(Throwable cause) {
super(cause);
}
}
}

View File

@@ -18,18 +18,18 @@ public class DeleteBrandProgressProperties {
* 商品风险PRODUCT_RISK_RESOLVERUNNING 超时自动收尾/失败,按分钟计算;
* 与删除品牌共用同一定时调度。
*/
private long productRiskStaleTimeoutMinutes = 10;
private long productRiskInitialTimeoutMinutes = 10;
private long productRiskStaleTimeoutMinutes = 20;
private long productRiskInitialTimeoutMinutes = 20;
/**
* Price track RUNNING timeout auto-finalize/fail threshold in minutes.
*/
private long priceTrackStaleTimeoutMinutes = 10;
private long priceTrackInitialTimeoutMinutes = 10;
private long priceTrackStaleTimeoutMinutes = 20;
private long priceTrackInitialTimeoutMinutes = 20;
/**
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
*/
private long shopMatchStaleTimeoutMinutes = 10;
private long shopMatchInitialTimeoutMinutes = 10;
private long shopMatchStaleTimeoutMinutes = 20;
private long shopMatchInitialTimeoutMinutes = 20;
}

View File

@@ -9,7 +9,11 @@ import com.nanri.aiimage.modules.brand.model.dto.BrandFileAggregateCacheDto;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
@@ -94,21 +98,28 @@ public class BrandTaskProgressCacheService {
public void saveParsedPayload(Long taskId, Object payload) {
try {
stringRedisTemplate.opsForValue().set(buildPayloadKey(taskId), objectMapper.writeValueAsString(payload), ttl());
Files.createDirectories(buildTaskDir(taskId));
Files.writeString(
buildPayloadFile(taskId),
objectMapper.writeValueAsString(payload),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (Exception ex) {
throw new BusinessException("暂存品牌原始数据失败");
throw new BusinessException("鏆傚瓨鍝佺墝鍘熷鏁版嵁澶辫触");
}
}
public <T> T getParsedPayload(Long taskId, TypeReference<T> typeReference) {
String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId));
if (raw == null || raw.isBlank()) {
Path payloadFile = buildPayloadFile(taskId);
if (!Files.isRegularFile(payloadFile)) {
return null;
}
try {
return objectMapper.readValue(raw, typeReference);
return objectMapper.readValue(Files.readString(payloadFile), typeReference);
} catch (Exception ex) {
throw new BusinessException("读取品牌原始数据失败");
throw new BusinessException("璇诲彇鍝佺墝鍘熷鏁版嵁澶辫触");
}
}
@@ -126,7 +137,7 @@ public class BrandTaskProgressCacheService {
int finishedFiles = countCompletedFiles(taskId);
return new ChunkStoreResult(false, false, finishedFiles, aggregate);
}
throw new BusinessException("同一分片重复提交但内容不一致: " + fileUrl + "#" + file.getChunkIndex());
throw new BusinessException("鍚屼竴鍒嗙墖閲嶅鎻愪氦浣嗗唴瀹逛笉涓€鑷? " + fileUrl + "#" + file.getChunkIndex());
}
BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, fileUrl);
@@ -174,7 +185,7 @@ public class BrandTaskProgressCacheService {
try {
return objectMapper.readValue(json, BrandFileAggregateCacheDto.class);
} catch (Exception ex) {
throw new BusinessException("读取品牌文件聚合缓存失败");
throw new BusinessException("璇诲彇鍝佺墝鏂囦欢鑱氬悎缂撳瓨澶辫触");
}
}
@@ -209,11 +220,11 @@ public class BrandTaskProgressCacheService {
public void delete(Long taskId) {
stringRedisTemplate.delete(buildKey(taskId));
stringRedisTemplate.delete(buildPayloadKey(taskId));
stringRedisTemplate.delete(buildFileAggregateKey(taskId));
stringRedisTemplate.delete(buildCompletedFilesKey(taskId));
stringRedisTemplate.delete(buildFinalizeLockKey(taskId));
stringRedisTemplate.delete(buildLegacyResultKey(taskId));
deleteTaskDirQuietly(taskId);
}
public void deleteFileState(Long taskId, String fileUrl) {
@@ -236,19 +247,19 @@ public class BrandTaskProgressCacheService {
stringRedisTemplate.opsForHash().put(buildFileAggregateKey(taskId), normalizeFileUrl(aggregate.getFileUrl()), objectMapper.writeValueAsString(aggregate));
refreshTaskKey(buildFileAggregateKey(taskId));
} catch (Exception ex) {
throw new BusinessException("暂存品牌文件聚合结果失败");
throw new BusinessException("鏆傚瓨鍝佺墝鏂囦欢鑱氬悎缁撴灉澶辫触");
}
}
private void validateChunk(BrandCrawlResultFileDto file) {
if (file == null) {
throw new BusinessException("分片不能为空");
throw new BusinessException("鍒嗙墖涓嶈兘涓虹┖");
}
if (file.getChunkIndex() == null || file.getChunkTotal() == null || file.getChunkIndex() <= 0 || file.getChunkTotal() <= 0 || file.getChunkIndex() > file.getChunkTotal()) {
throw new BusinessException("分片参数不合法");
throw new BusinessException("鍒嗙墖鍙傛暟涓嶅悎娉?");
}
if (file.getFileUrl() == null || file.getFileUrl().isBlank()) {
throw new BusinessException("fileUrl 不能为空");
throw new BusinessException("fileUrl 涓嶈兘涓虹┖");
}
}
@@ -270,7 +281,7 @@ public class BrandTaskProgressCacheService {
private void ensureChunkConsistency(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) {
if (aggregate.getChunkTotal() != null && file.getChunkTotal() != null && !aggregate.getChunkTotal().equals(file.getChunkTotal())) {
throw new BusinessException("同一文件的 chunkTotal 不一致: " + aggregate.getFileUrl());
throw new BusinessException("鍚屼竴鏂囦欢鐨?chunkTotal 涓嶄竴鑷? " + aggregate.getFileUrl());
}
if (aggregate.getChunkTotal() == null) {
aggregate.setChunkTotal(file.getChunkTotal());
@@ -322,7 +333,7 @@ public class BrandTaskProgressCacheService {
}
return sb.toString();
} catch (Exception ex) {
throw new BusinessException("计算品牌结果分片摘要失败");
throw new BusinessException("璁$畻鍝佺墝缁撴灉鍒嗙墖鎽樿澶辫触");
}
}
@@ -340,8 +351,28 @@ public class BrandTaskProgressCacheService {
return Duration.ofHours(brandProgressProperties.getTtlHours());
}
private String buildPayloadKey(Long taskId) {
return "brand:task:parsed-payload:" + taskId;
private Path buildTaskDir(Long taskId) {
return Path.of(System.getProperty("java.io.tmpdir"), "brand-task-cache", String.valueOf(taskId));
}
private Path buildPayloadFile(Long taskId) {
return buildTaskDir(taskId).resolve("parsed-payload.json");
}
private void deleteTaskDirQuietly(Long taskId) {
Path taskDir = buildTaskDir(taskId);
if (!Files.exists(taskDir)) {
return;
}
try (var walk = Files.walk(taskDir)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
} catch (IOException ignored) {
}
}
private String buildFileAggregateKey(Long taskId) {
@@ -383,7 +414,7 @@ public class BrandTaskProgressCacheService {
}
return sb.toString();
} catch (Exception ex) {
throw new BusinessException("生成文件缓存键失败");
throw new BusinessException("鐢熸垚鏂囦欢缂撳瓨閿け璐?");
}
}

View File

@@ -327,6 +327,7 @@ public class BrandTaskService {
if (deleted == 0) {
throw new BusinessException("任务不存在或正在执行中无法删除");
}
brandTaskProgressCacheService.delete(taskId);
}
public String resolveDownloadUrl(Long taskId) {

View File

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest;
import com.nanri.aiimage.modules.convert.model.dto.UploadedSourceFileDto;
@@ -19,15 +20,10 @@ 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.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.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Service;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -242,14 +238,30 @@ public class ConvertRunService {
private List<GeneratedConvertFile> generateOutputFiles(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
List<String> outputFilenames = resolveOutputFilenames(templateEntity);
List<String> rows = buildTxtRows(inputFile, templateEntity);
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
List<GeneratedConvertFile> generatedFiles = new ArrayList<>();
Map<String, BufferedWriter> writers = new LinkedHashMap<>();
for (String outputFilename : outputFilenames) {
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8);
generatedFiles.add(new GeneratedConvertFile(outputFilename, outputFile));
writers.put(outputFilename, Files.newBufferedWriter(outputFile.toPath(), StandardCharsets.UTF_8));
}
try {
streamTxtRowsToOutputs(inputFile, templateEntity, writers);
} finally {
IOException closeException = null;
for (BufferedWriter writer : writers.values()) {
try {
writer.close();
} catch (IOException ex) {
if (closeException == null) {
closeException = ex;
}
}
}
if (closeException != null) {
throw closeException;
}
}
return generatedFiles;
}
@@ -266,7 +278,9 @@ public class ConvertRunService {
return List.of(outputFilename);
}
private List<String> buildTxtRows(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
private void streamTxtRowsToOutputs(File inputFile,
ConvertTemplateEntity templateEntity,
Map<String, BufferedWriter> writers) throws IOException {
List<String> requiredColumns = readStringList(templateEntity.getRequiredSourceColumnsJson());
List<String> headerColumns = readStringList(templateEntity.getHeaderColumnsJson());
Map<String, String> fieldMapping = readStringMap(templateEntity.getFieldMappingJson());
@@ -275,65 +289,60 @@ public class ConvertRunService {
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null
? 0
: templateEntity.getBlankHeaderRowsAfterSchema();
long batchId = System.currentTimeMillis();
int[] rowIndex = new int[]{1};
boolean[] headerRead = new boolean[]{false};
Map<String, Integer>[] headerMapHolder = new Map[]{null};
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile);
Workbook workbook = WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException("Excel header row is empty.");
}
Map<String, Integer> headerMap = new LinkedHashMap<>();
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
String value = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i)));
if (!value.isBlank() && !headerMap.containsKey(value)) {
headerMap.put(value, i);
ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
Map<String, Integer> resolvedHeaderMap = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
String value = normalizeCellText(entry.getValue());
if (!value.isBlank() && !resolvedHeaderMap.containsKey(value)) {
resolvedHeaderMap.put(value, entry.getKey());
}
}
List<String> missing = requiredColumns.stream()
.filter(column -> !headerMap.containsKey(column))
.filter(column -> !resolvedHeaderMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
List<String> rows = new ArrayList<>();
rows.addAll(preambleLines);
rows.add(String.join("\t", headerColumns));
headerRead[0] = true;
headerMapHolder[0] = resolvedHeaderMap;
for (String line : preambleLines) {
writeLineToAll(writers, line);
}
writeLineToAll(writers, String.join("\t", headerColumns));
for (int i = 0; i < blankHeaderRowsAfterSchema; i++) {
rows.add("\t".repeat(Math.max(0, headerColumns.size() - 1)));
writeLineToAll(writers, "\t".repeat(Math.max(0, headerColumns.size() - 1)));
}
}
long batchId = System.currentTimeMillis();
int rowIndex = 1;
int asinIndex = headerMap.getOrDefault("ASIN", -1);
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
@Override
public void onRow(String sheetName, Integer sheetNo, int excelRowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception {
Map<String, Integer> resolvedHeaderMap = headerMapHolder[0];
if (resolvedHeaderMap == null) {
throw new BusinessException("Excel header row is empty.");
}
String asin = asinIndex >= 0 ? normalizeCellText(formatter.formatCellValue(row.getCell(asinIndex))) : "";
int asinIndex = resolvedHeaderMap.getOrDefault("ASIN", -1);
String asin = asinIndex >= 0 ? normalizeCellText(rowMap.get(asinIndex)) : "";
if (asin.isBlank()) {
continue;
return;
}
List<String> lineValues = new ArrayList<>();
for (String column : headerColumns) {
if ("sku".equals(column)) {
lineValues.add(batchId + "-" + rowIndex);
lineValues.add(batchId + "-" + rowIndex[0]);
continue;
}
if (fieldMapping.containsKey(column)) {
String sourceColumn = fieldMapping.get(column);
Integer sourceIndex = headerMap.get(sourceColumn);
String value = sourceIndex == null
? ""
: normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
lineValues.add(value);
Integer sourceIndex = resolvedHeaderMap.get(sourceColumn);
lineValues.add(sourceIndex == null ? "" : normalizeCellText(rowMap.get(sourceIndex)));
continue;
}
if (defaults.containsKey(column)) {
@@ -343,10 +352,19 @@ public class ConvertRunService {
lineValues.add("");
}
rows.add(String.join("\t", lineValues));
rowIndex++;
writeLineToAll(writers, String.join("\t", lineValues));
rowIndex[0]++;
}
return rows;
});
if (!headerRead[0]) {
throw new BusinessException("Excel header row is empty.");
}
}
private void writeLineToAll(Map<String, BufferedWriter> writers, String line) throws IOException {
for (BufferedWriter writer : writers.values()) {
writer.write(line);
writer.newLine();
}
}

View File

@@ -20,7 +20,7 @@ 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.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -232,7 +232,7 @@ public class DedupeRunService {
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile);
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis);
XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
@@ -275,7 +275,6 @@ public class DedupeRunService {
// The preliminary global scan for mainIdHasSubIdMap was removed.
// We now determine subset existence contextually (per group) during the main loop.
}
List<Row> candidateRows = new ArrayList<>();
Set<String> candidateAsinValues = new HashSet<>();
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
@@ -288,7 +287,6 @@ public class DedupeRunService {
continue;
}
}
candidateRows.add(row);
if (asinColumnIndex != null) {
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
if (!asinValue.isBlank()) {
@@ -300,7 +298,17 @@ public class DedupeRunService {
Set<String> matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues);
Set<String> writtenAsinValues = new HashSet<>();
int outputRowIndex = 1;
for (Row row : candidateRows) {
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
if (idColumnIndex != null) {
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, sheet, rowNum, idColumnIndex, formatter)) {
continue;
}
}
if (asinColumnIndex != null) {
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
if (!asinValue.isBlank()) {
@@ -324,6 +332,7 @@ public class DedupeRunService {
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
outputWorkbook.write(fos);
}
outputWorkbook.dispose();
}
}

View File

@@ -23,7 +23,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
@@ -114,9 +116,9 @@ public class DedupeTotalDataService {
importProgressMap.put(importId, progress);
try {
byte[] fileBytes = file.getBytes();
File tempFile = saveMultipartToTempFile(file);
String filename = file.getOriginalFilename();
Thread.ofVirtual().start(() -> runImportTask(importId, fileBytes, filename));
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename));
} catch (Exception e) {
importProgressMap.remove(importId);
throw new BusinessException("读取上传文件失败");
@@ -150,9 +152,9 @@ public class DedupeTotalDataService {
deleteImportProgressMap.put(importId, progress);
try {
byte[] fileBytes = file.getBytes();
File tempFile = saveMultipartToTempFile(file);
String filename = file.getOriginalFilename();
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, fileBytes, filename));
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename));
} catch (Exception e) {
deleteImportProgressMap.remove(importId);
throw new BusinessException("读取上传文件失败");
@@ -211,6 +213,74 @@ public class DedupeTotalDataService {
}
}
private void runDeleteImportTask(String importId, File tempFile, String filename) {
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
if (progress == null) {
return;
}
progress.setStatus("running");
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress);
progress.setTotalRows(result.getTotalRows());
progress.setAsinCount(result.getAsinCount());
progress.setInsertedCount(result.getInsertedCount());
progress.setSkippedCount(result.getSkippedCount());
progress.setProcessedRows(result.getTotalRows());
progress.setStatus("success");
} catch (Exception e) {
progress.setStatus("failed");
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "鍒犻櫎 Excel 鍖归厤鏁版嵁澶辫触");
} finally {
deleteQuietly(tempFile);
}
}
private void runImportTask(String importId, File tempFile, String filename) {
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
if (progress == null) {
return;
}
progress.setStatus("running");
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
DedupeTotalDataImportVo result = importFromExcelInternal(inputStream, filename, progress);
progress.setTotalRows(result.getTotalRows());
progress.setAsinCount(result.getAsinCount());
progress.setInsertedCount(result.getInsertedCount());
progress.setSkippedCount(result.getSkippedCount());
progress.setProcessedRows(result.getTotalRows());
progress.setStatus("success");
} catch (Exception e) {
progress.setStatus("failed");
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "瀵煎叆 Excel 澶辫触");
} finally {
deleteQuietly(tempFile);
}
}
private File saveMultipartToTempFile(MultipartFile file) throws Exception {
String suffix = ".xlsx";
String name = file.getOriginalFilename();
if (name != null) {
int idx = name.lastIndexOf('.');
if (idx >= 0) {
suffix = name.substring(idx);
}
}
File tempFile = File.createTempFile("dedupe-total-data-", suffix);
file.transferTo(tempFile);
return tempFile;
}
private void deleteQuietly(File file) {
if (file == null) {
return;
}
try {
Files.deleteIfExists(file.toPath());
} catch (Exception ignored) {
}
}
@Transactional
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
if (file == null || file.isEmpty()) {

View File

@@ -85,6 +85,12 @@ public class DeleteBrandRunController {
return ApiResponse.success(deleteBrandRunService.getTaskDetails(request.getTaskIds()));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量获取删除品牌任务进度摘要", description = "仅返回任务基础状态和行进度摘要,用于前端轮询降载。")
public ApiResponse<DeleteBrandTaskBatchVo> getTaskProgressBatch(@Valid @RequestBody DeleteBrandTaskBatchRequest request) {
return ApiResponse.success(deleteBrandRunService.getTaskProgress(request.getTaskIds()));
}
@GetMapping("/tasks/{taskId}/deletion-status")
@Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。")
public ApiResponse<DeleteBrandTaskDeletionStatusVo> getTaskDeletionStatus(

View File

@@ -544,10 +544,87 @@ public class DeleteBrandRunService {
return vo;
}
public com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskBatchVo getTaskProgress(java.util.List<Long> taskIds) {
com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskBatchVo vo = new com.nanri.aiimage.modules.deletebrand.model.vo.DeleteBrandTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return vo;
}
java.util.List<Long> normalizedTaskIds = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.limit(50)
.toList();
if (normalizedTaskIds.isEmpty()) {
return vo;
}
java.util.Map<Long, FileTaskEntity> cachedTasks = deleteBrandTaskCacheService.getTaskCacheBatch(normalizedTaskIds);
java.util.List<Long> missingTaskIds = new java.util.ArrayList<>();
for (Long taskId : normalizedTaskIds) {
if (!cachedTasks.containsKey(taskId)) {
missingTaskIds.add(taskId);
}
}
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
if (!missingTaskIds.isEmpty()) {
java.util.List<FileTaskEntity> dbTasks = fileTaskMapper.selectBatchIds(missingTaskIds).stream()
.filter(task -> task != null && MODULE_TYPE.equals(task.getModuleType()))
.toList();
for (FileTaskEntity dbTask : dbTasks) {
taskById.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
deleteBrandTaskCacheService.saveTaskCache(dbTask);
}
}
}
java.util.Map<Long, java.util.Map<Object, Object>> progressByTaskId = deleteBrandTaskCacheService.getProgressBatch(normalizedTaskIds);
for (Long taskId : normalizedTaskIds) {
FileTaskEntity task = taskById.get(taskId);
if (task == null) {
vo.getMissingTaskIds().add(taskId);
continue;
}
vo.getItems().add(buildTaskProgressDetail(task, progressByTaskId.get(taskId)));
}
return vo;
}
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task) {
return buildTaskDetail(task, null);
}
private DeleteBrandTaskDetailVo buildTaskProgressDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
task = refreshIndexedMatchesIfNeeded(task);
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());
if ("SUCCESS".equals(task.getStatus())) {
taskVo.setDownloadUrl(resolveTaskDownloadUrl(task.getId()));
taskVo.setDownloadFilename(resolveTaskDownloadFilename(task.getId()));
} else {
taskVo.setDownloadUrl(null);
taskVo.setDownloadFilename(null);
}
DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo();
detail.setTask(taskVo);
detail.setLine_progress(buildLineProgress(task.getId(), progress));
detail.setFileProgress(java.util.List.of());
return detail;
}
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
task = refreshIndexedMatchesIfNeeded(task);
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();

View File

@@ -17,10 +17,15 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
@Service
@@ -34,6 +39,8 @@ public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration TEMP_DIR_CLEANUP_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration TEMP_DIR_RETENTION = Duration.ofHours(48);
private final FileTaskMapper fileTaskMapper;
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
@@ -289,7 +296,7 @@ public class DeleteBrandStaleTaskService {
log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
for (FileTaskEntity task : runningTasks) {
boolean hasUploadedPayload = !shopMatchTaskCacheService.getAllShopMergedPayload(task.getId()).isEmpty();
boolean hasUploadedPayload = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId());
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
@@ -352,6 +359,74 @@ public class DeleteBrandStaleTaskService {
}
}
@Scheduled(cron = "${aiimage.temp-dir.cleanup-cron:15 */30 * * * *}")
public void cleanupExpiredTempDirs() {
DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("temp-dir:cleanup", TEMP_DIR_CLEANUP_LOCK_TTL);
if (lockHandle == null) {
log.info("[temp-dir-cleanup] skip because another instance holds the distributed lock");
return;
}
try (lockHandle) {
Instant cutoff = Instant.now().minus(TEMP_DIR_RETENTION);
List<String> dirNames = List.of(
"shop-match-cache",
"brand-task-cache",
"delete-brand-task-cache",
"shop-match-result",
"delete-brand-result",
"product-risk-result",
"price-track-result"
);
for (String dirName : dirNames) {
cleanupTempRoot(dirName, cutoff);
}
}
}
private void cleanupTempRoot(String dirName, Instant cutoff) {
Path root = Path.of(System.getProperty("java.io.tmpdir"), dirName);
if (!Files.isDirectory(root)) {
return;
}
try (var stream = Files.list(root)) {
for (Path child : stream.toList()) {
try {
FileTime lastModified = Files.getLastModifiedTime(child);
if (lastModified.toInstant().isAfter(cutoff)) {
continue;
}
deleteRecursively(child);
log.info("[temp-dir-cleanup] deleted expired temp path dir={} path={}", dirName, child);
} catch (Exception ex) {
log.warn("[temp-dir-cleanup] delete failed dir={} path={} msg={}", dirName, child, ex.getMessage());
}
}
} catch (IOException ex) {
log.warn("[temp-dir-cleanup] scan failed dir={} msg={}", dirName, ex.getMessage());
}
}
private void deleteRecursively(Path path) throws IOException {
if (!Files.exists(path)) {
return;
}
try (var walk = Files.walk(path)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(item -> {
try {
Files.deleteIfExists(item);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
});
} catch (RuntimeException ex) {
if (ex.getCause() instanceof IOException ioException) {
throw ioException;
}
throw ex;
}
}
private static final class ProductRiskStaleCheckStats {
private int scannedTaskCount;
private int finalizedTaskCount;

View File

@@ -7,6 +7,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
@@ -28,10 +31,17 @@ public class DeleteBrandTaskCacheService {
public void saveParsedPayload(Long taskId, Object payload) {
try {
stringRedisTemplate.opsForValue().set(buildPayloadKey(taskId), objectMapper.writeValueAsString(payload), Duration.ofHours(PAYLOAD_TTL_HOURS));
Files.createDirectories(buildTaskDir(taskId));
Files.writeString(
buildPayloadFile(taskId),
objectMapper.writeValueAsString(payload),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(System.currentTimeMillis(), payload));
} catch (Exception ex) {
throw new BusinessException("暂存删除品牌原始数据失败");
throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝鍘熷鏁版嵁澶辫触");
}
}
@@ -46,21 +56,21 @@ public class DeleteBrandTaskCacheService {
try {
return objectMapper.convertValue(cached.value(), typeReference);
} catch (Exception ignored) {
// fallthrough to redis
// fall through to file
}
}
String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId));
if (raw == null || raw.isBlank()) {
Path payloadFile = buildPayloadFile(taskId);
if (!Files.isRegularFile(payloadFile)) {
parsedPayloadLocalCache.remove(taskId);
return null;
}
try {
T parsed = objectMapper.readValue(raw, typeReference);
T parsed = objectMapper.readValue(Files.readString(payloadFile), typeReference);
parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(now, parsed));
return parsed;
} catch (Exception ex) {
throw new BusinessException("读取删除品牌原始数据失败");
throw new BusinessException("璇诲彇鍒犻櫎鍝佺墝鍘熷鏁版嵁澶辫触");
}
}
@@ -114,17 +124,17 @@ public class DeleteBrandTaskCacheService {
public boolean hasSameResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
if (chunkIndex == null || chunkIndex <= 0) {
throw new BusinessException("chunkIndex 不合法");
throw new BusinessException("chunkIndex 涓嶅悎娉?");
}
if (fileIdentity == null || fileIdentity.isBlank()) {
throw new BusinessException("fileIdentity 不能为空");
throw new BusinessException("fileIdentity 涓嶈兘涓虹┖");
}
String field = fileIdentity.trim() + "#" + chunkIndex;
String chunkJson;
try {
chunkJson = objectMapper.writeValueAsString(chunk);
} catch (Exception ex) {
throw new BusinessException("读取删除品牌结果分片失败");
throw new BusinessException("璇诲彇鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触");
}
Object existing = stringRedisTemplate.opsForHash().get(buildResultChunksKey(taskId), field);
if (chunkJson.equals(existing)) {
@@ -137,17 +147,17 @@ public class DeleteBrandTaskCacheService {
public boolean mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
String resultKey = buildResultChunksKey(taskId);
if (chunkIndex == null || chunkIndex <= 0) {
throw new BusinessException("chunkIndex 不合法");
throw new BusinessException("chunkIndex 涓嶅悎娉?");
}
if (fileIdentity == null || fileIdentity.isBlank()) {
throw new BusinessException("fileIdentity 不能为空");
throw new BusinessException("fileIdentity 涓嶈兘涓虹┖");
}
String field = fileIdentity.trim() + "#" + chunkIndex;
String chunkJson;
try {
chunkJson = objectMapper.writeValueAsString(chunk);
} catch (Exception ex) {
throw new BusinessException("暂存删除品牌结果分片失败");
throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触");
}
Object existing = stringRedisTemplate.opsForHash().get(resultKey, field);
if (chunkJson.equals(existing)) {
@@ -178,14 +188,16 @@ public class DeleteBrandTaskCacheService {
public void delete(Long taskId) {
parsedPayloadLocalCache.remove(taskId);
stringRedisTemplate.delete(buildPayloadKey(taskId));
stringRedisTemplate.delete(buildProgressKey(taskId));
stringRedisTemplate.delete(buildResultChunksKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
deleteTaskDirQuietly(taskId);
}
public void saveTaskCache(com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task) {
if (task == null || task.getId() == null) return;
if (task == null || task.getId() == null) {
return;
}
try {
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
} catch (Exception ignored) {
@@ -194,10 +206,14 @@ public class DeleteBrandTaskCacheService {
public java.util.Map<Long, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
java.util.Map<Long, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity> result = new java.util.LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) return result;
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList();
if (normalized.isEmpty()) return result;
if (normalized.isEmpty()) {
return result;
}
java.util.List<String> keys = normalized.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
@@ -215,6 +231,30 @@ public class DeleteBrandTaskCacheService {
return result;
}
private Path buildTaskDir(Long taskId) {
return Path.of(System.getProperty("java.io.tmpdir"), "delete-brand-task-cache", String.valueOf(taskId));
}
private Path buildPayloadFile(Long taskId) {
return buildTaskDir(taskId).resolve("parsed-payload.json");
}
private void deleteTaskDirQuietly(Long taskId) {
Path taskDir = buildTaskDir(taskId);
if (!Files.exists(taskDir)) {
return;
}
try (var walk = Files.walk(taskDir)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
}
});
} catch (Exception ignored) {
}
}
private String buildTaskEntityKey(Long taskId) {
return "delete-brand:task:entity:" + taskId;
}
@@ -225,10 +265,6 @@ public class DeleteBrandTaskCacheService {
}
}
private String buildPayloadKey(Long taskId) {
return "delete-brand:task:parsed-payload:" + taskId;
}
private String buildProgressKey(Long taskId) {
return "delete-brand:task:progress:" + taskId;
}

View File

@@ -17,4 +17,6 @@ public class PermissionMenuCreateRequest {
@NotBlank(message = "菜单路由不能为空")
private String routePath;
private Integer sortOrder;
}

View File

@@ -17,4 +17,6 @@ public class PermissionMenuUpdateRequest {
@NotBlank(message = "菜单路由不能为空")
private String routePath;
private Integer sortOrder;
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.permission.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -11,5 +12,8 @@ public class AdminUserEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String role;
@TableField("created_by_id")
private Long createdById;
}

View File

@@ -17,5 +17,6 @@ public class PermissionMenuEntity {
private String columnKey;
private String menuType;
private String routePath;
private Integer sortOrder;
private LocalDateTime createdAt;
}

View File

@@ -12,5 +12,6 @@ public class PermissionMenuItemVo {
private String columnKey;
private String menuType;
private String routePath;
private Integer sortOrder;
private LocalDateTime createdAt;
}

View File

@@ -14,10 +14,16 @@ import java.util.List;
public class PermissionMenuSchemaInitializer {
private final JdbcTemplate jdbcTemplate;
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data"),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage"),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin")
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data", 30),
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
);
@PostConstruct
@@ -25,23 +31,19 @@ public class PermissionMenuSchemaInitializer {
executeQuietly("""
CREATE TABLE IF NOT EXISTS columns (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL COMMENT '栏目名',
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
name VARCHAR(128) NOT NULL COMMENT '菜单标题',
column_key VARCHAR(64) NOT NULL COMMENT '菜单唯一标识',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_column_key (column_key)
)
""");
executeQuietly("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key");
executeQuietly("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type");
executeQuietly("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path");
executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''");
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
ensureDefaultAdminMenus();
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path)
SELECT '店铺管理', 'admin_shop_manage', 'admin', 'shop-manage'
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = 'admin_shop_manage'
)
""");
executeQuietly("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
@@ -56,12 +58,17 @@ public class PermissionMenuSchemaInitializer {
private void ensureDefaultAdminMenus() {
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
executeQuietly("""
INSERT INTO columns (name, column_key, menu_type, route_path)
SELECT '%s', '%s', 'admin', '%s'
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
SELECT '%s', '%s', 'admin', '%s', %d
WHERE NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = '%s'
)
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.columnKey()));
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
executeQuietly("""
UPDATE columns
SET sort_order = %d
WHERE column_key = '%s' AND (sort_order IS NULL OR sort_order = 0)
""".formatted(menu.sortOrder(), menu.columnKey()));
}
}
@@ -73,6 +80,6 @@ public class PermissionMenuSchemaInitializer {
}
}
private record DefaultAdminMenu(String name, String columnKey, String routePath) {
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
}
}

View File

@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -41,6 +42,7 @@ public class PermissionMenuService {
public List<PermissionMenuItemVo> list(String menuType) {
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
.eq(isValidMenuType(menuType), PermissionMenuEntity::getMenuType, normalizeMenuType(menuType))
.orderByAsc(PermissionMenuEntity::getSortOrder)
.orderByAsc(PermissionMenuEntity::getId);
return permissionMenuMapper.selectList(query).stream()
.map(this::toItemVo)
@@ -54,12 +56,14 @@ public class PermissionMenuService {
String menuType = normalizeMenuTypeRequired(request.getMenuType());
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
ensureUniqueColumnKey(columnKey, null);
ensureUniqueRoutePath(menuType, routePath, null);
PermissionMenuEntity entity = new PermissionMenuEntity();
entity.setName(name);
entity.setColumnKey(columnKey);
entity.setMenuType(menuType);
entity.setRoutePath(routePath);
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), null));
permissionMenuMapper.insert(entity);
return toItemVo(getMenuById(entity.getId()));
}
@@ -72,11 +76,13 @@ public class PermissionMenuService {
String menuType = normalizeMenuTypeRequired(request.getMenuType());
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
ensureUniqueColumnKey(columnKey, id);
ensureUniqueRoutePath(menuType, routePath, id);
entity.setName(name);
entity.setColumnKey(columnKey);
entity.setMenuType(menuType);
entity.setRoutePath(routePath);
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), id));
permissionMenuMapper.updateById(entity);
return toItemVo(getMenuById(id));
}
@@ -141,6 +147,9 @@ public class PermissionMenuService {
.map(menuMap::get)
.filter(menu -> menu != null)
.filter(menu -> safeMenuType == null || safeMenuType.equals(menu.getMenuType()))
.sorted(Comparator
.comparing(PermissionMenuEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(PermissionMenuEntity::getId, Comparator.nullsLast(Long::compareTo)))
.map(this::toItemVo)
.toList();
}
@@ -196,6 +205,16 @@ public class PermissionMenuService {
}
}
private void ensureUniqueRoutePath(String menuType, String routePath, Long excludeId) {
PermissionMenuEntity exists = permissionMenuMapper.selectOne(new LambdaQueryWrapper<PermissionMenuEntity>()
.eq(PermissionMenuEntity::getMenuType, menuType)
.eq(PermissionMenuEntity::getRoutePath, routePath)
.last("LIMIT 1"));
if (exists != null && (excludeId == null || !excludeId.equals(exists.getId()))) {
throw new BusinessException("同一菜单类型下的菜单路由已存在");
}
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty() && !message.isEmpty()) {
@@ -247,7 +266,21 @@ public class PermissionMenuService {
vo.setColumnKey(entity.getColumnKey());
vo.setMenuType(entity.getMenuType());
vo.setRoutePath(entity.getRoutePath());
vo.setSortOrder(entity.getSortOrder());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
private Integer resolveSortOrder(Integer sortOrder, Long excludeId) {
if (sortOrder != null) {
return sortOrder;
}
PermissionMenuEntity tail = permissionMenuMapper.selectOne(new LambdaQueryWrapper<PermissionMenuEntity>()
.ne(excludeId != null, PermissionMenuEntity::getId, excludeId)
.orderByDesc(PermissionMenuEntity::getSortOrder)
.orderByDesc(PermissionMenuEntity::getId)
.last("LIMIT 1"));
int currentMax = tail == null || tail.getSortOrder() == null ? 0 : tail.getSortOrder();
return currentMax + 1;
}
}

View File

@@ -47,7 +47,8 @@ import java.util.List;
@RequestMapping("/api/price-track")
@Tag(
name = "跟价",
description = "亚马逊跟价处理模块包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需携带 Query 参数 user_id。")
description = "亚马逊跟价处理模块包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需携带 query 参数 user_id。"
)
public class PriceTrackController {
private final PriceTrackService priceTrackService;
@@ -62,13 +63,13 @@ public class PriceTrackController {
}
@PostMapping("/candidates")
@Operation(summary = "新增备选店铺", description = "把店铺名称写入当前用户的备选店铺列表,供后续匹配和创建任务使用。")
@Operation(summary = "新增备选店铺", description = "新增一条备选店铺记录,供后续匹配和创建任务使用。")
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
return ApiResponse.success(priceTrackService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,id 必须属于当前 user_id。")
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,记录必须属于当前 user_id。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@@ -107,7 +108,7 @@ public class PriceTrackController {
}
@GetMapping("/history")
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果,包含运行中和历史记录。")
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果记录,包含运行中和历史记录。")
public ApiResponse<PriceTrackHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
@@ -115,7 +116,7 @@ public class PriceTrackController {
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result并同步重算父任务状态。")
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result 结果记录,并同步重算父任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@@ -131,7 +132,7 @@ public class PriceTrackController {
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行RUNNING、SUCCESS、FAILED 均可。")
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行RUNNING、SUCCESS、FAILED 均可删除")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@@ -151,11 +152,17 @@ public class PriceTrackController {
}
@PostMapping("/tasks/batch")
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端询使用。")
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端询使用。")
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
return ApiResponse.success(priceTrackTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询任务进度摘要", description = "仅返回任务状态、时间和错误等轻量信息,供前端轮询降载。")
public ApiResponse<PriceTrackTaskBatchVo> taskProgressBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
return ApiResponse.success(priceTrackTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
public ApiResponse<Void> submitResult(

View File

@@ -8,13 +8,20 @@ import lombok.Data;
import java.util.List;
@Data
@Schema(description = "批量匹配店铺请求体:按店铺名查询紫鸟索引,返回是否命中及店铺元数据")
@Schema(description = "Price track match shops request")
public class PriceTrackMatchShopsRequest {
@NotNull(message = "user_id不能为空")
@Schema(description = "当前用户 ID用于审计和数据归属", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "userId is required")
@Schema(description = "Current user id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "店铺列表不能为空")
@Schema(description = "待匹配的店铺名称列表,至少 1 个;返回体 items 与列表顺序对应", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
@NotEmpty(message = "shopNames cannot be empty")
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> shopNames;
@Schema(description = "Selected ASIN file local paths")
private List<String> asinFiles;
@Schema(description = "Selected country codes")
private List<String> countryCodes;
}

View File

@@ -7,43 +7,73 @@ import java.util.List;
import java.util.Map;
@Data
@Schema(description = "Python 回传跟价结果请求体:按店铺提交各国家 ASIN 跟价结果或错误信息")
@Schema(description = "Python submit payload for price track task results")
public class PriceTrackSubmitResultRequest {
@Schema(description = "本次回传涉及的店铺列表,至少 1 个,店名需要能和任务结果行匹配", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Shop result list", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ShopResult> shops;
@Data
@Schema(description = "单个店铺回传结果:成功时提供 countries失败时可只提供 error")
@Schema(description = "Per-shop submit result")
public static class ShopResult {
@Schema(description = "店铺名称,用于和任务内结果行匹配", example = "测试店铺A")
@Schema(description = "Shop name used to match task rows", example = "Test Shop A")
private String shopName;
@Schema(description = "按国家分组的结果行key 为国家代码,例如 DEUKFRITES")
@Schema(description = "Rows grouped by country code, for example DE/UK/FR/IT/ES")
private Map<String, List<AsinResult>> countries;
@Schema(description = "失败时的错误信息;非空则该店铺记为失败")
@Schema(description = "Failure message")
private String error;
@Schema(description = "是否成功false 按失败处理true 或 null 通常表示结合 countries 判定")
@Schema(description = "Whether the shop finished successfully")
private Boolean success;
}
@Data
@Schema(description = "单条 ASIN 跟价结果")
@Schema(description = "Per-ASIN price track result row")
public static class AsinResult {
@Schema(description = "Shop mall name", example = "Amazon.de")
private String shopMallName;
@Schema(description = "ASIN", example = "B0ABCDE123")
private String asin;
@Schema(description = "当前店铺价格", example = "19.99")
@Schema(description = "Price", example = "19.99")
private String price;
@Schema(description = "Recommended price", example = "18.99")
private String recommendedPrice;
@Schema(description = "Lowest price", example = "18.50")
private String minimumPrice;
@Schema(description = "First place", example = "Competitor A")
private String firstPlace;
@Schema(description = "Second place", example = "Competitor B")
private String secondPlace;
@Schema(description = "Buy box shop name", example = "Shop A")
private String cartShopName;
@Schema(description = "Price change status", example = "UPDATED")
private String priceChangeStatus;
@Schema(description = "Modify count", example = "2")
private String modifyCount;
@Schema(description = "Status", example = "SUCCESS")
private String status;
@Schema(description = "Legacy current price", example = "19.99")
private String currentPrice;
@Schema(description = "竞争对手价格", example = "18.50")
@Schema(description = "Legacy competitor price", example = "18.50")
private String competitorPrice;
@Schema(description = "建议动作或跟价动作", example = "LOWER_PRICE")
@Schema(description = "Legacy action", example = "LOWER_PRICE")
private String action;
@Schema(description = "该行是否已处理完成")
@Schema(description = "Legacy done flag")
private Boolean done;
}
}

View File

@@ -4,13 +4,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "创建任务响应:包含任务 id 和各店铺的初始结果快照")
@Schema(description = "price track create task response")
public class PriceTrackCreateTaskVo {
@Schema(description = "新建任务主键 biz_file_task.id,前端推送队列时需要回传该值", example = "200")
@Schema(description = "task id", example = "200")
private Long taskId;
@Schema(description = "各店铺一条初始结果快照,包含 resultId、taskId、店铺信息和初始任务状态")
@Schema(description = "initial task items")
private List<PriceTrackResultItemVo> items;
@Schema(description = "all skip asins grouped by country")
private Map<String, List<String>> skipAsinsByCountry;
@Schema(description = "parsed asin rows by country")
private Map<String, List<Map<String, String>>> asinRowsByCountry;
}

View File

@@ -5,35 +5,47 @@ import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "批量匹配店铺响应")
@Schema(description = "Price track shop match response")
public class PriceTrackMatchShopsVo {
@Schema(description = "逐店返回的匹配结果,顺序与请求 shopNames 对应")
@Schema(description = "Match results in the same order as requested shop names")
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
@Schema(description = "All skip ASINs grouped by country code")
private Map<String, List<String>> skipAsinsByCountry;
@Schema(description = "Parsed asin rows grouped by country code")
private Map<String, List<Map<String, String>>> asinRowsByCountry;
@Data
@Schema(description = "单店匹配结果,同时也是创建任务时 items 的结构")
@Schema(description = "Single matched shop item")
public static class PriceTrackShopQueueItem {
@Schema(description = "店铺名称", example = "某某店铺")
@Schema(description = "Shop name", example = "Demo Shop")
private String shopName;
@Schema(description = "紫鸟店铺 ID未命中时可能为空")
@Schema(description = "Matched shop id")
private Object shopId;
@Schema(description = "平台,例如亚马逊", example = "亚马逊")
@Schema(description = "Platform", example = "Amazon")
private String platform;
@Schema(description = "公司名称或主体标识")
@Schema(description = "Company name")
private String companyName;
@Schema(description = "是否命中紫鸟索引false 时不可用于创建任务")
@Schema(description = "Whether the shop matched")
private Boolean matched;
@Schema(description = "匹配状态码,例如 MATCHEDPENDING、CONFLICT、INDEX_STALE")
@Schema(description = "Match status, such as MATCHED or PENDING")
private String matchStatus;
@Schema(description = "人类可读说明,供前端展示")
@Schema(description = "Readable match message")
private String matchMessage;
@Schema(description = "All skip ASINs grouped by country code")
private Map<String, List<String>> skipAsins;
}
}

View File

@@ -19,7 +19,19 @@ import java.util.Map;
@Slf4j
public class PriceTrackExcelAssemblyService {
private static final String[] HEADER = {"ASIN", "当前店铺价格", "竞争对手价格", "建议动作", "是否完成"};
private static final String[] RESULT_HEADER = {
"\u5e97\u94fa\u5546\u57ce\u540d\u79f0",
"ASIN",
"\u4ef7\u683c",
"\u63a8\u8350\u4ef7",
"\u6700\u4f4e\u4ef7",
"\u7b2c\u4e00\u540d",
"\u7b2c\u4e8c\u540d",
"\u8d2d\u7269\u8f66\u5e97\u94fa\u540d",
"\u6539\u4ef7\u60c5\u51b5",
"\u4fee\u6539\u6b21\u6570",
"\u72b6\u6001"
};
public void writeWorkbook(File outputXlsx, Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
@@ -28,8 +40,8 @@ public class PriceTrackExcelAssemblyService {
String sheetName = code.name();
Sheet sheet = wb.createSheet(sheetName);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < HEADER.length; i++) {
headerRow.createCell(i).setCellValue(HEADER[i]);
for (int i = 0; i < RESULT_HEADER.length; i++) {
headerRow.createCell(i).setCellValue(RESULT_HEADER[i]);
}
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
int rowIdx = 1;
@@ -38,20 +50,26 @@ public class PriceTrackExcelAssemblyService {
continue;
}
Row row = sheet.createRow(rowIdx++);
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
row.createCell(1).setCellValue(item.getCurrentPrice() == null ? "" : item.getCurrentPrice());
row.createCell(2).setCellValue(item.getCompetitorPrice() == null ? "" : item.getCompetitorPrice());
row.createCell(3).setCellValue(item.getAction() == null ? "" : item.getAction());
row.createCell(4).setCellValue(item.getDone() == null ? "" : String.valueOf(item.getDone()));
row.createCell(0).setCellValue(valueOf(item.getShopMallName()));
row.createCell(1).setCellValue(valueOf(item.getAsin()));
row.createCell(2).setCellValue(firstNonBlank(item.getPrice(), item.getCurrentPrice()));
row.createCell(3).setCellValue(valueOf(item.getRecommendedPrice()));
row.createCell(4).setCellValue(firstNonBlank(item.getMinimumPrice(), item.getCompetitorPrice()));
row.createCell(5).setCellValue(valueOf(item.getFirstPlace()));
row.createCell(6).setCellValue(valueOf(item.getSecondPlace()));
row.createCell(7).setCellValue(valueOf(item.getCartShopName()));
row.createCell(8).setCellValue(firstNonBlank(item.getPriceChangeStatus(), item.getAction()));
row.createCell(9).setCellValue(valueOf(item.getModifyCount()));
row.createCell(10).setCellValue(firstNonBlank(item.getStatus(), item.getDone() == null ? null : String.valueOf(item.getDone())));
}
for (int i = 0; i < HEADER.length; i++) {
for (int i = 0; i < RESULT_HEADER.length; i++) {
sheet.autoSizeColumn(i);
}
}
wb.write(fos);
} catch (Exception ex) {
log.warn("[price-track] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
throw new BusinessException("鐢熸垚 Excel 澶辫触: " + ex.getMessage());
}
}
@@ -82,4 +100,20 @@ public class PriceTrackExcelAssemblyService {
}
return out;
}
private static String valueOf(String value) {
return value == null ? "" : value;
}
private static String firstNonBlank(String... values) {
if (values == null || values.length == 0) {
return "";
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
}

View File

@@ -24,8 +24,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@@ -40,6 +42,7 @@ public class PriceTrackService {
private final ObjectMapper objectMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final SkipPriceAsinService skipPriceAsinService;
private final PriceTrackTaskService priceTrackTaskService;
public List<PriceTrackCandidateVo> listCandidates(Long userId) {
if (userId == null || userId <= 0) {
@@ -139,9 +142,22 @@ public class PriceTrackService {
throw new BusinessException("shop_names 没有有效店铺名");
}
boolean asinMode = request.getAsinFiles() != null && !request.getAsinFiles().isEmpty();
Map<String, List<String>> skipAsinsByCountry = asinMode
? new LinkedHashMap<>()
: skipPriceAsinService.listAllSkipAsinsByCountry();
Map<String, List<Map<String, String>>> asinRowsByCountry =
!asinMode
? new LinkedHashMap<>()
: priceTrackTaskService.parseMatchAsinRowsByCountry(
request.getAsinFiles(),
request.getCountryCodes());
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
vo.setSkipAsinsByCountry(skipAsinsByCountry);
vo.setAsinRowsByCountry(asinRowsByCountry);
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(shopName));
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
}
return vo;
}
@@ -198,9 +214,13 @@ public class PriceTrackService {
return vo;
}
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(String shopName) {
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(
String shopName,
Map<String, List<String>> skipAsins
) {
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
item.setShopName(shopName);
item.setSkipAsins(skipAsins == null ? new LinkedHashMap<>() : new LinkedHashMap<>(skipAsins));
try {
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (match == null) {

View File

@@ -4,7 +4,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
@@ -30,12 +32,19 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Locale;
@Service
@RequiredArgsConstructor
@@ -179,8 +188,33 @@ public class PriceTrackTaskService {
return batch;
}
public PriceTrackTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
batch.getMissingTaskIds().add(taskId);
continue;
}
PriceTrackTaskDetailVo detail = new PriceTrackTaskDetailVo();
detail.setTask(toTaskItemVo(task));
batch.getItems().add(detail);
}
return batch;
}
@Transactional
public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) {
if (request.isStatusMode() == request.isAsinMode()) throw new BusinessException("跟价模式必须二选一");
if (request.isAsinMode() && (request.getAsinFiles() == null || request.getAsinFiles().isEmpty())) {
throw new BusinessException("自定义 ASIN 模式必须上传 ASIN 文件");
}
if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法");
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
@@ -195,13 +229,14 @@ public class PriceTrackTaskService {
}
// 汇总店铺名并查询需要跳过的 ASIN
List<String> shopNames = uniqueItems.stream()
.map(PriceTrackMatchShopsVo.PriceTrackShopQueueItem::getShopName)
.filter(s -> s != null && !s.isBlank())
.distinct()
.toList();
Map<String, Map<String, String>> skipAsinsByShop = skipPriceAsinService.listSkipAsinsByShops(shopNames);
log.info("[price-track] createTask skipAsins shops={}", skipAsinsByShop.keySet());
Map<String, List<String>> skipAsinsByCountry = request.isAsinMode()
? new LinkedHashMap<>()
: skipPriceAsinService.listAllSkipAsinsByCountry();
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
: new LinkedHashMap<>();
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
skipAsinsByCountry.keySet(), request.isAsinMode());
// 创建任务记录
FileTaskEntity task = new FileTaskEntity();
@@ -242,7 +277,8 @@ public class PriceTrackTaskService {
ctx.put("statusMode", request.isStatusMode());
ctx.put("asinMode", request.isAsinMode());
ctx.put("countryCodes", request.getCountryCodes());
ctx.put("skipAsinsByShop", skipAsinsByShop);
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
ctx.put("asinRowsByCountry", asinRowsByCountry);
ctx.put("items", uniqueItems);
task.setRequestJson(objectMapper.writeValueAsString(ctx));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
@@ -257,6 +293,8 @@ public class PriceTrackTaskService {
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshot);
vo.setSkipAsinsByCountry(skipAsinsByCountry);
vo.setAsinRowsByCountry(asinRowsByCountry);
return vo;
}
@@ -272,7 +310,7 @@ public class PriceTrackTaskService {
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus());
throw new BusinessException("任务已结束,拒绝重复提交");
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
}
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
@@ -452,6 +490,211 @@ public class PriceTrackTaskService {
}
}
public Map<String, List<Map<String, String>>> parseMatchAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
return parseAsinRowsByCountry(asinFiles, countryCodes);
}
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
if (asinFiles == null || asinFiles.isEmpty()) {
return merged;
}
for (String rawPath : asinFiles) {
if (rawPath == null || rawPath.isBlank()) {
continue;
}
File file = new File(rawPath);
if (!file.isFile()) {
throw new BusinessException("ASIN file not found: " + rawPath);
}
String lowerName = file.getName().toLowerCase(Locale.ROOT);
if (lowerName.endsWith(".csv")) {
mergeCountryRows(merged, parseCsvAsinRows(file, countryCodes));
continue;
}
if (lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls")) {
mergeCountryRows(merged, parseWorkbookAsinRows(file, countryCodes));
continue;
}
throw new BusinessException("Unsupported ASIN file type: " + file.getName());
}
return merged;
}
private Map<String, List<Map<String, String>>> parseWorkbookAsinRows(File file, List<String> countryCodes) {
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
Map<String, Integer>[] headerIndexHolder = new Map[]{Map.of()};
String[] countryCodeHolder = new String[]{null};
int[] sheetCount = new int[]{0};
try {
ExcelStreamReader.readAllSheets(file, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
sheetCount[0]++;
countryCodeHolder[0] = resolveCountryCode(sheetName, countryCodes, false);
headerIndexHolder[0] = buildHeaderIndex(headerMap);
}
@Override
public void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
String countryCode = countryCodeHolder[0];
if (countryCode == null && sheetCount[0] == 1 && countryCodes != null && countryCodes.size() == 1) {
countryCode = countryCodes.get(0);
}
if (countryCode == null) {
return;
}
if (headerIndexHolder[0].isEmpty()) {
return;
}
Map<String, String> item = extractAsinRow(rowMap, headerIndexHolder[0]);
if (!item.isEmpty()) {
out.computeIfAbsent(countryCode, key -> new ArrayList<>()).add(item);
}
}
});
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("Failed to parse ASIN workbook");
}
return out;
}
private Map<String, List<Map<String, String>>> parseCsvAsinRows(File file, List<String> countryCodes) {
if (countryCodes == null || countryCodes.size() != 1) {
throw new BusinessException("CSV ASIN file requires exactly one selected country");
}
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
List<Map<String, String>> rows = out.computeIfAbsent(countryCodes.get(0), key -> new ArrayList<>());
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String headerLine = reader.readLine();
if (headerLine == null) {
return out;
}
List<String> headers = splitCsvLine(headerLine);
Map<String, Integer> headerIndex = buildHeaderIndex(headers);
String line;
while ((line = reader.readLine()) != null) {
List<String> values = splitCsvLine(line);
Map<String, String> item = extractAsinRow(values, headerIndex);
if (!item.isEmpty()) {
rows.add(item);
}
}
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
throw new BusinessException("Failed to parse ASIN csv");
}
return out;
}
private void mergeCountryRows(Map<String, List<Map<String, String>>> target, Map<String, List<Map<String, String>>> incoming) {
for (Map.Entry<String, List<Map<String, String>>> entry : incoming.entrySet()) {
target.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).addAll(entry.getValue());
}
}
private String resolveCountryCode(String sheetName, List<String> countryCodes, boolean singleSheetFallback) {
String normalized = normalizeHeaderText(sheetName).toUpperCase(Locale.ROOT);
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
if (code.name().equals(normalized) || normalizeHeaderText(code.getSheetName()).equals(normalizeHeaderText(sheetName))) {
return code.name();
}
}
if (singleSheetFallback && countryCodes != null && countryCodes.size() == 1) {
return countryCodes.get(0);
}
return null;
}
private Map<String, Integer> buildHeaderIndex(Map<Integer, String> headerRow) {
List<String> headers = new ArrayList<>();
int maxIndex = headerRow.keySet().stream().mapToInt(Integer::intValue).max().orElse(-1);
for (int i = 0; i <= maxIndex; i++) {
headers.add(normalizeCellText(headerRow.get(i)));
}
return buildHeaderIndex(headers);
}
private Map<String, Integer> buildHeaderIndex(List<String> headers) {
Map<String, Integer> index = new LinkedHashMap<>();
for (int i = 0; i < headers.size(); i++) {
String key = mapHeaderKey(headers.get(i));
if (key != null && !index.containsKey(key)) {
index.put(key, i);
}
}
return index;
}
private Map<String, String> extractAsinRow(Map<Integer, String> row, Map<String, Integer> headerIndex) {
Map<String, String> item = new LinkedHashMap<>();
for (Map.Entry<String, Integer> entry : headerIndex.entrySet()) {
item.put(entry.getKey(), normalizeCellText(row.get(entry.getValue())));
}
return isEmptyAsinRow(item) ? Map.of() : item;
}
private Map<String, String> extractAsinRow(List<String> values, Map<String, Integer> headerIndex) {
Map<String, String> item = new LinkedHashMap<>();
for (Map.Entry<String, Integer> entry : headerIndex.entrySet()) {
int idx = entry.getValue();
item.put(entry.getKey(), idx >= 0 && idx < values.size() ? normalizeCellText(values.get(idx)) : "");
}
return isEmptyAsinRow(item) ? Map.of() : item;
}
private boolean isEmptyAsinRow(Map<String, String> row) {
return row.values().stream().allMatch(value -> value == null || value.isBlank());
}
private String mapHeaderKey(String rawHeader) {
String header = normalizeHeaderText(rawHeader);
if (header.isEmpty()) {
return null;
}
return switch (header) {
case "店铺商城名称", "shopmallname" -> "shopMallName";
case "asin" -> "asin";
case "价格", "price" -> "price";
case "推荐价", "recommendedprice" -> "recommendedPrice";
case "最低价", "minimumprice" -> "minimumPrice";
case "第一名", "firstplace" -> "firstPlace";
case "第二名", "secondplace" -> "secondPlace";
case "购物车店铺名", "cartshopname" -> "cartShopName";
case "改价情况", "pricechangestatus" -> "priceChangeStatus";
case "修改次数", "modifycount" -> "modifyCount";
case "状态", "status" -> "status";
default -> null;
};
}
private String normalizeCellText(String value) {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
.replace("\r", " ")
.replace("\n", " ")
.trim()
.replaceAll("\\s+", " ");
}
private String normalizeHeaderText(String value) {
return normalizeCellText(value)
.replaceAll("\\s+", "")
.toLowerCase(Locale.ROOT);
}
private List<String> splitCsvLine(String line) {
return Arrays.stream((line == null ? "" : line).split(",", -1))
.map(String::trim)
.toList();
}
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> raw) {
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {

View File

@@ -159,6 +159,12 @@ public class ProductRiskResolveController {
return ApiResponse.success(productRiskTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询任务进度摘要", description = "仅返回任务状态、时间和错误等轻量信息,供前端轮询降载。")
public ApiResponse<ProductRiskTaskBatchVo> taskProgressBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(productRiskTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "Python 回传处理结果",

View File

@@ -147,6 +147,7 @@ public class ProductRiskTaskService {
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
}
/**
@@ -283,6 +284,27 @@ public class ProductRiskTaskService {
return batch;
}
public ProductRiskTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
ProductRiskTaskBatchVo batch = new ProductRiskTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
batch.getMissingTaskIds().add(taskId);
continue;
}
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
detail.setTask(toTaskItemVo(task));
batch.getItems().add(detail);
}
return batch;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
@@ -390,7 +412,7 @@ public class ProductRiskTaskService {
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
log.warn("[product-risk] submitResult rejected taskId={} status={} payloadShopCount={}",
taskId, task.getStatus(), request.getShops() == null ? 0 : request.getShops().size());
throw new BusinessException("任务已结束,拒绝重复提交");
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
}
String oldStatus = task.getStatus();

View File

@@ -44,7 +44,7 @@ public class ShopManageController {
private final ShopManageGroupService shopManageGroupService;
@GetMapping
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名称、创建人进行筛选")
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名、当前操作者可见范围筛选")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
@@ -56,14 +56,21 @@ public class ShopManageController {
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName,
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName, createdById));
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(shopManageService.page(
page,
pageSize,
groupId,
shopName,
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@PostMapping
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
public ApiResponse<ShopManageItemVo> create(
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageCreateRequest request) {
return ApiResponse.success("创建成功",
@@ -71,10 +78,10 @@ public class ShopManageController {
}
@PutMapping("/{id}")
@Operation(summary = "更新店铺", description = "按 ID 更新店铺,普通管理员只能修改自己创建的数据")
@Operation(summary = "更新店铺", description = "按 ID 更新店铺")
public ApiResponse<ShopManageItemVo> update(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageUpdateRequest request) {
return ApiResponse.success("更新成功",
@@ -82,33 +89,42 @@ public class ShopManageController {
}
@DeleteMapping("/{id}")
@Operation(summary = "删除店铺", description = "按 ID 删除店铺,普通管理员只能删除自己创建的数据")
@Operation(summary = "删除店铺", description = "按 ID 删除店铺")
public ApiResponse<Void> delete(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
@GetMapping("/groups")
@Operation(summary = "查询分组列表", description = "普通管理员返回自己创建的分组,超级管理员返回全部")
@Operation(summary = "查询分组列表", description = "普通管理员返回当前可访问的分组,超级管理员返回全部")
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
@Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
if (Boolean.TRUE.equals(superAdmin)) {
return ApiResponse.success(shopManageGroupService.list(createdById));
}
return ApiResponse.success(shopManageGroupService.listAccessible(operatorId, false));
}
@PostMapping("/groups")
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
public ApiResponse<ShopManageGroupItemVo> createGroup(@Valid @RequestBody ShopManageGroupCreateRequest request) {
return ApiResponse.success("创建成功", shopManageGroupService.create(request));
public ApiResponse<ShopManageGroupItemVo> createGroup(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageGroupCreateRequest request) {
return ApiResponse.success("创建成功",
shopManageGroupService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PutMapping("/groups/{id}")
@Operation(summary = "更新分组", description = "普通管理员只能修改自己创建的分组")
@Operation(summary = "更新分组", description = "普通管理员只能修改自己可访问的分组")
public ApiResponse<ShopManageGroupItemVo> updateGroup(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
return ApiResponse.success("更新成功",
@@ -116,10 +132,10 @@ public class ShopManageController {
}
@DeleteMapping("/groups/{id}")
@Operation(summary = "删除分组", description = "普通管理员只能删除自己创建的分组")
@Operation(summary = "删除分组", description = "普通管理员只能删除自己可访问的分组")
public ApiResponse<Void> deleteGroup(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);

View File

@@ -1,8 +1,8 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
@@ -36,14 +36,27 @@ public class SkipPriceAsinController {
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin) {
return ApiResponse.success(skipPriceAsinService.page(page, pageSize, groupId, shopName, asin));
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(skipPriceAsinService.page(
page,
pageSize,
groupId,
shopName,
asin,
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@PostMapping
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinItemVo> create(@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.createOrUpdate(request));
public ApiResponse<SkipPriceAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
return ApiResponse.success("保存成功",
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PutMapping("/{id}/countries/{country}")
@@ -51,16 +64,25 @@ public class SkipPriceAsinController {
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(id, country, request.getAsin()));
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
id,
country,
request.getAsin(),
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country) {
skipPriceAsinService.deleteCountry(id, country);
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -12,7 +12,11 @@ public class ShopManageGroupCreateRequest {
@NotBlank(message = "分组名称不能为空")
private String groupName;
@Schema(description = "创建人用户 ID,由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "关联用户不能为空")
private Long userId;
@Schema(description = "创建人用户ID由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "创建人不能为空")
private Long createdById;
}

View File

@@ -2,6 +2,7 @@ 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.NotNull;
import lombok.Data;
@Data
@@ -10,4 +11,8 @@ public class ShopManageGroupUpdateRequest {
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "分组名称不能为空")
private String groupName;
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "关联用户不能为空")
private Long userId;
}

View File

@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class SkipPriceAsinCreateRequest {
@@ -19,6 +20,13 @@ public class SkipPriceAsinCreateRequest {
@NotEmpty(message = "请至少选择一个国家")
private List<String> countries;
@NotBlank(message = "ASIN 不能为空")
/**
* 兼容旧请求: 选中的国家共用同一个 ASIN。
*/
private String asin;
/**
* 新请求: 每个国家对应独立 ASIN例如 {"DE":"B0...", "UK":"B0..."}。
*/
private Map<String, String> asinMappings;
}

View File

@@ -15,6 +15,8 @@ public class ShopManageGroupEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String groupName;
@TableField("user_id")
private Long userId;
@TableField("created_by_id")
private Long createdById;
private LocalDateTime createdAt;

View File

@@ -8,6 +8,8 @@ import java.time.LocalDateTime;
public class ShopManageGroupItemVo {
private Long id;
private String groupName;
private Long userId;
private String username;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -2,6 +2,8 @@ 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.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
@@ -13,7 +15,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -21,6 +25,7 @@ public class ShopManageGroupService {
private final ShopManageGroupMapper groupMapper;
private final ShopManageMapper shopManageMapper;
private final AdminUserMapper adminUserMapper;
public List<ShopManageGroupItemVo> list() {
return list(null);
@@ -35,13 +40,56 @@ public class ShopManageGroupService {
.toList();
}
public List<ShopManageGroupItemVo> listAccessible(Long operatorId, boolean superAdmin) {
if (superAdmin) {
return list();
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.and(wrapper -> wrapper
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
.or()
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId))
.orderByAsc(ShopManageGroupEntity::getId))
.stream()
.map(this::toVo)
.toList();
}
public Set<Long> listAccessibleGroupIds(Long operatorId, boolean superAdmin) {
if (superAdmin) {
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId))
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId)
.and(wrapper -> wrapper
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
.or()
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId)))
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
}
@Transactional
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request) {
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request, Long operatorId, boolean superAdmin) {
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameUnique(groupName, null, createdById, false);
ensureGroupNameMatchesUser(groupName, bindUser);
ensureGroupNameUnique(groupName, null);
ensureUserUnique(request.getUserId(), null);
ShopManageGroupEntity entity = new ShopManageGroupEntity();
entity.setGroupName(groupName);
entity.setUserId(bindUser.getId());
entity.setCreatedById(createdById);
groupMapper.insert(entity);
return toVo(getById(entity.getId()));
@@ -51,9 +99,14 @@ public class ShopManageGroupService {
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameUnique(groupName, id, operatorId, superAdmin);
ensureGroupNameMatchesUser(groupName, bindUser);
ensureGroupNameUnique(groupName, id);
ensureUserUnique(bindUser.getId(), id);
entity.setGroupName(groupName);
entity.setUserId(bindUser.getId());
groupMapper.updateById(entity);
return toVo(getById(id));
}
@@ -84,12 +137,9 @@ public class ShopManageGroupService {
return entity;
}
private void ensureGroupNameUnique(String groupName, Long excludeId, Long operatorId, boolean superAdmin) {
private void ensureGroupNameUnique(String groupName, Long excludeId) {
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(ShopManageGroupEntity::getGroupName, groupName);
if (!superAdmin) {
query.eq(ShopManageGroupEntity::getCreatedById, normalizePositiveId(operatorId, "操作人不能为空"));
}
if (excludeId != null) {
query.ne(ShopManageGroupEntity::getId, excludeId);
}
@@ -99,16 +149,54 @@ public class ShopManageGroupService {
}
}
private void ensureUserUnique(Long userId, Long excludeId) {
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(ShopManageGroupEntity::getUserId, userId);
if (excludeId != null) {
query.ne(ShopManageGroupEntity::getId, excludeId);
}
Long count = groupMapper.selectCount(query);
if (count != null && count > 0) {
throw new BusinessException("该用户已绑定分组");
}
}
private void ensureGroupNameMatchesUser(String groupName, AdminUserEntity bindUser) {
if (bindUser == null || bindUser.getUsername() == null || !groupName.equals(bindUser.getUsername().trim())) {
throw new BusinessException("分组名称必须与关联用户名一致");
}
}
private void validateOwnership(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
throw new BusinessException("只能操作自己创建的分组数据");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
boolean boundToSelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
if (!createdBySelf && !boundToSelf) {
throw new BusinessException("只能操作自己有权限的分组数据");
}
}
private AdminUserEntity getBindableUser(Long userId, Long operatorId, boolean superAdmin) {
Long normalizedUserId = normalizePositiveId(userId, "关联用户不能为空");
AdminUserEntity user = adminUserMapper.selectById(normalizedUserId);
if (user == null) {
throw new BusinessException("关联用户不存在");
}
if (superAdmin) {
return user;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean self = user.getId() != null && user.getId().equals(normalizedOperatorId);
boolean createdBySelf = user.getCreatedById() != null && user.getCreatedById().equals(normalizedOperatorId);
if (!self && !createdBySelf) {
throw new BusinessException("只能关联自己可见的用户");
}
return user;
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
@@ -128,6 +216,9 @@ public class ShopManageGroupService {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId());
vo.setGroupName(entity.getGroupName());
vo.setUserId(entity.getUserId());
AdminUserEntity bindUser = entity.getUserId() == null ? null : adminUserMapper.selectById(entity.getUserId());
vo.setUsername(bindUser == null ? "" : (bindUser.getUsername() == null ? "" : bindUser.getUsername()));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;

View File

@@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -27,24 +28,27 @@ public class ShopManageService {
private final ShopManageGroupService shopManageGroupService;
private final ShopCredentialCryptoService shopCredentialCryptoService;
public ShopManagePageVo page(long page, long pageSize) {
return page(page, pageSize, null, null, null);
}
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName) {
return page(page, pageSize, groupId, shopName, null);
}
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long createdById) {
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = shopName == null ? "" : shopName.trim();
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
.eq(createdById != null && createdById > 0, ShopManageEntity::getCreatedById, createdById)
.like(!safeShopName.isEmpty(), ShopManageEntity::getShopName, safeShopName)
.orderByDesc(ShopManageEntity::getId);
if (!superAdmin) {
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
query.and(wrapper -> {
wrapper.eq(ShopManageEntity::getCreatedById, normalizedOperatorId);
if (!accessibleGroupIds.isEmpty()) {
wrapper.or().in(ShopManageEntity::getGroupId, accessibleGroupIds);
}
});
}
Long total = shopManageMapper.selectCount(query);
List<ShopManageEntity> rows = shopManageMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
@@ -90,7 +94,7 @@ public class ShopManageService {
@Transactional
public ShopManageItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageUpdateRequest request) {
ShopManageEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
validateGroupAccess(entity, operatorId, superAdmin);
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名称不能为空");
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
@@ -110,7 +114,7 @@ public class ShopManageService {
@Transactional
public void delete(Long id, Long operatorId, boolean superAdmin) {
ShopManageEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
validateGroupAccess(entity, operatorId, superAdmin);
shopManageMapper.deleteById(entity.getId());
}
@@ -146,14 +150,8 @@ public class ShopManageService {
return entity;
}
private void validateOwnership(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
throw new BusinessException("只能操作自己创建的店铺数据");
}
private void validateGroupAccess(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
}
private String normalizeRequired(String value, String message) {
@@ -208,7 +206,6 @@ public class ShopManageService {
vo.setMallName(entity.getMallName());
vo.setAccount(entity.getAccount());
String masked = entity.getPassword() == null || entity.getPassword().isBlank() ? "" : "******";
// 兼容旧前端字段password 继续返回脱敏值
vo.setPassword(masked);
vo.setPasswordMasked(masked);
vo.setCreatedAt(entity.getCreatedAt());

View File

@@ -28,11 +28,13 @@ public class SkipPriceAsinService {
private final SkipPriceAsinMapper skipPriceAsinMapper;
private final ShopManageGroupService shopManageGroupService;
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin) {
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
@@ -45,6 +47,14 @@ public class SkipPriceAsinService {
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
.orderByDesc(SkipPriceAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(SkipPriceAsinEntity::getId, -1L);
} else {
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = skipPriceAsinMapper.selectCount(query);
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
@@ -64,11 +74,11 @@ public class SkipPriceAsinService {
}
@Transactional
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request) {
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String asin = normalizeAsin(request.getAsin());
Set<String> countries = normalizeCountries(request.getCountries());
Map<String, String> countryAsinMap = normalizeCountryAsinMap(countries, request);
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectOne(new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(SkipPriceAsinEntity::getGroupId, group.getId())
@@ -80,8 +90,8 @@ public class SkipPriceAsinService {
entity.setShopName(shopName);
}
for (String country : countries) {
setCountryAsin(entity, country, asin);
for (Map.Entry<String, String> entry : countryAsinMap.entrySet()) {
setCountryAsin(entity, entry.getKey(), entry.getValue());
}
if (entity.getId() == null) {
@@ -94,8 +104,9 @@ public class SkipPriceAsinService {
}
@Transactional
public void deleteCountry(Long id, String country) {
public void deleteCountry(Long id, String country, Long operatorId, boolean superAdmin) {
SkipPriceAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
String normalizedCountry = normalizeCountry(country);
setCountryAsin(entity, normalizedCountry, "");
if (isEmptyRow(entity)) {
@@ -106,8 +117,9 @@ public class SkipPriceAsinService {
}
@Transactional
public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin) {
public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin, Long operatorId, boolean superAdmin) {
SkipPriceAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
String normalizedCountry = normalizeCountry(country);
String normalizedAsin = normalizeAsin(asin);
setCountryAsin(entity, normalizedCountry, normalizedAsin);
@@ -131,6 +143,10 @@ public class SkipPriceAsinService {
return entity;
}
private void validateGroupAccess(SkipPriceAsinEntity entity, Long operatorId, boolean superAdmin) {
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
}
private SkipPriceAsinItemVo toItemVo(SkipPriceAsinEntity entity, String groupName) {
SkipPriceAsinItemVo vo = new SkipPriceAsinItemVo();
vo.setId(entity.getId());
@@ -176,6 +192,27 @@ public class SkipPriceAsinService {
return normalized;
}
private Map<String, String> normalizeCountryAsinMap(Set<String> countries, SkipPriceAsinCreateRequest request) {
LinkedHashMap<String, String> normalized = new LinkedHashMap<>();
Map<String, String> requestMappings = request.getAsinMappings();
if (requestMappings != null && !requestMappings.isEmpty()) {
for (String country : countries) {
String asin = requestMappings.get(country);
if (asin == null) {
asin = requestMappings.get(country.toLowerCase(Locale.ROOT));
}
normalized.put(country, normalizeAsin(asin));
}
return normalized;
}
String asin = normalizeAsin(request.getAsin());
for (String country : countries) {
normalized.put(country, asin);
}
return normalized;
}
private String normalizeCountry(String country) {
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
@@ -228,10 +265,6 @@ public class SkipPriceAsinService {
&& normalizeBlank(entity.getAsinEs()).isEmpty();
}
/**
* 按店铺名列表批量查询跳过跟价ASIN返回 Map{shopName -> {countryCode -> asin列表}}
* 用于跟价任务创建时将skip ASIN数据下发给Python队列
*/
public Map<String, Map<String, String>> listSkipAsinsByShops(List<String> shopNames) {
if (shopNames == null || shopNames.isEmpty()) {
return Map.of();
@@ -252,4 +285,37 @@ public class SkipPriceAsinService {
}
return result;
}
public Map<String, List<String>> listAllSkipAsinsByCountry() {
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(
new LambdaQueryWrapper<SkipPriceAsinEntity>()
.orderByDesc(SkipPriceAsinEntity::getId));
Map<String, LinkedHashSet<String>> grouped = new LinkedHashMap<>();
grouped.put("DE", new LinkedHashSet<>());
grouped.put("UK", new LinkedHashSet<>());
grouped.put("FR", new LinkedHashSet<>());
grouped.put("IT", new LinkedHashSet<>());
grouped.put("ES", new LinkedHashSet<>());
for (SkipPriceAsinEntity row : rows) {
addCountryAsin(grouped.get("DE"), row.getAsinDe());
addCountryAsin(grouped.get("UK"), row.getAsinUk());
addCountryAsin(grouped.get("FR"), row.getAsinFr());
addCountryAsin(grouped.get("IT"), row.getAsinIt());
addCountryAsin(grouped.get("ES"), row.getAsinEs());
}
Map<String, List<String>> result = new LinkedHashMap<>();
for (Map.Entry<String, LinkedHashSet<String>> entry : grouped.entrySet()) {
result.put(entry.getKey(), List.copyOf(entry.getValue()));
}
return result;
}
private void addCountryAsin(Set<String> bucket, String asin) {
String normalized = normalizeBlank(asin);
if (!normalized.isEmpty()) {
bucket.add(normalized);
}
}
}

View File

@@ -163,6 +163,12 @@ public class ShopMatchController {
return ApiResponse.success(shopMatchTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询任务进度摘要", description = "仅返回任务状态、阶段信息与时间等轻量字段,供前端轮询降载。")
public ApiResponse<ProductRiskTaskBatchVo> taskProgressBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(shopMatchTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
public ApiResponse<Void> submitResult(

View File

@@ -5,10 +5,17 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -19,21 +26,29 @@ public class ShopMatchTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return null;
}
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
if (!(raw instanceof String json) || json.isBlank()) {
Path file = buildShopPayloadFile(taskId, shopKey);
if (!Files.isRegularFile(file)) {
return null;
}
try {
return objectMapper.readValue(json, ShopMatchShopPayloadDto.class);
PayloadFile payloadFile = objectMapper.readValue(Files.readString(file), PayloadFile.class);
if (payloadFile == null || payloadFile.payload == null) {
return null;
}
if (payloadFile.shopKey == null || !shopKey.equals(payloadFile.shopKey)) {
log.warn("[shop-match] payload file shop key mismatch taskId={} shopKey={} storedKey={}",
taskId, shopKey, payloadFile.shopKey);
return null;
}
return payloadFile.payload;
} catch (Exception ex) {
throw new BusinessException("读取匹配店铺缓存失败");
throw new BusinessException("璇诲彇鍖归厤搴楅摵缂撳瓨澶辫触");
}
}
@@ -42,11 +57,16 @@ public class ShopMatchTaskCacheService {
return;
}
try {
String key = buildShopPayloadKey(taskId);
stringRedisTemplate.opsForHash().put(key, shopKey, objectMapper.writeValueAsString(payload));
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
Files.createDirectories(buildTaskDir(taskId));
Files.writeString(
buildShopPayloadFile(taskId, shopKey),
objectMapper.writeValueAsString(new PayloadFile(shopKey, payload)),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (Exception ex) {
throw new BusinessException("暂存匹配店铺结果失败");
throw new BusinessException("鏆傚瓨鍖归厤搴楅摵缁撴灉澶辫触");
}
}
@@ -54,25 +74,52 @@ public class ShopMatchTaskCacheService {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return;
}
stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey);
try {
Files.deleteIfExists(buildShopPayloadFile(taskId, shopKey));
} catch (IOException ex) {
log.warn("[shop-match] delete shop payload failed taskId={} shopKey={} msg={}", taskId, shopKey, ex.getMessage());
}
}
public Map<String, ShopMatchShopPayloadDto> getAllShopMergedPayload(Long taskId) {
try {
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
if (raw == null || raw.isEmpty()) {
Path taskDir = buildTaskDir(taskId);
if (!Files.isDirectory(taskDir)) {
return Map.of();
}
LinkedHashMap<String, ShopMatchShopPayloadDto> out = new LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
try (var stream = Files.list(taskDir)) {
for (Path path : stream
.filter(file -> file.getFileName().toString().endsWith(".json"))
.sorted(Comparator.comparing(file -> file.getFileName().toString()))
.toList()) {
PayloadFile payloadFile = objectMapper.readValue(Files.readString(path), PayloadFile.class);
if (payloadFile == null || payloadFile.shopKey == null || payloadFile.shopKey.isBlank() || payloadFile.payload == null) {
continue;
}
out.put(key, objectMapper.readValue(val, ShopMatchShopPayloadDto.class));
out.put(payloadFile.shopKey, payloadFile.payload);
}
}
return out;
} catch (Exception ex) {
throw new BusinessException("读取匹配店铺缓存失败");
throw new BusinessException("璇诲彇鍖归厤搴楅摵缂撳瓨澶辫触");
}
}
public boolean hasAnyShopMergedPayload(Long taskId) {
if (taskId == null || taskId <= 0) {
return false;
}
Path taskDir = buildTaskDir(taskId);
if (!Files.isDirectory(taskDir)) {
return false;
}
try (var stream = Files.list(taskDir)) {
return stream.anyMatch(file -> Files.isRegularFile(file)
&& file.getFileName().toString().endsWith(".json"));
} catch (IOException ex) {
log.warn("[shop-match] scan task cache failed taskId={} msg={}", taskId, ex.getMessage());
return false;
}
}
@@ -81,13 +128,57 @@ public class ShopMatchTaskCacheService {
return;
}
try {
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
Path taskDir = buildTaskDir(taskId);
if (!Files.exists(taskDir)) {
return;
}
try (var walk = Files.walk(taskDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ex) {
log.warn("[shop-match] delete task cache path failed taskId={} path={} msg={}",
taskId, path, ex.getMessage());
}
});
}
} catch (Exception ex) {
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
}
}
private String buildShopPayloadKey(Long taskId) {
return "shop-match:task:shop-payload:" + taskId;
public Duration getPayloadTtl() {
return Duration.ofHours(PAYLOAD_TTL_HOURS);
}
private Path buildTaskDir(Long taskId) {
return Path.of(System.getProperty("java.io.tmpdir"), "shop-match-cache", String.valueOf(taskId));
}
private Path buildShopPayloadFile(Long taskId, String shopKey) {
return buildTaskDir(taskId).resolve(hashShopKey(shopKey) + ".json");
}
private String hashShopKey(String shopKey) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(shopKey.trim().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (Exception ex) {
throw new IllegalStateException("failed to hash shop key", ex);
}
}
private static class PayloadFile {
public String shopKey;
public ShopMatchShopPayloadDto payload;
public PayloadFile() {
}
public PayloadFile(String shopKey, ShopMatchShopPayloadDto payload) {
this.shopKey = shopKey;
this.payload = payload;
}
}
}

View File

@@ -185,6 +185,25 @@ public class ShopMatchTaskService {
return batch;
}
public ProductRiskTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
ProductRiskTaskBatchVo batch = new ProductRiskTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
batch.getMissingTaskIds().add(taskId);
continue;
}
batch.getItems().add(buildTaskProgressDetail(task));
}
return batch;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
@@ -376,7 +395,7 @@ public class ShopMatchTaskService {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
@@ -595,7 +614,11 @@ public class ShopMatchTaskService {
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
task.setUpdatedAt(now());
if (!allDone) {
if (shouldRemainRunningUntilNextStage(task)) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setFinishedAt(null);
} else if (!allDone) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setFinishedAt(null);
@@ -623,6 +646,18 @@ public class ShopMatchTaskService {
}
}
private boolean shouldRemainRunningUntilNextStage(FileTaskEntity task) {
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request == null || request.getScheduleTimes() == null || request.getScheduleTimes().isEmpty()) {
return false;
}
Integer activeStageIndex = request.getActiveStageIndex();
if (activeStageIndex == null) {
return false;
}
return activeStageIndex < request.getScheduleTimes().size() - 1;
}
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
detail.setTask(toTaskItemVo(task));
@@ -636,6 +671,12 @@ public class ShopMatchTaskService {
return detail;
}
private ProductRiskTaskDetailVo buildTaskProgressDetail(FileTaskEntity task) {
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
detail.setTask(toTaskItemVo(task));
return detail;
}
private List<ProductRiskResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)

View File

@@ -5,6 +5,7 @@ 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.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest;
@@ -17,20 +18,14 @@ 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.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -217,84 +212,119 @@ public class SplitRunService {
String originalFilename,
SplitRunRequest request
) throws Exception {
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile);
Workbook workbook = WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException("Excel header row is empty.");
CountResult countResult = countSplitRows(inputFile, request.getSelectedColumns());
if (countResult.rowCount() <= 0) {
throw new BusinessException("No data rows available for splitting.");
}
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
List<Integer> chunkSizes = buildChunkSizes(countResult.rowCount(), request);
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result"));
String sourceStem = FileUtil.mainName(originalFilename);
List<SplitChunkResult> results = new ArrayList<>();
List<ChunkWriter> writers = new ArrayList<>();
try {
for (int i = 0; i < chunkSizes.size(); i++) {
File outputFile = buildNamedOutputFile(outputDir, sourceStem + "_split_" + (i + 1) + ".xlsx");
ChunkWriter writer = new ChunkWriter(outputFile, countResult.sheetName(), request.getSelectedColumns(), chunkSizes.get(i));
writers.add(writer);
results.add(new SplitChunkResult(outputFile, chunkSizes.get(i)));
}
Map<String, Integer>[] headerMapHolder = new Map[]{null};
int[] chunkIndex = new int[]{0};
ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
Map<String, Integer> resolvedHeaderMap = buildHeaderMap(headerMap);
List<String> missing = request.getSelectedColumns().stream()
.filter(column -> !headerMap.containsKey(column))
.filter(column -> !resolvedHeaderMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
headerMapHolder[0] = resolvedHeaderMap;
}
List<List<String>> rowsData = new ArrayList<>();
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
@Override
public void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception {
if (chunkIndex[0] >= writers.size()) {
return;
}
Map<String, Integer> resolvedHeaderMap = headerMapHolder[0];
if (resolvedHeaderMap == null) {
throw new BusinessException("Excel header row is empty.");
}
List<String> projected = new ArrayList<>();
for (String column : request.getSelectedColumns()) {
Integer sourceIndex = headerMap.get(column);
String value = sourceIndex == null
? ""
: normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
projected.add(value);
Integer sourceIndex = resolvedHeaderMap.get(column);
projected.add(sourceIndex == null ? "" : normalizeCellText(rowMap.get(sourceIndex)));
}
rowsData.add(projected);
}
if (rowsData.isEmpty()) {
throw new BusinessException("No data rows available for splitting.");
}
List<List<List<String>>> chunks = buildChunks(rowsData, request);
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result"));
String sourceStem = FileUtil.mainName(originalFilename);
List<SplitChunkResult> results = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
File outputFile = buildNamedOutputFile(outputDir, sourceStem + "_split_" + (i + 1) + ".xlsx");
try (XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName());
Row outputHeaderRow = outputSheet.createRow(0);
for (int colIndex = 0; colIndex < request.getSelectedColumns().size(); colIndex++) {
outputHeaderRow.createCell(colIndex).setCellValue(request.getSelectedColumns().get(colIndex));
}
int outputRowIndex = 1;
for (List<String> rowValues : chunks.get(i)) {
Row outputRow = outputSheet.createRow(outputRowIndex++);
for (int colIndex = 0; colIndex < rowValues.size(); colIndex++) {
outputRow.createCell(colIndex).setCellValue(rowValues.get(colIndex));
ChunkWriter writer = writers.get(chunkIndex[0]);
writer.writeRow(projected);
if (writer.isFull()) {
chunkIndex[0]++;
}
}
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
outputWorkbook.write(fos);
});
} finally {
IOException closeException = null;
for (ChunkWriter writer : writers) {
try {
writer.close();
} catch (IOException ex) {
if (closeException == null) {
closeException = ex;
}
}
results.add(new SplitChunkResult(outputFile, chunks.get(i).size()));
}
if (closeException != null) {
throw closeException;
}
}
return results;
}
private CountResult countSplitRows(File inputFile, List<String> selectedColumns) throws IOException {
int[] rowCount = new int[]{0};
String[] sheetName = new String[]{"Sheet1"};
boolean[] headerRead = new boolean[]{false};
ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String currentSheetName, Integer sheetNo, Map<Integer, String> headerMap) {
Map<String, Integer> resolvedHeaderMap = buildHeaderMap(headerMap);
List<String> missing = selectedColumns.stream()
.filter(column -> !resolvedHeaderMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
sheetName[0] = currentSheetName == null || currentSheetName.isBlank() ? "Sheet1" : currentSheetName;
headerRead[0] = true;
}
private Map<String, Integer> buildHeaderMap(Row headerRow, DataFormatter formatter) {
@Override
public void onRow(String currentSheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
if (!headerRead[0]) {
throw new BusinessException("Excel header row is empty.");
}
rowCount[0]++;
}
});
if (!headerRead[0]) {
throw new BusinessException("Excel header row is empty.");
}
return new CountResult(rowCount[0], sheetName[0]);
}
private Map<String, Integer> buildHeaderMap(Map<Integer, String> headerRow) {
Map<String, Integer> headerMap = new HashMap<>();
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
Cell cell = headerRow.getCell(i);
String value = normalizeHeaderName(cell == null ? null : formatter.formatCellValue(cell));
for (Map.Entry<Integer, String> entry : headerRow.entrySet()) {
String value = normalizeHeaderName(entry.getValue());
if (value.isBlank() || headerMap.containsKey(value)) {
continue;
}
headerMap.put(value, i);
headerMap.put(value, entry.getKey());
if (HEADER_STOP_COLUMN.equals(value)) {
break;
}
@@ -302,14 +332,13 @@ public class SplitRunService {
return headerMap;
}
private List<List<List<String>>> buildChunks(List<List<String>> rowsData, SplitRunRequest request) {
int totalRows = rowsData.size();
List<List<List<String>>> chunks = new ArrayList<>();
private List<Integer> buildChunkSizes(int totalRows, SplitRunRequest request) {
List<Integer> chunks = new ArrayList<>();
if (SPLIT_MODE_ROWS_PER_FILE.equals(request.getSplitMode())) {
int chunkSize = request.getRowsPerFile();
for (int i = 0; i < totalRows; i += chunkSize) {
chunks.add(rowsData.subList(i, Math.min(i + chunkSize, totalRows)));
chunks.add(Math.min(chunkSize, totalRows - i));
}
return chunks;
}
@@ -317,12 +346,8 @@ public class SplitRunService {
int actualParts = Math.min(request.getParts(), totalRows);
int base = totalRows / actualParts;
int remainder = totalRows % actualParts;
int start = 0;
for (int i = 0; i < actualParts; i++) {
int size = base + (i < remainder ? 1 : 0);
int end = start + size;
chunks.add(rowsData.subList(start, end));
start = end;
chunks.add(base + (i < remainder ? 1 : 0));
}
return chunks;
}
@@ -448,4 +473,48 @@ public class SplitRunService {
private record TaskSplitChunkResult(String relativePath, String sourceFilename, File outputFile, int rowCount) {
}
private record CountResult(int rowCount, String sheetName) {
}
private static final class ChunkWriter implements AutoCloseable {
private final File outputFile;
private final SXSSFWorkbook workbook;
private final org.apache.poi.ss.usermodel.Sheet sheet;
private final int rowLimit;
private int writtenRows;
private ChunkWriter(File outputFile, String sheetName, List<String> columns, int rowLimit) {
this.outputFile = outputFile;
this.rowLimit = rowLimit;
this.workbook = new SXSSFWorkbook(200);
this.sheet = workbook.createSheet(sheetName == null || sheetName.isBlank() ? "Sheet1" : sheetName);
org.apache.poi.ss.usermodel.Row headerRow = sheet.createRow(0);
for (int i = 0; i < columns.size(); i++) {
headerRow.createCell(i).setCellValue(columns.get(i));
}
}
private void writeRow(List<String> values) {
org.apache.poi.ss.usermodel.Row outputRow = sheet.createRow(writtenRows + 1);
for (int colIndex = 0; colIndex < values.size(); colIndex++) {
outputRow.createCell(colIndex).setCellValue(values.get(colIndex));
}
writtenRows++;
}
private boolean isFull() {
return writtenRows >= rowLimit;
}
@Override
public void close() throws IOException {
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
workbook.write(fos);
} finally {
workbook.dispose();
workbook.close();
}
}
}
}

View File

@@ -82,12 +82,12 @@ aiimage:
heartbeat-timeout-minutes: ${AIIMAGE_DELETE_BRAND_HEARTBEAT_TIMEOUT_MINUTES:15}
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:10}
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:10}
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:20}
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:20}
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:20}
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:20}
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:20}
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:20}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS biz_price_track_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
user_id BIGINT NOT NULL COMMENT 'user id',
shop_name VARCHAR(255) NOT NULL COMMENT 'normalized shop name',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
UNIQUE KEY uk_user_shop (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='price track candidate shops';
CREATE TABLE IF NOT EXISTS biz_price_track_country_pref (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT 'user id',
country_codes_json VARCHAR(256) NOT NULL COMMENT 'country code order json',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time'
) COMMENT='price track country preference';

View File

@@ -0,0 +1,6 @@
ALTER TABLE `columns`
ADD COLUMN `sort_order` INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER `route_path`;
UPDATE `columns`
SET `sort_order` = `id`
WHERE `sort_order` = 0;

View File

@@ -0,0 +1,55 @@
SET @db_name = DATABASE();
SET @user_id_col_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage_group'
AND COLUMN_NAME = 'user_id'
);
SET @sql_add_user_id_col := IF(@user_id_col_exists = 0,
'ALTER TABLE biz_shop_manage_group ADD COLUMN user_id BIGINT NULL COMMENT ''关联用户ID'' AFTER group_name',
'SELECT 1'
);
PREPARE stmt_add_user_id_col FROM @sql_add_user_id_col;
EXECUTE stmt_add_user_id_col;
DEALLOCATE PREPARE stmt_add_user_id_col;
UPDATE biz_shop_manage_group g
LEFT JOIN users u ON u.username = g.group_name
SET g.user_id = u.id
WHERE g.user_id IS NULL
AND u.id IS NOT NULL;
SET @user_id_idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage_group'
AND INDEX_NAME = 'idx_user_id'
);
SET @sql_add_user_id_idx := IF(@user_id_idx_exists = 0,
'ALTER TABLE biz_shop_manage_group ADD INDEX idx_user_id (user_id)',
'SELECT 1'
);
PREPARE stmt_add_user_id_idx FROM @sql_add_user_id_idx;
EXECUTE stmt_add_user_id_idx;
DEALLOCATE PREPARE stmt_add_user_id_idx;
SET @user_id_uk_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage_group'
AND INDEX_NAME = 'uk_user_id'
);
SET @sql_add_user_id_uk := IF(@user_id_uk_exists = 0,
'ALTER TABLE biz_shop_manage_group ADD UNIQUE KEY uk_user_id (user_id)',
'SELECT 1'
);
PREPARE stmt_add_user_id_uk FROM @sql_add_user_id_uk;
EXECUTE stmt_add_user_id_uk;
DEALLOCATE PREPARE stmt_add_user_id_uk;

View File

@@ -62,6 +62,34 @@ ADMIN_MENU_ACCESS_CONFIG = {
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
'users': {
'column_key': 'admin_users',
'route_path': 'users',
'error': '无权访问用户管理模块',
},
'columns': {
'column_key': 'admin_columns',
'route_path': 'columns',
'error': '无权访问栏目权限配置模块',
},
'shop-keys': {
'column_key': 'admin_shop_keys',
'route_path': 'shop-keys',
'error': '无权访问店铺密钥管理模块',
},
'history': {
'column_key': 'admin_history',
'route_path': 'history',
'error': '无权访问查看生成记录模块',
},
'version': {
'column_key': 'admin_version',
'route_path': 'version',
'error': '无权访问版本管理模块',
},
})
def _safe_version_key(version):
"""将版本号转换为安全的 OSS 对象名片段"""
@@ -98,10 +126,155 @@ def _format_permission_item(item):
'column_key': item.get('columnKey') or '',
'menu_type': item.get('menuType') or 'app',
'route_path': item.get('routePath') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
}
def _parse_optional_int(value):
if value is None:
return None
text = str(value).strip()
if text == '':
return None
return int(text)
def _ensure_column_sort_schema():
conn = get_db()
try:
with conn.cursor() as cur:
try:
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
except Exception:
pass
try:
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
except Exception:
pass
conn.commit()
finally:
conn.close()
def _load_local_column_sort_map():
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, sort_order FROM columns")
rows = cur.fetchall()
return {int(row['id']): int(row.get('sort_order') or 0) for row in rows if row.get('id') is not None}
finally:
conn.close()
def _load_local_columns(menu_type=None):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
FROM columns
"""
params = []
if menu_type:
sql += " WHERE menu_type = %s"
params.append(menu_type)
sql += " ORDER BY sort_order ASC, id ASC"
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()
def _sync_local_columns_from_backend(items):
if not items:
return
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
for item in items:
item_id = item.get('id')
if item_id is None:
continue
sort_order = item.get('sort_order')
cur.execute(
"""
INSERT INTO columns (id, name, column_key, menu_type, route_path, sort_order, created_at)
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, NOW()))
ON DUPLICATE KEY UPDATE
name = VALUES(name),
column_key = VALUES(column_key),
menu_type = VALUES(menu_type),
route_path = VALUES(route_path),
sort_order = CASE
WHEN columns.sort_order IS NULL OR columns.sort_order = 0 THEN VALUES(sort_order)
ELSE columns.sort_order
END
""",
(
int(item_id),
item.get('name') or '',
item.get('column_key') or '',
item.get('menu_type') or 'app',
item.get('route_path') or '',
int(sort_order if sort_order not in (None, '') else item_id),
item.get('created_at') or None,
),
)
conn.commit()
finally:
conn.close()
def _set_local_column_sort_order(column_id, sort_order):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE columns SET sort_order = %s WHERE id = %s",
(int(sort_order), int(column_id)),
)
conn.commit()
finally:
conn.close()
def _get_next_local_column_sort_order():
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM columns")
row = cur.fetchone() or {}
return int(row.get('max_sort') or 0) + 1
finally:
conn.close()
def _swap_local_column_sort_order(column_id, target_id):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, sort_order FROM columns WHERE id IN (%s, %s) ORDER BY id", (int(column_id), int(target_id)))
rows = cur.fetchall()
if len(rows) != 2:
raise ValueError('菜单不存在')
by_id = {int(row['id']): int(row.get('sort_order') or 0) for row in rows}
left_sort = by_id.get(int(column_id), 0) or int(column_id)
right_sort = by_id.get(int(target_id), 0) or int(target_id)
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (right_sort, int(column_id)))
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (left_sort, int(target_id)))
conn.commit()
finally:
conn.close()
def _sync_user_column_permissions(user_id, column_ids):
_, error_response, status = _proxy_backend_java(
'PUT',
@@ -111,8 +284,14 @@ def _sync_user_column_permissions(user_id, column_ids):
return error_response, status
def _get_current_admin_id(current_row=None):
if current_row and current_row.get('id'):
return current_row.get('id')
return session.get('user_id')
def _get_current_admin_permission_sets():
user_id = session.get('user_id')
user_id = _get_current_admin_id()
if not user_id:
return set(), set()
conn = get_db()
@@ -152,6 +331,60 @@ def _ensure_admin_menu_access(*menu_names):
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
def _ensure_dedupe_total_data_access():
return _ensure_admin_menu_access('dedupe-total-data')
def _load_current_admin_menu_items():
role, current_row = get_current_admin_role()
if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
user_id = _get_current_admin_id(current_row)
if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{user_id}/column-permissions',
params={'menuType': 'admin'},
)
if error_response is not None:
return role, current_row, None, (error_response, status)
items = [_format_permission_item(item) for item in (result.get('data') or [])]
return role, current_row, items, None
@admin_api.route('/current-user')
@admin_required
def get_admin_current_user():
role, current_row = get_current_admin_role()
if not role or not current_row:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return jsonify({
'success': True,
'item': {
'id': current_row.get('id'),
'username': current_row.get('username') or session.get('username') or '',
'role': role,
}
})
@admin_api.route('/current-user/menus')
@admin_required
def get_admin_current_user_menus():
_, _, items, denied = _load_current_admin_menu_items()
if denied:
return denied
return jsonify({'success': True, 'items': items})
@admin_api.route('/logout', methods=['POST'])
@admin_required
def admin_logout():
session.clear()
return jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login'})
# ---------- 用户管理 ----------
@admin_api.route('/users')
@@ -162,7 +395,13 @@ def list_users():
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
_, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin')
if denied:
return denied
_, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin')
if denied:
return denied
page_size = min(999, max(5, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
@@ -249,6 +488,9 @@ def list_users():
@admin_api.route('/user', methods=['POST'])
@admin_required
def create_user():
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
@@ -312,6 +554,9 @@ def create_user():
def update_user(uid):
"""更新用户(密码、角色)"""
data = request.get_json() or {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
password = data.get('password')
want_role = (data.get('role') or '').strip() or data.get('role')
role, current_row = get_current_admin_role()
@@ -368,6 +613,9 @@ def update_user(uid):
@admin_required
def delete_user(uid):
"""删除用户"""
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
if session.get('user_id') == uid:
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
role, current_row = get_current_admin_role()
@@ -405,6 +653,9 @@ def delete_user(uid):
@admin_required
def history():
"""管理员分页获取所有生成记录"""
_, _, denied = _ensure_admin_menu_access('history')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
@@ -473,28 +724,49 @@ def history():
@admin_api.route('/columns')
@admin_required
def list_columns():
params = {}
_, _, denied = _ensure_admin_menu_access('columns', 'users')
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
params = {'menuType': menu_type} if menu_type else None
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/permission-menus',
params=params or None,
params=params,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
if error_response is None:
_sync_local_columns_from_backend([_format_permission_item(item) for item in (result.get('data') or [])])
local_rows = _load_local_columns(menu_type or None)
items = [
{
'id': row.get('id'),
'name': row.get('name') or '',
'column_key': row.get('column_key') or '',
'menu_type': row.get('menu_type') or 'app',
'route_path': row.get('route_path') or '',
'sort_order': int(row.get('sort_order') or 0),
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
}
for row in local_rows
]
return jsonify({'success': True, 'items': items})
@admin_api.route('/column', methods=['POST'])
@admin_required
def create_column():
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
menu_type = (data.get('menu_type') or '').strip() or 'admin'
route_path = (data.get('route_path') or '').strip()
try:
sort_order = _parse_optional_int(data.get('sort_order'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '排序必须是数字'})
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
@@ -514,17 +786,27 @@ def create_column():
if error_response is not None:
return error_response, status
item = result.get('data') or {}
_sync_local_columns_from_backend([_format_permission_item(item)])
if item.get('id') is not None:
_set_local_column_sort_order(item.get('id'), sort_order if sort_order is not None else _get_next_local_column_sort_order())
return jsonify({'success': True, 'msg': result.get('message') or '创建成功', 'id': item.get('id')})
@admin_api.route('/column/<int:cid>', methods=['PUT'])
@admin_required
def update_column(cid):
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
menu_type = (data.get('menu_type') or '').strip() or 'admin'
route_path = (data.get('route_path') or '').strip()
try:
sort_order = _parse_optional_int(data.get('sort_order'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '排序必须是数字'})
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
@@ -543,32 +825,76 @@ def update_column(cid):
)
if error_response is not None:
return error_response, status
_sync_local_columns_from_backend([{
'id': cid,
'name': name,
'column_key': column_key,
'menu_type': menu_type,
'route_path': route_path,
'sort_order': sort_order if sort_order is not None else cid,
'created_at': None,
}])
if sort_order is not None:
_set_local_column_sort_order(cid, sort_order)
return jsonify({'success': True, 'msg': result.get('message') or '更新成功'})
@admin_api.route('/column/reorder', methods=['POST'])
@admin_required
def reorder_column():
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
try:
column_id = int(data.get('column_id'))
target_id = int(data.get('target_id'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '菜单重排参数无效'}), 400
if column_id == target_id:
return jsonify({'success': True, 'msg': '排序未变化'})
try:
_swap_local_column_sort_order(column_id, target_id)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
return jsonify({'success': True, 'msg': '排序更新成功'})
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
@admin_required
def delete_column(cid):
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/permission-menus/{cid}',
)
if error_response is not None:
return error_response, status
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM columns WHERE id = %s", (cid,))
conn.commit()
finally:
conn.close()
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/user/<int:uid>/columns')
@admin_required
def get_user_columns(uid):
params = {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/columns',
params=params or None,
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
@@ -579,14 +905,14 @@ def get_user_columns(uid):
@admin_api.route('/user/<int:uid>/column-permissions')
@admin_required
def get_user_column_permissions(uid):
params = {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
if menu_type:
params['menuType'] = menu_type
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{uid}/column-permissions',
params=params or None,
params={'menuType': menu_type} if menu_type else None,
)
if error_response is not None:
return error_response, status
@@ -610,6 +936,9 @@ def _set_user_column_permissions(cur, user_id, column_ids):
@admin_api.route('/versions')
@admin_required
def list_versions():
_, _, denied = _ensure_admin_menu_access('version')
if denied:
return denied
"""获取版本列表"""
try:
conn = get_db()
@@ -637,6 +966,9 @@ def list_versions():
@admin_required
def upload_version():
"""接收版本号 + zip 文件,上传到 OSS写入 web_config"""
_, _, denied = _ensure_admin_menu_access('version')
if denied:
return denied
version = (request.form.get('version') or '').strip()
if not version:
return jsonify({'success': False, 'error': '请填写版本号'})
@@ -675,6 +1007,9 @@ def upload_version():
@admin_api.route('/shop-keys')
@admin_required
def list_shop_keys():
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
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(
@@ -707,6 +1042,9 @@ def list_shop_keys():
@admin_api.route('/shop-key', methods=['POST'])
@admin_required
def create_shop_key():
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
data = request.get_json() or {}
payload = {
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
@@ -736,6 +1074,9 @@ def create_shop_key():
@admin_api.route('/shop-key/<int:item_id>', methods=['PUT'])
@admin_required
def update_shop_key(item_id):
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
data = request.get_json() or {}
payload = {
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
@@ -765,6 +1106,9 @@ def update_shop_key(item_id):
@admin_api.route('/shop-key/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_shop_key(item_id):
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-keys/{item_id}',
@@ -1007,8 +1351,9 @@ def list_shop_manages():
shop_name = (request.args.get('shop_name') or '').strip()
params = {'page': page, 'pageSize': page_size}
if role != 'super_admin' and current_row and current_row.get('id'):
params['createdById'] = current_row.get('id')
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if group_id_raw:
try:
group_id = int(group_id_raw)
@@ -1164,10 +1509,16 @@ def list_shop_manage_groups():
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
params = {}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if role != 'super_admin' and current_row and current_row.get('id'):
params['createdById'] = current_row.get('id')
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params={'createdById': current_row.get('id')} if role != 'super_admin' and current_row and current_row.get('id') else None,
params=params,
)
if error_response is not None:
return error_response, status
@@ -1175,6 +1526,8 @@ def list_shop_manage_groups():
{
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
@@ -1186,17 +1539,22 @@ def list_shop_manage_groups():
@admin_api.route('/shop-manage-group', methods=['POST'])
@admin_required
def create_shop_manage_group():
_, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {
'groupName': (data.get('group_name') or '').strip(),
'userId': data.get('user_id'),
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-manages/groups',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
@@ -1208,6 +1566,8 @@ def create_shop_manage_group():
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
@@ -1221,7 +1581,10 @@ def update_shop_manage_group(item_id):
if denied:
return denied
data = request.get_json() or {}
payload = {'groupName': (data.get('group_name') or '').strip()}
payload = {
'groupName': (data.get('group_name') or '').strip(),
'userId': data.get('user_id'),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-manages/groups/{item_id}',
@@ -1240,6 +1603,8 @@ def update_shop_manage_group(item_id):
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
@@ -1249,7 +1614,7 @@ def update_shop_manage_group(item_id):
@admin_api.route('/skip-price-asins')
@admin_required
def list_skip_price_asins():
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
@@ -1258,6 +1623,9 @@ def list_skip_price_asins():
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if group_id_raw:
try:
group_id = int(group_id_raw)
@@ -1305,19 +1673,31 @@ def list_skip_price_asins():
@admin_api.route('/skip-price-asin', methods=['POST'])
@admin_required
def create_skip_price_asin():
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
asin_mappings = data.get('asin_mappings') or {}
fallback_asin = (data.get('asin') or '').strip()
if not fallback_asin and isinstance(asin_mappings, dict):
for value in asin_mappings.values():
fallback_asin = (value or '').strip()
if fallback_asin:
break
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'countries': data.get('countries') or [],
'asin': (data.get('asin') or '').strip(),
'asin': fallback_asin,
'asinMappings': asin_mappings,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
@@ -1345,12 +1725,16 @@ def create_skip_price_asin():
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['DELETE'])
@admin_required
def delete_skip_price_asin_country(item_id, country):
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
@@ -1379,7 +1763,7 @@ def delete_shop_manage_group(item_id):
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
@admin_required
def update_skip_price_asin_country(item_id, country):
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
@@ -1387,6 +1771,10 @@ def update_skip_price_asin_country(item_id, country):
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:

View File

@@ -122,10 +122,18 @@ def init_db():
cur.execute("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type")
except Exception:
pass
try:
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
except Exception:
pass
try:
cur.execute("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''")
except Exception:
pass
try:
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -216,6 +216,7 @@ import {
deleteDeleteBrandHistory,
getDeleteBrandHistory,
getDeleteBrandTaskDetails,
getDeleteBrandTaskProgress,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
@@ -853,7 +854,7 @@ async function refreshTaskDetails(taskIds?: number[]) {
if (!ids.length) return
try {
const batch = await getDeleteBrandTaskDetails(ids)
const batch = await getDeleteBrandTaskProgress(ids)
let changed = false
for (const detail of batch.items || []) {
const id = detail?.task?.id
@@ -876,7 +877,7 @@ async function refreshTaskDetails(taskIds?: number[]) {
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 4500 : 10000
return document.visibilityState === 'visible' ? 6000 : 20000
}
function scheduleNextPoll(immediate = false) {

View File

@@ -8,12 +8,12 @@
<div class="section-title">跟价模式</div>
<div class="mode-zone">
<label class="mode-check-row">
<input type="checkbox" class="mode-check-input" v-model="statusModeEnabled" />
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="statusModeEnabled" @change="selectMode('status')" />
<span class="mode-check-text">按商品状态全量</span>
<span class="mode-hint-inline"> 获取店铺所有在售ASIN跑跟价</span>
</label>
<label class="mode-check-row">
<input type="checkbox" class="mode-check-input" v-model="asinModeEnabled" />
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="asinModeEnabled" @change="selectMode('asin')" />
<span class="mode-check-text">按指定ASIN文档自定义</span>
<span class="mode-hint-inline"> 上传ASIN表格按文档内ASIN跑跟价</span>
</label>
@@ -26,6 +26,7 @@
<div class="hint">上传包含ASIN的表格文件按文档内的ASIN进行跟价</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectAsinFile">选择ASIN文件</button>
<button type="button" class="opt-btn" @click="selectAsinFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder">
<template v-if="asinFiles.length">
@@ -225,6 +226,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import {
addPriceTrackCandidate,
@@ -237,11 +239,14 @@ import {
getPriceTrackDashboard,
getPriceTrackHistory,
getPriceTrackResultDownloadUrl,
getPriceTrackTaskProgressBatch,
getPriceTrackTasksBatch,
listPriceTrackCandidates,
matchPriceTrackShops,
putPriceTrackCountryPreference,
type PriceTrackAsinParsedRow,
type PriceTrackCandidateVo,
type PriceTrackCreateTaskVo,
type PriceTrackDashboardVo,
type PriceTrackHistoryItem,
type PriceTrackShopQueueItem,
@@ -267,6 +272,7 @@ const matching = ref(false)
const pushing = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
// 跟价模式(可同时启用)
const statusModeEnabled = ref(false) // 按商品状态(全量)
@@ -404,20 +410,56 @@ function saveMatchedItemsToStorage() {
// ========== 文件上传 ==========
async function selectAsinFile() {
const api = getPywebviewApi()
if (!api?.select_files) {
ElMessage.error('当前环境不支持选择文件')
return
}
const result = await api.select_files({ filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }] })
if (api?.select_files) {
const result = await api.select_files({
filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }],
multiple: true,
})
if (result?.paths?.length) {
asinFiles.value = result.paths
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
}
return
}
if (api?.select_brand_xlsx_files) {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
asinFiles.value = paths
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
return
}
ElMessage.warning('当前环境不支持文件选择,请在本地客户端中打开')
}
async function selectAsinFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
return
}
asinFiles.value = result.items.map((item) => item.absolutePath)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
// ========== 国家顺序相关 ==========
function selectMode(mode: 'status' | 'asin') {
statusModeEnabled.value = mode === 'status'
asinModeEnabled.value = mode === 'asin'
}
function countryLabel(code: string) {
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
return row?.label ?? code
@@ -595,6 +637,15 @@ function collectShopNamesForMatch(): string[] {
for (const r of selectedCandidates.value) {
if (r?.shopName?.trim()) set.add(r.shopName.trim())
}
if (asinModeEnabled.value) {
for (const filePath of asinFiles.value) {
const normalizedPath = (filePath || '').trim()
if (!normalizedPath) continue
const filename = normalizedPath.split(/[/\\]/).filter(Boolean).pop() || ''
const shopName = filename.replace(/\.[^.]+$/, '').trim()
if (shopName) set.add(shopName)
}
}
return [...set]
}
@@ -604,11 +655,25 @@ async function runMatch() {
ElMessage.warning('请勾选备选店铺,或在输入框中填写店名')
return
}
if (asinModeEnabled.value && !asinFiles.value.length) {
ElMessage.warning('请先选择 ASIN 文件')
return
}
matching.value = true
try {
const res = await matchPriceTrackShops(names)
const res = await matchPriceTrackShops(names, {
asinFiles: asinModeEnabled.value ? asinFiles.value : [],
countryCodes: [...orderedCountryCodes.value],
})
const batch = res.items || []
matchedItems.value = [...matchedItems.value, ...batch.filter((n) => !matchedItems.value.some((e) => e.shopName === n.shopName))]
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
const nextByShop = new Map(
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
)
for (const item of batch) {
nextByShop.set((item.shopName || '').trim(), item)
}
matchedItems.value = [...nextByShop.values()]
saveMatchedItemsToStorage()
ElMessage.success(`匹配 ${batch.length} 个店铺`)
} catch (e) {
@@ -668,11 +733,15 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
}
}
async function pushToPythonQueue() {
async function pushToPythonQueueLegacy() {
if (!hasValidMode.value) {
ElMessage.warning('请至少选择一个跟价模式')
return
}
if (asinModeEnabled.value && !asinFiles.value.length) {
ElMessage.warning('请先选择 ASIN 文件')
return
}
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json')
@@ -742,15 +811,146 @@ async function pushToPythonQueue() {
}
// ========== 任务状态轮询 ==========
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
const shopName = (row.shopName || '').trim()
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
return {
type: 'price-track-run',
ts: Date.now(),
data: {
task_id: taskVo.taskId,
shop_name: shopName,
shop_id: row.shopId ?? null,
country_codes: [...orderedCountryCodes.value],
mode: statusModeEnabled.value ? 'status' : 'asin',
skip_asins: skipAsinsByCountry,
skip_asins_by_country: skipAsinsByCountry,
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
? matchAsinRowsByCountry.value
: (taskVo.asinRowsByCountry || {}),
},
}
}
async function waitForTaskTerminal(taskId: number) {
while (true) {
const batch = await getPriceTrackTaskProgressBatch([taskId])
if ((batch.missingTaskIds || []).includes(taskId)) {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
return 'FAILED'
}
const detail = (batch.items || []).find((item) => item.task?.id === taskId)
const status = detail?.task?.status || ''
if (detail) {
const prev = taskSnapshots.value[taskId]
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }
saveTaskSnapshotsToStorage()
}
if (status) {
taskDetails.value = { ...taskDetails.value, [taskId]: status }
saveTaskDetailsToStorage()
}
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
return status
}
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
async function pushToPythonQueue() {
if (!hasValidMode.value) {
ElMessage.warning('请至少选择一个跟价模式')
return
}
if (asinModeEnabled.value && !asinFiles.value.length) {
ElMessage.warning('请先选择 ASIN 文件')
return
}
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json')
return
}
const matchedRows = matchedItems.value.filter((i) => i.matched)
if (!matchedRows.length) {
ElMessage.warning('无可用匹配店铺')
return
}
pushing.value = true
queuePayloadText.value = ''
try {
for (let index = 0; index < matchedRows.length; index += 1) {
const row = matchedRows[index]
const taskVo = await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: asinFiles.value,
countryCodes: [...orderedCountryCodes.value],
})
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
}
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
const queuePayload = buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload)
if (!pushResult?.success) {
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
}
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = matchedRows.length > 1
? `任务 ${taskVo.taskId} 已入队,等待完成后继续下一条(${index + 1}/${matchedRows.length}`
: `任务 ${taskVo.taskId} 已入队,等待执行完成`
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
if (finalStatus !== 'SUCCESS') {
throw new Error(`任务 ${taskVo.taskId} 执行失败,已停止后续任务`)
}
queuePushResult.value = index + 1 < matchedRows.length
? `任务 ${taskVo.taskId} 已完成,继续推送下一条(${index + 1}/${matchedRows.length}`
: `任务 ${taskVo.taskId} 已完成`
}
ElMessage.success(`已串行完成 ${matchedRows.length} 条店铺任务推送`)
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
} finally {
pushing.value = false
}
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 4500 : 10000
return document.visibilityState === 'visible' ? 6000 : 20000
}
async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
const batch = await getPriceTrackTasksBatch(ids)
const batch = await getPriceTrackTaskProgressBatch(ids)
let changed = false
const nextSnapshots = { ...taskSnapshots.value }
for (const missingId of batch.missingTaskIds || []) {
@@ -761,7 +961,8 @@ async function refreshTaskBatch() {
const taskId = detail.task?.id
const status = detail.task?.status
if (typeof taskId !== 'number' || taskId <= 0) continue
nextSnapshots[taskId] = detail
const prev = nextSnapshots[taskId]
nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail
if (status) {
taskDetails.value[taskId] = status
if (status === 'SUCCESS' || status === 'FAILED') {

View File

@@ -208,6 +208,7 @@ import {
getProductRiskDashboard,
getProductRiskHistory,
getProductRiskResultDownloadUrl,
getProductRiskTaskProgressBatch,
getProductRiskTasksBatch,
listProductRiskCandidates,
matchProductRiskShops,
@@ -641,14 +642,15 @@ async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
const batch = await getProductRiskTasksBatch(ids)
const batch = await getProductRiskTaskProgressBatch(ids)
let changed = false
const nextSnapshots = { ...taskSnapshots.value }
for (const d of batch.items || []) {
const id = d.task?.id
const st = d.task?.status
if (typeof id === 'number' && id > 0) {
nextSnapshots[id] = d
const prev = nextSnapshots[id]
nextSnapshots[id] = prev ? { ...prev, task: { ...(prev.task || {}), ...(d.task || {}) } } : d
if (st) {
taskDetails.value[id] = st
if (st === 'SUCCESS' || st === 'FAILED') {
@@ -671,7 +673,7 @@ async function refreshTaskBatch() {
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 4500 : 10000
return document.visibilityState === 'visible' ? 6000 : 20000
}
function scheduleNextPoll(immediate = false) {
@@ -717,14 +719,15 @@ function stopPolling() {
async function waitForTaskTerminal(taskId: number) {
while (true) {
const batch = await getProductRiskTasksBatch([taskId])
const batch = await getProductRiskTaskProgressBatch([taskId])
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
const status = detail?.task?.status || ''
if (detail) {
const prev = taskSnapshots.value[taskId]
taskSnapshots.value = {
...taskSnapshots.value,
[taskId]: detail,
[taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail,
}
saveTaskSnapshotsToStorage()
}

View File

@@ -49,11 +49,11 @@
<label class="schedule-switch"><input v-model="scheduleEnabled" type="checkbox" :disabled="pushing" @change="onScheduleToggle" /><span>启用多时间点调度</span></label>
<div v-if="scheduleEnabled" class="schedule-config">
<div v-for="(value, index) in schedulePickerValues" :key="`schedule-${index}`" class="schedule-row">
<el-time-picker :model-value="value" value-format="HH:mm:ss" format="HH:mm" placeholder="选择执行时间" :disabled="pushing" :disabled-hours="disablePastScheduleHours" :disabled-minutes="disablePastScheduleMinutes" :disabled-seconds="disableScheduleSeconds" class="schedule-picker" @update:model-value="updateScheduleValue(index, $event)" />
<el-date-picker :model-value="value" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" format="MM-DD HH:mm" placeholder="选择执行时间" :disabled="pushing" class="schedule-picker" @update:model-value="updateScheduleValue(index, $event)" />
<button type="button" class="schedule-remove" :disabled="pushing || schedulePickerValues.length <= 1" @click="removeScheduleValue(index)">删除</button>
</div>
<button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button>
<p class="hint schedule-hint">默认开启定时只需选择 24 小时制的时分系统会自动按最近可执行时间顺序处理</p>
<p class="hint schedule-hint">默认开启定时支持按 `MM-DD HH:mm` 配置多个时间点前端不再限制选择更早时间实际是否可执行以后端校验为准</p>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
@@ -108,7 +108,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
@@ -128,7 +128,7 @@ const countryPrefSaving = ref(false)
const countryPrefUserTouched = ref(false)
let countryPrefSaveTimer: ReturnType<typeof setTimeout> | null = null
const scheduleEnabled = ref(true)
const schedulePickerValues = ref<string[]>([getMinimumScheduleTime()])
const schedulePickerValues = ref<string[]>([getDefaultScheduleValue()])
const dashboard = ref<ShopMatchDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
const historyItems = ref<ShopMatchHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([])
@@ -158,20 +158,27 @@ function rowKeyForMatch(row: ShopMatchShopQueueItem) { return `${(row.shopName |
function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item.taskId)}-${item.resultId || 0}-${item.shopName || ''}` }
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
function formatScheduleDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` }
function formatScheduleTime(date: Date) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}` }
function getMinimumScheduleDate() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return date }
function getMinimumScheduleTime() { return formatScheduleTime(getMinimumScheduleDate()) }
function parseTimeParts(value: string) { const matched = value.match(/^(\d{2}):(\d{2})(?::(\d{2}))?$/); if (!matched) return null; const hours = Number(matched[1]); const minutes = Number(matched[2]); const seconds = Number(matched[3] || '0'); if (hours > 23 || minutes > 59 || seconds > 59) return null; return { hours, minutes, seconds } }
function buildTodayTime(parts: { hours: number; minutes: number; seconds: number }) { const candidate = new Date(); candidate.setHours(parts.hours, parts.minutes, parts.seconds, 0); return candidate }
function resolveScheduleDateTime(value: string) { const parts = parseTimeParts(value); if (!parts) return null; const minimum = getMinimumScheduleDate(); const candidate = new Date(); candidate.setHours(parts.hours, parts.minutes, parts.seconds, 0); if (candidate.getTime() < minimum.getTime()) candidate.setDate(candidate.getDate() + 1); return candidate }
function nextMinuteString(offsetMinutes: number) { const date = getMinimumScheduleDate(); date.setMinutes(date.getMinutes() + Math.max(0, offsetMinutes - 1)); return formatScheduleTime(date) }
function nextMinuteAfter(value: string, offsetMinutes: number) { const date = resolveScheduleDateTime(value); if (!date) return nextMinuteString(offsetMinutes); date.setMinutes(date.getMinutes() + offsetMinutes); return formatScheduleTime(date) }
function normalizeScheduleValue(value: string | null | undefined, showWarning = false) { if (!value) return ''; const parts = parseTimeParts(value); if (!parts) return value; const minimum = getMinimumScheduleDate(); const candidate = buildTodayTime(parts); if (candidate.getTime() < minimum.getTime()) { if (showWarning) ElMessage.warning('执行时间不能早于当前时间,已自动调整到当前时间后 1 分钟'); return formatScheduleTime(minimum) } return formatScheduleTime(candidate) }
function disablePastScheduleHours() { const minimum = getMinimumScheduleDate(); return Array.from({ length: minimum.getHours() }, (_, index) => index) }
function disablePastScheduleMinutes(selectedHour: number) { const minimum = getMinimumScheduleDate(); if (selectedHour !== minimum.getHours()) return []; return Array.from({ length: minimum.getMinutes() }, (_, index) => index) }
function disableScheduleSeconds() { return Array.from({ length: 59 }, (_, index) => index + 1) }
function formatPickerDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:00` }
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return formatPickerDateTime(date) }
function parseScheduleDateTime(value?: string | null) {
if (!value) return null
const normalized = value.trim().replace('T', ' ')
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/)
if (!matched) return null
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
const hours = Number(matched[4])
const minutes = Number(matched[5])
const seconds = Number(matched[6] || '0')
const date = new Date(year, month - 1, day, hours, minutes, seconds, 0)
if (Number.isNaN(date.getTime())) return null
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
return date
}
function nextScheduleValueAfter(value: string, offsetMinutes: number) { const date = parseScheduleDateTime(value) || new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + offsetMinutes); return formatPickerDateTime(date) }
function formatDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
function formatTimeOnly(value?: string) { return value ? value.slice(11, 16) : '-' }
function formatMonthDayTime(value?: string) { const date = parseScheduleDateTime(value); if (!date) return '-'; const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${month}-${day} ${hours}:${minutes}` }
function uniqueTaskRows(rows: ShopMatchHistoryItem[], snapshots: Record<number, ShopMatchTaskDetailVo>, section: 'current' | 'history') { const map = new Map<string, ShopMatchHistoryItem>(); for (const row of rows) map.set(taskRowKey(row), row); for (const [taskIdText, snapshot] of Object.entries(snapshots)) { const taskId = normalizeTaskId(taskIdText); const first = snapshot.items?.[0]; if (!first || !taskId) continue; const terminal = isTaskTerminalById(taskId); if ((section === 'current' && terminal) || (section === 'history' && !terminal)) continue; const key = taskRowKey({ ...first, taskId }); if (!map.has(key)) map.set(key, { ...first, taskId }) } return Array.from(map.values()) }
function loadPollingIdsFromStorage() { try { const raw = window.localStorage.getItem(sessionKey()); const parsed = raw ? JSON.parse(raw) : []; pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((item): item is number => typeof item === 'number' && item > 0) : [] } catch { pollingTaskIds.value = [] } }
function savePollingIds() { setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0) }
@@ -228,36 +235,37 @@ function onCountryToggle(code: string, checked: boolean) { countryPrefUserTouche
function onCountryDragStart(index: number) { dragCountryIndex.value = index }
function onCountryDragEnd() { dragCountryIndex.value = null }
function onCountryDrop(toIndex: number) { const from = dragCountryIndex.value; dragCountryIndex.value = null; if (from == null || from === toIndex) return; countryPrefUserTouched.value = true; const list = [...orderedCountryCodes.value]; const [item] = list.splice(from, 1); list.splice(toIndex, 0, item); orderedCountryCodes.value = list; scheduleSaveCountryPreference() }
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getMinimumScheduleTime()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextMinuteAfter(last, 60) : getMinimumScheduleTime(); schedulePickerValues.value = [...schedulePickerValues.value, normalizeScheduleValue(nextValue)] }
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getDefaultScheduleValue()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextScheduleValueAfter(last, 60) : getDefaultScheduleValue(); schedulePickerValues.value = [...schedulePickerValues.value, nextValue] }
function removeScheduleValue(index: number) { if (schedulePickerValues.value.length <= 1) return; schedulePickerValues.value = schedulePickerValues.value.filter((_, idx) => idx !== index) }
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value ? normalizeScheduleValue(value, true) : ''; schedulePickerValues.value = list }
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value || ''; schedulePickerValues.value = list }
function mergeMatchedItems(base: ShopMatchShopQueueItem[], incoming: ShopMatchShopQueueItem[]) { const map = new Map<string, ShopMatchShopQueueItem>(); for (const item of base) map.set(rowKeyForMatch(item), item); for (const item of incoming) map.set(rowKeyForMatch(item), item); return Array.from(map.values()) }
async function runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const normalized = normalizeScheduleValue(value); const date = resolveScheduleDateTime(normalized); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const date = parseScheduleDateTime(value); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = new Date(stage.scheduledAt).getTime(); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } finally { scheduledDispatchInFlight.value = false } }
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = new Date(scheduledAt).getTime(); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4000 : 10000 }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 6000 : 20000 }
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTaskProgressBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatTimeOnly(stage.scheduledAt) } if (item.scheduledAt) return formatTimeOnly(item.scheduledAt); return '' }
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatMonthDayTime(stage.scheduledAt) } if (item.scheduledAt) return formatMonthDayTime(item.scheduledAt); return '' }
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && !!item.downloadUrl && (status === 'SUCCESS' || status === 'COMPLETED') }

View File

@@ -931,10 +931,13 @@ export interface PriceTrackShopQueueItem {
matchMessage?: string;
platform?: string;
companyName?: string;
skipAsins?: Record<string, string[]>;
}
export interface PriceTrackMatchShopsVo {
items: PriceTrackShopQueueItem[];
skipAsinsByCountry?: Record<string, string[]>;
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
}
export interface PriceTrackCountryPreferenceVo {
@@ -970,9 +973,25 @@ export interface PriceTrackHistoryVo {
items: PriceTrackHistoryItem[];
}
export interface PriceTrackAsinParsedRow {
shopMallName?: string;
asin?: string;
price?: string;
recommendedPrice?: string;
minimumPrice?: string;
firstPlace?: string;
secondPlace?: string;
cartShopName?: string;
priceChangeStatus?: string;
modifyCount?: string;
status?: string;
}
export interface PriceTrackCreateTaskVo {
taskId: number;
items: PriceTrackHistoryItem[];
skipAsinsByCountry?: Record<string, string[]>;
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
}
export interface PriceTrackTaskSummary {
@@ -1046,11 +1065,52 @@ export function putPriceTrackCountryPreference(countryCodes: string[]) {
);
}
export function matchPriceTrackShops(shopNames: string[]) {
export function matchPriceTrackShops(
shopNames: string[],
options?: {
asinFiles?: string[];
countryCodes?: string[];
},
) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackMatchShopsVo>, { userId: number; shopNames: string[] }>(
post<
JavaApiResponse<PriceTrackMatchShopsVo>,
{ userId: number; shopNames: string[]; asinFiles?: string[]; countryCodes?: string[] }
>(
`${JAVA_API_PREFIX}/price-track/match-shops`,
{ userId: getCurrentUserId(), shopNames },
{
userId: getCurrentUserId(),
shopNames,
asinFiles: options?.asinFiles || [],
countryCodes: options?.countryCodes || [],
},
),
);
}
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
{ taskIds },
),
);
}
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
{ taskIds },
),
);
}
export function getDeleteBrandTaskProgress(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<DeleteBrandTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
{ taskIds },
),
);
}
@@ -1116,6 +1176,15 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
);
}
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
{ taskIds },
),
);
}
export function getPriceTrackResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
}