修复商品风险多店铺问题
This commit is contained in:
4
app/.env
4
app/.env
@@ -11,8 +11,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
java_api_base=http://47.111.163.154:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
@@ -196,9 +196,12 @@ def proxy(path):
|
||||
continue
|
||||
headers[key] = value
|
||||
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/tasks/progress/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/product-risk-resolve/tasks/progress/batch",
|
||||
]
|
||||
proxy_timeout = 180 if target_url.endswith("/api/delete-brand/run") else 30
|
||||
if target_url not in ignore_url:
|
||||
try:
|
||||
print("=============================")
|
||||
@@ -219,7 +222,7 @@ def proxy(path):
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
timeout=30
|
||||
timeout=proxy_timeout
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
|
||||
@@ -12,4 +12,8 @@ public class TransientStorageProperties {
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String region = "us-east-1";
|
||||
private int connectTimeoutSeconds = 10;
|
||||
private int readTimeoutSeconds = 60;
|
||||
private int writeTimeoutSeconds = 60;
|
||||
private int uploadMaxRetries = 3;
|
||||
}
|
||||
|
||||
@@ -240,7 +240,10 @@ public class DeleteBrandTaskStorageService {
|
||||
DeleteBrandResultFileDto dto = objectMapper.readValue(payloadJson, DeleteBrandResultFileDto.class);
|
||||
grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("failed to load delete-brand result chunks");
|
||||
log.warn("[delete-brand-storage] failed to load result chunk taskId={} scopeKey={} chunkIndex={} payload={}",
|
||||
taskId, entity.getScopeKey(), entity.getChunkIndex(), entity.getPayloadJson(), ex);
|
||||
throw new BusinessException("failed to load delete-brand result chunks: taskId="
|
||||
+ taskId + ", scopeKey=" + entity.getScopeKey() + ", chunkIndex=" + entity.getChunkIndex());
|
||||
}
|
||||
}
|
||||
for (List<DeleteBrandResultFileDto> chunks : grouped.values()) {
|
||||
|
||||
@@ -5,18 +5,24 @@ import io.minio.GetObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.StatObjectArgs;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RustfsObjectStorageService {
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private volatile OkHttpClient httpClient;
|
||||
|
||||
public boolean isConfigured() {
|
||||
return notBlank(properties.getEndpoint())
|
||||
@@ -29,18 +35,28 @@ public class RustfsObjectStorageService {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("transient storage is not configured");
|
||||
}
|
||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(
|
||||
Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8))) {
|
||||
buildClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.stream(stream, stream.available(), -1)
|
||||
.contentType("application/json")
|
||||
.build());
|
||||
return objectKey;
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("failed to upload payload to transient storage", ex);
|
||||
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
|
||||
Exception last = null;
|
||||
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
|
||||
buildClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.stream(stream, bytes.length, -1)
|
||||
.contentType("application/json")
|
||||
.build());
|
||||
verifyObjectVisible(objectKey);
|
||||
return objectKey;
|
||||
} catch (Exception ex) {
|
||||
last = ex;
|
||||
if (attempt < maxRetries) {
|
||||
log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex);
|
||||
sleepQuietly(500L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("failed to upload payload to transient storage", last);
|
||||
}
|
||||
|
||||
public String readObjectAsString(String objectKey) {
|
||||
@@ -76,9 +92,57 @@ public class RustfsObjectStorageService {
|
||||
.endpoint(properties.getEndpoint())
|
||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||
.region(properties.getRegion())
|
||||
.httpClient(getHttpClient())
|
||||
.build();
|
||||
}
|
||||
|
||||
private OkHttpClient getHttpClient() {
|
||||
OkHttpClient current = httpClient;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (httpClient == null) {
|
||||
httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyObjectVisible(String objectKey) {
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
buildClient().statObject(StatObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build());
|
||||
return;
|
||||
} catch (Exception ex) {
|
||||
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex);
|
||||
if (attempt < 3) {
|
||||
sleepQuietly(100L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw last == null
|
||||
? new IllegalStateException("transient payload is not visible after upload: " + objectKey)
|
||||
: last;
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while verifying transient payload upload", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean notBlank(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.nanri.aiimage.modules.shopkey.controller;
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
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.QueryAsinImportProgressVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
@@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -59,6 +62,40 @@ public class SkipPriceAsinController {
|
||||
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入新增跳过跟价 ASIN")
|
||||
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
||||
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始导入",
|
||||
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "查询导入新增进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@Operation(summary = "导入删除跳过跟价 ASIN")
|
||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||
@Parameter(description = "xlsx/xls 文件,文件名必须等于店铺名", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "页面选择的分组ID", required = true) @RequestParam Long groupId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始删除",
|
||||
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "查询导入删除进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
@@ -25,31 +26,31 @@ public class SkipPriceAsinEntity {
|
||||
@TableField("asin_de")
|
||||
private String asinDe;
|
||||
|
||||
@TableField("minimum_price_de")
|
||||
@TableField(value = "minimum_price_de", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceDe;
|
||||
|
||||
@TableField("asin_uk")
|
||||
private String asinUk;
|
||||
|
||||
@TableField("minimum_price_uk")
|
||||
@TableField(value = "minimum_price_uk", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceUk;
|
||||
|
||||
@TableField("asin_fr")
|
||||
private String asinFr;
|
||||
|
||||
@TableField("minimum_price_fr")
|
||||
@TableField(value = "minimum_price_fr", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceFr;
|
||||
|
||||
@TableField("asin_it")
|
||||
private String asinIt;
|
||||
|
||||
@TableField("minimum_price_it")
|
||||
@TableField(value = "minimum_price_it", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceIt;
|
||||
|
||||
@TableField("asin_es")
|
||||
private String asinEs;
|
||||
|
||||
@TableField("minimum_price_es")
|
||||
@TableField(value = "minimum_price_es", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private BigDecimal minimumPriceEs;
|
||||
|
||||
@TableField("created_at")
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
package com.nanri.aiimage.modules.shopkey.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -21,15 +37,20 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SkipPriceAsinService {
|
||||
|
||||
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
|
||||
private final SkipPriceAsinMapper skipPriceAsinMapper;
|
||||
private final ShopManageMapper shopManageMapper;
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||
|
||||
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
|
||||
Long operatorId, boolean superAdmin) {
|
||||
@@ -141,6 +162,486 @@ public class SkipPriceAsinService {
|
||||
return toItemVo(saved, groupName);
|
||||
}
|
||||
|
||||
public QueryAsinImportStartVo startImport(MultipartFile file, Long groupId, Long operatorId, boolean superAdmin) {
|
||||
return startImportTask(file, groupId, operatorId, superAdmin, false);
|
||||
}
|
||||
|
||||
public QueryAsinImportProgressVo getImportProgress(String importId) {
|
||||
QueryAsinImportProgressVo progress = importProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
throw new BusinessException("导入任务不存在");
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
public QueryAsinImportStartVo startDeleteImport(MultipartFile file, Long groupId, Long operatorId, boolean superAdmin) {
|
||||
return startImportTask(file, groupId, operatorId, superAdmin, true);
|
||||
}
|
||||
|
||||
public QueryAsinImportProgressVo getDeleteImportProgress(String importId) {
|
||||
QueryAsinImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
throw new BusinessException("删除任务不存在");
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
private QueryAsinImportStartVo startImportTask(MultipartFile file, Long groupId, Long operatorId,
|
||||
boolean superAdmin, boolean deleteMode) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 或 xls 文件");
|
||||
}
|
||||
validateExcelFilename(file.getOriginalFilename());
|
||||
if (groupId == null || groupId <= 0) {
|
||||
throw new BusinessException("请先选择分组");
|
||||
}
|
||||
shopManageGroupService.getAccessibleById(groupId, operatorId, superAdmin);
|
||||
String shopName = normalizeShopNameFromFilename(file.getOriginalFilename());
|
||||
ensureShopExistsInGroup(groupId, shopName);
|
||||
|
||||
String importId = IdUtil.fastSimpleUUID();
|
||||
QueryAsinImportProgressVo progress = newImportProgress();
|
||||
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
|
||||
progressMap.put(importId, progress);
|
||||
try {
|
||||
File tempFile = saveMultipartToTempFile(file, deleteMode ? "skip-price-asin-delete-" : "skip-price-asin-import-");
|
||||
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, file.getOriginalFilename(), groupId,
|
||||
shopName, deleteMode));
|
||||
} catch (Exception ex) {
|
||||
progressMap.remove(importId);
|
||||
throw new BusinessException("读取上传文件失败");
|
||||
}
|
||||
|
||||
QueryAsinImportStartVo vo = new QueryAsinImportStartVo();
|
||||
vo.setImportId(importId);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private QueryAsinImportProgressVo newImportProgress() {
|
||||
QueryAsinImportProgressVo progress = new QueryAsinImportProgressVo();
|
||||
progress.setStatus("pending");
|
||||
progress.setTotalRows(0);
|
||||
progress.setProcessedRows(0);
|
||||
progress.setAsinCount(0);
|
||||
progress.setInsertedCount(0);
|
||||
progress.setDeletedCount(0);
|
||||
progress.setSkippedCount(0);
|
||||
return progress;
|
||||
}
|
||||
|
||||
private void runImportTask(String importId, File tempFile, String filename, Long groupId,
|
||||
String shopName, boolean deleteMode) {
|
||||
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
|
||||
QueryAsinImportProgressVo progress = progressMap.get(importId);
|
||||
if (progress == null) {
|
||||
deleteQuietly(tempFile);
|
||||
return;
|
||||
}
|
||||
progress.setStatus("running");
|
||||
progress.setErrorMessage(null);
|
||||
try {
|
||||
processImportFile(tempFile, filename, groupId, shopName, deleteMode, progress);
|
||||
progress.setStatus("success");
|
||||
} catch (Exception ex) {
|
||||
log.warn("[skip-price-asin-import] failed importId={} deleteMode={} msg={}",
|
||||
importId, deleteMode, ex.getMessage());
|
||||
progress.setStatus("failed");
|
||||
progress.setErrorMessage(ex instanceof BusinessException ? ex.getMessage()
|
||||
: (deleteMode ? "导入删除 Excel 失败" : "导入新增 Excel 失败"));
|
||||
} finally {
|
||||
deleteQuietly(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private void processImportFile(File tempFile, String filename, Long groupId, String shopName,
|
||||
boolean deleteMode, QueryAsinImportProgressVo progress) throws Exception {
|
||||
validateExcelFilename(filename);
|
||||
try (FileInputStream inputStream = new FileInputStream(tempFile);
|
||||
Workbook workbook = WorkbookFactory.create(inputStream)) {
|
||||
Sheet sheet = workbook.getNumberOfSheets() == 0 ? null : workbook.getSheetAt(0);
|
||||
if (sheet == null) {
|
||||
throw new BusinessException("Excel 为空");
|
||||
}
|
||||
HeaderMapping mapping = resolveHeaderMapping(sheet);
|
||||
if (mapping.asinColumns().isEmpty()) {
|
||||
throw new BusinessException("Excel 至少需要包含一个国家的 ASIN 列");
|
||||
}
|
||||
|
||||
int firstDataRow = mapping.firstDataRowIndex();
|
||||
int lastRow = Math.max(sheet.getLastRowNum(), firstDataRow - 1);
|
||||
int totalRows = Math.max(0, lastRow - firstDataRow + 1);
|
||||
progress.setTotalRows(totalRows);
|
||||
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
ImportCounters counters = new ImportCounters();
|
||||
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (row == null || isBlankRow(row, formatter)) {
|
||||
counters.skippedCount++;
|
||||
updateImportProgress(progress, counters, rowIndex - firstDataRow + 1);
|
||||
continue;
|
||||
}
|
||||
if (deleteMode) {
|
||||
consumeDeleteImportRow(row, formatter, mapping, groupId, shopName, counters);
|
||||
} else {
|
||||
consumeAddImportRow(row, formatter, mapping, groupId, shopName, counters);
|
||||
}
|
||||
updateImportProgress(progress, counters, rowIndex - firstDataRow + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void consumeAddImportRow(Row row, DataFormatter formatter, HeaderMapping mapping,
|
||||
Long groupId, String shopName, ImportCounters counters) {
|
||||
boolean acceptedAny = false;
|
||||
boolean sawAnyAsin = false;
|
||||
SkipPriceAsinEntity entity = new SkipPriceAsinEntity();
|
||||
entity.setGroupId(groupId);
|
||||
entity.setShopName(shopName);
|
||||
for (Map.Entry<String, Integer> entry : mapping.asinColumns().entrySet()) {
|
||||
String country = entry.getKey();
|
||||
String asin = normalizeBlank(cellText(row, entry.getValue(), formatter)).toUpperCase(Locale.ROOT);
|
||||
if (asin.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sawAnyAsin = true;
|
||||
counters.asinCount++;
|
||||
if (asin.length() > 64) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
BigDecimal minimumPrice = parseOptionalMinimumPrice(
|
||||
cellText(row, mapping.priceColumns().get(country), formatter));
|
||||
if (existsCountryAsin(groupId, shopName, country, asin)) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
setCountryData(entity, country, asin, minimumPrice);
|
||||
counters.insertedCount++;
|
||||
acceptedAny = true;
|
||||
}
|
||||
if (!acceptedAny) {
|
||||
if (!sawAnyAsin) {
|
||||
counters.skippedCount++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
skipPriceAsinMapper.insert(entity);
|
||||
}
|
||||
|
||||
private void consumeDeleteImportRow(Row row, DataFormatter formatter, HeaderMapping mapping,
|
||||
Long groupId, String shopName, ImportCounters counters) {
|
||||
boolean acceptedAny = false;
|
||||
boolean sawAnyAsin = false;
|
||||
for (Map.Entry<String, Integer> entry : mapping.asinColumns().entrySet()) {
|
||||
String country = entry.getKey();
|
||||
String asin = normalizeBlank(cellText(row, entry.getValue(), formatter)).toUpperCase(Locale.ROOT);
|
||||
if (asin.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sawAnyAsin = true;
|
||||
counters.asinCount++;
|
||||
if (asin.length() > 64) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
SkipPriceAsinEntity entity = findCountryAsin(groupId, shopName, country, asin);
|
||||
if (entity == null) {
|
||||
counters.skippedCount++;
|
||||
continue;
|
||||
}
|
||||
setCountryData(entity, country, "", null);
|
||||
if (isEmptyRow(entity)) {
|
||||
skipPriceAsinMapper.deleteById(entity.getId());
|
||||
} else {
|
||||
skipPriceAsinMapper.updateById(entity);
|
||||
}
|
||||
counters.deletedCount++;
|
||||
acceptedAny = true;
|
||||
}
|
||||
if (!acceptedAny && !sawAnyAsin) {
|
||||
counters.skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean existsCountryAsin(Long groupId, String shopName, String country, String asin) {
|
||||
return findCountryAsin(groupId, shopName, country, asin) != null;
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity findCountryAsin(Long groupId, String shopName, String country, String asin) {
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.eq(SkipPriceAsinEntity::getGroupId, groupId)
|
||||
.eq(SkipPriceAsinEntity::getShopName, shopName);
|
||||
switch (country) {
|
||||
case "DE" -> query.eq(SkipPriceAsinEntity::getAsinDe, asin);
|
||||
case "UK" -> query.eq(SkipPriceAsinEntity::getAsinUk, asin);
|
||||
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
||||
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
||||
case "ES" -> query.eq(SkipPriceAsinEntity::getAsinEs, asin);
|
||||
default -> throw new BusinessException("鍥藉鍙傛暟涓嶆敮鎸? " + country);
|
||||
}
|
||||
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private HeaderMapping resolveHeaderMapping(Sheet sheet) {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
Row firstHeader = sheet.getRow(0);
|
||||
Row secondHeader = sheet.getRow(1);
|
||||
if (firstHeader == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
|
||||
int maxColumn = Math.max(lastCellNum(firstHeader), lastCellNum(secondHeader));
|
||||
Map<String, Integer> asinColumns = new LinkedHashMap<>();
|
||||
Map<String, Integer> priceColumns = new LinkedHashMap<>();
|
||||
for (int columnIndex = 0; columnIndex < maxColumn; columnIndex++) {
|
||||
String countryText = cellTextWithMerged(sheet, firstHeader, columnIndex, formatter);
|
||||
String fieldText = cellText(secondHeader, columnIndex, formatter);
|
||||
String country = normalizeCountryAlias(countryText);
|
||||
String fieldKey = normalizeHeaderKey(fieldText);
|
||||
if (country.isEmpty()) {
|
||||
country = normalizeCountryAlias(fieldText);
|
||||
fieldKey = normalizeHeaderKey(countryText);
|
||||
}
|
||||
if (country.isEmpty() || !SUPPORTED_COUNTRIES.contains(country)) {
|
||||
continue;
|
||||
}
|
||||
if (isAsinHeader(fieldKey)) {
|
||||
asinColumns.putIfAbsent(country, columnIndex);
|
||||
} else if (isPriceHeader(fieldKey)) {
|
||||
priceColumns.putIfAbsent(country, columnIndex);
|
||||
}
|
||||
}
|
||||
if (asinColumns.isEmpty()) {
|
||||
for (int columnIndex = 0; columnIndex < maxColumn; columnIndex++) {
|
||||
String headerText = cellText(firstHeader, columnIndex, formatter);
|
||||
String key = normalizeHeaderKey(headerText);
|
||||
String country = normalizeCountryAlias(headerText);
|
||||
if (country.isEmpty()) {
|
||||
country = countryFromHeaderKey(key);
|
||||
}
|
||||
if (!country.isEmpty() && isAsinHeader(key)) {
|
||||
asinColumns.putIfAbsent(country, columnIndex);
|
||||
}
|
||||
}
|
||||
return new HeaderMapping(asinColumns, priceColumns, 1);
|
||||
}
|
||||
return new HeaderMapping(asinColumns, priceColumns, 2);
|
||||
}
|
||||
|
||||
private int lastCellNum(Row row) {
|
||||
return row == null || row.getLastCellNum() < 0 ? 0 : row.getLastCellNum();
|
||||
}
|
||||
|
||||
private boolean isAsinHeader(String key) {
|
||||
return key.contains("asin");
|
||||
}
|
||||
|
||||
private boolean isPriceHeader(String key) {
|
||||
return key.contains("最低价") || key.contains("最低价格") || key.contains("minimumprice")
|
||||
|| key.contains("minprice") || key.equals("price");
|
||||
}
|
||||
|
||||
private String cellTextWithMerged(Sheet sheet, Row row, int columnIndex, DataFormatter formatter) {
|
||||
if (row == null) {
|
||||
return "";
|
||||
}
|
||||
int rowIndex = row.getRowNum();
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
var region = sheet.getMergedRegion(i);
|
||||
if (region.isInRange(rowIndex, columnIndex)) {
|
||||
Row firstRow = sheet.getRow(region.getFirstRow());
|
||||
return cellText(firstRow, region.getFirstColumn(), formatter);
|
||||
}
|
||||
}
|
||||
return cellText(row, columnIndex, formatter);
|
||||
}
|
||||
|
||||
private String cellText(Row row, Integer columnIndex, DataFormatter formatter) {
|
||||
if (row == null || columnIndex == null || columnIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
Cell cell = row.getCell(columnIndex);
|
||||
return cell == null ? "" : normalizeExcelText(formatter.formatCellValue(cell));
|
||||
}
|
||||
|
||||
private String normalizeExcelText(String value) {
|
||||
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").trim();
|
||||
}
|
||||
|
||||
private String normalizeHeaderKey(String value) {
|
||||
return normalizeExcelText(value)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replace(" ", "")
|
||||
.replace("_", "")
|
||||
.replace("-", "")
|
||||
.replace("/", "")
|
||||
.replace("\\", "")
|
||||
.replace(":", "")
|
||||
.replace(":", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "");
|
||||
}
|
||||
|
||||
private String normalizeCountryAlias(String country) {
|
||||
String normalized = normalizeBlank(country);
|
||||
if (normalized.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String upper = normalized.toUpperCase(Locale.ROOT);
|
||||
if (SUPPORTED_COUNTRIES.contains(upper)) {
|
||||
return upper;
|
||||
}
|
||||
return switch (normalizeHeaderKey(normalized)) {
|
||||
case "德国", "德", "de", "germany", "german", "deutschland" -> "DE";
|
||||
case "英国", "英", "uk", "gb", "greatbritain", "britain", "unitedkingdom" -> "UK";
|
||||
case "法国", "法", "fr", "france", "french" -> "FR";
|
||||
case "意大利", "意", "it", "italy", "italian" -> "IT";
|
||||
case "西班牙", "西", "es", "spain", "spanish", "espana" -> "ES";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private String countryFromHeaderKey(String key) {
|
||||
if (key.contains("asinde") || key.contains("deasin") || key.contains("德国")) return "DE";
|
||||
if (key.contains("asinuk") || key.contains("ukasin") || key.contains("英国")) return "UK";
|
||||
if (key.contains("asinfr") || key.contains("frasin") || key.contains("法国")) return "FR";
|
||||
if (key.contains("asinit") || key.contains("itasin") || key.contains("意大利")) return "IT";
|
||||
if (key.contains("asines") || key.contains("esasin") || key.contains("西班牙")) return "ES";
|
||||
return "";
|
||||
}
|
||||
|
||||
private BigDecimal parseOptionalMinimumPrice(String value) {
|
||||
String normalized = normalizeBlank(value);
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeMinimumPrice(new BigDecimal(normalized.replace(",", "")));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("最低价格式不正确: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean priceEquals(BigDecimal left, BigDecimal right) {
|
||||
if (left == null && right == null) {
|
||||
return true;
|
||||
}
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
return left.compareTo(right) == 0;
|
||||
}
|
||||
|
||||
private boolean isBlankRow(Row row, DataFormatter formatter) {
|
||||
if (row == null) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < lastCellNum(row); i++) {
|
||||
if (!cellText(row, i, formatter).isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updateImportProgress(QueryAsinImportProgressVo progress, ImportCounters counters, int processedRows) {
|
||||
progress.setProcessedRows(processedRows);
|
||||
progress.setAsinCount(counters.asinCount);
|
||||
progress.setInsertedCount(counters.insertedCount);
|
||||
progress.setDeletedCount(counters.deletedCount);
|
||||
progress.setSkippedCount(counters.skippedCount);
|
||||
}
|
||||
|
||||
private File saveMultipartToTempFile(MultipartFile file, String prefix) 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(prefix, suffix);
|
||||
file.transferTo(tempFile);
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
private void deleteQuietly(File file) {
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateExcelFilename(String filename) {
|
||||
String lowerFilename = filename == null ? "" : filename.toLowerCase(Locale.ROOT);
|
||||
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeShopNameFromFilename(String filename) {
|
||||
String normalized = normalizeBlank(filename);
|
||||
int slashIndex = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
|
||||
if (slashIndex >= 0) {
|
||||
normalized = normalized.substring(slashIndex + 1);
|
||||
}
|
||||
int dotIndex = normalized.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
normalized = normalized.substring(0, dotIndex);
|
||||
}
|
||||
return normalizeRequired(normalized, "文件名必须是店铺名");
|
||||
}
|
||||
|
||||
private void ensureShopExistsInGroup(Long groupId, String shopName) {
|
||||
Long count = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
|
||||
.eq(ShopManageEntity::getGroupId, groupId)
|
||||
.eq(ShopManageEntity::getShopName, shopName));
|
||||
if (count == null || count <= 0) {
|
||||
throw new BusinessException("当前分组下不存在文件名对应的店铺: " + shopName);
|
||||
}
|
||||
}
|
||||
|
||||
private String getCountryAsin(SkipPriceAsinEntity entity, String country) {
|
||||
return switch (country) {
|
||||
case "DE" -> normalizeBlank(entity.getAsinDe());
|
||||
case "UK" -> normalizeBlank(entity.getAsinUk());
|
||||
case "FR" -> normalizeBlank(entity.getAsinFr());
|
||||
case "IT" -> normalizeBlank(entity.getAsinIt());
|
||||
case "ES" -> normalizeBlank(entity.getAsinEs());
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private BigDecimal getCountryMinimumPrice(SkipPriceAsinEntity entity, String country) {
|
||||
return switch (country) {
|
||||
case "DE" -> entity.getMinimumPriceDe();
|
||||
case "UK" -> entity.getMinimumPriceUk();
|
||||
case "FR" -> entity.getMinimumPriceFr();
|
||||
case "IT" -> entity.getMinimumPriceIt();
|
||||
case "ES" -> entity.getMinimumPriceEs();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private record HeaderMapping(Map<String, Integer> asinColumns,
|
||||
Map<String, Integer> priceColumns,
|
||||
int firstDataRowIndex) {
|
||||
}
|
||||
|
||||
private static final class ImportCounters {
|
||||
private int asinCount;
|
||||
private int insertedCount;
|
||||
private int deletedCount;
|
||||
private int skippedCount;
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity getById(Long id) {
|
||||
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
|
||||
@@ -60,7 +60,7 @@ public class TransientPayloadStorageService {
|
||||
return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length()));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(errorMessage, ex);
|
||||
throw new IllegalStateException(errorMessage + ": " + pointer, ex);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ aiimage:
|
||||
access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:}
|
||||
region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1}
|
||||
connect-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS:10}
|
||||
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
|
||||
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
|
||||
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
|
||||
storage:
|
||||
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
SET @db_name = DATABASE();
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'uk_group_shop_name'
|
||||
);
|
||||
SET @sql := IF(@idx_exists > 0,
|
||||
'ALTER TABLE biz_skip_price_asin DROP INDEX uk_group_shop_name',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_name'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_name (group_id, shop_name)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_de'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_de (group_id, shop_name, asin_de)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_uk'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_uk (group_id, shop_name, asin_uk)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_fr'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_fr (group_id, shop_name, asin_fr)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_it'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_it (group_id, shop_name, asin_it)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = @db_name
|
||||
AND TABLE_NAME = 'biz_skip_price_asin'
|
||||
AND INDEX_NAME = 'idx_group_shop_asin_es'
|
||||
);
|
||||
SET @sql := IF(@idx_exists = 0,
|
||||
'ALTER TABLE biz_skip_price_asin ADD INDEX idx_group_shop_asin_es (group_id, shop_name, asin_es)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -1990,6 +1990,116 @@ def update_skip_price_asin_country(item_id, country):
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asins/import/<import_id>')
|
||||
@login_required
|
||||
def skip_price_asin_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/skip-price-asins/import/{import_id}',
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'progress': _format_query_asin_import_progress(result.get('data') or {}),
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asins/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_skip_price_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
file_storage = request.files.get('file')
|
||||
if not file_storage or file_storage.filename == '':
|
||||
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
|
||||
group_id_raw = (request.form.get('group_id') or '').strip()
|
||||
if not group_id_raw:
|
||||
return jsonify({'success': False, 'error': '请先选择分组'})
|
||||
params = {
|
||||
'groupId': group_id_raw,
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
files = {
|
||||
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/skip-price-asins/import',
|
||||
params=params,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
summary = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '开始导入',
|
||||
'import_id': summary.get('importId') or '',
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asins/delete-import/<import_id>')
|
||||
@login_required
|
||||
def skip_price_asin_delete_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/skip-price-asins/delete-import/{import_id}',
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'progress': _format_query_asin_import_progress(result.get('data') or {}),
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/skip-price-asins/delete-import', methods=['POST'])
|
||||
@login_required
|
||||
def delete_import_skip_price_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
|
||||
if denied:
|
||||
return denied
|
||||
file_storage = request.files.get('file')
|
||||
if not file_storage or file_storage.filename == '':
|
||||
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
|
||||
group_id_raw = (request.form.get('group_id') or '').strip()
|
||||
if not group_id_raw:
|
||||
return jsonify({'success': False, 'error': '请先选择分组'})
|
||||
params = {
|
||||
'groupId': group_id_raw,
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
files = {
|
||||
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/skip-price-asins/delete-import',
|
||||
params=params,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
summary = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '开始删除',
|
||||
'import_id': summary.get('importId') or '',
|
||||
})
|
||||
|
||||
|
||||
def _format_query_asin_item(item):
|
||||
return {
|
||||
'id': item.get('id'),
|
||||
|
||||
@@ -26,6 +26,7 @@ import os
|
||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
@@ -227,6 +227,65 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.request-loading-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: #667eea;
|
||||
opacity: 0;
|
||||
z-index: 3000;
|
||||
transition: width 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.request-loading-bar.show {
|
||||
width: 74%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.request-loading-bar.finishing {
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.request-loading {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 72px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
color: #333;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
font-size: 13px;
|
||||
z-index: 3000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.request-loading.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.request-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #dce2ff;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: request-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes request-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -661,6 +720,11 @@
|
||||
|
||||
<body>
|
||||
<!-- <div class="nav"><a href="/home">返回首页</a></div> -->
|
||||
<div class="request-loading-bar" id="requestLoadingBar"></div>
|
||||
<div class="request-loading" id="requestLoading">
|
||||
<span class="request-spinner"></span>
|
||||
<span>请求处理中...</span>
|
||||
</div>
|
||||
<h1>管理后台</h1>
|
||||
|
||||
<div class="admin-user-box" style="position:absolute;top:24px;right:24px;z-index:10;">
|
||||
@@ -1042,6 +1106,42 @@
|
||||
<button class="btn" id="btnCreateSkipPriceAsin">新增 ASIN</button>
|
||||
</div>
|
||||
<p class="msg" id="msgSkipPriceAsin"></p>
|
||||
<div class="form-row" style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
||||
<div style="flex:1;min-width:320px;">
|
||||
<h3 style="margin-bottom:12px;font-size:14px;">导入文件新增</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:320px;">
|
||||
<label>上传 Excel(先选择分组;文件名必须是该分组下的店铺名;表头为 国家 / 删除ASIN / 最低价)</label>
|
||||
<input type="file" id="skipPriceAsinImportFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<button class="btn" id="btnImportSkipPriceAsin" type="button">上传并新增</button>
|
||||
</div>
|
||||
<p class="msg" id="msgSkipPriceAsinImport"></p>
|
||||
<div class="progress-wrap" id="skipPriceAsinImportProgressWrap" style="display:none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="skipPriceAsinImportProgressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="skipPriceAsinImportProgressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:320px;">
|
||||
<h3 style="margin-bottom:12px;font-size:14px;">导入文件删除</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:320px;">
|
||||
<label>上传 Excel(先选择分组;文件名必须是该分组下的店铺名;按各国家删除ASIN列匹配删除)</label>
|
||||
<input type="file" id="skipPriceAsinDeleteImportFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<button class="btn btn-danger" id="btnDeleteImportSkipPriceAsin" type="button">上传并删除</button>
|
||||
</div>
|
||||
<p class="msg" id="msgSkipPriceAsinDeleteImport"></p>
|
||||
<div class="progress-wrap" id="skipPriceAsinDeleteImportProgressWrap" style="display:none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="skipPriceAsinDeleteImportProgressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="skipPriceAsinDeleteImportProgressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">店铺列表</h3>
|
||||
@@ -1578,6 +1678,100 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// 全局请求动画:拦截本页所有 fetch 和 XMLHttpRequest 请求。
|
||||
(function installRequestLoadingInterceptor() {
|
||||
if (window.__adminRequestLoadingInstalled) return;
|
||||
window.__adminRequestLoadingInstalled = true;
|
||||
var activeRequests = 0;
|
||||
var showTimer = null;
|
||||
var hideTimer = null;
|
||||
var showDelayMs = 180;
|
||||
var loadingEl = document.getElementById('requestLoading');
|
||||
var loadingBarEl = document.getElementById('requestLoadingBar');
|
||||
|
||||
function showRequestLoading() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
if (!loadingEl || !loadingBarEl) return;
|
||||
loadingBarEl.classList.remove('finishing');
|
||||
loadingBarEl.classList.add('show');
|
||||
loadingEl.classList.add('show');
|
||||
}
|
||||
|
||||
function hideRequestLoading() {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
if (!loadingEl || !loadingBarEl) return;
|
||||
loadingBarEl.classList.remove('show');
|
||||
loadingBarEl.classList.add('finishing');
|
||||
loadingEl.classList.remove('show');
|
||||
hideTimer = setTimeout(function () {
|
||||
loadingBarEl.classList.remove('finishing');
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function beginRequest() {
|
||||
activeRequests += 1;
|
||||
if (activeRequests === 1) {
|
||||
showTimer = setTimeout(showRequestLoading, showDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
function endRequest() {
|
||||
activeRequests = Math.max(0, activeRequests - 1);
|
||||
if (activeRequests === 0) {
|
||||
hideRequestLoading();
|
||||
}
|
||||
}
|
||||
|
||||
if (window.fetch) {
|
||||
var nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
var options = init || {};
|
||||
var skipLoading = !!options.__skipLoading;
|
||||
if (skipLoading) {
|
||||
options = Object.assign({}, options);
|
||||
delete options.__skipLoading;
|
||||
} else {
|
||||
beginRequest();
|
||||
}
|
||||
try {
|
||||
return nativeFetch(input, options).finally(function () {
|
||||
if (!skipLoading) endRequest();
|
||||
});
|
||||
} catch (err) {
|
||||
if (!skipLoading) endRequest();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (window.XMLHttpRequest) {
|
||||
var nativeOpen = XMLHttpRequest.prototype.open;
|
||||
var nativeSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function () {
|
||||
this.__adminSkipLoading = false;
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
if (!this.__adminSkipLoading) {
|
||||
beginRequest();
|
||||
this.addEventListener('loadend', endRequest, { once: true });
|
||||
}
|
||||
try {
|
||||
return nativeSend.apply(this, arguments);
|
||||
} catch (err) {
|
||||
if (!this.__adminSkipLoading) endRequest();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
// Tab 切换
|
||||
var adminTabsEl = document.getElementById('adminTabs');
|
||||
if (adminTabsEl) adminTabsEl.innerHTML = '';
|
||||
@@ -3441,6 +3635,270 @@
|
||||
document.getElementById('skipPriceAsinListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
var skipPriceAsinImportPollTimer = null;
|
||||
var skipPriceAsinDeleteImportPollTimer = null;
|
||||
var skipPriceAsinImportPollSeq = 0;
|
||||
var skipPriceAsinDeleteImportPollSeq = 0;
|
||||
function stopSkipPriceAsinImportProgress() {
|
||||
skipPriceAsinImportPollSeq += 1;
|
||||
if (skipPriceAsinImportPollTimer) {
|
||||
clearTimeout(skipPriceAsinImportPollTimer);
|
||||
skipPriceAsinImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function stopSkipPriceAsinDeleteImportProgress() {
|
||||
skipPriceAsinDeleteImportPollSeq += 1;
|
||||
if (skipPriceAsinDeleteImportPollTimer) {
|
||||
clearTimeout(skipPriceAsinDeleteImportPollTimer);
|
||||
skipPriceAsinDeleteImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function setSkipPriceAsinImportProgress(percent, text) {
|
||||
var wrap = document.getElementById('skipPriceAsinImportProgressWrap');
|
||||
var fill = document.getElementById('skipPriceAsinImportProgressFill');
|
||||
var textEl = document.getElementById('skipPriceAsinImportProgressText');
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
|
||||
textEl.textContent = text || '';
|
||||
}
|
||||
function setSkipPriceAsinDeleteImportProgress(percent, text) {
|
||||
var wrap = document.getElementById('skipPriceAsinDeleteImportProgressWrap');
|
||||
var fill = document.getElementById('skipPriceAsinDeleteImportProgressFill');
|
||||
var textEl = document.getElementById('skipPriceAsinDeleteImportProgressText');
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
|
||||
textEl.textContent = text || '';
|
||||
}
|
||||
function pollSkipPriceAsinImport(importId) {
|
||||
stopSkipPriceAsinImportProgress();
|
||||
var pollSeq = ++skipPriceAsinImportPollSeq;
|
||||
var finished = false;
|
||||
var inFlight = false;
|
||||
var attempts = 0;
|
||||
var maxAttempts = 300;
|
||||
function isActive() {
|
||||
return !finished && pollSeq === skipPriceAsinImportPollSeq;
|
||||
}
|
||||
function finish() {
|
||||
finished = true;
|
||||
if (skipPriceAsinImportPollTimer) {
|
||||
clearTimeout(skipPriceAsinImportPollTimer);
|
||||
skipPriceAsinImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function schedule() {
|
||||
if (isActive()) {
|
||||
skipPriceAsinImportPollTimer = setTimeout(tick, 1500);
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
if (!isActive() || inFlight) {
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts > maxAttempts) {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinImport').textContent = '查询导入进度超时,请稍后刷新列表确认结果';
|
||||
document.getElementById('msgSkipPriceAsinImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
fetch('/api/admin/skip-price-asins/import/' + encodeURIComponent(importId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (!res.success) {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinImport').textContent = res.error || '查询导入进度失败';
|
||||
document.getElementById('msgSkipPriceAsinImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var progress = res.progress || {};
|
||||
var totalRows = progress.total_rows || 0;
|
||||
var processedRows = progress.processed_rows || 0;
|
||||
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
|
||||
setSkipPriceAsinImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
|
||||
if (progress.status === 'success') {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinImport').textContent = '导入成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
|
||||
document.getElementById('msgSkipPriceAsinImport').className = 'msg ok';
|
||||
loadSkipPriceAsin(1);
|
||||
} else if (progress.status === 'failed') {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinImport').textContent = progress.error_message || '导入失败';
|
||||
document.getElementById('msgSkipPriceAsinImport').className = 'msg err';
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinImport').textContent = '查询导入进度失败';
|
||||
document.getElementById('msgSkipPriceAsinImport').className = 'msg err';
|
||||
})
|
||||
.finally(function () {
|
||||
inFlight = false;
|
||||
});
|
||||
}
|
||||
tick();
|
||||
}
|
||||
function pollSkipPriceAsinDeleteImport(importId) {
|
||||
stopSkipPriceAsinDeleteImportProgress();
|
||||
var pollSeq = ++skipPriceAsinDeleteImportPollSeq;
|
||||
var finished = false;
|
||||
var inFlight = false;
|
||||
var attempts = 0;
|
||||
var maxAttempts = 300;
|
||||
function isActive() {
|
||||
return !finished && pollSeq === skipPriceAsinDeleteImportPollSeq;
|
||||
}
|
||||
function finish() {
|
||||
finished = true;
|
||||
if (skipPriceAsinDeleteImportPollTimer) {
|
||||
clearTimeout(skipPriceAsinDeleteImportPollTimer);
|
||||
skipPriceAsinDeleteImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function schedule() {
|
||||
if (isActive()) {
|
||||
skipPriceAsinDeleteImportPollTimer = setTimeout(tick, 1500);
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
if (!isActive() || inFlight) {
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts > maxAttempts) {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '查询删除进度超时,请稍后刷新列表确认结果';
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
fetch('/api/admin/skip-price-asins/delete-import/' + encodeURIComponent(importId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (!res.success) {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').textContent = res.error || '查询删除进度失败';
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var progress = res.progress || {};
|
||||
var totalRows = progress.total_rows || 0;
|
||||
var processedRows = progress.processed_rows || 0;
|
||||
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
|
||||
setSkipPriceAsinDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
|
||||
if (progress.status === 'success') {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '删除成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg ok';
|
||||
loadSkipPriceAsin(1);
|
||||
} else if (progress.status === 'failed') {
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').textContent = progress.error_message || '删除失败';
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err';
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '查询删除进度失败';
|
||||
document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err';
|
||||
})
|
||||
.finally(function () {
|
||||
inFlight = false;
|
||||
});
|
||||
}
|
||||
tick();
|
||||
}
|
||||
function uploadSkipPriceAsinImport(deleteMode) {
|
||||
var fileInput = document.getElementById(deleteMode ? 'skipPriceAsinDeleteImportFile' : 'skipPriceAsinImportFile');
|
||||
var msgEl = document.getElementById(deleteMode ? 'msgSkipPriceAsinDeleteImport' : 'msgSkipPriceAsinImport');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (deleteMode) {
|
||||
stopSkipPriceAsinDeleteImportProgress();
|
||||
document.getElementById('skipPriceAsinDeleteImportProgressWrap').style.display = 'none';
|
||||
} else {
|
||||
stopSkipPriceAsinImportProgress();
|
||||
document.getElementById('skipPriceAsinImportProgressWrap').style.display = 'none';
|
||||
}
|
||||
var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim();
|
||||
if (!groupId) {
|
||||
msgEl.textContent = '请先选择分组';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
msgEl.textContent = '请选择 Excel 文件';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var file = fileInput.files[0];
|
||||
var lowerName = (file.name || '').toLowerCase();
|
||||
if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) {
|
||||
msgEl.textContent = '仅支持 .xlsx 或 .xls 文件';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (deleteMode && !confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) {
|
||||
return;
|
||||
}
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('group_id', groupId);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', deleteMode ? '/api/admin/skip-price-asins/delete-import' : '/api/admin/skip-price-asins/import', true);
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (event.lengthComputable) {
|
||||
var percent = Math.round(event.loaded * 100 / event.total);
|
||||
if (deleteMode) setSkipPriceAsinDeleteImportProgress(percent, '上传中:' + percent + '%');
|
||||
else setSkipPriceAsinImportProgress(percent, '上传中:' + percent + '%');
|
||||
}
|
||||
};
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var res;
|
||||
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; }
|
||||
if (!res.success) {
|
||||
msgEl.textContent = res.error || (deleteMode ? '删除失败' : '导入失败');
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (deleteMode) {
|
||||
setSkipPriceAsinDeleteImportProgress(100, '上传完成,后端处理中...');
|
||||
pollSkipPriceAsinDeleteImport(res.import_id);
|
||||
} else {
|
||||
setSkipPriceAsinImportProgress(100, '上传完成,后端处理中...');
|
||||
pollSkipPriceAsinImport(res.import_id);
|
||||
}
|
||||
fileInput.value = '';
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
document.getElementById('btnChooseSkipPriceAsinShop').onclick = openChooseSkipPriceAsinShopModal;
|
||||
document.getElementById('btnSearchChooseSkipPriceAsinShop').onclick = function () {
|
||||
loadChooseSkipPriceAsinShops(1);
|
||||
@@ -3590,6 +4048,12 @@
|
||||
};
|
||||
setupSkipPriceAsinShopPicker();
|
||||
initDropdownMultiSelect('skipPriceAsinCountries', '请选择国家');
|
||||
document.getElementById('btnImportSkipPriceAsin').onclick = function () {
|
||||
uploadSkipPriceAsinImport(false);
|
||||
};
|
||||
document.getElementById('btnDeleteImportSkipPriceAsin').onclick = function () {
|
||||
uploadSkipPriceAsinImport(true);
|
||||
};
|
||||
renderSkipPriceAsinInputs();
|
||||
|
||||
// ========== 查询 ASIN ==========
|
||||
|
||||
@@ -170,6 +170,7 @@ const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pendingFileTaskIds = ref<number[]>([])
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
let disposed = false
|
||||
@@ -204,12 +205,14 @@ function pollingKey() {
|
||||
|
||||
function savePollingIds() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!pollingTaskIds.value.length) window.localStorage.removeItem(pollingKey())
|
||||
else window.localStorage.setItem(pollingKey(), JSON.stringify(pollingTaskIds.value))
|
||||
const ids = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
|
||||
if (!ids.length) window.localStorage.removeItem(pollingKey())
|
||||
else window.localStorage.setItem(pollingKey(), JSON.stringify(ids))
|
||||
}
|
||||
|
||||
function clearPollingIds() {
|
||||
pollingTaskIds.value = []
|
||||
pendingFileTaskIds.value = []
|
||||
savePollingIds()
|
||||
stopPolling()
|
||||
}
|
||||
@@ -391,6 +394,18 @@ function removePollingTask(taskId: number) {
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function addPendingFileTask(taskId: number) {
|
||||
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||||
pendingFileTaskIds.value = [...pendingFileTaskIds.value, taskId]
|
||||
savePollingIds()
|
||||
}
|
||||
}
|
||||
|
||||
function removePendingFileTask(taskId: number) {
|
||||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) return
|
||||
@@ -414,9 +429,9 @@ function scheduleNextPoll(immediate = false) {
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (!pollingTaskIds.value.length) return
|
||||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
|
||||
await refreshTaskProgress()
|
||||
if (!disposed && pollingTaskIds.value.length) {
|
||||
if (!disposed && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
@@ -425,34 +440,68 @@ function scheduleNextPoll(immediate = false) {
|
||||
}
|
||||
|
||||
async function refreshTaskProgress() {
|
||||
if (pollingInFlight.value || !pollingTaskIds.value.length) {
|
||||
if (!pollingTaskIds.value.length) stopPolling()
|
||||
if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) {
|
||||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling()
|
||||
return
|
||||
}
|
||||
pollingInFlight.value = true
|
||||
try {
|
||||
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
|
||||
let hasTerminalTask = false
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
hasTerminalTask = true
|
||||
let shouldRefreshHistory = pendingFileTaskIds.value.length > 0
|
||||
if (pollingTaskIds.value.length) {
|
||||
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
shouldRefreshHistory = true
|
||||
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
|
||||
}
|
||||
}
|
||||
if (batch.missingTaskIds?.length) {
|
||||
batch.missingTaskIds.forEach((taskId) => {
|
||||
removePollingTask(taskId)
|
||||
removePendingFileTask(taskId)
|
||||
})
|
||||
shouldRefreshHistory = true
|
||||
}
|
||||
}
|
||||
if (batch.missingTaskIds?.length) {
|
||||
batch.missingTaskIds.forEach((taskId) => removePollingTask(taskId))
|
||||
}
|
||||
if (hasTerminalTask) {
|
||||
if (shouldRefreshHistory) {
|
||||
await loadDashboard()
|
||||
await loadHistory()
|
||||
settlePendingFileTasks()
|
||||
}
|
||||
} finally {
|
||||
pollingInFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function settlePendingFileTasks() {
|
||||
if (!pendingFileTaskIds.value.length) return
|
||||
const remaining: number[] = []
|
||||
for (const taskId of pendingFileTaskIds.value) {
|
||||
const item = historyItems.value.find((row) => row.taskId === taskId)
|
||||
if (!item) continue
|
||||
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||||
if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
|
||||
continue
|
||||
}
|
||||
remaining.push(taskId)
|
||||
}
|
||||
pendingFileTaskIds.value = remaining
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function seedPendingFileTasksFromHistory() {
|
||||
const ids = historyItems.value
|
||||
.filter((item) => item.taskId != null && isResultPreparing(item))
|
||||
.map((item) => item.taskId as number)
|
||||
pendingFileTaskIds.value = Array.from(new Set(ids))
|
||||
savePollingIds()
|
||||
if (pendingFileTaskIds.value.length) ensurePolling()
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboard.value = await getAppearancePatentDashboard()
|
||||
}
|
||||
@@ -551,6 +600,7 @@ onMounted(async () => {
|
||||
loadDashboard().catch(() => undefined),
|
||||
loadHistory().catch(() => undefined),
|
||||
])
|
||||
seedPendingFileTasksFromHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -940,9 +940,6 @@ async function runMatch() {
|
||||
const batch = res.items || []
|
||||
matchedItems.value = mergeMatchedItems(matchedItems.value, batch)
|
||||
saveMatchedItemsToStorage()
|
||||
if (autoQueueEnabled.value) {
|
||||
void processMatchedQueue()
|
||||
}
|
||||
if (!batch.length) {
|
||||
ElMessage.warning('未返回匹配结果')
|
||||
} else {
|
||||
|
||||
@@ -521,6 +521,7 @@ export function runDeleteBrand(
|
||||
...request,
|
||||
user_id: getCurrentUserId(),
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user