修复商品风险多店铺问题
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user