完成匹配、跟价、权限部分

This commit is contained in:
super
2026-04-17 12:52:16 +08:00
parent ecc8b6ce6c
commit d5764f3b50
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 -> !resolvedHeaderMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
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++) {
writeLineToAll(writers, "\t".repeat(Math.max(0, headerColumns.size() - 1)));
}
}
List<String> missing = requiredColumns.stream()
.filter(column -> !headerMap.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));
for (int i = 0; i < blankHeaderRowsAfterSchema; i++) {
rows.add("\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) {
return ApiResponse.success(shopManageGroupService.list(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()) {
continue;
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(payloadFile.shopKey, payloadFile.payload);
}
out.put(key, objectMapper.readValue(val, ShopMatchShopPayloadDto.class));
}
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.");
}
Map<String, Integer> headerMap = buildHeaderMap(headerRow, formatter);
List<String> missing = request.getSelectedColumns().stream()
.filter(column -> !headerMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
List<List<String>> rowsData = new ArrayList<>();
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) {
continue;
}
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);
}
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));
}
}
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
outputWorkbook.write(fos);
}
}
results.add(new SplitChunkResult(outputFile, chunks.get(i).size()));
}
return results;
CountResult countResult = countSplitRows(inputFile, request.getSelectedColumns());
if (countResult.rowCount() <= 0) {
throw new BusinessException("No data rows available for splitting.");
}
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 -> !resolvedHeaderMap.containsKey(column))
.toList();
if (!missing.isEmpty()) {
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
}
headerMapHolder[0] = resolvedHeaderMap;
}
@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 = resolvedHeaderMap.get(column);
projected.add(sourceIndex == null ? "" : normalizeCellText(rowMap.get(sourceIndex)));
}
ChunkWriter writer = writers.get(chunkIndex[0]);
writer.writeRow(projected);
if (writer.isFull()) {
chunkIndex[0]++;
}
}
});
} finally {
IOException closeException = null;
for (ChunkWriter writer : writers) {
try {
writer.close();
} catch (IOException ex) {
if (closeException == null) {
closeException = ex;
}
}
}
if (closeException != null) {
throw closeException;
}
}
return results;
}
private Map<String, Integer> buildHeaderMap(Row headerRow, DataFormatter formatter) {
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;
}
@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();
}
}
}
}