feat(需求): 撞款重复检查改查数据库——①采集明细先落库再更新文件(V111 biz_shop_data_crawl_item行级明细,归档时countryResults展开,同店同日幂等替换) ②撞款扫描换源直接查明细表(不再解析OSS xlsx,修复旧文件表头差异致4家店静默丢弃) ③存量Excel数据自动迁移(日报快照row_payload优先+OSS宽松表头回退:ASIN码/小写兼容) ④移除84家店铺全量空态注入(重复检查基数=已采集店,超管口径8家) ⑤新增明细聚合/宽松解析单测6个

This commit is contained in:
2026-09-07 02:38:39 +08:00
parent 678605e9fb
commit 5f8c469b8e
24 changed files with 638 additions and 160 deletions
@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.shopdatacrawl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlItemEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface ShopDataCrawlItemMapper extends BaseMapper<ShopDataCrawlItemEntity> {
/** 删除某店某批次(每日快照替换语义:先删后插)。 */
@Delete("""
DELETE FROM biz_shop_data_crawl_item
WHERE shop_name = #{shopName} AND business_date = #{businessDate}
""")
int deleteBatch(@Param("shopName") String shopName, @Param("businessDate") java.time.LocalDate businessDate);
}
@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.shopdatacrawl.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("biz_shop_data_crawl_item")
public class ShopDataCrawlItemEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long shopId;
private String shopName;
private String groupName;
private LocalDate businessDate;
private String country;
private String asin;
private String brand;
private String price;
private String itemDate;
private String unitsSold;
private String itemJson;
private Long resultId;
private Long taskId;
private Long dailyFileId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,129 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.shopdatacrawl.mapper.ShopDataCrawlItemMapper;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlItemEntity;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 店铺数据采集行级明细落库:采集数据先落库、再更新 Excel 文件;
* 撞款重复检查(shopduplicatecheck)直接查明细表聚合,不再解析 OSS xlsx。
* 语义:每店×每日一份累计快照(先删同批再插,幂等可重放)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShopDataCrawlItemStoreService {
private final ShopDataCrawlItemMapper itemMapper;
private final ObjectMapper objectMapper;
/** 国家站点码(旧文件 sheet 未命中映射则保留原文参与撞款,国家序列为空)。 */
public static String normalizeCountry(String raw) {
return raw == null ? "" : raw.trim().toUpperCase();
}
/**
* 把当日累计快照(countryResults 展开)落为行级明细。
* 增量归档调用点在 Excel 组装/上传之前(先落库再更新文件);
* 存量迁移由 shopduplicatecheck 模块构造 Vo 后复用(幂等:先删同批再插)。
*/
@Transactional(rollbackFor = Exception.class)
public void saveShopBatchFromSnapshot(String shopName, LocalDate businessDate,
List<ShopDataCrawlResultItemVo> snapshots,
Long resultId, Long taskId, Long dailyFileId) {
List<ShopDataCrawlItemEntity> rows = new ArrayList<>();
for (ShopDataCrawlResultItemVo vo : snapshots) {
if (vo == null || Boolean.FALSE.equals(vo.getSuccess()) || vo.getCountryResults() == null) {
continue;
}
for (ShopDataCrawlCountryResultDto countryResult : vo.getCountryResults()) {
if (countryResult == null || countryResult.getItems() == null) {
continue;
}
String country = normalizeCountry(countryResult.getCountry());
for (ShopDataCrawlRowDto item : countryResult.getItems()) {
if (item == null || blank(item.getAsin())) {
continue;
}
rows.add(toEntity(shopName, businessDate, country, item, resultId, taskId, dailyFileId));
}
}
}
replaceBatch(shopName, businessDate, rows, taskId);
}
private void replaceBatch(String shopName, LocalDate businessDate,
List<ShopDataCrawlItemEntity> rows, Long taskId) {
int deleted = itemMapper.deleteBatch(shopName, businessDate);
int inserted = 0;
for (ShopDataCrawlItemEntity row : rows) {
itemMapper.insert(row);
inserted++;
}
log.info("[shop-data-crawl-item] 店铺明细批次落库 shop={} date={} 删除 {} 行,新插 {} 行(taskId={}",
shopName, businessDate, deleted, inserted, taskId);
}
private ShopDataCrawlItemEntity toEntity(String shopName, LocalDate businessDate, String country,
ShopDataCrawlRowDto item, Long resultId, Long taskId, Long dailyFileId) {
ShopDataCrawlItemEntity entity = new ShopDataCrawlItemEntity();
entity.setShopName(shopName);
entity.setBusinessDate(businessDate);
entity.setCountry(country);
entity.setAsin(item.getAsin().trim());
entity.setBrand(trimToNull(item.getBrand()));
entity.setPrice(trimToNull(item.getPrice()));
entity.setItemDate(trimToNull(item.getDate()));
entity.setUnitsSold(trimToNull(item.getUnitsSold()));
entity.setItemJson(extraColumnsJson(item));
entity.setResultId(resultId);
entity.setTaskId(taskId);
entity.setDailyFileId(dailyFileId);
return entity;
}
/** 其余展示列(库存销量/销售排名/页面浏览量/商品图)原文兜底为 JSON,明细查询不依赖。 */
private String extraColumnsJson(ShopDataCrawlRowDto item) {
try {
Map<String, Object> extra = new LinkedHashMap<>();
if (item.getInventorySales() != null) {
extra.put("inventorySales", item.getInventorySales());
}
if (item.getSalesRank() != null) {
extra.put("salesRank", item.getSalesRank());
}
if (item.getPageViews() != null) {
extra.put("pageViews", item.getPageViews());
}
if (item.getCommodityImage() != null) {
extra.put("commodityImage", item.getCommodityImage());
}
return extra.isEmpty() ? null : objectMapper.writeValueAsString(extra);
} catch (Exception ex) {
log.warn("[shop-data-crawl-item] 明细附加列序列化失败 shop={} asin={}",
item.getAsin(), ex.getMessage());
return null;
}
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -118,6 +118,7 @@ public class ShopDataCrawlTaskService {
private final TransientPayloadStorageService transientPayloadStorageService;
private final InstanceMetadata instanceMetadata;
private final ShopDataCrawlDailyFileService dailyFileService;
private final ShopDataCrawlItemStoreService shopDataCrawlItemStoreService;
private final PlatformTransactionManager transactionManager;
private final TaskProgressLightAssembler taskProgressLightAssembler;
@@ -1931,6 +1932,14 @@ public class ShopDataCrawlTaskService {
// 不再读回旧累计对象(readObjectBytes)并整表重写(replaceCountriesWorkbook);
// 历史成员行无 payload 时按结果快照兜底,兼容旧归档数据。
List<ShopDataCrawlResultItemVo> accumulatedItems = buildDailyFileFromData(baseDailyFile, List.of(snapshot));
// 先落库再更新文件(用户约定):行级明细先于 Excel 组装/上传;
// 失败抛异常走现有失败/补偿路径,明细幂等(同店同日先删后插)。
LocalDate itemBatchDate = baseDailyFile != null && baseDailyFile.getBusinessDate() != null
? baseDailyFile.getBusinessDate()
: dailyFileService.currentBusinessDateTime().toLocalDate();
shopDataCrawlItemStoreService.saveShopBatchFromSnapshot(
snapshot.getShopName(), itemBatchDate, accumulatedItems,
snapshot.getResultId(), task.getId(), baseDailyFile == null ? null : baseDailyFile.getId());
int rowCount = excelAssemblyService.writeWorkbook(outputXlsx, accumulatedItems);
String objectKey = ossStorageService.uploadResultFile(outputXlsx, MODULE_TYPE);
if (blank(objectKey)) {
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.shopduplicatecheck.mapper;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopDailyFileMigrationDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopLatestItemRowDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
/**
* 撞款扫描的明细表数据源:biz_shop_data_crawl_item(采集先落库)。
* 不依赖 OSS xlsx 解析,每店取最新 business_date 批次行聚合。
*/
@Mapper
public interface ShopDuplicateCheckItemMapper {
/** 每店最新 business_date 批次的全部明细行(撞款统计直接数据源)。 */
@Select("""
SELECT i.shop_name AS shopName,
i.country AS country,
i.asin AS asin,
i.item_date AS itemDate,
i.price AS price,
i.brand AS brand
FROM biz_shop_data_crawl_item i
JOIN (
SELECT shop_name, MAX(business_date) AS max_date
FROM biz_shop_data_crawl_item
GROUP BY shop_name
) latest
ON latest.shop_name = i.shop_name AND latest.max_date = i.business_date
""")
List<ShopLatestItemRowDto> selectLatestShopItems();
/** 幂等判定:该店该日是否已有明细批次。 */
@Select("""
SELECT COUNT(1) FROM biz_shop_data_crawl_item
WHERE shop_name = #{shopName} AND business_date = #{businessDate}
""")
long countShopBatch(@Param("shopName") String shopName, @Param("businessDate") LocalDate businessDate);
/** 存量迁移数据源:每店最新已补偿 daily_file(join 最新结果行拿店名/文件地址)。 */
@Select("""
SELECT ranked.daily_file_id AS dailyFileId,
ranked.business_date AS businessDate,
ranked.latest_result_id AS latestResultId,
fr.source_filename AS shopName,
fr.result_file_url AS resultFileUrl,
fr.task_id AS taskId
FROM (
SELECT df.id AS daily_file_id,
df.business_date,
df.latest_result_id,
ROW_NUMBER() OVER (
PARTITION BY df.shop_key
ORDER BY df.business_date DESC, df.id DESC) AS row_no
FROM biz_shop_data_crawl_daily_file df
WHERE df.compensation_done = 1
) ranked
JOIN biz_file_result fr ON fr.id = ranked.latest_result_id
WHERE ranked.row_no = 1
AND fr.module_type = 'SHOP_DATA_CRAWL'
""")
List<ShopDailyFileMigrationDto> selectLegacyMigrationFiles();
/** 取每日成员行(row_payload 快照 JSON),无 payload 时返回 null。 */
@Select("""
SELECT row_payload FROM biz_shop_data_crawl_daily_member
WHERE daily_file_id = #{dailyFileId} AND result_id = #{resultId}
LIMIT 1
""")
String selectMemberPayload(@Param("dailyFileId") Long dailyFileId, @Param("resultId") Long resultId);
}
@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
import java.time.LocalDate;
/** 存量迁移数据源:每店最新已补偿每日文件(含店名/结果文件地址)。 */
public record ShopDailyFileMigrationDto(
Long dailyFileId,
LocalDate businessDate,
Long latestResultId,
String shopName,
String resultFileUrl,
Long taskId) {
}
@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.shopduplicatecheck.model.dto;
/** 撞款扫描明细行(biz_shop_data_crawl_item 每店最新批次行)。 */
public record ShopLatestItemRowDto(
String shopName,
String country,
String asin,
String itemDate,
String price,
String brand) {
}
@@ -5,43 +5,47 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlItemStoreService;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckItemMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanFullRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ScanLightRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopDailyFileMigrationDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopGroupLabelDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopSourceRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopLatestItemRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanSummary;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* 撞款扫描:每日 00:00 定时 + force 同步全量重扫。逐店取最新结果文件、拉取并解析 xlsx、
* 聚合成 shops/items/summary 落库 shop_data_duplicate_scan(格式与 Python 现行版本一致)。
* 撞款扫描:每日 00:00 定时 + force 同步全量重扫。
* 数据源 = 采集明细表(biz_shop_data_crawl_item,采集先落库再更新文件),
* 直接查库聚合 shops/items/summary 落库 shop_data_duplicate_scan(输出契约不变)。
* 双实例通过 Redis 分布式锁防重;扫描失败落 FAILED 行并上抛,force 场景由端点转 409/500。
*/
@Service
@@ -53,7 +57,6 @@ public class ShopDataDuplicateCheckScanService {
private static final long MAX_RESULT_BYTES = 256L * 1024 * 1024;
private static final long MEM_CACHE_MAX_BYTES = 64L * 1024 * 1024;
private static final int PARSE_THREADS = 4;
private static final java.time.Duration SCAN_LOCK_TTL = java.time.Duration.ofHours(6);
private final ShopDataDuplicateScanMapper scanMapper;
@@ -61,6 +64,8 @@ public class ShopDataDuplicateCheckScanService {
private final OssStorageService ossStorageService;
private final DistributedJobLockService distributedJobLockService;
private final ObjectMapper objectMapper;
private final ShopDuplicateCheckItemMapper itemMapper;
private final ShopDataCrawlItemStoreService itemStoreService;
/** 进程内读缓存:轻量行 id 判活,行变化才全量读(对应 Python _duplicate_scan_memory)。 */
private final AtomicLong cachedRowId = new AtomicLong(-1L);
@@ -71,12 +76,16 @@ public class ShopDataDuplicateCheckScanService {
ShopDuplicateCheckSourceMapper sourceMapper,
OssStorageService ossStorageService,
DistributedJobLockService distributedJobLockService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
ShopDuplicateCheckItemMapper itemMapper,
ShopDataCrawlItemStoreService itemStoreService) {
this.scanMapper = scanMapper;
this.sourceMapper = sourceMapper;
this.ossStorageService = ossStorageService;
this.distributedJobLockService = distributedJobLockService;
this.objectMapper = objectMapper;
this.itemMapper = itemMapper;
this.itemStoreService = itemStoreService;
}
/** 读侧视图:scanned_at 为最新 SUCCESS 行 created_atyyyy-MM-dd HH:mm:ss)。 */
@@ -171,6 +180,8 @@ public class ShopDataDuplicateCheckScanService {
private void runScanAndSave(DistributedJobLockService.LockHandle lock) {
try {
// 存量 Excel 数据先同步进 MySQL 明细(幂等),之后扫描直接查库聚合。
migrateLegacyItemsOnce();
List<ShopParsed> parsedShops = collectShopParsed(lock);
DuplicateCheckAggregator.Aggregate aggregate =
DuplicateCheckAggregator.aggregate(parsedShops, "job");
@@ -215,183 +226,190 @@ public class ShopDataDuplicateCheckScanService {
return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message;
}
/** 逐店并发拉取/解析结果文件;单店失败仅告警跳过,不中断整体。
* 扫描基线与店铺表全量对齐:无结果文件的店以空态(0 记录)注入,保证撞款控制台可见全部店铺。 */
/**
* 撞款扫描数据源:直接查采集明细表(biz_shop_data_crawl_item,每店最新 business_date 批次)。
* 用户约定:不再解析 OSS xlsx(旧 4 家店因 xlsx 格式差异被静默丢弃的根因),
* 重复检查基数 = 已采集落库的店(超管应见 8 家)。
*/
private List<ShopParsed> collectShopParsed(DistributedJobLockService.LockHandle lock) {
List<ShopSourceRowDto> rows = sourceMapper.selectLatestResultRows();
List<ShopParsed> parsedShops = new ArrayList<>();
List<ShopLatestItemRowDto> rows = itemMapper.selectLatestShopItems();
if (rows.isEmpty()) {
log.info("[shop-duplicate-check] 明细表暂无采集数据,本次扫描不出库");
return List.of();
}
// 店铺名去重(分组标签来自店铺管理 biz_shop_manage,与页面口径一致)。
Set<String> shopNames = new LinkedHashSet<>();
for (ShopLatestItemRowDto row : rows) {
if (row != null) {
shopNames.add(normalizeShopName(row.shopName()));
}
}
Map<String, String> groupLabels = new HashMap<>();
for (ShopGroupLabelDto dto : sourceMapper.selectGroupLabels(new ArrayList<>(shopNames))) {
groupLabels.put(shopKey(dto.getShopName()), dto.getGroupName() == null ? "" : dto.getGroupName());
}
List<ShopParsed> parsedShops = buildParsedShops(rows, groupLabels);
log.info("[shop-duplicate-check] 明细数据源扫描:{} 家店铺,{} 行明细", parsedShops.size(), rawRowsCount(parsedShops));
return parsedShops;
}
Map<String, String> groupLabels = loadGroupLabels(rows);
Set<String> parsedNames = new HashSet<>();
ExecutorService executor = Executors.newFixedThreadPool(Math.min(PARSE_THREADS, rows.size()));
try {
List<Future<ShopParsed>> futures = new ArrayList<>(rows.size());
for (ShopSourceRowDto row : rows) {
futures.add(executor.submit(() -> parseShop(row, groupLabels)));
}
for (Future<ShopParsed> future : futures) {
if (lock != null) {
lock.renew(SCAN_LOCK_TTL);
}
try {
ShopParsed item = future.get();
if (item != null) {
parsedShops.add(item);
parsedNames.add(normalizeShopName(item.shopName()));
}
} catch (ExecutionException ex) {
log.warn("[shop-duplicate-check] 并发解析店铺结果文件异常: {}", ex.getCause() == null
? ex.getMessage() : ex.getCause().getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
log.warn("[shop-duplicate-check] 解析线程被中断");
break;
}
}
} finally {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
// 基线扩展:店铺表全量中尚未解析到结果文件行的店 → 空态注入(0 记录、分组标签来自店铺表)。
List<ShopGroupLabelDto> allShops = sourceMapper.selectAllShopLabels();
int injectedEmptyShops = 0;
for (ShopGroupLabelDto all : allShops) {
String name = normalizeShopName(all.getShopName());
if (name.isEmpty() || parsedNames.contains(name)) {
/** 明细行 → 按店 ShopParsed(纯函数,便于单测):空 ASIN 行过滤、国家去重大写、分组标签注入。 */
static List<ShopParsed> buildParsedShops(List<ShopLatestItemRowDto> rows, Map<String, String> groupLabels) {
Map<String, List<ShopLatestItemRowDto>> byShop = new LinkedHashMap<>();
for (ShopLatestItemRowDto row : rows) {
if (row == null) {
continue;
}
String groupName = all.getGroupName() == null ? "" : all.getGroupName();
parsedShops.add(new ShopParsed(name, groupName, List.of(), List.of()));
injectedEmptyShops++;
String name = row.shopName() == null ? "未命名" : (row.shopName().trim().isEmpty() ? "未命名" : row.shopName().trim());
byShop.computeIfAbsent(name, k -> new ArrayList<>()).add(row);
}
if (injectedEmptyShops > 0) {
log.info("[shop-duplicate-check] 扫描基线含空态店铺 {} 家(无结果文件,以 0 记录参与撞款视图)", injectedEmptyShops);
Map<String, String> safeGroups = groupLabels == null ? Map.of() : groupLabels;
List<ShopParsed> parsedShops = new ArrayList<>();
for (Map.Entry<String, List<ShopLatestItemRowDto>> entry : byShop.entrySet()) {
String shopName = entry.getKey();
List<RawRow> rawRows = new ArrayList<>();
Set<String> countries = new LinkedHashSet<>();
for (ShopLatestItemRowDto row : entry.getValue()) {
if (blank(row.asin())) {
continue;
}
String country = row.country() == null ? "" : row.country().trim().toUpperCase(Locale.ROOT);
rawRows.add(new RawRow(
row.asin().trim(),
nvl(row.itemDate()),
nvl(row.price()),
nvl(row.brand()),
country));
if (!country.isEmpty()) {
countries.add(country);
}
}
parsedShops.add(new ShopParsed(shopName,
safeGroups.getOrDefault(shopKey(shopName), ""),
new ArrayList<>(countries),
rawRows));
}
return parsedShops;
}
private Map<String, String> loadGroupLabels(List<ShopSourceRowDto> rows) {
Set<String> names = new LinkedHashSet<>();
for (ShopSourceRowDto row : rows) {
String name = normalizeShopName(row.getShopName());
if (!name.isEmpty()) {
names.add(name);
private static int rawRowsCount(List<ShopParsed> shops) {
int total = 0;
for (ShopParsed shop : shops) {
total += shop.rows().size();
}
}
Map<String, String> labels = new HashMap<>();
if (names.isEmpty()) {
return labels;
}
for (ShopGroupLabelDto dto : sourceMapper.selectGroupLabels(new ArrayList<>(names))) {
labels.put(shopKey(dto.getShopName()), dto.getGroupName() == null ? "" : dto.getGroupName());
}
return labels;
return total;
}
private ShopParsed parseShop(ShopSourceRowDto row, Map<String, String> groupLabels) {
long resultId = row.getResultId() == null ? 0L : row.getResultId();
if (resultId <= 0) {
return null;
/**
* 存量数据迁移(幂等):每店最新每日文件的采集数据 → 行级明细表。
* 优先 daily_member.row_payload(快照 JSON,无需拉 OSS);老归档无 payload 时
* 回退解析 OSS xlsx(宽松表头),保证 8 家历史店全部入库。
*/
private void migrateLegacyItemsOnce() {
List<ShopDailyFileMigrationDto> files = itemMapper.selectLegacyMigrationFiles();
if (files.isEmpty()) {
log.info("[shop-data-crawl-item] 存量明细迁移:无待迁移每日文件");
return;
}
String shopName = normalizeShopName(row.getShopName());
if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
return null;
int migrated = 0;
int skipped = 0;
int failed = 0;
for (ShopDailyFileMigrationDto file : files) {
LocalDate businessDate = file.businessDate();
String shopName = normalizeShopName(file.shopName());
if (businessDate == null || file.latestResultId() == null) {
log.warn("[shop-data-crawl-item] 存量迁移跳过(缺日期/结果行) shop={}", shopName);
failed++;
continue;
}
try {
if (row.getResultFileSize() != null && row.getResultFileSize() > MAX_RESULT_BYTES) {
log.warn("[shop-data-crawl] 结果文件过大跳过 result_id={} size={}", resultId, row.getResultFileSize());
return null;
if (itemMapper.countShopBatch(shopName, businessDate) > 0) {
skipped++;
continue;
}
byte[] bytes;
try {
bytes = ossStorageService.readObjectBytesBounded(row.getResultFileUrl(), MAX_RESULT_BYTES);
} catch (IllegalArgumentException ex) {
log.warn("[shop-data-crawl] 结果文件过大跳过 result_id={}: {}", resultId, ex.getMessage());
return null;
List<ShopDataCrawlResultItemVo> snapshots = buildLegacySnapshot(file);
if (snapshots == null) {
log.warn("[shop-data-crawl-item] 存量迁移无数据源 shop={} date={}(无快照且文件不可解析)",
shopName, businessDate);
failed++;
continue;
}
List<com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow> parsed;
try {
parsed = DuplicateCheckWorkbookParser.parse(bytes);
itemStoreService.saveShopBatchFromSnapshot(shopName, businessDate, snapshots,
file.latestResultId(), file.taskId(), file.dailyFileId());
migrated++;
} catch (Exception ex) {
log.warn("[shop-data-crawl] 解析结果文件失败 result_id={}: {}", resultId,
ex.getMessage() == null ? ex.toString() : ex.getMessage());
return null;
log.error("[shop-data-crawl-item] 存量迁移失败 shop={} date={} msg={}",
shopName, businessDate, ex.getMessage());
failed++;
}
List<String> countryCodes = parseCountryCodesJson(row.getCountryCodesJson());
if (countryCodes.isEmpty()) {
countryCodes = parseCountryCodesRequest(row.getRequestJson());
}
String groupName = groupLabels.getOrDefault(shopKey(shopName), "");
return new ShopParsed(shopName, groupName, countryCodes, parsed);
log.info("[shop-data-crawl-item] 存量明细迁移完成:迁移 {} 家,跳过 {} 家(已有明细),失败 {} 家",
migrated, skipped, failed);
}
/** 构造迁移快照:row_payload 优先,回退 OSS xlsx 宽松解析;均不可用返回 null。 */
private List<ShopDataCrawlResultItemVo> buildLegacySnapshot(ShopDailyFileMigrationDto file) throws Exception {
String payload = itemMapper.selectMemberPayload(file.dailyFileId(), file.latestResultId());
if (payload != null && !payload.isBlank()) {
try {
return List.of(objectMapper.readValue(payload, ShopDataCrawlResultItemVo.class));
} catch (Exception ex) {
log.warn("[shop-data-crawl] 拉取结果文件失败 result_id={}: {}", resultId,
ex.getMessage() == null ? ex.toString() : ex.getMessage());
log.warn("[shop-data-crawl-item] 迁移快照 JSON 解析失败,回退文件解析 shop={} date={} msg={}",
file.shopName(), file.businessDate(), ex.getMessage());
}
}
if (file.resultFileUrl() == null || file.resultFileUrl().isBlank()) {
return null;
}
byte[] bytes = ossStorageService.readObjectBytesBounded(file.resultFileUrl(), MAX_RESULT_BYTES);
List<RawRow> rawRows = DuplicateCheckWorkbookParser.parse(bytes);
return List.of(rawRowsToSnapshot(file, rawRows));
}
/** RawRow(宽松解析结果)→ 快照 VOcountryResults 按国家聚合)。 */
private ShopDataCrawlResultItemVo rawRowsToSnapshot(ShopDailyFileMigrationDto file, List<RawRow> rawRows) {
ShopDataCrawlResultItemVo vo = new ShopDataCrawlResultItemVo();
vo.setShopName(normalizeShopName(file.shopName()));
vo.setSuccess(true);
vo.setResultId(file.latestResultId());
vo.setTaskId(file.taskId());
Map<String, List<ShopDataCrawlRowDto>> byCountry = new LinkedHashMap<>();
for (RawRow row : rawRows) {
if (row == null || blank(row.asin())) {
continue;
}
ShopDataCrawlRowDto dto = new ShopDataCrawlRowDto();
dto.setAsin(row.asin().trim());
dto.setDate(nvl(row.date()));
dto.setPrice(nvl(row.price()));
dto.setBrand(nvl(row.brand()));
String country = ShopDataCrawlItemStoreService.normalizeCountry(row.country());
byCountry.computeIfAbsent(country, k -> new ArrayList<>()).add(dto);
}
List<ShopDataCrawlCountryResultDto> countryResults = new ArrayList<>();
for (Map.Entry<String, List<ShopDataCrawlRowDto>> entry : byCountry.entrySet()) {
ShopDataCrawlCountryResultDto cr = new ShopDataCrawlCountryResultDto();
cr.setCountry(entry.getKey());
cr.setItems(entry.getValue());
countryResults.add(cr);
}
vo.setCountryResults(countryResults);
return vo;
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
private static String nvl(String value) {
return value == null ? "" : value.trim();
}
private String normalizeShopName(String raw) {
return raw == null ? "未命名" : (raw.trim().isEmpty() ? "未命名" : raw.trim());
}
private String shopKey(String name) {
private static String shopKey(String name) {
return (name == null ? "" : name.trim()).toLowerCase(Locale.ROOT);
}
private List<String> parseCountryCodesJson(String jsonValue) {
if (jsonValue == null || jsonValue.isBlank()) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(jsonValue);
if (node != null && node.isArray()) {
return normalizeCodes(node);
}
} catch (Exception ex) {
log.warn("[shop-data-crawl] 解析 country_codes_json 失败: {}", ex.getMessage());
}
return List.of();
}
private List<String> parseCountryCodesRequest(String requestJson) {
if (requestJson == null || requestJson.isBlank()) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(requestJson);
if (node == null || !node.isObject()) {
return List.of();
}
JsonNode raw = node.get("countryCodes");
if (raw == null) {
raw = node.get("country_codes");
}
if (raw != null && raw.isArray()) {
return normalizeCodes(raw);
}
} catch (Exception ex) {
log.warn("[shop-data-crawl] 解析 request_json 国家代码失败: {}", ex.getMessage());
}
return List.of();
}
private List<String> normalizeCodes(JsonNode array) {
List<String> codes = new ArrayList<>();
for (JsonNode item : array) {
String value = item.asText();
if (value != null && !value.trim().isEmpty()) {
codes.add(value.trim().toUpperCase(Locale.ROOT));
}
}
return codes;
}
}
@@ -68,8 +68,9 @@ public final class DuplicateCheckWorkbookParser {
}
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
String header = cell(entry.getValue());
String normalized = header.replaceAll("\\s+", "").toUpperCase();
int index = entry.getKey();
if ("ASIN".equals(header)) {
if (isAsinHeader(normalized)) {
asinCol = index;
} else if ("日期".equals(header)) {
dateCol = index;
@@ -81,6 +82,11 @@ public final class DuplicateCheckWorkbookParser {
}
}
/** 表头宽松匹配(兼容旧版/大小写/「ASIN码」类变体):含 ASIN 即视为 ASIN 列。 */
private static boolean isAsinHeader(String normalized) {
return normalized.contains("ASIN");
}
@Override
public void onRow(String sheetName, Integer sheetNo, int rowIndex,
Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
@@ -0,0 +1,25 @@
-- 店铺数据采集行级明细表:采集数据先落库再更新文件,撞款重复检查直接查库(不再解析 Excel)。
-- 语义:每店×每日一份快照(countryResults 展开为行);撞款扫描取每家店最新 business_date 的明细。
CREATE TABLE IF NOT EXISTS biz_shop_data_crawl_item (
id BIGINT NOT NULL AUTO_INCREMENT,
shop_id BIGINT NULL COMMENT '店铺 IDbiz_shop_manage.id',
shop_name VARCHAR(255) NOT NULL COMMENT '规范化店铺名(与结果文件 source_filename 一致)',
group_name VARCHAR(255) NULL COMMENT '归档时店铺分组名快照(多分组「、」连接)',
business_date DATE NOT NULL COMMENT '采集批次日期(每日文件日期)',
country VARCHAR(8) NOT NULL COMMENT '国家站点码(UK/DE/FR/ES/IT,展示层统一中文)',
asin VARCHAR(32) NOT NULL COMMENT 'ASIN',
brand VARCHAR(512) NULL COMMENT '品牌',
price VARCHAR(64) NULL COMMENT '价格(原始文本)',
item_date VARCHAR(64) NULL COMMENT '原「日期」列原文(如 2026年8月15日 上午10:59',
units_sold VARCHAR(64) NULL COMMENT '售出件数(原始文本)',
item_json JSON NULL COMMENT '其余列原文兜底(库存销量/销售排名/浏览量/商品图等)',
result_id BIGINT NULL COMMENT '来源结果文件行 idbiz_file_result.id',
task_id BIGINT NULL COMMENT '来源任务 idbiz_file_task.id',
daily_file_id BIGINT NULL COMMENT '来源每日文件行 idbiz_shop_data_crawl_daily_file.id',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_shop_data_crawl_item_batch (shop_name, business_date, country, asin),
KEY idx_shop_data_crawl_item_asin (asin),
KEY idx_shop_data_crawl_item_shop_date (shop_name, business_date),
KEY idx_shop_data_crawl_item_biz_date (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='店铺数据采集行级明细(快照批次)';
@@ -136,6 +136,7 @@ class ShopDataCrawlChunkUpsertTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -172,6 +172,7 @@ class ShopDataCrawlCleanupTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 30L);
@@ -145,6 +145,7 @@ class ShopDataCrawlDailyFileIncrementalTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -151,6 +151,7 @@ class ShopDataCrawlDailyFileJobSplitTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -151,6 +151,7 @@ class ShopDataCrawlDailyFileLockTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -133,6 +133,7 @@ class ShopDataCrawlLightweightProgressTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -132,6 +132,7 @@ class ShopDataCrawlOwnerColumnTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -145,6 +145,7 @@ class ShopDataCrawlProgressQueryTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -132,6 +132,7 @@ class ShopDataCrawlRowDedupKeyTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -133,6 +133,7 @@ class ShopDataCrawlScopeCounterTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -134,6 +134,7 @@ class ShopDataCrawlScopeMergeTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -128,6 +128,7 @@ class ShopDataCrawlTaskServiceChunkTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
mock(ShopDataCrawlItemStoreService.class),
null,
mock(TaskProgressLightAssembler.class));
@@ -0,0 +1,52 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service;
import com.nanri.aiimage.modules.shopduplicatecheck.model.dto.ShopLatestItemRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 撞款明细数据源(biz_shop_data_crawl_item)→ ShopParsed 转换:
* 按店分组、空 ASIN 行过滤、国家去重大写、分组标签注入。
*/
class ShopDataDuplicateCheckScanServiceTest {
@Test
void buildParsedShops_groupsByShopAndNormalizesCountry() {
List<ShopLatestItemRowDto> rows = List.of(
new ShopLatestItemRowDto("店A", "uk", "B0001", "2026-09-01 10:00", "9.90", "品牌甲"),
new ShopLatestItemRowDto("店A", "DE", "B0001", "2026-09-01 11:00", "9.90", "品牌甲"),
new ShopLatestItemRowDto("店B", "FR", "B0002", "2026-09-02 09:00", "19.9", "品牌乙"),
new ShopLatestItemRowDto("店B", "FR", "", "2026-09-02 09:01", "1.00", "空ASIN行"),
new ShopLatestItemRowDto(null, "UK", "B0003", "", "", ""));
List<ShopParsed> parsed = ShopDataDuplicateCheckScanService.buildParsedShops(
rows, Map.of("店b", "华东组"));
assertEquals(3, parsed.size(), "按店分组(含未命名兜底)");
ShopParsed shopA = parsed.stream().filter(s -> s.shopName().equals("店A")).findFirst().orElseThrow();
assertEquals(List.of("UK", "DE"), shopA.countryCodes(), "国家码去重大写");
assertEquals(2, shopA.rows().size());
RawRow ukRow = shopA.rows().get(0);
assertEquals("B0001", ukRow.asin());
assertEquals("UK", ukRow.country());
ShopParsed shopB = parsed.stream().filter(s -> s.shopName().equals("店B")).findFirst().orElseThrow();
assertEquals(1, shopB.rows().size(), "空 ASIN 行过滤");
assertEquals("华东组", shopB.groupName(), "分组标签注入(shopKey 大小写不敏感)");
ShopParsed unnamed = parsed.stream().filter(s -> s.shopName().equals("未命名")).findFirst().orElseThrow();
assertEquals(1, unnamed.rows().size(), "无店铺名兜底未命名");
assertEquals("", unnamed.rows().get(0).brand(), "缺省字段空串兜底");
}
@Test
void buildParsedShops_emptyInputReturnsEmpty() {
assertTrue(ShopDataDuplicateCheckScanService.buildParsedShops(List.of(), Map.of()).isEmpty());
}
}
@@ -0,0 +1,78 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service.support;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 宽松表头解析(兼容旧版文件):ASIN 列大小写/空白/「ASIN码」类变体均可识别,
* 是存量 Excel 数据迁移入库的前提(旧版 4 家店因表头差异被整店丢弃的根因)。
*/
class DuplicateCheckWorkbookParserTest {
private static byte[] workbookWith(boolean stream, String sheetName, String[] headers, String[] values) throws Exception {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
if (stream) {
SXSSFWorkbook workbook = new SXSSFWorkbook();
write(workbook, sheetName, headers, values);
workbook.write(out);
workbook.dispose();
} else {
XSSFWorkbook workbook = new XSSFWorkbook();
write(workbook, sheetName, headers, values);
workbook.write(out);
workbook.close();
}
return out.toByteArray();
}
}
private static void write(org.apache.poi.ss.usermodel.Workbook workbook, String sheetName,
String[] headers, String[] values) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet(sheetName);
org.apache.poi.ss.usermodel.Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
org.apache.poi.ss.usermodel.Row row = sheet.createRow(1);
for (int i = 0; i < values.length; i++) {
row.createCell(i).setCellValue(values[i]);
}
}
@Test
void parsesLegacyAsinCodeHeader() throws Exception {
byte[] bytes = workbookWith(false, "英国",
new String[]{"ASIN码", "日期", "价格", "品牌"},
new String[]{"B0OLD001", "2026年8月15日 上午10:59", "£34.46", "DOLGABA"});
var rows = DuplicateCheckWorkbookParser.parse(bytes);
assertEquals(1, rows.size(), "「ASIN码」表头变体可识别");
assertEquals("B0OLD001", rows.get(0).asin());
assertEquals("UK", rows.get(0).country(), "中文 sheet 名映射 UK");
assertEquals("2026年8月15日 上午10:59", rows.get(0).date());
assertEquals("DOLGABA", rows.get(0).brand());
}
@Test
void parsesLowercaseAsinHeader() throws Exception {
byte[] bytes = workbookWith(true, "asin",
new String[]{"asin", "date", "price", "brand"},
new String[]{"B0LOW001", "2026-08-15 10:59", "9.99", "X"});
var rows = DuplicateCheckWorkbookParser.parse(bytes);
assertEquals(1, rows.size(), "小写 asin 表头可识别");
assertEquals("B0LOW001", rows.get(0).asin());
assertEquals(rows.get(0).country(), "", "未知 sheet 名不丢行(国家为空)");
}
@Test
void skipsSheetWithoutAsinColumn() throws Exception {
byte[] bytes = workbookWith(false, "德国",
new String[]{"日期", "价格"},
new String[]{"2026-08-15", "9.99"});
assertEquals(0, DuplicateCheckWorkbookParser.parse(bytes).size(), "无 ASIN 列的 sheet 跳过");
}
}