修复商品风险多店铺问题

This commit is contained in:
super
2026-04-30 21:31:11 +08:00
parent b073bbe97d
commit 01bae23df5
17 changed files with 1392 additions and 46 deletions

View File

@@ -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

View File

@@ -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
)
# 流式响应

View File

@@ -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;
}

View File

@@ -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()) {

View File

@@ -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,19 +35,29 @@ 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))) {
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, stream.available(), -1)
.stream(stream, bytes.length, -1)
.contentType("application/json")
.build());
verifyObjectVisible(objectKey);
return objectKey;
} catch (Exception ex) {
throw new IllegalStateException("failed to upload payload to transient storage", 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) {
if (!isConfigured()) {
@@ -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();
}

View File

@@ -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(

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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"

View File

@@ -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 ==========

View File

@@ -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 {
let shouldRefreshHistory = pendingFileTaskIds.value.length > 0
if (pollingTaskIds.value.length) {
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
shouldRefreshHistory = true
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
}
}
if (batch.missingTaskIds?.length) {
batch.missingTaskIds.forEach((taskId) => removePollingTask(taskId))
batch.missingTaskIds.forEach((taskId) => {
removePollingTask(taskId)
removePendingFileTask(taskId)
})
shouldRefreshHistory = true
}
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(() => {

View File

@@ -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 {

View File

@@ -521,6 +521,7 @@ export function runDeleteBrand(
...request,
user_id: getCurrentUserId(),
},
{ timeout: 180000 },
),
);
}