Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin
This commit is contained in:
Binary file not shown.
@@ -1,13 +1,11 @@
|
|||||||
package com.nanri.aiimage.common.api;
|
package com.nanri.aiimage.common.api;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
|
||||||
@Schema(description = "统一响应体")
|
@Schema(description = "统一响应体")
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
@@ -20,6 +18,20 @@ public class ApiResponse<T> {
|
|||||||
@Schema(description = "响应数据")
|
@Schema(description = "响应数据")
|
||||||
private T data;
|
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) {
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
return new ApiResponse<>(true, "操作成功", data);
|
return new ApiResponse<>(true, "操作成功", data);
|
||||||
}
|
}
|
||||||
@@ -31,4 +43,8 @@ public class ApiResponse<T> {
|
|||||||
public static <T> ApiResponse<T> fail(String message) {
|
public static <T> ApiResponse<T> fail(String message) {
|
||||||
return new ApiResponse<>(false, message, null);
|
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 {
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final Integer code;
|
||||||
|
|
||||||
public BusinessException(String message) {
|
public BusinessException(String message) {
|
||||||
|
this(null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(Integer code, String message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCode() {
|
||||||
|
return code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
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)
|
@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 超时自动收尾/失败,按分钟计算;
|
* 商品风险(PRODUCT_RISK_RESOLVE)RUNNING 超时自动收尾/失败,按分钟计算;
|
||||||
* 与删除品牌共用同一定时调度。
|
* 与删除品牌共用同一定时调度。
|
||||||
*/
|
*/
|
||||||
private long productRiskStaleTimeoutMinutes = 10;
|
private long productRiskStaleTimeoutMinutes = 20;
|
||||||
private long productRiskInitialTimeoutMinutes = 10;
|
private long productRiskInitialTimeoutMinutes = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Price track RUNNING timeout auto-finalize/fail threshold in minutes.
|
* Price track RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||||
*/
|
*/
|
||||||
private long priceTrackStaleTimeoutMinutes = 10;
|
private long priceTrackStaleTimeoutMinutes = 20;
|
||||||
private long priceTrackInitialTimeoutMinutes = 10;
|
private long priceTrackInitialTimeoutMinutes = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
|
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||||
*/
|
*/
|
||||||
private long shopMatchStaleTimeoutMinutes = 10;
|
private long shopMatchStaleTimeoutMinutes = 20;
|
||||||
private long shopMatchInitialTimeoutMinutes = 10;
|
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.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.security.MessageDigest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -94,21 +98,28 @@ public class BrandTaskProgressCacheService {
|
|||||||
|
|
||||||
public void saveParsedPayload(Long taskId, Object payload) {
|
public void saveParsedPayload(Long taskId, Object payload) {
|
||||||
try {
|
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) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("暂存品牌原始数据失败");
|
throw new BusinessException("鏆傚瓨鍝佺墝鍘熷鏁版嵁澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T getParsedPayload(Long taskId, TypeReference<T> typeReference) {
|
public <T> T getParsedPayload(Long taskId, TypeReference<T> typeReference) {
|
||||||
String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId));
|
Path payloadFile = buildPayloadFile(taskId);
|
||||||
if (raw == null || raw.isBlank()) {
|
if (!Files.isRegularFile(payloadFile)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(raw, typeReference);
|
return objectMapper.readValue(Files.readString(payloadFile), typeReference);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("读取品牌原始数据失败");
|
throw new BusinessException("璇诲彇鍝佺墝鍘熷鏁版嵁澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +137,7 @@ public class BrandTaskProgressCacheService {
|
|||||||
int finishedFiles = countCompletedFiles(taskId);
|
int finishedFiles = countCompletedFiles(taskId);
|
||||||
return new ChunkStoreResult(false, false, finishedFiles, aggregate);
|
return new ChunkStoreResult(false, false, finishedFiles, aggregate);
|
||||||
}
|
}
|
||||||
throw new BusinessException("同一分片重复提交但内容不一致: " + fileUrl + "#" + file.getChunkIndex());
|
throw new BusinessException("鍚屼竴鍒嗙墖閲嶅鎻愪氦浣嗗唴瀹逛笉涓€鑷? " + fileUrl + "#" + file.getChunkIndex());
|
||||||
}
|
}
|
||||||
|
|
||||||
BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, fileUrl);
|
BrandFileAggregateCacheDto aggregate = getFileAggregate(taskId, fileUrl);
|
||||||
@@ -174,7 +185,7 @@ public class BrandTaskProgressCacheService {
|
|||||||
try {
|
try {
|
||||||
return objectMapper.readValue(json, BrandFileAggregateCacheDto.class);
|
return objectMapper.readValue(json, BrandFileAggregateCacheDto.class);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("读取品牌文件聚合缓存失败");
|
throw new BusinessException("璇诲彇鍝佺墝鏂囦欢鑱氬悎缂撳瓨澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +220,11 @@ public class BrandTaskProgressCacheService {
|
|||||||
|
|
||||||
public void delete(Long taskId) {
|
public void delete(Long taskId) {
|
||||||
stringRedisTemplate.delete(buildKey(taskId));
|
stringRedisTemplate.delete(buildKey(taskId));
|
||||||
stringRedisTemplate.delete(buildPayloadKey(taskId));
|
|
||||||
stringRedisTemplate.delete(buildFileAggregateKey(taskId));
|
stringRedisTemplate.delete(buildFileAggregateKey(taskId));
|
||||||
stringRedisTemplate.delete(buildCompletedFilesKey(taskId));
|
stringRedisTemplate.delete(buildCompletedFilesKey(taskId));
|
||||||
stringRedisTemplate.delete(buildFinalizeLockKey(taskId));
|
stringRedisTemplate.delete(buildFinalizeLockKey(taskId));
|
||||||
stringRedisTemplate.delete(buildLegacyResultKey(taskId));
|
stringRedisTemplate.delete(buildLegacyResultKey(taskId));
|
||||||
|
deleteTaskDirQuietly(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteFileState(Long taskId, String fileUrl) {
|
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));
|
stringRedisTemplate.opsForHash().put(buildFileAggregateKey(taskId), normalizeFileUrl(aggregate.getFileUrl()), objectMapper.writeValueAsString(aggregate));
|
||||||
refreshTaskKey(buildFileAggregateKey(taskId));
|
refreshTaskKey(buildFileAggregateKey(taskId));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("暂存品牌文件聚合结果失败");
|
throw new BusinessException("鏆傚瓨鍝佺墝鏂囦欢鑱氬悎缁撴灉澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateChunk(BrandCrawlResultFileDto file) {
|
private void validateChunk(BrandCrawlResultFileDto file) {
|
||||||
if (file == null) {
|
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()) {
|
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()) {
|
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) {
|
private void ensureChunkConsistency(BrandFileAggregateCacheDto aggregate, BrandCrawlResultFileDto file) {
|
||||||
if (aggregate.getChunkTotal() != null && file.getChunkTotal() != null && !aggregate.getChunkTotal().equals(file.getChunkTotal())) {
|
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) {
|
if (aggregate.getChunkTotal() == null) {
|
||||||
aggregate.setChunkTotal(file.getChunkTotal());
|
aggregate.setChunkTotal(file.getChunkTotal());
|
||||||
@@ -322,7 +333,7 @@ public class BrandTaskProgressCacheService {
|
|||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("计算品牌结果分片摘要失败");
|
throw new BusinessException("璁$畻鍝佺墝缁撴灉鍒嗙墖鎽樿澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,8 +351,28 @@ public class BrandTaskProgressCacheService {
|
|||||||
return Duration.ofHours(brandProgressProperties.getTtlHours());
|
return Duration.ofHours(brandProgressProperties.getTtlHours());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildPayloadKey(Long taskId) {
|
private Path buildTaskDir(Long taskId) {
|
||||||
return "brand:task:parsed-payload:" + 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) {
|
private String buildFileAggregateKey(Long taskId) {
|
||||||
@@ -383,7 +414,7 @@ public class BrandTaskProgressCacheService {
|
|||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("生成文件缓存键失败");
|
throw new BusinessException("鐢熸垚鏂囦欢缂撳瓨閿け璐?");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ public class BrandTaskService {
|
|||||||
if (deleted == 0) {
|
if (deleted == 0) {
|
||||||
throw new BusinessException("任务不存在或正在执行中无法删除");
|
throw new BusinessException("任务不存在或正在执行中无法删除");
|
||||||
}
|
}
|
||||||
|
brandTaskProgressCacheService.delete(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolveDownloadUrl(Long 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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.common.util.ExcelStreamReader;
|
||||||
import com.nanri.aiimage.config.StorageProperties;
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest;
|
import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest;
|
||||||
import com.nanri.aiimage.modules.convert.model.dto.UploadedSourceFileDto;
|
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.FileResultEntity;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedWriter;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -242,14 +238,30 @@ public class ConvertRunService {
|
|||||||
|
|
||||||
private List<GeneratedConvertFile> generateOutputFiles(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
private List<GeneratedConvertFile> generateOutputFiles(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||||
List<String> outputFilenames = resolveOutputFilenames(templateEntity);
|
List<String> outputFilenames = resolveOutputFilenames(templateEntity);
|
||||||
List<String> rows = buildTxtRows(inputFile, templateEntity);
|
|
||||||
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||||
|
|
||||||
List<GeneratedConvertFile> generatedFiles = new ArrayList<>();
|
List<GeneratedConvertFile> generatedFiles = new ArrayList<>();
|
||||||
|
Map<String, BufferedWriter> writers = new LinkedHashMap<>();
|
||||||
for (String outputFilename : outputFilenames) {
|
for (String outputFilename : outputFilenames) {
|
||||||
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||||
FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8);
|
|
||||||
generatedFiles.add(new GeneratedConvertFile(outputFilename, outputFile));
|
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;
|
return generatedFiles;
|
||||||
}
|
}
|
||||||
@@ -266,7 +278,9 @@ public class ConvertRunService {
|
|||||||
return List.of(outputFilename);
|
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> requiredColumns = readStringList(templateEntity.getRequiredSourceColumnsJson());
|
||||||
List<String> headerColumns = readStringList(templateEntity.getHeaderColumnsJson());
|
List<String> headerColumns = readStringList(templateEntity.getHeaderColumnsJson());
|
||||||
Map<String, String> fieldMapping = readStringMap(templateEntity.getFieldMappingJson());
|
Map<String, String> fieldMapping = readStringMap(templateEntity.getFieldMappingJson());
|
||||||
@@ -275,65 +289,60 @@ public class ConvertRunService {
|
|||||||
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null
|
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null
|
||||||
? 0
|
? 0
|
||||||
: templateEntity.getBlankHeaderRowsAfterSchema();
|
: 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();
|
ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() {
|
||||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
@Override
|
||||||
Workbook workbook = WorkbookFactory.create(fis)) {
|
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
|
||||||
Sheet sheet = workbook.getSheetAt(0);
|
Map<String, Integer> resolvedHeaderMap = new LinkedHashMap<>();
|
||||||
Row headerRow = sheet.getRow(0);
|
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
|
||||||
if (headerRow == null) {
|
String value = normalizeCellText(entry.getValue());
|
||||||
throw new BusinessException("Excel header row is empty.");
|
if (!value.isBlank() && !resolvedHeaderMap.containsKey(value)) {
|
||||||
}
|
resolvedHeaderMap.put(value, entry.getKey());
|
||||||
|
}
|
||||||
Map<String, Integer> headerMap = new LinkedHashMap<>();
|
}
|
||||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
List<String> missing = requiredColumns.stream()
|
||||||
String value = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i)));
|
.filter(column -> !resolvedHeaderMap.containsKey(column))
|
||||||
if (!value.isBlank() && !headerMap.containsKey(value)) {
|
.toList();
|
||||||
headerMap.put(value, i);
|
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()
|
@Override
|
||||||
.filter(column -> !headerMap.containsKey(column))
|
public void onRow(String sheetName, Integer sheetNo, int excelRowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception {
|
||||||
.toList();
|
Map<String, Integer> resolvedHeaderMap = headerMapHolder[0];
|
||||||
if (!missing.isEmpty()) {
|
if (resolvedHeaderMap == null) {
|
||||||
throw new BusinessException("Missing required columns: " + String.join(", ", missing));
|
throw new BusinessException("Excel header row is empty.");
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
int asinIndex = resolvedHeaderMap.getOrDefault("ASIN", -1);
|
||||||
String asin = asinIndex >= 0 ? normalizeCellText(formatter.formatCellValue(row.getCell(asinIndex))) : "";
|
String asin = asinIndex >= 0 ? normalizeCellText(rowMap.get(asinIndex)) : "";
|
||||||
if (asin.isBlank()) {
|
if (asin.isBlank()) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> lineValues = new ArrayList<>();
|
List<String> lineValues = new ArrayList<>();
|
||||||
for (String column : headerColumns) {
|
for (String column : headerColumns) {
|
||||||
if ("sku".equals(column)) {
|
if ("sku".equals(column)) {
|
||||||
lineValues.add(batchId + "-" + rowIndex);
|
lineValues.add(batchId + "-" + rowIndex[0]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (fieldMapping.containsKey(column)) {
|
if (fieldMapping.containsKey(column)) {
|
||||||
String sourceColumn = fieldMapping.get(column);
|
String sourceColumn = fieldMapping.get(column);
|
||||||
Integer sourceIndex = headerMap.get(sourceColumn);
|
Integer sourceIndex = resolvedHeaderMap.get(sourceColumn);
|
||||||
String value = sourceIndex == null
|
lineValues.add(sourceIndex == null ? "" : normalizeCellText(rowMap.get(sourceIndex)));
|
||||||
? ""
|
|
||||||
: normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
|
||||||
lineValues.add(value);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (defaults.containsKey(column)) {
|
if (defaults.containsKey(column)) {
|
||||||
@@ -343,10 +352,19 @@ public class ConvertRunService {
|
|||||||
lineValues.add("");
|
lineValues.add("");
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.add(String.join("\t", lineValues));
|
writeLineToAll(writers, String.join("\t", lineValues));
|
||||||
rowIndex++;
|
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.Row;
|
||||||
import org.apache.poi.ss.usermodel.Sheet;
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
import org.apache.poi.ss.usermodel.Workbook;
|
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.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -232,7 +232,7 @@ public class DedupeRunService {
|
|||||||
DataFormatter formatter = new DataFormatter();
|
DataFormatter formatter = new DataFormatter();
|
||||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
try (FileInputStream fis = new FileInputStream(inputFile);
|
||||||
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis);
|
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis);
|
||||||
XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
|
SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) {
|
||||||
Sheet sheet = workbook.getSheetAt(0);
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
Row headerRow = sheet.getRow(0);
|
Row headerRow = sheet.getRow(0);
|
||||||
if (headerRow == null) {
|
if (headerRow == null) {
|
||||||
@@ -275,7 +275,6 @@ public class DedupeRunService {
|
|||||||
// The preliminary global scan for mainIdHasSubIdMap was removed.
|
// The preliminary global scan for mainIdHasSubIdMap was removed.
|
||||||
// We now determine subset existence contextually (per group) during the main loop.
|
// We now determine subset existence contextually (per group) during the main loop.
|
||||||
}
|
}
|
||||||
List<Row> candidateRows = new ArrayList<>();
|
|
||||||
Set<String> candidateAsinValues = new HashSet<>();
|
Set<String> candidateAsinValues = new HashSet<>();
|
||||||
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||||
Row row = sheet.getRow(rowNum);
|
Row row = sheet.getRow(rowNum);
|
||||||
@@ -288,7 +287,6 @@ public class DedupeRunService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
candidateRows.add(row);
|
|
||||||
if (asinColumnIndex != null) {
|
if (asinColumnIndex != null) {
|
||||||
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
|
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
|
||||||
if (!asinValue.isBlank()) {
|
if (!asinValue.isBlank()) {
|
||||||
@@ -300,7 +298,17 @@ public class DedupeRunService {
|
|||||||
Set<String> matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues);
|
Set<String> matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues);
|
||||||
Set<String> writtenAsinValues = new HashSet<>();
|
Set<String> writtenAsinValues = new HashSet<>();
|
||||||
int outputRowIndex = 1;
|
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) {
|
if (asinColumnIndex != null) {
|
||||||
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
|
String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex)));
|
||||||
if (!asinValue.isBlank()) {
|
if (!asinValue.isBlank()) {
|
||||||
@@ -324,6 +332,7 @@ public class DedupeRunService {
|
|||||||
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
|
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
|
||||||
outputWorkbook.write(fos);
|
outputWorkbook.write(fos);
|
||||||
}
|
}
|
||||||
|
outputWorkbook.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.File;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -114,9 +116,9 @@ public class DedupeTotalDataService {
|
|||||||
importProgressMap.put(importId, progress);
|
importProgressMap.put(importId, progress);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] fileBytes = file.getBytes();
|
File tempFile = saveMultipartToTempFile(file);
|
||||||
String filename = file.getOriginalFilename();
|
String filename = file.getOriginalFilename();
|
||||||
Thread.ofVirtual().start(() -> runImportTask(importId, fileBytes, filename));
|
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
importProgressMap.remove(importId);
|
importProgressMap.remove(importId);
|
||||||
throw new BusinessException("读取上传文件失败");
|
throw new BusinessException("读取上传文件失败");
|
||||||
@@ -150,9 +152,9 @@ public class DedupeTotalDataService {
|
|||||||
deleteImportProgressMap.put(importId, progress);
|
deleteImportProgressMap.put(importId, progress);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] fileBytes = file.getBytes();
|
File tempFile = saveMultipartToTempFile(file);
|
||||||
String filename = file.getOriginalFilename();
|
String filename = file.getOriginalFilename();
|
||||||
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, fileBytes, filename));
|
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
deleteImportProgressMap.remove(importId);
|
deleteImportProgressMap.remove(importId);
|
||||||
throw new BusinessException("读取上传文件失败");
|
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
|
@Transactional
|
||||||
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
|
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ public class DeleteBrandRunController {
|
|||||||
return ApiResponse.success(deleteBrandRunService.getTaskDetails(request.getTaskIds()));
|
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")
|
@GetMapping("/tasks/{taskId}/deletion-status")
|
||||||
@Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。")
|
@Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。")
|
||||||
public ApiResponse<DeleteBrandTaskDeletionStatusVo> getTaskDeletionStatus(
|
public ApiResponse<DeleteBrandTaskDeletionStatusVo> getTaskDeletionStatus(
|
||||||
|
|||||||
@@ -544,10 +544,87 @@ public class DeleteBrandRunService {
|
|||||||
return vo;
|
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) {
|
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task) {
|
||||||
return buildTaskDetail(task, null);
|
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) {
|
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
|
||||||
task = refreshIndexedMatchesIfNeeded(task);
|
task = refreshIndexedMatchesIfNeeded(task);
|
||||||
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
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.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -34,6 +39,8 @@ public class DeleteBrandStaleTaskService {
|
|||||||
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
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 STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||||
private static final Duration FINALIZE_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 FileTaskMapper fileTaskMapper;
|
||||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||||
@@ -289,7 +296,7 @@ public class DeleteBrandStaleTaskService {
|
|||||||
log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
|
log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
|
||||||
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||||
for (FileTaskEntity task : runningTasks) {
|
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)
|
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|
||||||
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
||||||
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
|
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 static final class ProductRiskStaleCheckStats {
|
||||||
private int scannedTaskCount;
|
private int scannedTaskCount;
|
||||||
private int finalizedTaskCount;
|
private int finalizedTaskCount;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
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.time.Duration;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@@ -28,10 +31,17 @@ public class DeleteBrandTaskCacheService {
|
|||||||
|
|
||||||
public void saveParsedPayload(Long taskId, Object payload) {
|
public void saveParsedPayload(Long taskId, Object payload) {
|
||||||
try {
|
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));
|
parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(System.currentTimeMillis(), payload));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("暂存删除品牌原始数据失败");
|
throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝鍘熷鏁版嵁澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,21 +56,21 @@ public class DeleteBrandTaskCacheService {
|
|||||||
try {
|
try {
|
||||||
return objectMapper.convertValue(cached.value(), typeReference);
|
return objectMapper.convertValue(cached.value(), typeReference);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
// fallthrough to redis
|
// fall through to file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String raw = stringRedisTemplate.opsForValue().get(buildPayloadKey(taskId));
|
Path payloadFile = buildPayloadFile(taskId);
|
||||||
if (raw == null || raw.isBlank()) {
|
if (!Files.isRegularFile(payloadFile)) {
|
||||||
parsedPayloadLocalCache.remove(taskId);
|
parsedPayloadLocalCache.remove(taskId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
T parsed = objectMapper.readValue(raw, typeReference);
|
T parsed = objectMapper.readValue(Files.readString(payloadFile), typeReference);
|
||||||
parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(now, parsed));
|
parsedPayloadLocalCache.put(taskId, new LocalParsedPayloadCacheEntry(now, parsed));
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (Exception ex) {
|
} 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) {
|
public boolean hasSameResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
|
||||||
if (chunkIndex == null || chunkIndex <= 0) {
|
if (chunkIndex == null || chunkIndex <= 0) {
|
||||||
throw new BusinessException("chunkIndex 不合法");
|
throw new BusinessException("chunkIndex 涓嶅悎娉?");
|
||||||
}
|
}
|
||||||
if (fileIdentity == null || fileIdentity.isBlank()) {
|
if (fileIdentity == null || fileIdentity.isBlank()) {
|
||||||
throw new BusinessException("fileIdentity 不能为空");
|
throw new BusinessException("fileIdentity 涓嶈兘涓虹┖");
|
||||||
}
|
}
|
||||||
String field = fileIdentity.trim() + "#" + chunkIndex;
|
String field = fileIdentity.trim() + "#" + chunkIndex;
|
||||||
String chunkJson;
|
String chunkJson;
|
||||||
try {
|
try {
|
||||||
chunkJson = objectMapper.writeValueAsString(chunk);
|
chunkJson = objectMapper.writeValueAsString(chunk);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("读取删除品牌结果分片失败");
|
throw new BusinessException("璇诲彇鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触");
|
||||||
}
|
}
|
||||||
Object existing = stringRedisTemplate.opsForHash().get(buildResultChunksKey(taskId), field);
|
Object existing = stringRedisTemplate.opsForHash().get(buildResultChunksKey(taskId), field);
|
||||||
if (chunkJson.equals(existing)) {
|
if (chunkJson.equals(existing)) {
|
||||||
@@ -137,17 +147,17 @@ public class DeleteBrandTaskCacheService {
|
|||||||
public boolean mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
|
public boolean mergeResultChunk(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
|
||||||
String resultKey = buildResultChunksKey(taskId);
|
String resultKey = buildResultChunksKey(taskId);
|
||||||
if (chunkIndex == null || chunkIndex <= 0) {
|
if (chunkIndex == null || chunkIndex <= 0) {
|
||||||
throw new BusinessException("chunkIndex 不合法");
|
throw new BusinessException("chunkIndex 涓嶅悎娉?");
|
||||||
}
|
}
|
||||||
if (fileIdentity == null || fileIdentity.isBlank()) {
|
if (fileIdentity == null || fileIdentity.isBlank()) {
|
||||||
throw new BusinessException("fileIdentity 不能为空");
|
throw new BusinessException("fileIdentity 涓嶈兘涓虹┖");
|
||||||
}
|
}
|
||||||
String field = fileIdentity.trim() + "#" + chunkIndex;
|
String field = fileIdentity.trim() + "#" + chunkIndex;
|
||||||
String chunkJson;
|
String chunkJson;
|
||||||
try {
|
try {
|
||||||
chunkJson = objectMapper.writeValueAsString(chunk);
|
chunkJson = objectMapper.writeValueAsString(chunk);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("暂存删除品牌结果分片失败");
|
throw new BusinessException("鏆傚瓨鍒犻櫎鍝佺墝缁撴灉鍒嗙墖澶辫触");
|
||||||
}
|
}
|
||||||
Object existing = stringRedisTemplate.opsForHash().get(resultKey, field);
|
Object existing = stringRedisTemplate.opsForHash().get(resultKey, field);
|
||||||
if (chunkJson.equals(existing)) {
|
if (chunkJson.equals(existing)) {
|
||||||
@@ -178,14 +188,16 @@ public class DeleteBrandTaskCacheService {
|
|||||||
|
|
||||||
public void delete(Long taskId) {
|
public void delete(Long taskId) {
|
||||||
parsedPayloadLocalCache.remove(taskId);
|
parsedPayloadLocalCache.remove(taskId);
|
||||||
stringRedisTemplate.delete(buildPayloadKey(taskId));
|
|
||||||
stringRedisTemplate.delete(buildProgressKey(taskId));
|
stringRedisTemplate.delete(buildProgressKey(taskId));
|
||||||
stringRedisTemplate.delete(buildResultChunksKey(taskId));
|
stringRedisTemplate.delete(buildResultChunksKey(taskId));
|
||||||
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
||||||
|
deleteTaskDirQuietly(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveTaskCache(com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task) {
|
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 {
|
try {
|
||||||
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||||
} catch (Exception ignored) {
|
} 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) {
|
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<>();
|
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();
|
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> keys = normalized.stream().map(this::buildTaskEntityKey).toList();
|
||||||
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
|
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||||
@@ -215,6 +231,30 @@ public class DeleteBrandTaskCacheService {
|
|||||||
return result;
|
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) {
|
private String buildTaskEntityKey(Long taskId) {
|
||||||
return "delete-brand:task:entity:" + 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) {
|
private String buildProgressKey(Long taskId) {
|
||||||
return "delete-brand:task:progress:" + taskId;
|
return "delete-brand:task:progress:" + taskId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ public class PermissionMenuCreateRequest {
|
|||||||
|
|
||||||
@NotBlank(message = "菜单路由不能为空")
|
@NotBlank(message = "菜单路由不能为空")
|
||||||
private String routePath;
|
private String routePath;
|
||||||
|
|
||||||
|
private Integer sortOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ public class PermissionMenuUpdateRequest {
|
|||||||
|
|
||||||
@NotBlank(message = "菜单路由不能为空")
|
@NotBlank(message = "菜单路由不能为空")
|
||||||
private String routePath;
|
private String routePath;
|
||||||
|
|
||||||
|
private Integer sortOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.permission.model.entity;
|
package com.nanri.aiimage.modules.permission.model.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -11,5 +12,8 @@ public class AdminUserEntity {
|
|||||||
|
|
||||||
@TableId(type = IdType.AUTO)
|
@TableId(type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private String username;
|
||||||
private String role;
|
private String role;
|
||||||
|
@TableField("created_by_id")
|
||||||
|
private Long createdById;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ public class PermissionMenuEntity {
|
|||||||
private String columnKey;
|
private String columnKey;
|
||||||
private String menuType;
|
private String menuType;
|
||||||
private String routePath;
|
private String routePath;
|
||||||
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ public class PermissionMenuItemVo {
|
|||||||
private String columnKey;
|
private String columnKey;
|
||||||
private String menuType;
|
private String menuType;
|
||||||
private String routePath;
|
private String routePath;
|
||||||
|
private Integer sortOrder;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ import java.util.List;
|
|||||||
public class PermissionMenuSchemaInitializer {
|
public class PermissionMenuSchemaInitializer {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||||
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data"),
|
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage"),
|
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
||||||
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin")
|
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
|
@PostConstruct
|
||||||
@@ -25,23 +31,19 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
executeQuietly("""
|
executeQuietly("""
|
||||||
CREATE TABLE IF NOT EXISTS columns (
|
CREATE TABLE IF NOT EXISTS columns (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(128) NOT NULL COMMENT '栏目名',
|
name VARCHAR(128) NOT NULL COMMENT '菜单标题',
|
||||||
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
|
column_key VARCHAR(64) NOT NULL COMMENT '菜单唯一标识',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uk_column_key (column_key)
|
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 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 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 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();
|
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("""
|
executeQuietly("""
|
||||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
@@ -56,12 +58,17 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
private void ensureDefaultAdminMenus() {
|
private void ensureDefaultAdminMenus() {
|
||||||
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
|
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
|
||||||
executeQuietly("""
|
executeQuietly("""
|
||||||
INSERT INTO columns (name, column_key, menu_type, route_path)
|
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||||
SELECT '%s', '%s', 'admin', '%s'
|
SELECT '%s', '%s', 'admin', '%s', %d
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM columns WHERE column_key = '%s'
|
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 org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -41,6 +42,7 @@ public class PermissionMenuService {
|
|||||||
public List<PermissionMenuItemVo> list(String menuType) {
|
public List<PermissionMenuItemVo> list(String menuType) {
|
||||||
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
|
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||||
.eq(isValidMenuType(menuType), PermissionMenuEntity::getMenuType, normalizeMenuType(menuType))
|
.eq(isValidMenuType(menuType), PermissionMenuEntity::getMenuType, normalizeMenuType(menuType))
|
||||||
|
.orderByAsc(PermissionMenuEntity::getSortOrder)
|
||||||
.orderByAsc(PermissionMenuEntity::getId);
|
.orderByAsc(PermissionMenuEntity::getId);
|
||||||
return permissionMenuMapper.selectList(query).stream()
|
return permissionMenuMapper.selectList(query).stream()
|
||||||
.map(this::toItemVo)
|
.map(this::toItemVo)
|
||||||
@@ -54,12 +56,14 @@ public class PermissionMenuService {
|
|||||||
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
||||||
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
||||||
ensureUniqueColumnKey(columnKey, null);
|
ensureUniqueColumnKey(columnKey, null);
|
||||||
|
ensureUniqueRoutePath(menuType, routePath, null);
|
||||||
|
|
||||||
PermissionMenuEntity entity = new PermissionMenuEntity();
|
PermissionMenuEntity entity = new PermissionMenuEntity();
|
||||||
entity.setName(name);
|
entity.setName(name);
|
||||||
entity.setColumnKey(columnKey);
|
entity.setColumnKey(columnKey);
|
||||||
entity.setMenuType(menuType);
|
entity.setMenuType(menuType);
|
||||||
entity.setRoutePath(routePath);
|
entity.setRoutePath(routePath);
|
||||||
|
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), null));
|
||||||
permissionMenuMapper.insert(entity);
|
permissionMenuMapper.insert(entity);
|
||||||
return toItemVo(getMenuById(entity.getId()));
|
return toItemVo(getMenuById(entity.getId()));
|
||||||
}
|
}
|
||||||
@@ -72,11 +76,13 @@ public class PermissionMenuService {
|
|||||||
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
||||||
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
||||||
ensureUniqueColumnKey(columnKey, id);
|
ensureUniqueColumnKey(columnKey, id);
|
||||||
|
ensureUniqueRoutePath(menuType, routePath, id);
|
||||||
|
|
||||||
entity.setName(name);
|
entity.setName(name);
|
||||||
entity.setColumnKey(columnKey);
|
entity.setColumnKey(columnKey);
|
||||||
entity.setMenuType(menuType);
|
entity.setMenuType(menuType);
|
||||||
entity.setRoutePath(routePath);
|
entity.setRoutePath(routePath);
|
||||||
|
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), id));
|
||||||
permissionMenuMapper.updateById(entity);
|
permissionMenuMapper.updateById(entity);
|
||||||
return toItemVo(getMenuById(id));
|
return toItemVo(getMenuById(id));
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,9 @@ public class PermissionMenuService {
|
|||||||
.map(menuMap::get)
|
.map(menuMap::get)
|
||||||
.filter(menu -> menu != null)
|
.filter(menu -> menu != null)
|
||||||
.filter(menu -> safeMenuType == null || safeMenuType.equals(menu.getMenuType()))
|
.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)
|
.map(this::toItemVo)
|
||||||
.toList();
|
.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) {
|
private String normalizeRequired(String value, String message) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = value == null ? "" : value.trim();
|
||||||
if (normalized.isEmpty() && !message.isEmpty()) {
|
if (normalized.isEmpty() && !message.isEmpty()) {
|
||||||
@@ -247,7 +266,21 @@ public class PermissionMenuService {
|
|||||||
vo.setColumnKey(entity.getColumnKey());
|
vo.setColumnKey(entity.getColumnKey());
|
||||||
vo.setMenuType(entity.getMenuType());
|
vo.setMenuType(entity.getMenuType());
|
||||||
vo.setRoutePath(entity.getRoutePath());
|
vo.setRoutePath(entity.getRoutePath());
|
||||||
|
vo.setSortOrder(entity.getSortOrder());
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
return vo;
|
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")
|
@RequestMapping("/api/price-track")
|
||||||
@Tag(
|
@Tag(
|
||||||
name = "跟价",
|
name = "跟价",
|
||||||
description = "亚马逊跟价处理模块:包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需要携带 Query 参数 user_id。")
|
description = "亚马逊跟价处理模块,包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需携带 query 参数 user_id。"
|
||||||
|
)
|
||||||
public class PriceTrackController {
|
public class PriceTrackController {
|
||||||
|
|
||||||
private final PriceTrackService priceTrackService;
|
private final PriceTrackService priceTrackService;
|
||||||
@@ -62,13 +63,13 @@ public class PriceTrackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/candidates")
|
@PostMapping("/candidates")
|
||||||
@Operation(summary = "新增备选店铺", description = "把店铺名称写入当前用户的备选店铺列表,供后续匹配和创建任务使用。")
|
@Operation(summary = "新增备选店铺", description = "新增一条备选店铺记录,供后续匹配和创建任务使用。")
|
||||||
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
|
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
|
||||||
return ApiResponse.success(priceTrackService.addCandidate(request));
|
return ApiResponse.success(priceTrackService.addCandidate(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/candidates/{id}")
|
@DeleteMapping("/candidates/{id}")
|
||||||
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,id 必须属于当前 user_id。")
|
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,记录必须属于当前 user_id。")
|
||||||
public ApiResponse<Void> deleteCandidate(
|
public ApiResponse<Void> deleteCandidate(
|
||||||
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
|
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
|
||||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||||
@@ -107,7 +108,7 @@ public class PriceTrackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/history")
|
@GetMapping("/history")
|
||||||
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果行,包含运行中和历史记录。")
|
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果记录,包含运行中和历史记录。")
|
||||||
public ApiResponse<PriceTrackHistoryVo> history(
|
public ApiResponse<PriceTrackHistoryVo> history(
|
||||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||||
@RequestParam("user_id") Long userId) {
|
@RequestParam("user_id") Long userId) {
|
||||||
@@ -115,7 +116,7 @@ public class PriceTrackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/history/{resultId}")
|
@DeleteMapping("/history/{resultId}")
|
||||||
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result,并同步重算父任务状态。")
|
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result 结果记录,并同步重算父任务状态。")
|
||||||
public ApiResponse<Void> deleteHistory(
|
public ApiResponse<Void> deleteHistory(
|
||||||
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
|
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
|
||||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||||
@@ -131,7 +132,7 @@ public class PriceTrackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/tasks/{taskId}")
|
@DeleteMapping("/tasks/{taskId}")
|
||||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下属全部结果行,RUNNING、SUCCESS、FAILED 均可。")
|
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行,RUNNING、SUCCESS、FAILED 均可删除。")
|
||||||
public ApiResponse<Void> deleteTask(
|
public ApiResponse<Void> deleteTask(
|
||||||
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
|
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
|
||||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||||
@@ -151,11 +152,17 @@ public class PriceTrackController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/tasks/batch")
|
@PostMapping("/tasks/batch")
|
||||||
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端轮询使用。")
|
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端查询使用。")
|
||||||
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
|
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
|
||||||
return ApiResponse.success(priceTrackTaskService.getTaskDetailsBatch(request.getTaskIds()));
|
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")
|
@PostMapping("/tasks/{taskId}/result")
|
||||||
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
|
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
|
||||||
public ApiResponse<Void> submitResult(
|
public ApiResponse<Void> submitResult(
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ import lombok.Data;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "批量匹配店铺请求体:按店铺名查询紫鸟索引,返回是否命中及店铺元数据")
|
@Schema(description = "Price track match shops request")
|
||||||
public class PriceTrackMatchShopsRequest {
|
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;
|
private Long userId;
|
||||||
|
|
||||||
@NotEmpty(message = "店铺列表不能为空")
|
@NotEmpty(message = "shopNames cannot be empty")
|
||||||
@Schema(description = "待匹配的店铺名称列表,至少 1 个;返回体 items 与列表顺序对应", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
|
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<String> shopNames;
|
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;
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "Python 回传跟价结果请求体:按店铺提交各国家 ASIN 跟价结果或错误信息")
|
@Schema(description = "Python submit payload for price track task results")
|
||||||
public class PriceTrackSubmitResultRequest {
|
public class PriceTrackSubmitResultRequest {
|
||||||
@Schema(description = "本次回传涉及的店铺列表,至少 1 个,店名需要能和任务结果行匹配", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "Shop result list", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<ShopResult> shops;
|
private List<ShopResult> shops;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "单个店铺回传结果:成功时提供 countries,失败时可只提供 error")
|
@Schema(description = "Per-shop submit result")
|
||||||
public static class ShopResult {
|
public static class ShopResult {
|
||||||
@Schema(description = "店铺名称,用于和任务内结果行匹配", example = "测试店铺A")
|
@Schema(description = "Shop name used to match task rows", example = "Test Shop A")
|
||||||
private String shopName;
|
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;
|
private Map<String, List<AsinResult>> countries;
|
||||||
|
|
||||||
@Schema(description = "失败时的错误信息;非空则该店铺记为失败")
|
@Schema(description = "Failure message")
|
||||||
private String error;
|
private String error;
|
||||||
|
|
||||||
@Schema(description = "是否成功;false 按失败处理,true 或 null 通常表示结合 countries 判定")
|
@Schema(description = "Whether the shop finished successfully")
|
||||||
private Boolean success;
|
private Boolean success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "单条 ASIN 跟价结果")
|
@Schema(description = "Per-ASIN price track result row")
|
||||||
public static class AsinResult {
|
public static class AsinResult {
|
||||||
|
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||||
|
private String shopMallName;
|
||||||
|
|
||||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||||
private String asin;
|
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;
|
private String currentPrice;
|
||||||
|
|
||||||
@Schema(description = "竞争对手价格", example = "18.50")
|
@Schema(description = "Legacy competitor price", example = "18.50")
|
||||||
private String competitorPrice;
|
private String competitorPrice;
|
||||||
|
|
||||||
@Schema(description = "建议动作或跟价动作", example = "LOWER_PRICE")
|
@Schema(description = "Legacy action", example = "LOWER_PRICE")
|
||||||
private String action;
|
private String action;
|
||||||
|
|
||||||
@Schema(description = "该行是否已处理完成")
|
@Schema(description = "Legacy done flag")
|
||||||
private Boolean done;
|
private Boolean done;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "创建任务响应:包含任务 id 和各店铺的初始结果快照")
|
@Schema(description = "price track create task response")
|
||||||
public class PriceTrackCreateTaskVo {
|
public class PriceTrackCreateTaskVo {
|
||||||
@Schema(description = "新建任务主键 biz_file_task.id,前端推送队列时需要回传该值", example = "200")
|
@Schema(description = "task id", example = "200")
|
||||||
private Long taskId;
|
private Long taskId;
|
||||||
|
|
||||||
@Schema(description = "各店铺一条初始结果快照,包含 resultId、taskId、店铺信息和初始任务状态")
|
@Schema(description = "initial task items")
|
||||||
private List<PriceTrackResultItemVo> 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.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "批量匹配店铺响应")
|
@Schema(description = "Price track shop match response")
|
||||||
public class PriceTrackMatchShopsVo {
|
public class PriceTrackMatchShopsVo {
|
||||||
@Schema(description = "逐店返回的匹配结果,顺序与请求 shopNames 对应")
|
|
||||||
|
@Schema(description = "Match results in the same order as requested shop names")
|
||||||
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
|
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
|
@Data
|
||||||
@Schema(description = "单店匹配结果,同时也是创建任务时 items 的结构")
|
@Schema(description = "Single matched shop item")
|
||||||
public static class PriceTrackShopQueueItem {
|
public static class PriceTrackShopQueueItem {
|
||||||
@Schema(description = "店铺名称", example = "某某店铺")
|
|
||||||
|
@Schema(description = "Shop name", example = "Demo Shop")
|
||||||
private String shopName;
|
private String shopName;
|
||||||
|
|
||||||
@Schema(description = "紫鸟店铺 ID,未命中时可能为空")
|
@Schema(description = "Matched shop id")
|
||||||
private Object shopId;
|
private Object shopId;
|
||||||
|
|
||||||
@Schema(description = "平台,例如亚马逊", example = "亚马逊")
|
@Schema(description = "Platform", example = "Amazon")
|
||||||
private String platform;
|
private String platform;
|
||||||
|
|
||||||
@Schema(description = "公司名称或主体标识")
|
@Schema(description = "Company name")
|
||||||
private String companyName;
|
private String companyName;
|
||||||
|
|
||||||
@Schema(description = "是否命中紫鸟索引;false 时不可用于创建任务")
|
@Schema(description = "Whether the shop matched")
|
||||||
private Boolean matched;
|
private Boolean matched;
|
||||||
|
|
||||||
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE")
|
@Schema(description = "Match status, such as MATCHED or PENDING")
|
||||||
private String matchStatus;
|
private String matchStatus;
|
||||||
|
|
||||||
@Schema(description = "人类可读说明,供前端展示")
|
@Schema(description = "Readable match message")
|
||||||
private String matchMessage;
|
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
|
@Slf4j
|
||||||
public class PriceTrackExcelAssemblyService {
|
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) {
|
public void writeWorkbook(File outputXlsx, Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
|
||||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
|
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
|
||||||
@@ -28,8 +40,8 @@ public class PriceTrackExcelAssemblyService {
|
|||||||
String sheetName = code.name();
|
String sheetName = code.name();
|
||||||
Sheet sheet = wb.createSheet(sheetName);
|
Sheet sheet = wb.createSheet(sheetName);
|
||||||
Row headerRow = sheet.createRow(0);
|
Row headerRow = sheet.createRow(0);
|
||||||
for (int i = 0; i < HEADER.length; i++) {
|
for (int i = 0; i < RESULT_HEADER.length; i++) {
|
||||||
headerRow.createCell(i).setCellValue(HEADER[i]);
|
headerRow.createCell(i).setCellValue(RESULT_HEADER[i]);
|
||||||
}
|
}
|
||||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
|
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
|
||||||
int rowIdx = 1;
|
int rowIdx = 1;
|
||||||
@@ -38,20 +50,26 @@ public class PriceTrackExcelAssemblyService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Row row = sheet.createRow(rowIdx++);
|
Row row = sheet.createRow(rowIdx++);
|
||||||
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
|
row.createCell(0).setCellValue(valueOf(item.getShopMallName()));
|
||||||
row.createCell(1).setCellValue(item.getCurrentPrice() == null ? "" : item.getCurrentPrice());
|
row.createCell(1).setCellValue(valueOf(item.getAsin()));
|
||||||
row.createCell(2).setCellValue(item.getCompetitorPrice() == null ? "" : item.getCompetitorPrice());
|
row.createCell(2).setCellValue(firstNonBlank(item.getPrice(), item.getCurrentPrice()));
|
||||||
row.createCell(3).setCellValue(item.getAction() == null ? "" : item.getAction());
|
row.createCell(3).setCellValue(valueOf(item.getRecommendedPrice()));
|
||||||
row.createCell(4).setCellValue(item.getDone() == null ? "" : String.valueOf(item.getDone()));
|
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);
|
sheet.autoSizeColumn(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wb.write(fos);
|
wb.write(fos);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[price-track] write workbook failed: {}", ex.getMessage());
|
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;
|
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.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -40,6 +42,7 @@ public class PriceTrackService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
private final SkipPriceAsinService skipPriceAsinService;
|
private final SkipPriceAsinService skipPriceAsinService;
|
||||||
|
private final PriceTrackTaskService priceTrackTaskService;
|
||||||
|
|
||||||
public List<PriceTrackCandidateVo> listCandidates(Long userId) {
|
public List<PriceTrackCandidateVo> listCandidates(Long userId) {
|
||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
@@ -139,9 +142,22 @@ public class PriceTrackService {
|
|||||||
throw new BusinessException("shop_names 没有有效店铺名");
|
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();
|
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||||
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
|
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||||
for (String shopName : ordered) {
|
for (String shopName : ordered) {
|
||||||
vo.getItems().add(matchOneShop(shopName));
|
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
||||||
}
|
}
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
@@ -198,9 +214,13 @@ public class PriceTrackService {
|
|||||||
return vo;
|
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();
|
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
|
||||||
item.setShopName(shopName);
|
item.setShopName(shopName);
|
||||||
|
item.setSkipAsins(skipAsins == null ? new LinkedHashMap<>() : new LinkedHashMap<>(skipAsins));
|
||||||
try {
|
try {
|
||||||
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||||
if (match == null) {
|
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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.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.mapper.PriceTrackShopCandidateMapper;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -179,8 +188,33 @@ public class PriceTrackTaskService {
|
|||||||
return batch;
|
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
|
@Transactional
|
||||||
public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) {
|
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.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法");
|
||||||
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
|
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
|
||||||
|
|
||||||
@@ -195,13 +229,14 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 汇总店铺名并查询需要跳过的 ASIN
|
// 汇总店铺名并查询需要跳过的 ASIN
|
||||||
List<String> shopNames = uniqueItems.stream()
|
Map<String, List<String>> skipAsinsByCountry = request.isAsinMode()
|
||||||
.map(PriceTrackMatchShopsVo.PriceTrackShopQueueItem::getShopName)
|
? new LinkedHashMap<>()
|
||||||
.filter(s -> s != null && !s.isBlank())
|
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||||
.distinct()
|
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
||||||
.toList();
|
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
||||||
Map<String, Map<String, String>> skipAsinsByShop = skipPriceAsinService.listSkipAsinsByShops(shopNames);
|
: new LinkedHashMap<>();
|
||||||
log.info("[price-track] createTask skipAsins shops={}", skipAsinsByShop.keySet());
|
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
|
||||||
|
skipAsinsByCountry.keySet(), request.isAsinMode());
|
||||||
|
|
||||||
// 创建任务记录
|
// 创建任务记录
|
||||||
FileTaskEntity task = new FileTaskEntity();
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
@@ -242,7 +277,8 @@ public class PriceTrackTaskService {
|
|||||||
ctx.put("statusMode", request.isStatusMode());
|
ctx.put("statusMode", request.isStatusMode());
|
||||||
ctx.put("asinMode", request.isAsinMode());
|
ctx.put("asinMode", request.isAsinMode());
|
||||||
ctx.put("countryCodes", request.getCountryCodes());
|
ctx.put("countryCodes", request.getCountryCodes());
|
||||||
ctx.put("skipAsinsByShop", skipAsinsByShop);
|
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||||
|
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||||
ctx.put("items", uniqueItems);
|
ctx.put("items", uniqueItems);
|
||||||
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
||||||
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
||||||
@@ -257,6 +293,8 @@ public class PriceTrackTaskService {
|
|||||||
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
|
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
|
||||||
vo.setTaskId(task.getId());
|
vo.setTaskId(task.getId());
|
||||||
vo.setItems(snapshot);
|
vo.setItems(snapshot);
|
||||||
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
|
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +310,7 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||||
log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus());
|
log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus());
|
||||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
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) {
|
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> raw) {
|
||||||
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
|
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
|
||||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {
|
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ public class ProductRiskResolveController {
|
|||||||
return ApiResponse.success(productRiskTaskService.getTaskDetailsBatch(request.getTaskIds()));
|
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")
|
@PostMapping("/tasks/{taskId}/result")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Python 回传处理结果",
|
summary = "Python 回传处理结果",
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ public class ProductRiskTaskService {
|
|||||||
.eq(FileResultEntity::getTaskId, taskId)
|
.eq(FileResultEntity::getTaskId, taskId)
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||||
fileTaskMapper.deleteById(taskId);
|
fileTaskMapper.deleteById(taskId);
|
||||||
|
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,6 +284,27 @@ public class ProductRiskTaskService {
|
|||||||
return batch;
|
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) {
|
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
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())) {
|
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||||
log.warn("[product-risk] submitResult rejected taskId={} status={} payloadShopCount={}",
|
log.warn("[product-risk] submitResult rejected taskId={} status={} payloadShopCount={}",
|
||||||
taskId, task.getStatus(), request.getShops() == null ? 0 : request.getShops().size());
|
taskId, task.getStatus(), request.getShops() == null ? 0 : request.getShops().size());
|
||||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
|
||||||
}
|
}
|
||||||
String oldStatus = task.getStatus();
|
String oldStatus = task.getStatus();
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class ShopManageController {
|
|||||||
private final ShopManageGroupService shopManageGroupService;
|
private final ShopManageGroupService shopManageGroupService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名称、创建人进行筛选")
|
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名、当前操作者可见范围筛选")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
responseCode = "200",
|
responseCode = "200",
|
||||||
@@ -56,14 +56,21 @@ public class ShopManageController {
|
|||||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||||
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName,
|
@Parameter(description = "店铺名称") @RequestParam(required = false) String shopName,
|
||||||
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
|
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||||
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName, createdById));
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||||
|
return ApiResponse.success(shopManageService.page(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
groupId,
|
||||||
|
shopName,
|
||||||
|
operatorId,
|
||||||
|
Boolean.TRUE.equals(superAdmin)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
|
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
|
||||||
public ApiResponse<ShopManageItemVo> create(
|
public ApiResponse<ShopManageItemVo> create(
|
||||||
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody ShopManageCreateRequest request) {
|
@Valid @RequestBody ShopManageCreateRequest request) {
|
||||||
return ApiResponse.success("创建成功",
|
return ApiResponse.success("创建成功",
|
||||||
@@ -71,10 +78,10 @@ public class ShopManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@Operation(summary = "更新店铺", description = "按 ID 更新店铺,普通管理员只能修改自己创建的数据")
|
@Operation(summary = "更新店铺", description = "按 ID 更新店铺")
|
||||||
public ApiResponse<ShopManageItemVo> update(
|
public ApiResponse<ShopManageItemVo> update(
|
||||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
@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,
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody ShopManageUpdateRequest request) {
|
@Valid @RequestBody ShopManageUpdateRequest request) {
|
||||||
return ApiResponse.success("更新成功",
|
return ApiResponse.success("更新成功",
|
||||||
@@ -82,33 +89,42 @@ public class ShopManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@Operation(summary = "删除店铺", description = "按 ID 删除店铺,普通管理员只能删除自己创建的数据")
|
@Operation(summary = "删除店铺", description = "按 ID 删除店铺")
|
||||||
public ApiResponse<Void> delete(
|
public ApiResponse<Void> delete(
|
||||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
@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) {
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||||
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||||
return ApiResponse.success("删除成功", null);
|
return ApiResponse.success("删除成功", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/groups")
|
@GetMapping("/groups")
|
||||||
@Operation(summary = "查询分组列表", description = "普通管理员只返回自己创建的分组,超级管理员返回全部")
|
@Operation(summary = "查询分组列表", description = "普通管理员返回当前可访问的分组,超级管理员返回全部")
|
||||||
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
|
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
|
||||||
@Parameter(description = "创建人用户 ID,普通管理员只传自己") @RequestParam(required = false) Long createdById) {
|
@Parameter(description = "创建人用户ID") @RequestParam(required = false) Long createdById,
|
||||||
return ApiResponse.success(shopManageGroupService.list(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")
|
@PostMapping("/groups")
|
||||||
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
|
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
|
||||||
public ApiResponse<ShopManageGroupItemVo> createGroup(@Valid @RequestBody ShopManageGroupCreateRequest request) {
|
public ApiResponse<ShopManageGroupItemVo> createGroup(
|
||||||
return ApiResponse.success("创建成功", shopManageGroupService.create(request));
|
@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}")
|
@PutMapping("/groups/{id}")
|
||||||
@Operation(summary = "更新分组", description = "普通管理员只能修改自己创建的分组")
|
@Operation(summary = "更新分组", description = "普通管理员只能修改自己可访问的分组")
|
||||||
public ApiResponse<ShopManageGroupItemVo> updateGroup(
|
public ApiResponse<ShopManageGroupItemVo> updateGroup(
|
||||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
@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,
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
|
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
|
||||||
return ApiResponse.success("更新成功",
|
return ApiResponse.success("更新成功",
|
||||||
@@ -116,10 +132,10 @@ public class ShopManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/groups/{id}")
|
@DeleteMapping("/groups/{id}")
|
||||||
@Operation(summary = "删除分组", description = "普通管理员只能删除自己创建的分组")
|
@Operation(summary = "删除分组", description = "普通管理员只能删除自己可访问的分组")
|
||||||
public ApiResponse<Void> deleteGroup(
|
public ApiResponse<Void> deleteGroup(
|
||||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
@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) {
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||||
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||||
return ApiResponse.success("删除成功", null);
|
return ApiResponse.success("删除成功", null);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.nanri.aiimage.modules.shopkey.controller;
|
package com.nanri.aiimage.modules.shopkey.controller;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
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.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.SkipPriceAsinItemVo;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||||
@@ -36,14 +36,27 @@ public class SkipPriceAsinController {
|
|||||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||||
@Parameter(description = "ASIN") @RequestParam(required = false) String asin) {
|
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
|
||||||
return ApiResponse.success(skipPriceAsinService.page(page, pageSize, groupId, shopName, 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
|
@PostMapping
|
||||||
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
|
@Operation(summary = "新增或覆盖跳过跟价 ASIN")
|
||||||
public ApiResponse<SkipPriceAsinItemVo> create(@Valid @RequestBody SkipPriceAsinCreateRequest request) {
|
public ApiResponse<SkipPriceAsinItemVo> create(
|
||||||
return ApiResponse.success("保存成功", skipPriceAsinService.createOrUpdate(request));
|
@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}")
|
@PutMapping("/{id}/countries/{country}")
|
||||||
@@ -51,16 +64,25 @@ public class SkipPriceAsinController {
|
|||||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||||
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
||||||
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||||
|
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
|
||||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
@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}")
|
@DeleteMapping("/{id}/countries/{country}")
|
||||||
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
||||||
public ApiResponse<Void> deleteCountry(
|
public ApiResponse<Void> deleteCountry(
|
||||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||||
@Parameter(description = "国家编码", required = true) @PathVariable String country) {
|
@Parameter(description = "国家编码", required = true) @PathVariable String country,
|
||||||
skipPriceAsinService.deleteCountry(id, 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);
|
return ApiResponse.success("删除成功", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ public class ShopManageGroupCreateRequest {
|
|||||||
@NotBlank(message = "分组名称不能为空")
|
@NotBlank(message = "分组名称不能为空")
|
||||||
private String groupName;
|
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 = "创建人不能为空")
|
@NotNull(message = "创建人不能为空")
|
||||||
private Long createdById;
|
private Long createdById;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.model.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -10,4 +11,8 @@ public class ShopManageGroupUpdateRequest {
|
|||||||
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotBlank(message = "分组名称不能为空")
|
@NotBlank(message = "分组名称不能为空")
|
||||||
private String groupName;
|
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 lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class SkipPriceAsinCreateRequest {
|
public class SkipPriceAsinCreateRequest {
|
||||||
@@ -19,6 +20,13 @@ public class SkipPriceAsinCreateRequest {
|
|||||||
@NotEmpty(message = "请至少选择一个国家")
|
@NotEmpty(message = "请至少选择一个国家")
|
||||||
private List<String> countries;
|
private List<String> countries;
|
||||||
|
|
||||||
@NotBlank(message = "ASIN 不能为空")
|
/**
|
||||||
|
* 兼容旧请求: 选中的国家共用同一个 ASIN。
|
||||||
|
*/
|
||||||
private String 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)
|
@TableId(type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
private String groupName;
|
private String groupName;
|
||||||
|
@TableField("user_id")
|
||||||
|
private Long userId;
|
||||||
@TableField("created_by_id")
|
@TableField("created_by_id")
|
||||||
private Long createdById;
|
private Long createdById;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import java.time.LocalDateTime;
|
|||||||
public class ShopManageGroupItemVo {
|
public class ShopManageGroupItemVo {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String groupName;
|
private String groupName;
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.nanri.aiimage.modules.shopkey.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.ShopManageGroupMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
|
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
|
||||||
@@ -13,7 +15,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -21,6 +25,7 @@ public class ShopManageGroupService {
|
|||||||
|
|
||||||
private final ShopManageGroupMapper groupMapper;
|
private final ShopManageGroupMapper groupMapper;
|
||||||
private final ShopManageMapper shopManageMapper;
|
private final ShopManageMapper shopManageMapper;
|
||||||
|
private final AdminUserMapper adminUserMapper;
|
||||||
|
|
||||||
public List<ShopManageGroupItemVo> list() {
|
public List<ShopManageGroupItemVo> list() {
|
||||||
return list(null);
|
return list(null);
|
||||||
@@ -35,13 +40,56 @@ public class ShopManageGroupService {
|
|||||||
.toList();
|
.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
|
@Transactional
|
||||||
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request) {
|
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request, Long operatorId, boolean superAdmin) {
|
||||||
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
||||||
|
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
|
||||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||||
ensureGroupNameUnique(groupName, null, createdById, false);
|
ensureGroupNameMatchesUser(groupName, bindUser);
|
||||||
|
ensureGroupNameUnique(groupName, null);
|
||||||
|
ensureUserUnique(request.getUserId(), null);
|
||||||
|
|
||||||
ShopManageGroupEntity entity = new ShopManageGroupEntity();
|
ShopManageGroupEntity entity = new ShopManageGroupEntity();
|
||||||
entity.setGroupName(groupName);
|
entity.setGroupName(groupName);
|
||||||
|
entity.setUserId(bindUser.getId());
|
||||||
entity.setCreatedById(createdById);
|
entity.setCreatedById(createdById);
|
||||||
groupMapper.insert(entity);
|
groupMapper.insert(entity);
|
||||||
return toVo(getById(entity.getId()));
|
return toVo(getById(entity.getId()));
|
||||||
@@ -51,9 +99,14 @@ public class ShopManageGroupService {
|
|||||||
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
|
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
|
||||||
ShopManageGroupEntity entity = getById(id);
|
ShopManageGroupEntity entity = getById(id);
|
||||||
validateOwnership(entity, operatorId, superAdmin);
|
validateOwnership(entity, operatorId, superAdmin);
|
||||||
|
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
|
||||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||||
ensureGroupNameUnique(groupName, id, operatorId, superAdmin);
|
ensureGroupNameMatchesUser(groupName, bindUser);
|
||||||
|
ensureGroupNameUnique(groupName, id);
|
||||||
|
ensureUserUnique(bindUser.getId(), id);
|
||||||
|
|
||||||
entity.setGroupName(groupName);
|
entity.setGroupName(groupName);
|
||||||
|
entity.setUserId(bindUser.getId());
|
||||||
groupMapper.updateById(entity);
|
groupMapper.updateById(entity);
|
||||||
return toVo(getById(id));
|
return toVo(getById(id));
|
||||||
}
|
}
|
||||||
@@ -84,12 +137,9 @@ public class ShopManageGroupService {
|
|||||||
return entity;
|
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>()
|
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||||
.eq(ShopManageGroupEntity::getGroupName, groupName);
|
.eq(ShopManageGroupEntity::getGroupName, groupName);
|
||||||
if (!superAdmin) {
|
|
||||||
query.eq(ShopManageGroupEntity::getCreatedById, normalizePositiveId(operatorId, "操作人不能为空"));
|
|
||||||
}
|
|
||||||
if (excludeId != null) {
|
if (excludeId != null) {
|
||||||
query.ne(ShopManageGroupEntity::getId, excludeId);
|
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) {
|
private void validateOwnership(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
if (superAdmin) {
|
if (superAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||||
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
|
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
||||||
throw new BusinessException("只能操作自己创建的分组数据");
|
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) {
|
private String normalizeRequired(String value, String message) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = value == null ? "" : value.trim();
|
||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
@@ -128,6 +216,9 @@ public class ShopManageGroupService {
|
|||||||
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
|
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
vo.setGroupName(entity.getGroupName());
|
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.setCreatedAt(entity.getCreatedAt());
|
||||||
vo.setUpdatedAt(entity.getUpdatedAt());
|
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -27,24 +28,27 @@ public class ShopManageService {
|
|||||||
private final ShopManageGroupService shopManageGroupService;
|
private final ShopManageGroupService shopManageGroupService;
|
||||||
private final ShopCredentialCryptoService shopCredentialCryptoService;
|
private final ShopCredentialCryptoService shopCredentialCryptoService;
|
||||||
|
|
||||||
public ShopManagePageVo page(long page, long pageSize) {
|
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long operatorId, boolean superAdmin) {
|
||||||
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) {
|
|
||||||
long safePage = Math.max(page, 1);
|
long safePage = Math.max(page, 1);
|
||||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||||
String safeShopName = shopName == null ? "" : shopName.trim();
|
String safeShopName = shopName == null ? "" : shopName.trim();
|
||||||
|
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
|
||||||
|
|
||||||
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
|
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
|
||||||
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
|
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
|
||||||
.eq(createdById != null && createdById > 0, ShopManageEntity::getCreatedById, createdById)
|
|
||||||
.like(!safeShopName.isEmpty(), ShopManageEntity::getShopName, safeShopName)
|
.like(!safeShopName.isEmpty(), ShopManageEntity::getShopName, safeShopName)
|
||||||
.orderByDesc(ShopManageEntity::getId);
|
.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);
|
Long total = shopManageMapper.selectCount(query);
|
||||||
List<ShopManageEntity> rows = shopManageMapper
|
List<ShopManageEntity> rows = shopManageMapper
|
||||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||||
@@ -90,7 +94,7 @@ public class ShopManageService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public ShopManageItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageUpdateRequest request) {
|
public ShopManageItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageUpdateRequest request) {
|
||||||
ShopManageEntity entity = getById(id);
|
ShopManageEntity entity = getById(id);
|
||||||
validateOwnership(entity, operatorId, superAdmin);
|
validateGroupAccess(entity, operatorId, superAdmin);
|
||||||
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
||||||
String shopName = normalizeRequired(request.getShopName(), "店铺名称不能为空");
|
String shopName = normalizeRequired(request.getShopName(), "店铺名称不能为空");
|
||||||
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
|
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
|
||||||
@@ -110,7 +114,7 @@ public class ShopManageService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
||||||
ShopManageEntity entity = getById(id);
|
ShopManageEntity entity = getById(id);
|
||||||
validateOwnership(entity, operatorId, superAdmin);
|
validateGroupAccess(entity, operatorId, superAdmin);
|
||||||
shopManageMapper.deleteById(entity.getId());
|
shopManageMapper.deleteById(entity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +150,8 @@ public class ShopManageService {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateOwnership(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
|
private void validateGroupAccess(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
if (superAdmin) {
|
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
|
||||||
return;
|
|
||||||
}
|
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
|
||||||
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
|
|
||||||
throw new BusinessException("只能操作自己创建的店铺数据");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeRequired(String value, String message) {
|
private String normalizeRequired(String value, String message) {
|
||||||
@@ -208,7 +206,6 @@ public class ShopManageService {
|
|||||||
vo.setMallName(entity.getMallName());
|
vo.setMallName(entity.getMallName());
|
||||||
vo.setAccount(entity.getAccount());
|
vo.setAccount(entity.getAccount());
|
||||||
String masked = entity.getPassword() == null || entity.getPassword().isBlank() ? "" : "******";
|
String masked = entity.getPassword() == null || entity.getPassword().isBlank() ? "" : "******";
|
||||||
// 兼容旧前端字段:password 继续返回脱敏值
|
|
||||||
vo.setPassword(masked);
|
vo.setPassword(masked);
|
||||||
vo.setPasswordMasked(masked);
|
vo.setPasswordMasked(masked);
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ public class SkipPriceAsinService {
|
|||||||
private final SkipPriceAsinMapper skipPriceAsinMapper;
|
private final SkipPriceAsinMapper skipPriceAsinMapper;
|
||||||
private final ShopManageGroupService shopManageGroupService;
|
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 safePage = Math.max(page, 1);
|
||||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||||
String safeShopName = normalizeBlank(shopName);
|
String safeShopName = normalizeBlank(shopName);
|
||||||
String safeAsin = normalizeBlank(asin);
|
String safeAsin = normalizeBlank(asin);
|
||||||
|
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
|
||||||
|
|
||||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||||
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
|
.eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId)
|
||||||
@@ -45,6 +47,14 @@ public class SkipPriceAsinService {
|
|||||||
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
|
.or().like(SkipPriceAsinEntity::getAsinEs, safeAsin))
|
||||||
.orderByDesc(SkipPriceAsinEntity::getId);
|
.orderByDesc(SkipPriceAsinEntity::getId);
|
||||||
|
|
||||||
|
if (!superAdmin) {
|
||||||
|
if (accessibleGroupIds.isEmpty()) {
|
||||||
|
query.eq(SkipPriceAsinEntity::getId, -1L);
|
||||||
|
} else {
|
||||||
|
query.in(SkipPriceAsinEntity::getGroupId, accessibleGroupIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Long total = skipPriceAsinMapper.selectCount(query);
|
Long total = skipPriceAsinMapper.selectCount(query);
|
||||||
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
|
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper
|
||||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
|
||||||
@@ -64,11 +74,11 @@ public class SkipPriceAsinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request) {
|
public SkipPriceAsinItemVo createOrUpdate(SkipPriceAsinCreateRequest request, Long operatorId, boolean superAdmin) {
|
||||||
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
|
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
|
||||||
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
|
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
|
||||||
String asin = normalizeAsin(request.getAsin());
|
|
||||||
Set<String> countries = normalizeCountries(request.getCountries());
|
Set<String> countries = normalizeCountries(request.getCountries());
|
||||||
|
Map<String, String> countryAsinMap = normalizeCountryAsinMap(countries, request);
|
||||||
|
|
||||||
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectOne(new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectOne(new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||||
.eq(SkipPriceAsinEntity::getGroupId, group.getId())
|
.eq(SkipPriceAsinEntity::getGroupId, group.getId())
|
||||||
@@ -80,8 +90,8 @@ public class SkipPriceAsinService {
|
|||||||
entity.setShopName(shopName);
|
entity.setShopName(shopName);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String country : countries) {
|
for (Map.Entry<String, String> entry : countryAsinMap.entrySet()) {
|
||||||
setCountryAsin(entity, country, asin);
|
setCountryAsin(entity, entry.getKey(), entry.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity.getId() == null) {
|
if (entity.getId() == null) {
|
||||||
@@ -94,8 +104,9 @@ public class SkipPriceAsinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteCountry(Long id, String country) {
|
public void deleteCountry(Long id, String country, Long operatorId, boolean superAdmin) {
|
||||||
SkipPriceAsinEntity entity = getById(id);
|
SkipPriceAsinEntity entity = getById(id);
|
||||||
|
validateGroupAccess(entity, operatorId, superAdmin);
|
||||||
String normalizedCountry = normalizeCountry(country);
|
String normalizedCountry = normalizeCountry(country);
|
||||||
setCountryAsin(entity, normalizedCountry, "");
|
setCountryAsin(entity, normalizedCountry, "");
|
||||||
if (isEmptyRow(entity)) {
|
if (isEmptyRow(entity)) {
|
||||||
@@ -106,8 +117,9 @@ public class SkipPriceAsinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@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);
|
SkipPriceAsinEntity entity = getById(id);
|
||||||
|
validateGroupAccess(entity, operatorId, superAdmin);
|
||||||
String normalizedCountry = normalizeCountry(country);
|
String normalizedCountry = normalizeCountry(country);
|
||||||
String normalizedAsin = normalizeAsin(asin);
|
String normalizedAsin = normalizeAsin(asin);
|
||||||
setCountryAsin(entity, normalizedCountry, normalizedAsin);
|
setCountryAsin(entity, normalizedCountry, normalizedAsin);
|
||||||
@@ -131,6 +143,10 @@ public class SkipPriceAsinService {
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateGroupAccess(SkipPriceAsinEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
|
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
private SkipPriceAsinItemVo toItemVo(SkipPriceAsinEntity entity, String groupName) {
|
private SkipPriceAsinItemVo toItemVo(SkipPriceAsinEntity entity, String groupName) {
|
||||||
SkipPriceAsinItemVo vo = new SkipPriceAsinItemVo();
|
SkipPriceAsinItemVo vo = new SkipPriceAsinItemVo();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
@@ -176,6 +192,27 @@ public class SkipPriceAsinService {
|
|||||||
return normalized;
|
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) {
|
private String normalizeCountry(String country) {
|
||||||
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
|
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
|
||||||
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
|
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
|
||||||
@@ -228,10 +265,6 @@ public class SkipPriceAsinService {
|
|||||||
&& normalizeBlank(entity.getAsinEs()).isEmpty();
|
&& normalizeBlank(entity.getAsinEs()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按店铺名列表批量查询跳过跟价ASIN,返回 Map{shopName -> {countryCode -> asin列表}}
|
|
||||||
* 用于跟价任务创建时将skip ASIN数据下发给Python队列
|
|
||||||
*/
|
|
||||||
public Map<String, Map<String, String>> listSkipAsinsByShops(List<String> shopNames) {
|
public Map<String, Map<String, String>> listSkipAsinsByShops(List<String> shopNames) {
|
||||||
if (shopNames == null || shopNames.isEmpty()) {
|
if (shopNames == null || shopNames.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
@@ -252,4 +285,37 @@ public class SkipPriceAsinService {
|
|||||||
}
|
}
|
||||||
return result;
|
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()));
|
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")
|
@PostMapping("/tasks/{taskId}/result")
|
||||||
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
|
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
|
||||||
public ApiResponse<Void> submitResult(
|
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 com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
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.Duration;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -19,21 +26,29 @@ public class ShopMatchTaskCacheService {
|
|||||||
|
|
||||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||||
|
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
|
Path file = buildShopPayloadFile(taskId, shopKey);
|
||||||
if (!(raw instanceof String json) || json.isBlank()) {
|
if (!Files.isRegularFile(file)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("读取匹配店铺缓存失败");
|
throw new BusinessException("璇诲彇鍖归厤搴楅摵缂撳瓨澶辫触");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +57,16 @@ public class ShopMatchTaskCacheService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String key = buildShopPayloadKey(taskId);
|
Files.createDirectories(buildTaskDir(taskId));
|
||||||
stringRedisTemplate.opsForHash().put(key, shopKey, objectMapper.writeValueAsString(payload));
|
Files.writeString(
|
||||||
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
|
buildShopPayloadFile(taskId, shopKey),
|
||||||
|
objectMapper.writeValueAsString(new PayloadFile(shopKey, payload)),
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING,
|
||||||
|
StandardOpenOption.WRITE
|
||||||
|
);
|
||||||
} catch (Exception ex) {
|
} 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()) {
|
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||||
return;
|
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) {
|
public Map<String, ShopMatchShopPayloadDto> getAllShopMergedPayload(Long taskId) {
|
||||||
try {
|
try {
|
||||||
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
|
Path taskDir = buildTaskDir(taskId);
|
||||||
if (raw == null || raw.isEmpty()) {
|
if (!Files.isDirectory(taskDir)) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
LinkedHashMap<String, ShopMatchShopPayloadDto> out = new LinkedHashMap<>();
|
LinkedHashMap<String, ShopMatchShopPayloadDto> out = new LinkedHashMap<>();
|
||||||
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
|
try (var stream = Files.list(taskDir)) {
|
||||||
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
|
for (Path path : stream
|
||||||
continue;
|
.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;
|
return out;
|
||||||
} catch (Exception ex) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (Exception ex) {
|
||||||
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
|
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildShopPayloadKey(Long taskId) {
|
public Duration getPayloadTtl() {
|
||||||
return "shop-match:task:shop-payload:" + taskId;
|
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;
|
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) {
|
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||||
@@ -376,7 +395,7 @@ public class ShopMatchTaskService {
|
|||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
@@ -595,7 +614,11 @@ public class ShopMatchTaskService {
|
|||||||
task.setSuccessFileCount(ok);
|
task.setSuccessFileCount(ok);
|
||||||
task.setFailedFileCount(fail);
|
task.setFailedFileCount(fail);
|
||||||
task.setUpdatedAt(now());
|
task.setUpdatedAt(now());
|
||||||
if (!allDone) {
|
if (shouldRemainRunningUntilNextStage(task)) {
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setErrorMessage(null);
|
||||||
|
task.setFinishedAt(null);
|
||||||
|
} else if (!allDone) {
|
||||||
task.setStatus("RUNNING");
|
task.setStatus("RUNNING");
|
||||||
task.setErrorMessage(null);
|
task.setErrorMessage(null);
|
||||||
task.setFinishedAt(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) {
|
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {
|
||||||
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
|
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
|
||||||
detail.setTask(toTaskItemVo(task));
|
detail.setTask(toTaskItemVo(task));
|
||||||
@@ -636,6 +671,12 @@ public class ShopMatchTaskService {
|
|||||||
return detail;
|
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) {
|
private List<ProductRiskResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
||||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getTaskId, taskId)
|
.eq(FileResultEntity::getTaskId, taskId)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cn.hutool.core.util.IdUtil;
|
|||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.common.util.ExcelStreamReader;
|
||||||
import com.nanri.aiimage.config.StorageProperties;
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest;
|
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.FileResultEntity;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.poi.ss.usermodel.Cell;
|
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||||
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.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -217,84 +212,119 @@ public class SplitRunService {
|
|||||||
String originalFilename,
|
String originalFilename,
|
||||||
SplitRunRequest request
|
SplitRunRequest request
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
DataFormatter formatter = new DataFormatter();
|
CountResult countResult = countSplitRows(inputFile, request.getSelectedColumns());
|
||||||
try (FileInputStream fis = new FileInputStream(inputFile);
|
if (countResult.rowCount() <= 0) {
|
||||||
Workbook workbook = WorkbookFactory.create(fis)) {
|
throw new BusinessException("No data rows available for splitting.");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<>();
|
Map<String, Integer> headerMap = new HashMap<>();
|
||||||
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
for (Map.Entry<Integer, String> entry : headerRow.entrySet()) {
|
||||||
Cell cell = headerRow.getCell(i);
|
String value = normalizeHeaderName(entry.getValue());
|
||||||
String value = normalizeHeaderName(cell == null ? null : formatter.formatCellValue(cell));
|
|
||||||
if (value.isBlank() || headerMap.containsKey(value)) {
|
if (value.isBlank() || headerMap.containsKey(value)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
headerMap.put(value, i);
|
headerMap.put(value, entry.getKey());
|
||||||
if (HEADER_STOP_COLUMN.equals(value)) {
|
if (HEADER_STOP_COLUMN.equals(value)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -302,14 +332,13 @@ public class SplitRunService {
|
|||||||
return headerMap;
|
return headerMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<List<List<String>>> buildChunks(List<List<String>> rowsData, SplitRunRequest request) {
|
private List<Integer> buildChunkSizes(int totalRows, SplitRunRequest request) {
|
||||||
int totalRows = rowsData.size();
|
List<Integer> chunks = new ArrayList<>();
|
||||||
List<List<List<String>>> chunks = new ArrayList<>();
|
|
||||||
|
|
||||||
if (SPLIT_MODE_ROWS_PER_FILE.equals(request.getSplitMode())) {
|
if (SPLIT_MODE_ROWS_PER_FILE.equals(request.getSplitMode())) {
|
||||||
int chunkSize = request.getRowsPerFile();
|
int chunkSize = request.getRowsPerFile();
|
||||||
for (int i = 0; i < totalRows; i += chunkSize) {
|
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;
|
return chunks;
|
||||||
}
|
}
|
||||||
@@ -317,12 +346,8 @@ public class SplitRunService {
|
|||||||
int actualParts = Math.min(request.getParts(), totalRows);
|
int actualParts = Math.min(request.getParts(), totalRows);
|
||||||
int base = totalRows / actualParts;
|
int base = totalRows / actualParts;
|
||||||
int remainder = totalRows % actualParts;
|
int remainder = totalRows % actualParts;
|
||||||
int start = 0;
|
|
||||||
for (int i = 0; i < actualParts; i++) {
|
for (int i = 0; i < actualParts; i++) {
|
||||||
int size = base + (i < remainder ? 1 : 0);
|
chunks.add(base + (i < remainder ? 1 : 0));
|
||||||
int end = start + size;
|
|
||||||
chunks.add(rowsData.subList(start, end));
|
|
||||||
start = end;
|
|
||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
@@ -448,4 +473,48 @@ public class SplitRunService {
|
|||||||
|
|
||||||
private record TaskSplitChunkResult(String relativePath, String sourceFilename, File outputFile, int rowCount) {
|
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}
|
heartbeat-timeout-minutes: ${AIIMAGE_DELETE_BRAND_HEARTBEAT_TIMEOUT_MINUTES:15}
|
||||||
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
||||||
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
|
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-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:20}
|
||||||
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
|
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:20}
|
||||||
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:10}
|
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:20}
|
||||||
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:10}
|
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:20}
|
||||||
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
|
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:20}
|
||||||
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
|
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:20}
|
||||||
module-cleanup:
|
module-cleanup:
|
||||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
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;
|
||||||
Binary file not shown.
Binary file not shown.
@@ -62,6 +62,34 @@ ADMIN_MENU_ACCESS_CONFIG = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ADMIN_MENU_ACCESS_CONFIG.update({
|
||||||
|
'users': {
|
||||||
|
'column_key': 'admin_users',
|
||||||
|
'route_path': 'users',
|
||||||
|
'error': '无权访问用户管理模块',
|
||||||
|
},
|
||||||
|
'columns': {
|
||||||
|
'column_key': 'admin_columns',
|
||||||
|
'route_path': 'columns',
|
||||||
|
'error': '无权访问栏目权限配置模块',
|
||||||
|
},
|
||||||
|
'shop-keys': {
|
||||||
|
'column_key': 'admin_shop_keys',
|
||||||
|
'route_path': 'shop-keys',
|
||||||
|
'error': '无权访问店铺密钥管理模块',
|
||||||
|
},
|
||||||
|
'history': {
|
||||||
|
'column_key': 'admin_history',
|
||||||
|
'route_path': 'history',
|
||||||
|
'error': '无权访问查看生成记录模块',
|
||||||
|
},
|
||||||
|
'version': {
|
||||||
|
'column_key': 'admin_version',
|
||||||
|
'route_path': 'version',
|
||||||
|
'error': '无权访问版本管理模块',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _safe_version_key(version):
|
def _safe_version_key(version):
|
||||||
"""将版本号转换为安全的 OSS 对象名片段"""
|
"""将版本号转换为安全的 OSS 对象名片段"""
|
||||||
@@ -98,10 +126,155 @@ def _format_permission_item(item):
|
|||||||
'column_key': item.get('columnKey') or '',
|
'column_key': item.get('columnKey') or '',
|
||||||
'menu_type': item.get('menuType') or 'app',
|
'menu_type': item.get('menuType') or 'app',
|
||||||
'route_path': item.get('routePath') or '',
|
'route_path': item.get('routePath') or '',
|
||||||
|
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_int(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if text == '':
|
||||||
|
return None
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column_sort_schema():
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_local_column_sort_map():
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, sort_order FROM columns")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {int(row['id']): int(row.get('sort_order') or 0) for row in rows if row.get('id') is not None}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_local_columns(menu_type=None):
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
sql = """
|
||||||
|
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
|
||||||
|
FROM columns
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if menu_type:
|
||||||
|
sql += " WHERE menu_type = %s"
|
||||||
|
params.append(menu_type)
|
||||||
|
sql += " ORDER BY sort_order ASC, id ASC"
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_local_columns_from_backend(items):
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for item in items:
|
||||||
|
item_id = item.get('id')
|
||||||
|
if item_id is None:
|
||||||
|
continue
|
||||||
|
sort_order = item.get('sort_order')
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO columns (id, name, column_key, menu_type, route_path, sort_order, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, NOW()))
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
column_key = VALUES(column_key),
|
||||||
|
menu_type = VALUES(menu_type),
|
||||||
|
route_path = VALUES(route_path),
|
||||||
|
sort_order = CASE
|
||||||
|
WHEN columns.sort_order IS NULL OR columns.sort_order = 0 THEN VALUES(sort_order)
|
||||||
|
ELSE columns.sort_order
|
||||||
|
END
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(item_id),
|
||||||
|
item.get('name') or '',
|
||||||
|
item.get('column_key') or '',
|
||||||
|
item.get('menu_type') or 'app',
|
||||||
|
item.get('route_path') or '',
|
||||||
|
int(sort_order if sort_order not in (None, '') else item_id),
|
||||||
|
item.get('created_at') or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_local_column_sort_order(column_id, sort_order):
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE columns SET sort_order = %s WHERE id = %s",
|
||||||
|
(int(sort_order), int(column_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_next_local_column_sort_order():
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM columns")
|
||||||
|
row = cur.fetchone() or {}
|
||||||
|
return int(row.get('max_sort') or 0) + 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_local_column_sort_order(column_id, target_id):
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, sort_order FROM columns WHERE id IN (%s, %s) ORDER BY id", (int(column_id), int(target_id)))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if len(rows) != 2:
|
||||||
|
raise ValueError('菜单不存在')
|
||||||
|
by_id = {int(row['id']): int(row.get('sort_order') or 0) for row in rows}
|
||||||
|
left_sort = by_id.get(int(column_id), 0) or int(column_id)
|
||||||
|
right_sort = by_id.get(int(target_id), 0) or int(target_id)
|
||||||
|
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (right_sort, int(column_id)))
|
||||||
|
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (left_sort, int(target_id)))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _sync_user_column_permissions(user_id, column_ids):
|
def _sync_user_column_permissions(user_id, column_ids):
|
||||||
_, error_response, status = _proxy_backend_java(
|
_, error_response, status = _proxy_backend_java(
|
||||||
'PUT',
|
'PUT',
|
||||||
@@ -111,8 +284,14 @@ def _sync_user_column_permissions(user_id, column_ids):
|
|||||||
return error_response, status
|
return error_response, status
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_admin_id(current_row=None):
|
||||||
|
if current_row and current_row.get('id'):
|
||||||
|
return current_row.get('id')
|
||||||
|
return session.get('user_id')
|
||||||
|
|
||||||
|
|
||||||
def _get_current_admin_permission_sets():
|
def _get_current_admin_permission_sets():
|
||||||
user_id = session.get('user_id')
|
user_id = _get_current_admin_id()
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return set(), set()
|
return set(), set()
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -152,6 +331,60 @@ def _ensure_admin_menu_access(*menu_names):
|
|||||||
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dedupe_total_data_access():
|
||||||
|
return _ensure_admin_menu_access('dedupe-total-data')
|
||||||
|
|
||||||
|
|
||||||
|
def _load_current_admin_menu_items():
|
||||||
|
role, current_row = get_current_admin_role()
|
||||||
|
if not role or not current_row:
|
||||||
|
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
|
||||||
|
user_id = _get_current_admin_id(current_row)
|
||||||
|
if not user_id:
|
||||||
|
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
|
||||||
|
result, error_response, status = _proxy_backend_java(
|
||||||
|
'GET',
|
||||||
|
f'/api/admin/permission-users/{user_id}/column-permissions',
|
||||||
|
params={'menuType': 'admin'},
|
||||||
|
)
|
||||||
|
if error_response is not None:
|
||||||
|
return role, current_row, None, (error_response, status)
|
||||||
|
items = [_format_permission_item(item) for item in (result.get('data') or [])]
|
||||||
|
return role, current_row, items, None
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/current-user')
|
||||||
|
@admin_required
|
||||||
|
def get_admin_current_user():
|
||||||
|
role, current_row = get_current_admin_role()
|
||||||
|
if not role or not current_row:
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'item': {
|
||||||
|
'id': current_row.get('id'),
|
||||||
|
'username': current_row.get('username') or session.get('username') or '',
|
||||||
|
'role': role,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/current-user/menus')
|
||||||
|
@admin_required
|
||||||
|
def get_admin_current_user_menus():
|
||||||
|
_, _, items, denied = _load_current_admin_menu_items()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
return jsonify({'success': True, 'items': items})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/logout', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def admin_logout():
|
||||||
|
session.clear()
|
||||||
|
return jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login'})
|
||||||
|
|
||||||
|
|
||||||
# ---------- 用户管理 ----------
|
# ---------- 用户管理 ----------
|
||||||
|
|
||||||
@admin_api.route('/users')
|
@admin_api.route('/users')
|
||||||
@@ -162,7 +395,13 @@ def list_users():
|
|||||||
if not role:
|
if not role:
|
||||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
_, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
_, _, denied = _ensure_admin_menu_access('users', 'history', 'shop-manage', 'skip-price-asin')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
page_size = min(999, max(5, int(request.args.get('page_size', 15))))
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||||
@@ -249,6 +488,9 @@ def list_users():
|
|||||||
@admin_api.route('/user', methods=['POST'])
|
@admin_api.route('/user', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_user():
|
def create_user():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
username = (data.get('username') or '').strip()
|
username = (data.get('username') or '').strip()
|
||||||
password = data.get('password') or ''
|
password = data.get('password') or ''
|
||||||
@@ -312,6 +554,9 @@ def create_user():
|
|||||||
def update_user(uid):
|
def update_user(uid):
|
||||||
"""更新用户(密码、角色)"""
|
"""更新用户(密码、角色)"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
_, _, denied = _ensure_admin_menu_access('users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
want_role = (data.get('role') or '').strip() or data.get('role')
|
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||||
role, current_row = get_current_admin_role()
|
role, current_row = get_current_admin_role()
|
||||||
@@ -368,6 +613,9 @@ def update_user(uid):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def delete_user(uid):
|
def delete_user(uid):
|
||||||
"""删除用户"""
|
"""删除用户"""
|
||||||
|
_, _, denied = _ensure_admin_menu_access('users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
if session.get('user_id') == uid:
|
if session.get('user_id') == uid:
|
||||||
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||||
role, current_row = get_current_admin_role()
|
role, current_row = get_current_admin_role()
|
||||||
@@ -405,6 +653,9 @@ def delete_user(uid):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def history():
|
def history():
|
||||||
"""管理员分页获取所有生成记录"""
|
"""管理员分页获取所有生成记录"""
|
||||||
|
_, _, denied = _ensure_admin_menu_access('history')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@@ -473,28 +724,49 @@ def history():
|
|||||||
@admin_api.route('/columns')
|
@admin_api.route('/columns')
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_columns():
|
def list_columns():
|
||||||
params = {}
|
_, _, denied = _ensure_admin_menu_access('columns', 'users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
menu_type = (request.args.get('menu_type') or '').strip()
|
menu_type = (request.args.get('menu_type') or '').strip()
|
||||||
if menu_type:
|
params = {'menuType': menu_type} if menu_type else None
|
||||||
params['menuType'] = menu_type
|
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/admin/permission-menus',
|
'/api/admin/permission-menus',
|
||||||
params=params or None,
|
params=params,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is None:
|
||||||
return error_response, status
|
_sync_local_columns_from_backend([_format_permission_item(item) for item in (result.get('data') or [])])
|
||||||
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in (result.get('data') or [])]})
|
local_rows = _load_local_columns(menu_type or None)
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
'id': row.get('id'),
|
||||||
|
'name': row.get('name') or '',
|
||||||
|
'column_key': row.get('column_key') or '',
|
||||||
|
'menu_type': row.get('menu_type') or 'app',
|
||||||
|
'route_path': row.get('route_path') or '',
|
||||||
|
'sort_order': int(row.get('sort_order') or 0),
|
||||||
|
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
|
||||||
|
}
|
||||||
|
for row in local_rows
|
||||||
|
]
|
||||||
|
return jsonify({'success': True, 'items': items})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/column', methods=['POST'])
|
@admin_api.route('/column', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_column():
|
def create_column():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('columns')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
name = (data.get('name') or '').strip()
|
name = (data.get('name') or '').strip()
|
||||||
column_key = (data.get('column_key') or '').strip()
|
column_key = (data.get('column_key') or '').strip()
|
||||||
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
|
menu_type = (data.get('menu_type') or '').strip() or 'admin'
|
||||||
route_path = (data.get('route_path') or '').strip()
|
route_path = (data.get('route_path') or '').strip()
|
||||||
|
try:
|
||||||
|
sort_order = _parse_optional_int(data.get('sort_order'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': '排序必须是数字'})
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({'success': False, 'error': '栏目名不能为空'})
|
return jsonify({'success': False, 'error': '栏目名不能为空'})
|
||||||
if not column_key:
|
if not column_key:
|
||||||
@@ -514,17 +786,27 @@ def create_column():
|
|||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
item = result.get('data') or {}
|
item = result.get('data') or {}
|
||||||
|
_sync_local_columns_from_backend([_format_permission_item(item)])
|
||||||
|
if item.get('id') is not None:
|
||||||
|
_set_local_column_sort_order(item.get('id'), sort_order if sort_order is not None else _get_next_local_column_sort_order())
|
||||||
return jsonify({'success': True, 'msg': result.get('message') or '创建成功', 'id': item.get('id')})
|
return jsonify({'success': True, 'msg': result.get('message') or '创建成功', 'id': item.get('id')})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/column/<int:cid>', methods=['PUT'])
|
@admin_api.route('/column/<int:cid>', methods=['PUT'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_column(cid):
|
def update_column(cid):
|
||||||
|
_, _, denied = _ensure_admin_menu_access('columns')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
name = (data.get('name') or '').strip()
|
name = (data.get('name') or '').strip()
|
||||||
column_key = (data.get('column_key') or '').strip()
|
column_key = (data.get('column_key') or '').strip()
|
||||||
menu_type = (data.get('menu_type') or 'app').strip() or 'app'
|
menu_type = (data.get('menu_type') or '').strip() or 'admin'
|
||||||
route_path = (data.get('route_path') or '').strip()
|
route_path = (data.get('route_path') or '').strip()
|
||||||
|
try:
|
||||||
|
sort_order = _parse_optional_int(data.get('sort_order'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': '排序必须是数字'})
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({'success': False, 'error': '栏目名不能为空'})
|
return jsonify({'success': False, 'error': '栏目名不能为空'})
|
||||||
if not column_key:
|
if not column_key:
|
||||||
@@ -543,32 +825,76 @@ def update_column(cid):
|
|||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
|
_sync_local_columns_from_backend([{
|
||||||
|
'id': cid,
|
||||||
|
'name': name,
|
||||||
|
'column_key': column_key,
|
||||||
|
'menu_type': menu_type,
|
||||||
|
'route_path': route_path,
|
||||||
|
'sort_order': sort_order if sort_order is not None else cid,
|
||||||
|
'created_at': None,
|
||||||
|
}])
|
||||||
|
if sort_order is not None:
|
||||||
|
_set_local_column_sort_order(cid, sort_order)
|
||||||
return jsonify({'success': True, 'msg': result.get('message') or '更新成功'})
|
return jsonify({'success': True, 'msg': result.get('message') or '更新成功'})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/column/reorder', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def reorder_column():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('columns')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
data = request.get_json() or {}
|
||||||
|
try:
|
||||||
|
column_id = int(data.get('column_id'))
|
||||||
|
target_id = int(data.get('target_id'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'success': False, 'error': '菜单重排参数无效'}), 400
|
||||||
|
if column_id == target_id:
|
||||||
|
return jsonify({'success': True, 'msg': '排序未变化'})
|
||||||
|
try:
|
||||||
|
_swap_local_column_sort_order(column_id, target_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'success': False, 'error': str(exc)}), 400
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({'success': False, 'error': str(exc)}), 500
|
||||||
|
return jsonify({'success': True, 'msg': '排序更新成功'})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
|
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_column(cid):
|
def delete_column(cid):
|
||||||
|
_, _, denied = _ensure_admin_menu_access('columns')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
f'/api/admin/permission-menus/{cid}',
|
f'/api/admin/permission-menus/{cid}',
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM columns WHERE id = %s", (cid,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/user/<int:uid>/columns')
|
@admin_api.route('/user/<int:uid>/columns')
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_user_columns(uid):
|
def get_user_columns(uid):
|
||||||
params = {}
|
_, _, denied = _ensure_admin_menu_access('users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
menu_type = (request.args.get('menu_type') or '').strip()
|
menu_type = (request.args.get('menu_type') or '').strip()
|
||||||
if menu_type:
|
|
||||||
params['menuType'] = menu_type
|
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
f'/api/admin/permission-users/{uid}/columns',
|
f'/api/admin/permission-users/{uid}/columns',
|
||||||
params=params or None,
|
params={'menuType': menu_type} if menu_type else None,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -579,14 +905,14 @@ def get_user_columns(uid):
|
|||||||
@admin_api.route('/user/<int:uid>/column-permissions')
|
@admin_api.route('/user/<int:uid>/column-permissions')
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_user_column_permissions(uid):
|
def get_user_column_permissions(uid):
|
||||||
params = {}
|
_, _, denied = _ensure_admin_menu_access('users')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
menu_type = (request.args.get('menu_type') or '').strip()
|
menu_type = (request.args.get('menu_type') or '').strip()
|
||||||
if menu_type:
|
|
||||||
params['menuType'] = menu_type
|
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
f'/api/admin/permission-users/{uid}/column-permissions',
|
f'/api/admin/permission-users/{uid}/column-permissions',
|
||||||
params=params or None,
|
params={'menuType': menu_type} if menu_type else None,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -610,6 +936,9 @@ def _set_user_column_permissions(cur, user_id, column_ids):
|
|||||||
@admin_api.route('/versions')
|
@admin_api.route('/versions')
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_versions():
|
def list_versions():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('version')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
"""获取版本列表"""
|
"""获取版本列表"""
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -637,6 +966,9 @@ def list_versions():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def upload_version():
|
def upload_version():
|
||||||
"""接收版本号 + zip 文件,上传到 OSS,写入 web_config"""
|
"""接收版本号 + zip 文件,上传到 OSS,写入 web_config"""
|
||||||
|
_, _, denied = _ensure_admin_menu_access('version')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
version = (request.form.get('version') or '').strip()
|
version = (request.form.get('version') or '').strip()
|
||||||
if not version:
|
if not version:
|
||||||
return jsonify({'success': False, 'error': '请填写版本号'})
|
return jsonify({'success': False, 'error': '请填写版本号'})
|
||||||
@@ -675,6 +1007,9 @@ def upload_version():
|
|||||||
@admin_api.route('/shop-keys')
|
@admin_api.route('/shop-keys')
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_shop_keys():
|
def list_shop_keys():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('shop-keys')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
@@ -707,6 +1042,9 @@ def list_shop_keys():
|
|||||||
@admin_api.route('/shop-key', methods=['POST'])
|
@admin_api.route('/shop-key', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_shop_key():
|
def create_shop_key():
|
||||||
|
_, _, denied = _ensure_admin_menu_access('shop-keys')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
payload = {
|
payload = {
|
||||||
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
||||||
@@ -736,6 +1074,9 @@ def create_shop_key():
|
|||||||
@admin_api.route('/shop-key/<int:item_id>', methods=['PUT'])
|
@admin_api.route('/shop-key/<int:item_id>', methods=['PUT'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_shop_key(item_id):
|
def update_shop_key(item_id):
|
||||||
|
_, _, denied = _ensure_admin_menu_access('shop-keys')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
payload = {
|
payload = {
|
||||||
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
|
||||||
@@ -765,6 +1106,9 @@ def update_shop_key(item_id):
|
|||||||
@admin_api.route('/shop-key/<int:item_id>', methods=['DELETE'])
|
@admin_api.route('/shop-key/<int:item_id>', methods=['DELETE'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_shop_key(item_id):
|
def delete_shop_key(item_id):
|
||||||
|
_, _, denied = _ensure_admin_menu_access('shop-keys')
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
f'/api/admin/shop-keys/{item_id}',
|
f'/api/admin/shop-keys/{item_id}',
|
||||||
@@ -1007,8 +1351,9 @@ def list_shop_manages():
|
|||||||
shop_name = (request.args.get('shop_name') or '').strip()
|
shop_name = (request.args.get('shop_name') or '').strip()
|
||||||
|
|
||||||
params = {'page': page, 'pageSize': page_size}
|
params = {'page': page, 'pageSize': page_size}
|
||||||
if role != 'super_admin' and current_row and current_row.get('id'):
|
if current_row and current_row.get('id'):
|
||||||
params['createdById'] = current_row.get('id')
|
params['operatorId'] = current_row.get('id')
|
||||||
|
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
|
||||||
if group_id_raw:
|
if group_id_raw:
|
||||||
try:
|
try:
|
||||||
group_id = int(group_id_raw)
|
group_id = int(group_id_raw)
|
||||||
@@ -1164,10 +1509,16 @@ def list_shop_manage_groups():
|
|||||||
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
|
params = {}
|
||||||
|
if current_row and current_row.get('id'):
|
||||||
|
params['operatorId'] = current_row.get('id')
|
||||||
|
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
|
||||||
|
if role != 'super_admin' and current_row and current_row.get('id'):
|
||||||
|
params['createdById'] = current_row.get('id')
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/admin/shop-manages/groups',
|
'/api/admin/shop-manages/groups',
|
||||||
params={'createdById': current_row.get('id')} if role != 'super_admin' and current_row and current_row.get('id') else None,
|
params=params,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -1175,6 +1526,8 @@ def list_shop_manage_groups():
|
|||||||
{
|
{
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'group_name': item.get('groupName') or '',
|
||||||
|
'user_id': item.get('userId'),
|
||||||
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||||
}
|
}
|
||||||
@@ -1186,17 +1539,22 @@ def list_shop_manage_groups():
|
|||||||
@admin_api.route('/shop-manage-group', methods=['POST'])
|
@admin_api.route('/shop-manage-group', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_shop_manage_group():
|
def create_shop_manage_group():
|
||||||
_, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
payload = {
|
payload = {
|
||||||
'groupName': (data.get('group_name') or '').strip(),
|
'groupName': (data.get('group_name') or '').strip(),
|
||||||
|
'userId': data.get('user_id'),
|
||||||
'createdById': current_row.get('id') if current_row else None,
|
'createdById': current_row.get('id') if current_row else None,
|
||||||
}
|
}
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/admin/shop-manages/groups',
|
'/api/admin/shop-manages/groups',
|
||||||
|
params={
|
||||||
|
'operatorId': current_row.get('id') if current_row else None,
|
||||||
|
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||||
|
},
|
||||||
json_data=payload,
|
json_data=payload,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
@@ -1208,6 +1566,8 @@ def create_shop_manage_group():
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'group_name': item.get('groupName') or '',
|
||||||
|
'user_id': item.get('userId'),
|
||||||
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||||
},
|
},
|
||||||
@@ -1221,7 +1581,10 @@ def update_shop_manage_group(item_id):
|
|||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
payload = {'groupName': (data.get('group_name') or '').strip()}
|
payload = {
|
||||||
|
'groupName': (data.get('group_name') or '').strip(),
|
||||||
|
'userId': data.get('user_id'),
|
||||||
|
}
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'PUT',
|
'PUT',
|
||||||
f'/api/admin/shop-manages/groups/{item_id}',
|
f'/api/admin/shop-manages/groups/{item_id}',
|
||||||
@@ -1240,6 +1603,8 @@ def update_shop_manage_group(item_id):
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'group_name': item.get('groupName') or '',
|
||||||
|
'user_id': item.get('userId'),
|
||||||
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||||
},
|
},
|
||||||
@@ -1249,7 +1614,7 @@ def update_shop_manage_group(item_id):
|
|||||||
@admin_api.route('/skip-price-asins')
|
@admin_api.route('/skip-price-asins')
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_skip_price_asins():
|
def list_skip_price_asins():
|
||||||
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
@@ -1258,6 +1623,9 @@ def list_skip_price_asins():
|
|||||||
shop_name = (request.args.get('shop_name') or '').strip()
|
shop_name = (request.args.get('shop_name') or '').strip()
|
||||||
asin = (request.args.get('asin') or '').strip()
|
asin = (request.args.get('asin') or '').strip()
|
||||||
params = {'page': page, 'pageSize': page_size}
|
params = {'page': page, 'pageSize': page_size}
|
||||||
|
if current_row and current_row.get('id'):
|
||||||
|
params['operatorId'] = current_row.get('id')
|
||||||
|
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
|
||||||
if group_id_raw:
|
if group_id_raw:
|
||||||
try:
|
try:
|
||||||
group_id = int(group_id_raw)
|
group_id = int(group_id_raw)
|
||||||
@@ -1305,19 +1673,31 @@ def list_skip_price_asins():
|
|||||||
@admin_api.route('/skip-price-asin', methods=['POST'])
|
@admin_api.route('/skip-price-asin', methods=['POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_skip_price_asin():
|
def create_skip_price_asin():
|
||||||
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
asin_mappings = data.get('asin_mappings') or {}
|
||||||
|
fallback_asin = (data.get('asin') or '').strip()
|
||||||
|
if not fallback_asin and isinstance(asin_mappings, dict):
|
||||||
|
for value in asin_mappings.values():
|
||||||
|
fallback_asin = (value or '').strip()
|
||||||
|
if fallback_asin:
|
||||||
|
break
|
||||||
payload = {
|
payload = {
|
||||||
'groupId': data.get('group_id'),
|
'groupId': data.get('group_id'),
|
||||||
'shopName': (data.get('shop_name') or '').strip(),
|
'shopName': (data.get('shop_name') or '').strip(),
|
||||||
'countries': data.get('countries') or [],
|
'countries': data.get('countries') or [],
|
||||||
'asin': (data.get('asin') or '').strip(),
|
'asin': fallback_asin,
|
||||||
|
'asinMappings': asin_mappings,
|
||||||
}
|
}
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/admin/skip-price-asins',
|
'/api/admin/skip-price-asins',
|
||||||
|
params={
|
||||||
|
'operatorId': current_row.get('id') if current_row else None,
|
||||||
|
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||||
|
},
|
||||||
json_data=payload,
|
json_data=payload,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
@@ -1345,12 +1725,16 @@ def create_skip_price_asin():
|
|||||||
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['DELETE'])
|
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['DELETE'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_skip_price_asin_country(item_id, country):
|
def delete_skip_price_asin_country(item_id, country):
|
||||||
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
|
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
|
||||||
|
params={
|
||||||
|
'operatorId': current_row.get('id') if current_row else None,
|
||||||
|
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -1379,7 +1763,7 @@ def delete_shop_manage_group(item_id):
|
|||||||
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
|
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def update_skip_price_asin_country(item_id, country):
|
def update_skip_price_asin_country(item_id, country):
|
||||||
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
|
role, current_row, denied = _ensure_admin_menu_access('skip-price-asin')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -1387,6 +1771,10 @@ def update_skip_price_asin_country(item_id, country):
|
|||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'PUT',
|
'PUT',
|
||||||
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
|
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
|
||||||
|
params={
|
||||||
|
'operatorId': current_row.get('id') if current_row else None,
|
||||||
|
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||||
|
},
|
||||||
json_data=payload,
|
json_data=payload,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
|
|||||||
Binary file not shown.
@@ -122,10 +122,18 @@ def init_db():
|
|||||||
cur.execute("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type")
|
cur.execute("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
cur.execute("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''")
|
cur.execute("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
|
|||||||
2337
backend/web_source/_admin_extracted.js
Normal file
2337
backend/web_source/_admin_extracted.js
Normal file
File diff suppressed because it is too large
Load Diff
2000
backend/web_source/_tmp_prefix.js
Normal file
2000
backend/web_source/_tmp_prefix.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,7 @@ import {
|
|||||||
deleteDeleteBrandHistory,
|
deleteDeleteBrandHistory,
|
||||||
getDeleteBrandHistory,
|
getDeleteBrandHistory,
|
||||||
getDeleteBrandTaskDetails,
|
getDeleteBrandTaskDetails,
|
||||||
|
getDeleteBrandTaskProgress,
|
||||||
getDeleteBrandTaskDownloadUrl,
|
getDeleteBrandTaskDownloadUrl,
|
||||||
runDeleteBrand,
|
runDeleteBrand,
|
||||||
type DeleteBrandPreviewRow,
|
type DeleteBrandPreviewRow,
|
||||||
@@ -853,7 +854,7 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
|||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batch = await getDeleteBrandTaskDetails(ids)
|
const batch = await getDeleteBrandTaskProgress(ids)
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const detail of batch.items || []) {
|
for (const detail of batch.items || []) {
|
||||||
const id = detail?.task?.id
|
const id = detail?.task?.id
|
||||||
@@ -876,7 +877,7 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 4500 : 10000
|
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextPoll(immediate = false) {
|
function scheduleNextPoll(immediate = false) {
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
<div class="section-title">跟价模式</div>
|
<div class="section-title">跟价模式</div>
|
||||||
<div class="mode-zone">
|
<div class="mode-zone">
|
||||||
<label class="mode-check-row">
|
<label class="mode-check-row">
|
||||||
<input type="checkbox" class="mode-check-input" v-model="statusModeEnabled" />
|
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="statusModeEnabled" @change="selectMode('status')" />
|
||||||
<span class="mode-check-text">按商品状态(全量)</span>
|
<span class="mode-check-text">按商品状态(全量)</span>
|
||||||
<span class="mode-hint-inline">— 获取店铺所有在售ASIN跑跟价</span>
|
<span class="mode-hint-inline">— 获取店铺所有在售ASIN跑跟价</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="mode-check-row">
|
<label class="mode-check-row">
|
||||||
<input type="checkbox" class="mode-check-input" v-model="asinModeEnabled" />
|
<input type="radio" name="price-track-mode" class="mode-check-input" :checked="asinModeEnabled" @change="selectMode('asin')" />
|
||||||
<span class="mode-check-text">按指定ASIN文档(自定义)</span>
|
<span class="mode-check-text">按指定ASIN文档(自定义)</span>
|
||||||
<span class="mode-hint-inline">— 上传ASIN表格,按文档内ASIN跑跟价</span>
|
<span class="mode-hint-inline">— 上传ASIN表格,按文档内ASIN跑跟价</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<div class="hint">上传包含ASIN的表格文件,按文档内的ASIN进行跟价。</div>
|
<div class="hint">上传包含ASIN的表格文件,按文档内的ASIN进行跟价。</div>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button type="button" class="opt-btn" @click="selectAsinFile">选择ASIN文件</button>
|
<button type="button" class="opt-btn" @click="selectAsinFile">选择ASIN文件</button>
|
||||||
|
<button type="button" class="opt-btn" @click="selectAsinFolder">选择文件夹</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="selected-files clean-placeholder">
|
<div class="selected-files clean-placeholder">
|
||||||
<template v-if="asinFiles.length">
|
<template v-if="asinFiles.length">
|
||||||
@@ -225,6 +226,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
|
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
import {
|
import {
|
||||||
addPriceTrackCandidate,
|
addPriceTrackCandidate,
|
||||||
@@ -237,11 +239,14 @@ import {
|
|||||||
getPriceTrackDashboard,
|
getPriceTrackDashboard,
|
||||||
getPriceTrackHistory,
|
getPriceTrackHistory,
|
||||||
getPriceTrackResultDownloadUrl,
|
getPriceTrackResultDownloadUrl,
|
||||||
|
getPriceTrackTaskProgressBatch,
|
||||||
getPriceTrackTasksBatch,
|
getPriceTrackTasksBatch,
|
||||||
listPriceTrackCandidates,
|
listPriceTrackCandidates,
|
||||||
matchPriceTrackShops,
|
matchPriceTrackShops,
|
||||||
putPriceTrackCountryPreference,
|
putPriceTrackCountryPreference,
|
||||||
|
type PriceTrackAsinParsedRow,
|
||||||
type PriceTrackCandidateVo,
|
type PriceTrackCandidateVo,
|
||||||
|
type PriceTrackCreateTaskVo,
|
||||||
type PriceTrackDashboardVo,
|
type PriceTrackDashboardVo,
|
||||||
type PriceTrackHistoryItem,
|
type PriceTrackHistoryItem,
|
||||||
type PriceTrackShopQueueItem,
|
type PriceTrackShopQueueItem,
|
||||||
@@ -267,6 +272,7 @@ const matching = ref(false)
|
|||||||
const pushing = ref(false)
|
const pushing = ref(false)
|
||||||
const queuePushResult = ref('')
|
const queuePushResult = ref('')
|
||||||
const queuePayloadText = ref('')
|
const queuePayloadText = ref('')
|
||||||
|
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
|
||||||
|
|
||||||
// 跟价模式(可同时启用)
|
// 跟价模式(可同时启用)
|
||||||
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
const statusModeEnabled = ref(false) // 按商品状态(全量)
|
||||||
@@ -404,20 +410,56 @@ function saveMatchedItemsToStorage() {
|
|||||||
// ========== 文件上传 ==========
|
// ========== 文件上传 ==========
|
||||||
async function selectAsinFile() {
|
async function selectAsinFile() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.select_files) {
|
if (api?.select_files) {
|
||||||
ElMessage.error('当前环境不支持选择文件')
|
const result = await api.select_files({
|
||||||
|
filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }],
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
if (result?.paths?.length) {
|
||||||
|
asinFiles.value = result.paths
|
||||||
|
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await api.select_files({ filters: [{ name: 'Excel/CSV', extensions: 'xlsx,xls,csv' }] })
|
if (api?.select_brand_xlsx_files) {
|
||||||
if (result?.paths?.length) {
|
const paths = await api.select_brand_xlsx_files()
|
||||||
asinFiles.value = result.paths
|
if (!paths?.length) return
|
||||||
ElMessage.success(`已选择 ${result.paths.length} 个ASIN文件`)
|
asinFiles.value = paths
|
||||||
|
ElMessage.success(`已选择 ${paths.length} 个ASIN文件`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.warning('当前环境不支持文件选择,请在本地客户端中打开')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAsinFolder() {
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.select_brand_folder) {
|
||||||
|
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const folder = await api.select_brand_folder()
|
||||||
|
if (!folder) return
|
||||||
|
const result = await expandBrandFolderRecursive(folder)
|
||||||
|
if (!result.success || !result.items?.length) {
|
||||||
|
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asinFiles.value = result.items.map((item) => item.absolutePath)
|
||||||
|
ElMessage.success(`已选择文件夹内 ${result.items.length} 个ASIN文件`)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ========== 国家顺序相关 ==========
|
// ========== 国家顺序相关 ==========
|
||||||
|
function selectMode(mode: 'status' | 'asin') {
|
||||||
|
statusModeEnabled.value = mode === 'status'
|
||||||
|
asinModeEnabled.value = mode === 'asin'
|
||||||
|
}
|
||||||
|
|
||||||
function countryLabel(code: string) {
|
function countryLabel(code: string) {
|
||||||
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
||||||
return row?.label ?? code
|
return row?.label ?? code
|
||||||
@@ -595,6 +637,15 @@ function collectShopNamesForMatch(): string[] {
|
|||||||
for (const r of selectedCandidates.value) {
|
for (const r of selectedCandidates.value) {
|
||||||
if (r?.shopName?.trim()) set.add(r.shopName.trim())
|
if (r?.shopName?.trim()) set.add(r.shopName.trim())
|
||||||
}
|
}
|
||||||
|
if (asinModeEnabled.value) {
|
||||||
|
for (const filePath of asinFiles.value) {
|
||||||
|
const normalizedPath = (filePath || '').trim()
|
||||||
|
if (!normalizedPath) continue
|
||||||
|
const filename = normalizedPath.split(/[/\\]/).filter(Boolean).pop() || ''
|
||||||
|
const shopName = filename.replace(/\.[^.]+$/, '').trim()
|
||||||
|
if (shopName) set.add(shopName)
|
||||||
|
}
|
||||||
|
}
|
||||||
return [...set]
|
return [...set]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,11 +655,25 @@ async function runMatch() {
|
|||||||
ElMessage.warning('请勾选备选店铺,或在输入框中填写店名')
|
ElMessage.warning('请勾选备选店铺,或在输入框中填写店名')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||||
|
ElMessage.warning('请先选择 ASIN 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
matching.value = true
|
matching.value = true
|
||||||
try {
|
try {
|
||||||
const res = await matchPriceTrackShops(names)
|
const res = await matchPriceTrackShops(names, {
|
||||||
|
asinFiles: asinModeEnabled.value ? asinFiles.value : [],
|
||||||
|
countryCodes: [...orderedCountryCodes.value],
|
||||||
|
})
|
||||||
const batch = res.items || []
|
const batch = res.items || []
|
||||||
matchedItems.value = [...matchedItems.value, ...batch.filter((n) => !matchedItems.value.some((e) => e.shopName === n.shopName))]
|
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
|
||||||
|
const nextByShop = new Map(
|
||||||
|
matchedItems.value.map((item) => [((item.shopName || '').trim()), item] as const),
|
||||||
|
)
|
||||||
|
for (const item of batch) {
|
||||||
|
nextByShop.set((item.shopName || '').trim(), item)
|
||||||
|
}
|
||||||
|
matchedItems.value = [...nextByShop.values()]
|
||||||
saveMatchedItemsToStorage()
|
saveMatchedItemsToStorage()
|
||||||
ElMessage.success(`匹配 ${batch.length} 个店铺`)
|
ElMessage.success(`匹配 ${batch.length} 个店铺`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -668,11 +733,15 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushToPythonQueue() {
|
async function pushToPythonQueueLegacy() {
|
||||||
if (!hasValidMode.value) {
|
if (!hasValidMode.value) {
|
||||||
ElMessage.warning('请至少选择一个跟价模式')
|
ElMessage.warning('请至少选择一个跟价模式')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||||
|
ElMessage.warning('请先选择 ASIN 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.enqueue_json) {
|
if (!api?.enqueue_json) {
|
||||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||||
@@ -742,15 +811,146 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== 任务状态轮询 ==========
|
// ========== 任务状态轮询 ==========
|
||||||
|
function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQueueItem) {
|
||||||
|
const shopName = (row.shopName || '').trim()
|
||||||
|
const skipAsinsByCountry = row.skipAsins || taskVo.skipAsinsByCountry || {}
|
||||||
|
return {
|
||||||
|
type: 'price-track-run',
|
||||||
|
ts: Date.now(),
|
||||||
|
data: {
|
||||||
|
task_id: taskVo.taskId,
|
||||||
|
shop_name: shopName,
|
||||||
|
shop_id: row.shopId ?? null,
|
||||||
|
country_codes: [...orderedCountryCodes.value],
|
||||||
|
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||||
|
skip_asins: skipAsinsByCountry,
|
||||||
|
skip_asins_by_country: skipAsinsByCountry,
|
||||||
|
asin_rows_by_country: Object.keys(matchAsinRowsByCountry.value).length
|
||||||
|
? matchAsinRowsByCountry.value
|
||||||
|
: (taskVo.asinRowsByCountry || {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTaskTerminal(taskId: number) {
|
||||||
|
while (true) {
|
||||||
|
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||||
|
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||||||
|
removePollingTask(taskId)
|
||||||
|
await loadHistory()
|
||||||
|
await loadDashboard()
|
||||||
|
syncPollingIdsWithHistory()
|
||||||
|
return 'FAILED'
|
||||||
|
}
|
||||||
|
const detail = (batch.items || []).find((item) => item.task?.id === taskId)
|
||||||
|
const status = detail?.task?.status || ''
|
||||||
|
if (detail) {
|
||||||
|
const prev = taskSnapshots.value[taskId]
|
||||||
|
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }
|
||||||
|
saveTaskSnapshotsToStorage()
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
taskDetails.value = { ...taskDetails.value, [taskId]: status }
|
||||||
|
saveTaskDetailsToStorage()
|
||||||
|
}
|
||||||
|
if (status === 'SUCCESS' || status === 'FAILED') {
|
||||||
|
removePollingTask(taskId)
|
||||||
|
await loadHistory()
|
||||||
|
await loadDashboard()
|
||||||
|
syncPollingIdsWithHistory()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushToPythonQueue() {
|
||||||
|
if (!hasValidMode.value) {
|
||||||
|
ElMessage.warning('请至少选择一个跟价模式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (asinModeEnabled.value && !asinFiles.value.length) {
|
||||||
|
ElMessage.warning('请先选择 ASIN 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.enqueue_json) {
|
||||||
|
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const matchedRows = matchedItems.value.filter((i) => i.matched)
|
||||||
|
if (!matchedRows.length) {
|
||||||
|
ElMessage.warning('无可用匹配店铺')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushing.value = true
|
||||||
|
queuePayloadText.value = ''
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < matchedRows.length; index += 1) {
|
||||||
|
const row = matchedRows[index]
|
||||||
|
const taskVo = await createPriceTrackTask({
|
||||||
|
userId: 0,
|
||||||
|
statusMode: statusModeEnabled.value,
|
||||||
|
asinMode: asinModeEnabled.value,
|
||||||
|
items: [row] as unknown as Record<string, unknown>[],
|
||||||
|
asinFiles: asinFiles.value,
|
||||||
|
countryCodes: [...orderedCountryCodes.value],
|
||||||
|
})
|
||||||
|
taskSnapshots.value = {
|
||||||
|
...taskSnapshots.value,
|
||||||
|
[taskVo.taskId]: {
|
||||||
|
task: { id: taskVo.taskId, status: 'RUNNING' },
|
||||||
|
items: taskVo.items,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
taskDetails.value = {
|
||||||
|
...taskDetails.value,
|
||||||
|
[taskVo.taskId]: 'RUNNING',
|
||||||
|
}
|
||||||
|
saveTaskSnapshotsToStorage()
|
||||||
|
saveTaskDetailsToStorage()
|
||||||
|
|
||||||
|
const queuePayload = buildQueuePayload(taskVo, row)
|
||||||
|
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
|
||||||
|
const pushResult = await api.enqueue_json(queuePayload)
|
||||||
|
if (!pushResult?.success) {
|
||||||
|
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
|
||||||
|
}
|
||||||
|
|
||||||
|
addPollingTask(taskVo.taskId)
|
||||||
|
scheduleNextPoll(true)
|
||||||
|
removeMatchedRowsLocally([row])
|
||||||
|
queuePushResult.value = matchedRows.length > 1
|
||||||
|
? `任务 ${taskVo.taskId} 已入队,等待完成后继续下一条(${index + 1}/${matchedRows.length})`
|
||||||
|
: `任务 ${taskVo.taskId} 已入队,等待执行完成`
|
||||||
|
|
||||||
|
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
|
||||||
|
if (finalStatus !== 'SUCCESS') {
|
||||||
|
throw new Error(`任务 ${taskVo.taskId} 执行失败,已停止后续任务`)
|
||||||
|
}
|
||||||
|
|
||||||
|
queuePushResult.value = index + 1 < matchedRows.length
|
||||||
|
? `任务 ${taskVo.taskId} 已完成,继续推送下一条(${index + 1}/${matchedRows.length})`
|
||||||
|
: `任务 ${taskVo.taskId} 已完成`
|
||||||
|
}
|
||||||
|
ElMessage.success(`已串行完成 ${matchedRows.length} 条店铺任务推送`)
|
||||||
|
} catch (e) {
|
||||||
|
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||||
|
ElMessage.error(queuePushResult.value)
|
||||||
|
} finally {
|
||||||
|
pushing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 4500 : 10000
|
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTaskBatch() {
|
async function refreshTaskBatch() {
|
||||||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
try {
|
try {
|
||||||
const batch = await getPriceTrackTasksBatch(ids)
|
const batch = await getPriceTrackTaskProgressBatch(ids)
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextSnapshots = { ...taskSnapshots.value }
|
const nextSnapshots = { ...taskSnapshots.value }
|
||||||
for (const missingId of batch.missingTaskIds || []) {
|
for (const missingId of batch.missingTaskIds || []) {
|
||||||
@@ -761,7 +961,8 @@ async function refreshTaskBatch() {
|
|||||||
const taskId = detail.task?.id
|
const taskId = detail.task?.id
|
||||||
const status = detail.task?.status
|
const status = detail.task?.status
|
||||||
if (typeof taskId !== 'number' || taskId <= 0) continue
|
if (typeof taskId !== 'number' || taskId <= 0) continue
|
||||||
nextSnapshots[taskId] = detail
|
const prev = nextSnapshots[taskId]
|
||||||
|
nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail
|
||||||
if (status) {
|
if (status) {
|
||||||
taskDetails.value[taskId] = status
|
taskDetails.value[taskId] = status
|
||||||
if (status === 'SUCCESS' || status === 'FAILED') {
|
if (status === 'SUCCESS' || status === 'FAILED') {
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ import {
|
|||||||
getProductRiskDashboard,
|
getProductRiskDashboard,
|
||||||
getProductRiskHistory,
|
getProductRiskHistory,
|
||||||
getProductRiskResultDownloadUrl,
|
getProductRiskResultDownloadUrl,
|
||||||
|
getProductRiskTaskProgressBatch,
|
||||||
getProductRiskTasksBatch,
|
getProductRiskTasksBatch,
|
||||||
listProductRiskCandidates,
|
listProductRiskCandidates,
|
||||||
matchProductRiskShops,
|
matchProductRiskShops,
|
||||||
@@ -641,14 +642,15 @@ async function refreshTaskBatch() {
|
|||||||
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
const ids = pollingTaskIds.value.filter((id) => id > 0)
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
try {
|
try {
|
||||||
const batch = await getProductRiskTasksBatch(ids)
|
const batch = await getProductRiskTaskProgressBatch(ids)
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextSnapshots = { ...taskSnapshots.value }
|
const nextSnapshots = { ...taskSnapshots.value }
|
||||||
for (const d of batch.items || []) {
|
for (const d of batch.items || []) {
|
||||||
const id = d.task?.id
|
const id = d.task?.id
|
||||||
const st = d.task?.status
|
const st = d.task?.status
|
||||||
if (typeof id === 'number' && id > 0) {
|
if (typeof id === 'number' && id > 0) {
|
||||||
nextSnapshots[id] = d
|
const prev = nextSnapshots[id]
|
||||||
|
nextSnapshots[id] = prev ? { ...prev, task: { ...(prev.task || {}), ...(d.task || {}) } } : d
|
||||||
if (st) {
|
if (st) {
|
||||||
taskDetails.value[id] = st
|
taskDetails.value[id] = st
|
||||||
if (st === 'SUCCESS' || st === 'FAILED') {
|
if (st === 'SUCCESS' || st === 'FAILED') {
|
||||||
@@ -671,7 +673,7 @@ async function refreshTaskBatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 4500 : 10000
|
return document.visibilityState === 'visible' ? 6000 : 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextPoll(immediate = false) {
|
function scheduleNextPoll(immediate = false) {
|
||||||
@@ -717,14 +719,15 @@ function stopPolling() {
|
|||||||
|
|
||||||
async function waitForTaskTerminal(taskId: number) {
|
async function waitForTaskTerminal(taskId: number) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const batch = await getProductRiskTasksBatch([taskId])
|
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||||
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
||||||
const status = detail?.task?.status || ''
|
const status = detail?.task?.status || ''
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
const prev = taskSnapshots.value[taskId]
|
||||||
taskSnapshots.value = {
|
taskSnapshots.value = {
|
||||||
...taskSnapshots.value,
|
...taskSnapshots.value,
|
||||||
[taskId]: detail,
|
[taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail,
|
||||||
}
|
}
|
||||||
saveTaskSnapshotsToStorage()
|
saveTaskSnapshotsToStorage()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
<label class="schedule-switch"><input v-model="scheduleEnabled" type="checkbox" :disabled="pushing" @change="onScheduleToggle" /><span>启用多时间点调度</span></label>
|
<label class="schedule-switch"><input v-model="scheduleEnabled" type="checkbox" :disabled="pushing" @change="onScheduleToggle" /><span>启用多时间点调度</span></label>
|
||||||
<div v-if="scheduleEnabled" class="schedule-config">
|
<div v-if="scheduleEnabled" class="schedule-config">
|
||||||
<div v-for="(value, index) in schedulePickerValues" :key="`schedule-${index}`" class="schedule-row">
|
<div v-for="(value, index) in schedulePickerValues" :key="`schedule-${index}`" class="schedule-row">
|
||||||
<el-time-picker :model-value="value" value-format="HH:mm:ss" format="HH:mm" placeholder="选择执行时间" :disabled="pushing" :disabled-hours="disablePastScheduleHours" :disabled-minutes="disablePastScheduleMinutes" :disabled-seconds="disableScheduleSeconds" class="schedule-picker" @update:model-value="updateScheduleValue(index, $event)" />
|
<el-date-picker :model-value="value" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" format="MM-DD HH:mm" placeholder="选择执行时间" :disabled="pushing" class="schedule-picker" @update:model-value="updateScheduleValue(index, $event)" />
|
||||||
<button type="button" class="schedule-remove" :disabled="pushing || schedulePickerValues.length <= 1" @click="removeScheduleValue(index)">删除</button>
|
<button type="button" class="schedule-remove" :disabled="pushing || schedulePickerValues.length <= 1" @click="removeScheduleValue(index)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button>
|
<button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button>
|
||||||
<p class="hint schedule-hint">默认开启定时;只需选择 24 小时制的时分,系统会自动按最近可执行时间顺序处理。</p>
|
<p class="hint schedule-hint">默认开启定时;支持按 `MM-DD HH:mm` 配置多个时间点,前端不再限制选择更早时间,实际是否可执行以后端校验为准。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="run-row">
|
<div class="run-row">
|
||||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
|
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
|
||||||
@@ -108,7 +108,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||||
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
|
|
||||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||||
@@ -128,7 +128,7 @@ const countryPrefSaving = ref(false)
|
|||||||
const countryPrefUserTouched = ref(false)
|
const countryPrefUserTouched = ref(false)
|
||||||
let countryPrefSaveTimer: ReturnType<typeof setTimeout> | null = null
|
let countryPrefSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const scheduleEnabled = ref(true)
|
const scheduleEnabled = ref(true)
|
||||||
const schedulePickerValues = ref<string[]>([getMinimumScheduleTime()])
|
const schedulePickerValues = ref<string[]>([getDefaultScheduleValue()])
|
||||||
const dashboard = ref<ShopMatchDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
|
const dashboard = ref<ShopMatchDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
|
||||||
const historyItems = ref<ShopMatchHistoryItem[]>([])
|
const historyItems = ref<ShopMatchHistoryItem[]>([])
|
||||||
const pollingTaskIds = ref<number[]>([])
|
const pollingTaskIds = ref<number[]>([])
|
||||||
@@ -158,20 +158,27 @@ function rowKeyForMatch(row: ShopMatchShopQueueItem) { return `${(row.shopName |
|
|||||||
function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item.taskId)}-${item.resultId || 0}-${item.shopName || ''}` }
|
function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item.taskId)}-${item.resultId || 0}-${item.shopName || ''}` }
|
||||||
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
|
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
|
||||||
function formatScheduleDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` }
|
function formatScheduleDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` }
|
||||||
function formatScheduleTime(date: Date) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}` }
|
function formatPickerDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:00` }
|
||||||
function getMinimumScheduleDate() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return date }
|
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return formatPickerDateTime(date) }
|
||||||
function getMinimumScheduleTime() { return formatScheduleTime(getMinimumScheduleDate()) }
|
function parseScheduleDateTime(value?: string | null) {
|
||||||
function parseTimeParts(value: string) { const matched = value.match(/^(\d{2}):(\d{2})(?::(\d{2}))?$/); if (!matched) return null; const hours = Number(matched[1]); const minutes = Number(matched[2]); const seconds = Number(matched[3] || '0'); if (hours > 23 || minutes > 59 || seconds > 59) return null; return { hours, minutes, seconds } }
|
if (!value) return null
|
||||||
function buildTodayTime(parts: { hours: number; minutes: number; seconds: number }) { const candidate = new Date(); candidate.setHours(parts.hours, parts.minutes, parts.seconds, 0); return candidate }
|
const normalized = value.trim().replace('T', ' ')
|
||||||
function resolveScheduleDateTime(value: string) { const parts = parseTimeParts(value); if (!parts) return null; const minimum = getMinimumScheduleDate(); const candidate = new Date(); candidate.setHours(parts.hours, parts.minutes, parts.seconds, 0); if (candidate.getTime() < minimum.getTime()) candidate.setDate(candidate.getDate() + 1); return candidate }
|
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/)
|
||||||
function nextMinuteString(offsetMinutes: number) { const date = getMinimumScheduleDate(); date.setMinutes(date.getMinutes() + Math.max(0, offsetMinutes - 1)); return formatScheduleTime(date) }
|
if (!matched) return null
|
||||||
function nextMinuteAfter(value: string, offsetMinutes: number) { const date = resolveScheduleDateTime(value); if (!date) return nextMinuteString(offsetMinutes); date.setMinutes(date.getMinutes() + offsetMinutes); return formatScheduleTime(date) }
|
const year = Number(matched[1])
|
||||||
function normalizeScheduleValue(value: string | null | undefined, showWarning = false) { if (!value) return ''; const parts = parseTimeParts(value); if (!parts) return value; const minimum = getMinimumScheduleDate(); const candidate = buildTodayTime(parts); if (candidate.getTime() < minimum.getTime()) { if (showWarning) ElMessage.warning('执行时间不能早于当前时间,已自动调整到当前时间后 1 分钟'); return formatScheduleTime(minimum) } return formatScheduleTime(candidate) }
|
const month = Number(matched[2])
|
||||||
function disablePastScheduleHours() { const minimum = getMinimumScheduleDate(); return Array.from({ length: minimum.getHours() }, (_, index) => index) }
|
const day = Number(matched[3])
|
||||||
function disablePastScheduleMinutes(selectedHour: number) { const minimum = getMinimumScheduleDate(); if (selectedHour !== minimum.getHours()) return []; return Array.from({ length: minimum.getMinutes() }, (_, index) => index) }
|
const hours = Number(matched[4])
|
||||||
function disableScheduleSeconds() { return Array.from({ length: 59 }, (_, index) => index + 1) }
|
const minutes = Number(matched[5])
|
||||||
|
const seconds = Number(matched[6] || '0')
|
||||||
|
const date = new Date(year, month - 1, day, hours, minutes, seconds, 0)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
function nextScheduleValueAfter(value: string, offsetMinutes: number) { const date = parseScheduleDateTime(value) || new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + offsetMinutes); return formatPickerDateTime(date) }
|
||||||
function formatDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
|
function formatDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
|
||||||
function formatTimeOnly(value?: string) { return value ? value.slice(11, 16) : '-' }
|
function formatMonthDayTime(value?: string) { const date = parseScheduleDateTime(value); if (!date) return '-'; const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${month}-${day} ${hours}:${minutes}` }
|
||||||
function uniqueTaskRows(rows: ShopMatchHistoryItem[], snapshots: Record<number, ShopMatchTaskDetailVo>, section: 'current' | 'history') { const map = new Map<string, ShopMatchHistoryItem>(); for (const row of rows) map.set(taskRowKey(row), row); for (const [taskIdText, snapshot] of Object.entries(snapshots)) { const taskId = normalizeTaskId(taskIdText); const first = snapshot.items?.[0]; if (!first || !taskId) continue; const terminal = isTaskTerminalById(taskId); if ((section === 'current' && terminal) || (section === 'history' && !terminal)) continue; const key = taskRowKey({ ...first, taskId }); if (!map.has(key)) map.set(key, { ...first, taskId }) } return Array.from(map.values()) }
|
function uniqueTaskRows(rows: ShopMatchHistoryItem[], snapshots: Record<number, ShopMatchTaskDetailVo>, section: 'current' | 'history') { const map = new Map<string, ShopMatchHistoryItem>(); for (const row of rows) map.set(taskRowKey(row), row); for (const [taskIdText, snapshot] of Object.entries(snapshots)) { const taskId = normalizeTaskId(taskIdText); const first = snapshot.items?.[0]; if (!first || !taskId) continue; const terminal = isTaskTerminalById(taskId); if ((section === 'current' && terminal) || (section === 'history' && !terminal)) continue; const key = taskRowKey({ ...first, taskId }); if (!map.has(key)) map.set(key, { ...first, taskId }) } return Array.from(map.values()) }
|
||||||
function loadPollingIdsFromStorage() { try { const raw = window.localStorage.getItem(sessionKey()); const parsed = raw ? JSON.parse(raw) : []; pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((item): item is number => typeof item === 'number' && item > 0) : [] } catch { pollingTaskIds.value = [] } }
|
function loadPollingIdsFromStorage() { try { const raw = window.localStorage.getItem(sessionKey()); const parsed = raw ? JSON.parse(raw) : []; pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((item): item is number => typeof item === 'number' && item > 0) : [] } catch { pollingTaskIds.value = [] } }
|
||||||
function savePollingIds() { setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0) }
|
function savePollingIds() { setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0) }
|
||||||
@@ -228,36 +235,37 @@ function onCountryToggle(code: string, checked: boolean) { countryPrefUserTouche
|
|||||||
function onCountryDragStart(index: number) { dragCountryIndex.value = index }
|
function onCountryDragStart(index: number) { dragCountryIndex.value = index }
|
||||||
function onCountryDragEnd() { dragCountryIndex.value = null }
|
function onCountryDragEnd() { dragCountryIndex.value = null }
|
||||||
function onCountryDrop(toIndex: number) { const from = dragCountryIndex.value; dragCountryIndex.value = null; if (from == null || from === toIndex) return; countryPrefUserTouched.value = true; const list = [...orderedCountryCodes.value]; const [item] = list.splice(from, 1); list.splice(toIndex, 0, item); orderedCountryCodes.value = list; scheduleSaveCountryPreference() }
|
function onCountryDrop(toIndex: number) { const from = dragCountryIndex.value; dragCountryIndex.value = null; if (from == null || from === toIndex) return; countryPrefUserTouched.value = true; const list = [...orderedCountryCodes.value]; const [item] = list.splice(from, 1); list.splice(toIndex, 0, item); orderedCountryCodes.value = list; scheduleSaveCountryPreference() }
|
||||||
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getMinimumScheduleTime()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
|
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getDefaultScheduleValue()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
|
||||||
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextMinuteAfter(last, 60) : getMinimumScheduleTime(); schedulePickerValues.value = [...schedulePickerValues.value, normalizeScheduleValue(nextValue)] }
|
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextScheduleValueAfter(last, 60) : getDefaultScheduleValue(); schedulePickerValues.value = [...schedulePickerValues.value, nextValue] }
|
||||||
function removeScheduleValue(index: number) { if (schedulePickerValues.value.length <= 1) return; schedulePickerValues.value = schedulePickerValues.value.filter((_, idx) => idx !== index) }
|
function removeScheduleValue(index: number) { if (schedulePickerValues.value.length <= 1) return; schedulePickerValues.value = schedulePickerValues.value.filter((_, idx) => idx !== index) }
|
||||||
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value ? normalizeScheduleValue(value, true) : ''; schedulePickerValues.value = list }
|
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value || ''; schedulePickerValues.value = list }
|
||||||
function mergeMatchedItems(base: ShopMatchShopQueueItem[], incoming: ShopMatchShopQueueItem[]) { const map = new Map<string, ShopMatchShopQueueItem>(); for (const item of base) map.set(rowKeyForMatch(item), item); for (const item of incoming) map.set(rowKeyForMatch(item), item); return Array.from(map.values()) }
|
function mergeMatchedItems(base: ShopMatchShopQueueItem[], incoming: ShopMatchShopQueueItem[]) { const map = new Map<string, ShopMatchShopQueueItem>(); for (const item of base) map.set(rowKeyForMatch(item), item); for (const item of incoming) map.set(rowKeyForMatch(item), item); return Array.from(map.values()) }
|
||||||
async function runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
|
async function runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
|
||||||
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
|
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
|
||||||
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
|
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
|
||||||
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const normalized = normalizeScheduleValue(value); const date = resolveScheduleDateTime(normalized); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
|
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const date = parseScheduleDateTime(value); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
|
||||||
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
||||||
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
||||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await activateShopMatchTask(taskId, stage.stageIndex); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||||
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
|
function hasRunningTask() { return Object.values(taskDetails.value).some((status) => status === 'RUNNING') || Object.values(taskSnapshots.value).some((detail) => detail.task?.status === 'RUNNING') }
|
||||||
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = new Date(stage.scheduledAt).getTime(); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
|
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
|
||||||
|
function findNextReadyScheduledTask() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready[0] || null }
|
||||||
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } finally { scheduledDispatchInFlight.value = false } }
|
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value || hasRunningTask()) return; const readyTask = findNextReadyScheduledTask(); if (!readyTask) return; scheduledDispatchInFlight.value = true; try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } finally { scheduledDispatchInFlight.value = false } }
|
||||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = new Date(scheduledAt).getTime(); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
|
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
|
||||||
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
|
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
|
||||||
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
||||||
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
function stopScheduleHeartbeat() { if (!scheduleHeartbeatTimer) return; window.clearInterval(scheduleHeartbeatTimer); scheduleHeartbeatTimer = null }
|
||||||
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
|
function handleScheduleRecovery() { restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) }
|
||||||
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTasksBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; nextSnapshots[taskId] = detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
|
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
|
||||||
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4000 : 10000 }
|
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 6000 : 20000 }
|
||||||
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
|
||||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||||
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
|
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTaskProgressBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
|
||||||
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
|
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
|
||||||
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
|
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
|
||||||
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total} 次`; return `执行进度: 共 ${total} 次`}
|
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total} 次`; return `执行进度: 共 ${total} 次`}
|
||||||
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatTimeOnly(stage.scheduledAt) } if (item.scheduledAt) return formatTimeOnly(item.scheduledAt); return '' }
|
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatMonthDayTime(stage.scheduledAt) } if (item.scheduledAt) return formatMonthDayTime(item.scheduledAt); return '' }
|
||||||
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
|
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
|
||||||
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
||||||
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && !!item.downloadUrl && (status === 'SUCCESS' || status === 'COMPLETED') }
|
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && !!item.downloadUrl && (status === 'SUCCESS' || status === 'COMPLETED') }
|
||||||
|
|||||||
@@ -931,10 +931,13 @@ export interface PriceTrackShopQueueItem {
|
|||||||
matchMessage?: string;
|
matchMessage?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
skipAsins?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceTrackMatchShopsVo {
|
export interface PriceTrackMatchShopsVo {
|
||||||
items: PriceTrackShopQueueItem[];
|
items: PriceTrackShopQueueItem[];
|
||||||
|
skipAsinsByCountry?: Record<string, string[]>;
|
||||||
|
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceTrackCountryPreferenceVo {
|
export interface PriceTrackCountryPreferenceVo {
|
||||||
@@ -970,9 +973,25 @@ export interface PriceTrackHistoryVo {
|
|||||||
items: PriceTrackHistoryItem[];
|
items: PriceTrackHistoryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceTrackAsinParsedRow {
|
||||||
|
shopMallName?: string;
|
||||||
|
asin?: string;
|
||||||
|
price?: string;
|
||||||
|
recommendedPrice?: string;
|
||||||
|
minimumPrice?: string;
|
||||||
|
firstPlace?: string;
|
||||||
|
secondPlace?: string;
|
||||||
|
cartShopName?: string;
|
||||||
|
priceChangeStatus?: string;
|
||||||
|
modifyCount?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PriceTrackCreateTaskVo {
|
export interface PriceTrackCreateTaskVo {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
items: PriceTrackHistoryItem[];
|
items: PriceTrackHistoryItem[];
|
||||||
|
skipAsinsByCountry?: Record<string, string[]>;
|
||||||
|
asinRowsByCountry?: Record<string, PriceTrackAsinParsedRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceTrackTaskSummary {
|
export interface PriceTrackTaskSummary {
|
||||||
@@ -1046,11 +1065,52 @@ export function putPriceTrackCountryPreference(countryCodes: string[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchPriceTrackShops(shopNames: string[]) {
|
export function matchPriceTrackShops(
|
||||||
|
shopNames: string[],
|
||||||
|
options?: {
|
||||||
|
asinFiles?: string[];
|
||||||
|
countryCodes?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
return unwrapJavaResponse(
|
return unwrapJavaResponse(
|
||||||
post<JavaApiResponse<PriceTrackMatchShopsVo>, { userId: number; shopNames: string[] }>(
|
post<
|
||||||
|
JavaApiResponse<PriceTrackMatchShopsVo>,
|
||||||
|
{ userId: number; shopNames: string[]; asinFiles?: string[]; countryCodes?: string[] }
|
||||||
|
>(
|
||||||
`${JAVA_API_PREFIX}/price-track/match-shops`,
|
`${JAVA_API_PREFIX}/price-track/match-shops`,
|
||||||
{ userId: getCurrentUserId(), shopNames },
|
{
|
||||||
|
userId: getCurrentUserId(),
|
||||||
|
shopNames,
|
||||||
|
asinFiles: options?.asinFiles || [],
|
||||||
|
countryCodes: options?.countryCodes || [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
|
||||||
|
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
|
||||||
|
{ taskIds },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
|
||||||
|
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
|
||||||
|
{ taskIds },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeleteBrandTaskProgress(taskIds: number[]) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<DeleteBrandTaskBatchVo>, { taskIds: number[] }>(
|
||||||
|
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
|
||||||
|
{ taskIds },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1176,15 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
||||||
|
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
|
||||||
|
{ taskIds },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPriceTrackResultDownloadUrl(resultId: number) {
|
export function getPriceTrackResultDownloadUrl(resultId: number) {
|
||||||
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
|
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user