完成匹配、跟价、权限部分
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,18 @@ public class DeleteBrandProgressProperties {
|
||||
* 商品风险(PRODUCT_RISK_RESOLVE)RUNNING 超时自动收尾/失败,按分钟计算;
|
||||
* 与删除品牌共用同一定时调度。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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("鐢熸垚鏂囦欢缂撳瓨閿け璐?");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ public class BrandTaskService {
|
||||
if (deleted == 0) {
|
||||
throw new BusinessException("任务不存在或正在执行中无法删除");
|
||||
}
|
||||
brandTaskProgressCacheService.delete(taskId);
|
||||
}
|
||||
|
||||
public String resolveDownloadUrl(Long taskId) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,6 @@ public class PermissionMenuCreateRequest {
|
||||
|
||||
@NotBlank(message = "菜单路由不能为空")
|
||||
private String routePath;
|
||||
|
||||
private Integer sortOrder;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,6 @@ public class PermissionMenuUpdateRequest {
|
||||
|
||||
@NotBlank(message = "菜单路由不能为空")
|
||||
private String routePath;
|
||||
|
||||
private Integer sortOrder;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ public class PermissionMenuEntity {
|
||||
private String columnKey;
|
||||
private String menuType;
|
||||
private String routePath;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ public class PermissionMenuItemVo {
|
||||
private String columnKey;
|
||||
private String menuType;
|
||||
private String routePath;
|
||||
private Integer sortOrder;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 为国家代码,例如 DE、UK、FR、IT、ES")
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = "匹配状态码,例如 MATCHED、PENDING、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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 回传处理结果",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,12 @@ aiimage:
|
||||
heartbeat-timeout-minutes: ${AIIMAGE_DELETE_BRAND_HEARTBEAT_TIMEOUT_MINUTES:15}
|
||||
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
||||
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
|
||||
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
|
||||
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
|
||||
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:10}
|
||||
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:10}
|
||||
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
|
||||
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
|
||||
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:20}
|
||||
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:20}
|
||||
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:20}
|
||||
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:20}
|
||||
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:20}
|
||||
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:20}
|
||||
module-cleanup:
|
||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_price_track_shop_candidate (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
|
||||
user_id BIGINT NOT NULL COMMENT 'user id',
|
||||
shop_name VARCHAR(255) NOT NULL COMMENT 'normalized shop name',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
|
||||
UNIQUE KEY uk_user_shop (user_id, shop_name),
|
||||
KEY idx_user_id (user_id)
|
||||
) COMMENT='price track candidate shops';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_price_track_country_pref (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY COMMENT 'user id',
|
||||
country_codes_json VARCHAR(256) NOT NULL COMMENT 'country code order json',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time'
|
||||
) COMMENT='price track country preference';
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE `columns`
|
||||
ADD COLUMN `sort_order` INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER `route_path`;
|
||||
|
||||
UPDATE `columns`
|
||||
SET `sort_order` = `id`
|
||||
WHERE `sort_order` = 0;
|
||||
@@ -0,0 +1,55 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @user_id_col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage_group'
|
||||
AND COLUMN_NAME = 'user_id'
|
||||
);
|
||||
|
||||
SET @sql_add_user_id_col := IF(@user_id_col_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage_group ADD COLUMN user_id BIGINT NULL COMMENT ''关联用户ID'' AFTER group_name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_user_id_col FROM @sql_add_user_id_col;
|
||||
EXECUTE stmt_add_user_id_col;
|
||||
DEALLOCATE PREPARE stmt_add_user_id_col;
|
||||
|
||||
UPDATE biz_shop_manage_group g
|
||||
LEFT JOIN users u ON u.username = g.group_name
|
||||
SET g.user_id = u.id
|
||||
WHERE g.user_id IS NULL
|
||||
AND u.id IS NOT NULL;
|
||||
|
||||
SET @user_id_idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage_group'
|
||||
AND INDEX_NAME = 'idx_user_id'
|
||||
);
|
||||
|
||||
SET @sql_add_user_id_idx := IF(@user_id_idx_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage_group ADD INDEX idx_user_id (user_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_user_id_idx FROM @sql_add_user_id_idx;
|
||||
EXECUTE stmt_add_user_id_idx;
|
||||
DEALLOCATE PREPARE stmt_add_user_id_idx;
|
||||
|
||||
SET @user_id_uk_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_shop_manage_group'
|
||||
AND INDEX_NAME = 'uk_user_id'
|
||||
);
|
||||
|
||||
SET @sql_add_user_id_uk := IF(@user_id_uk_exists = 0,
|
||||
'ALTER TABLE biz_shop_manage_group ADD UNIQUE KEY uk_user_id (user_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_user_id_uk FROM @sql_add_user_id_uk;
|
||||
EXECUTE stmt_add_user_id_uk;
|
||||
DEALLOCATE PREPARE stmt_add_user_id_uk;
|
||||
Reference in New Issue
Block a user