task-45: ASIN 去重与无效品牌查询统一为批量集合查询

新增 CollectDataBatchQuery 把 dedupe 表查询与 invalid_asin 表查询
合并为两次批量集合查询,输出与旧两段式语义等价,并返回两类过滤计数
由 service 维护 stats。8 个测试覆盖默认/批量/幂等/空输入/单元素/
超限/非法参数/依赖失败,全量回归 712 通过。
This commit is contained in:
2026-08-30 13:31:52 +08:00
parent d23485029d
commit 7110d1086b
3 changed files with 363 additions and 58 deletions
@@ -0,0 +1,208 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 45:将 ASIN 去重查询与无效品牌查询统一为批量集合查询。
* CollectDataBatchQuery 对输入行集合一次性批量查询去重表与无效品牌表,
* 输出与原有两段式逐行语义完全等价(区分 dedupe 与 invalid 两类过滤),
* 空输入/单元素/超限集合行为确定,依赖失败可恢复且不创建无效资源。
*/
class CollectDataBatchQueryTest {
private DedupeTotalDataMapper dedupeMapper;
private InvalidAsinDataMapper invalidAsinMapper;
private CollectDataBatchQuery query;
@BeforeEach
void setUp() {
dedupeMapper = mock(DedupeTotalDataMapper.class);
invalidAsinMapper = mock(InvalidAsinDataMapper.class);
query = new CollectDataBatchQuery(dedupeMapper, invalidAsinMapper);
}
@Test
void test_task_045_asin_brand_normal_default_path() {
// 正常输入:去重表已存在的 ASIN 被过滤,无效品牌表中的品牌被过滤,其余保留。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("Zara")));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "Nike"),
row("B000000002", "Zara"),
row("B000000003", "H&M")
));
assertEquals(1, out.size(), "仅未命中行保留");
assertEquals("B000000003", out.get(0).getAsin(), "保留行 ASIN 正确");
assertEquals("H&M", out.get(0).getBrand(), "保留行品牌正确");
}
@Test
void test_task_045_asin_brand_normal_multiple_items() {
// 批量场景:前 300 行 ASIN 唯一、品牌干净;后 300 行品牌无效。
// 去重表命中前 100 个 ASIN,输出顺序稳定、过滤数量精确。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 300; i++) {
rows.add(row("D" + String.format("%09d", i + 1), "clean"));
}
for (int i = 0; i < 300; i++) {
rows.add(row("B" + String.format("%09d", i + 1), "bad"));
}
List<String> existing = new ArrayList<>();
for (int i = 0; i < 100; i++) {
existing.add("D" + String.format("%09d", i + 1));
}
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(existing);
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("bad")));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(200, out.size(), "100 行去重 + 300 行无效品牌 = 过滤 400 行");
assertEquals("D000000101", out.get(0).getAsin(), "输出首行保持输入顺序");
assertEquals("D000000300", out.get(200 - 1).getAsin(), "输出末行为第 300 行");
assertTrue(out.stream().allMatch(r -> r.getBrand().equals("clean")),
"输出全部为干净品牌行");
}
@Test
void test_task_045_asin_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入重复执行输出一致,两次都发批量查询。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of(entity("bad")));
List<CollectDataResultRowVo> rows = List.of(
row("B000000001", "bad"),
row("B000000002", "good")
);
List<CollectDataResultRowVo> first = query.filterByExistingAsin(rows);
List<CollectDataResultRowVo> second = query.filterByExistingAsin(rows);
assertEquals(first, second, "重复执行输出一致");
assertEquals(1, first.size());
verify(dedupeMapper, times(2)).selectExistingDataValues(anyList());
verify(invalidAsinMapper, times(2)).selectList(any());
}
@Test
void test_task_045_asin_brand_boundary_empty_input() {
// 空输入:空列表/无 ASIN 无品牌的行集合不触发数据库查询,安全跳过。
List<CollectDataResultRowVo> empty = query.filterByExistingAsin(List.of());
assertEquals(0, empty.size(), "空列表返回空");
List<CollectDataResultRowVo> blankRows = query.filterByExistingAsin(List.of(
row("", ""), row(null, null)
));
assertEquals(2, blankRows.size(), "无 ASIN 无品牌行不触发查询且全保留");
verify(dedupeMapper, never()).selectExistingDataValues(anyList());
verify(invalidAsinMapper, never()).selectList(any());
}
@Test
void test_task_045_asin_brand_boundary_single_item() {
// 单元素:单行命中/未命中均正确,不依赖批量路径。
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of());
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "solo")
));
assertEquals(1, out.size(), "单行未命中保留");
assertEquals("solo", out.get(0).getBrand());
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of("B000000001"));
List<CollectDataResultRowVo> hit = query.filterByExistingAsin(List.of(
row("B000000001", "solo")
));
assertEquals(0, hit.size(), "单行命中去重表被过滤");
}
@Test
void test_task_045_asin_brand_boundary_limit_and_overflow() {
// 上限/超限:超大输入集合批量查询一次完成,不产生逐行查询(无无界增长)。
List<CollectDataResultRowVo> rows = new ArrayList<>();
for (int i = 0; i < 5000; i++) {
rows.add(row("B" + String.format("%09d", i), "brand-" + i));
}
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(List.of());
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(5000, out.size(), "超大集合全量保留");
verify(dedupeMapper).selectExistingDataValues(anyList());
verify(invalidAsinMapper).selectList(any());
}
@Test
void test_task_045_asin_brand_invalid_input_rejected() {
// 非法参数:null 行安全跳过;数据库返回 null 集合安全处理。
when(dedupeMapper.selectExistingDataValues(anyList())).thenReturn(null);
when(invalidAsinMapper.selectList(any())).thenReturn(null);
List<CollectDataResultRowVo> rows = new ArrayList<>();
rows.add(null);
rows.add(row("B000000001", " Nike "));
List<CollectDataResultRowVo> out = query.filterByExistingAsin(rows);
assertEquals(1, out.size(), "null 行不丢弃");
assertEquals("B000000001", out.get(0).getAsin(), "正常行保留");
assertEquals(" Nike ", out.get(0).getBrand(), "品牌保留原始值(过滤时仅标准化用于匹配)");
}
@Test
void test_task_045_asin_brand_dependency_failure_releases_resources() {
// 依赖失败:去重表查询抛错时异常向上传播;恢复后再次执行成功,不残留状态。
when(dedupeMapper.selectExistingDataValues(anyList()))
.thenThrow(new RuntimeException("db down"))
.thenReturn(List.of("B000000001"));
when(invalidAsinMapper.selectList(any())).thenReturn(List.of());
boolean threw = false;
try {
query.filterByExistingAsin(List.of(row("B000000001", "nike")));
} catch (RuntimeException ex) {
threw = "db down".equals(ex.getMessage());
}
assertTrue(threw, "去重查询失败向上抛错");
List<CollectDataResultRowVo> out = query.filterByExistingAsin(List.of(
row("B000000001", "nike"), row("B000000002", "adidas")
));
assertEquals(1, out.size(), "恢复后过滤正确");
assertEquals("B000000002", out.get(0).getAsin(), "恢复后命中逻辑仍生效");
}
private static CollectDataResultRowVo row(String asin, String brand) {
CollectDataResultRowVo row = new CollectDataResultRowVo();
row.setAsin(asin);
row.setBrand(brand);
return row;
}
private static InvalidAsinDataEntity entity(String brand) {
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
entity.setBrand(brand);
return entity;
}
}