修复商品风险多店铺问题

This commit is contained in:
super
2026-04-30 21:31:11 +08:00
parent 955a6439a0
commit 11f0d2c745
18 changed files with 18927 additions and 46 deletions

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,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();
}

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;