feat(admin): 店铺数据重复检查撞款抽屉样式优化与导入测试按钮
Build Backend JAR / build (push) Has been cancelled

- 撞款详情抽屉:上架时间默认降序(晚到早)、去掉列头(按时间升序)小括号、加宽至 760px、
  去掉全屏遮罩(底层仍可点击)、抽屉贴到「撞款详情」区块位置而非浏览器顶部
- 修复抽屉 fixed 定位失效:.tab-panel.active 入场动画带 transform 会成为包含块,
  打开时把 drawer mask 挂到 body 下恢复视口定位
- 新增导入(新增-测试)/ 导入(删除-测试)按钮与上传弹窗,对应后端端点
  /api/admin/shop-data-crawl/duplicate-check-import(ADD=按店名替换基线记录、DELETE=移除该店记录)
- 新增 ShopDataDuplicateCheckImportService 导入服务(复用 WorkbookParser/Aggregator,与扫描共用 Redis 锁)
- 新增 3 个单测:导入替换重聚合、删除移除、无基线报错
- _preview_mock.py 加入 .gitignore(本地预览工具不入库)
This commit is contained in:
2026-09-04 12:25:31 +08:00
parent 8241cd704e
commit 05bda8915a
8 changed files with 766 additions and 130 deletions
@@ -0,0 +1,126 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service;
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.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 撞款导入(新增-测试/删除-测试)单测:Excel 解析、ADDR 替换、DELETE 移除、基线缺失报错。
*/
class ShopDataDuplicateCheckImportServiceTest {
private ShopDataDuplicateCheckScanService scanService;
private ShopDataDuplicateScanMapper scanMapper;
private ShopDataDuplicateCheckImportService service;
@BeforeEach
void setUp() {
scanService = mock(ShopDataDuplicateCheckScanService.class);
scanMapper = mock(ShopDataDuplicateScanMapper.class);
DistributedJobLockService lockService = mock(DistributedJobLockService.class);
DistributedJobLockService.LockHandle lock = mock(DistributedJobLockService.LockHandle.class);
when(lockService.tryLock(anyString(), any())).thenReturn(lock);
service = new ShopDataDuplicateCheckImportService(
scanService, scanMapper, lockService, new ObjectMapper());
}
private DuplicateScanView baseView() {
DuplicateOccurrence occA = new DuplicateOccurrence("B09PNTZM4L", "2026-08-18 04:34", "12.3", "HANYTON", "小曾", "G1", List.of("UK"), "UK");
DuplicateOccurrence occB = new DuplicateOccurrence("B09PNTZM4L", "2026-08-19 05:48", "10.1", "HANYTON", "郭悦晴", "G2", List.of("DE"), "DE");
DuplicateShop shopA = new DuplicateShop("小曾", "G1", List.of("UK"), 1, 1);
DuplicateShop shopB = new DuplicateShop("郭悦晴", "G2", List.of("DE"), 1, 1);
DuplicateItem item = new DuplicateItem("B09PNTZM4L", 2, 2, List.of(occA, occB));
DuplicateScanPayload payload = new DuplicateScanPayload(List.of(shopA, shopB), List.of(item));
return new DuplicateScanView("2026-09-04 00:00:00", null, payload);
}
@Test
void addImportReplacesShopRowsAndReaggregates() throws Exception {
when(scanService.loadLatest()).thenReturn(baseView());
when(scanMapper.insert(any(ShopDataDuplicateScanEntity.class))).thenAnswer(invocation -> {
invocation.<ShopDataDuplicateScanEntity>getArgument(0).setId(99L);
return 1;
});
ShopDataDuplicateCheckImportService.ImportSummary summary =
service.importExcel("小曾", "UK,FR", workbook(), false);
assertThat(summary.totalRows()).isEqualTo(2);
assertThat(summary.shopCount()).isEqualTo(2);
assertThat(summary.recordCount()).isEqualTo(3);
verify(scanMapper).insert(any(ShopDataDuplicateScanEntity.class));
}
@Test
void deleteImportRemovesShopRows() throws Exception {
when(scanService.loadLatest()).thenReturn(baseView());
when(scanMapper.insert(any(ShopDataDuplicateScanEntity.class))).thenAnswer(invocation -> {
invocation.<ShopDataDuplicateScanEntity>getArgument(0).setId(99L);
return 1;
});
ShopDataDuplicateCheckImportService.ImportSummary summary =
service.importExcel("小曾", "UK", workbook(), true);
assertThat(summary.shopCount()).isEqualTo(1);
assertThat(summary.recordCount()).isEqualTo(1);
verify(scanMapper).insert(any(ShopDataDuplicateScanEntity.class));
}
@Test
void importWithoutBaselineFails() {
when(scanService.loadLatest()).thenReturn(null);
assertThatThrownBy(() -> service.importExcel("小曾", "UK", workbook(), false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("暂无扫描结果");
verify(scanMapper, never()).insert(any(ShopDataDuplicateScanEntity.class));
}
/** 构造含 ASIN/日期两行的最小 xlsx。 */
private byte[] workbook() throws Exception {
var wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
var sheet = wb.createSheet("英国");
var header = sheet.createRow(0);
header.createCell(0).setCellValue("ASIN");
header.createCell(1).setCellValue("日期");
header.createCell(2).setCellValue("价格");
header.createCell(3).setCellValue("品牌");
var r1 = sheet.createRow(1);
r1.createCell(0).setCellValue("B09PNTZM4L");
r1.createCell(1).setCellValue("2026-09-02 10:00");
r1.createCell(2).setCellValue("15.5");
r1.createCell(3).setCellValue("HANYTON");
var r2 = sheet.createRow(2);
r2.createCell(0).setCellValue("B0TEST1234");
r2.createCell(1).setCellValue("2026-09-03 11:30");
r2.createCell(2).setCellValue("9.9");
r2.createCell(3).setCellValue("OTHER");
try (var out = new java.io.ByteArrayOutputStream()) {
wb.write(out);
return out.toByteArray();
}
}
}