完成匹配、跟价、权限部分
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>格式转换 - 数富AI</title>
|
<title>格式转换 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据去重 - 数富AI</title>
|
<title>数据去重 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>删除品牌 - 数富AI</title>
|
<title>删除品牌 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
|
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CdxGWtvK.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据拆分 - 数富AI</title>
|
<title>数据拆分 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-D4gpiFjY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CXHemZwJ.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend-java/aiimage-backend.log.2026-04-16.0.gz
Normal file
BIN
backend-java/aiimage-backend.log.2026-04-16.0.gz
Normal file
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`);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
xlsx/40-1.xlsx
BIN
xlsx/40-1.xlsx
Binary file not shown.
BIN
xlsx/40-2.xlsx
BIN
xlsx/40-2.xlsx
Binary file not shown.
BIN
xlsx/40.xlsx
BIN
xlsx/40.xlsx
Binary file not shown.
BIN
xlsx/5.xlsx
BIN
xlsx/5.xlsx
Binary file not shown.
Reference in New Issue
Block a user