diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixture.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixture.java new file mode 100644 index 00000000..7b135a51 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixture.java @@ -0,0 +1,145 @@ +package com.nanri.aiimage.modules.collectdata.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * 采集数据性能基线夹具:1k/10k 行、多个 chunk 和品牌检测场景的确定性生成器。 + * 同一输入必然产生相同输出(幂等);品牌检测场景通过 failedBrandCount / + * queryFailedBrandCount 指定前 N 行落入失败/查询失败品牌,空品牌行用于 + * 无品牌拒绝路径。上限约束:单次最多 MAX_ROWS 行,防止基线夹具无界内存增长。 + */ +@Slf4j +public class CollectDataPerfFixture { + + public static final int MAX_ROWS = 10000; + private static final String[] DELIVERY_METHODS = {"FBA", "FBM", "AMZ", ""}; + + private final ObjectMapper objectMapper; + + public CollectDataPerfFixture(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * 生成 rowCount 行采集结果,按 keywordSet 循环分配关键词; + * 前 failedBrandCount 行落入失败品牌(brand-rejected 路径), + * 随后 queryFailedBrandCount 行落入查询失败品牌(query-failed 路径), + * 其余行使用有效品牌。asin 为空的行只在 failedBrandCount 之前按 + * blankAsinCount 生成,用于空 ASIN 不进入过滤链的基线。 + */ + public List generateRows(int rowCount, List keywordSet, + int failedBrandCount, int queryFailedBrandCount, + int blankAsinCount) { + if (rowCount < 0 || rowCount > MAX_ROWS) { + throw new IllegalArgumentException("rowCount 必须在 [0, " + MAX_ROWS + "] 范围内,实际 " + rowCount); + } + if (keywordSet == null || keywordSet.isEmpty()) { + throw new IllegalArgumentException("keywordSet 不能为空"); + } + if (failedBrandCount < 0 || queryFailedBrandCount < 0 || blankAsinCount < 0) { + throw new IllegalArgumentException("品牌/空 ASIN 计数不能为负"); + } + if (failedBrandCount + queryFailedBrandCount > rowCount) { + throw new IllegalArgumentException("失败品牌与查询失败品牌合计不能超过行数"); + } + if (blankAsinCount > rowCount) { + throw new IllegalArgumentException("blankAsinCount 不能超过行数"); + } + if (rowCount == 0) { + return new ArrayList<>(); + } + List rows = new ArrayList<>(rowCount); + for (int i = 0; i < rowCount; i++) { + rows.add(buildRow(i, keywordSet, failedBrandCount, queryFailedBrandCount, blankAsinCount)); + } + return rows; + } + + private CollectDataResultRowVo buildRow(int index, List keywordSet, + int failedBrandCount, int queryFailedBrandCount, + int blankAsinCount) { + String keyword = keywordSet.get(index % keywordSet.size()); + String brand; + if (index < failedBrandCount) { + brand = "RejectedBrand-" + (index % 10); + } else if (index < failedBrandCount + queryFailedBrandCount) { + brand = "QueryFailedBrand-" + (index % 10); + } else { + brand = "ValidBrand-" + (index % 50); + } + CollectDataResultRowVo row = new CollectDataResultRowVo(); + row.setBrand(brand); + row.setAsin(blankAsinCount > index ? "" : deterministicAsin(index * 31L + keyword.hashCode())); + row.setPrice(String.format("%.2f", 1 + (index % 9900) / 100.0)); + row.setSellerName("Seller-" + (index % 200)); + row.setKeyword(keyword); + row.setDeliveryMethod(DELIVERY_METHODS[index % DELIVERY_METHODS.length]); + row.setPage(1 + index % 20); + return row; + } + + /** + * 采样全量行 payload 大小、chunk 划分数与品牌检测场景行数统计。 + * 序列化失败时向上抛出不产生部分结果。 + */ + public Metrics samplePayload(List rows, int chunkSize) { + if (rows == null) { + throw new IllegalArgumentException("rows 不能为 null"); + } + if (chunkSize <= 0) { + throw new IllegalArgumentException("chunkSize 必须为正数,实际 " + chunkSize); + } + int failedBrandRows = 0; + int queryFailedBrandRows = 0; + int blankAsinRows = 0; + int blankBrandRows = 0; + for (CollectDataResultRowVo row : rows) { + if (row == null) { + continue; + } + String brand = row.getBrand(); + if (brand == null || brand.isBlank()) { + blankBrandRows++; + } else if (brand.startsWith("RejectedBrand-")) { + failedBrandRows++; + } else if (brand.startsWith("QueryFailedBrand-")) { + queryFailedBrandRows++; + } + if (row.getAsin() == null || row.getAsin().isBlank()) { + blankAsinRows++; + } + } + int chunkCount = rows.isEmpty() ? 0 : (rows.size() + chunkSize - 1) / chunkSize; + if (rows.isEmpty()) { + return new Metrics(0, 0, 0, 0, 0, 0, 0); + } + try { + byte[] bytes = objectMapper.writeValueAsBytes(rows); + return new Metrics(rows.size(), chunkCount, bytes.length, + failedBrandRows, queryFailedBrandRows, blankAsinRows, blankBrandRows); + } catch (Exception ex) { + throw new IllegalStateException("采集数据基线 payload 采样序列化失败", ex); + } + } + + public record Metrics(int rowCount, int chunkCount, long payloadBytes, + int failedBrandRows, int queryFailedBrandRows, + int blankAsinRows, int blankBrandRows) { + } + + private static String deterministicAsin(long seed) { + StringBuilder sb = new StringBuilder("B0"); + long state = seed & 0x7fffffffL; + for (int i = 0; i < 8; i++) { + state = state * 6364136223846793005L + 1442695040888963407L; + int pick = (int) ((state >>> 33) % 36); + sb.append(pick < 10 ? (char) ('0' + pick) : (char) ('A' + pick - 10)); + } + return sb.toString(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixtureTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixtureTest.java new file mode 100644 index 00000000..a85cb8cd --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/util/CollectDataPerfFixtureTest.java @@ -0,0 +1,166 @@ +package com.nanri.aiimage.modules.collectdata.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Task 41:Collect Data 性能基线夹具。 + * 覆盖 1k/10k 行、多个 chunk 与品牌检测(成功/失败/查询失败/空 ASIN)场景的 + * 确定性生成与 payload 采样;同一输入重复生成结果一致(幂等),超限与非法 + * 输入被拒绝,序列化失败抛可识别异常且不产生部分结果。 + */ +class CollectDataPerfFixtureTest { + + private final CollectDataPerfFixture fixture = + new CollectDataPerfFixture(new ObjectMapper()); + + private static final List KEYWORDS = List.of("phone case", "iphone case", "samsung case"); + + @Test + void test_task_041_chunk_brand_normal_default_path() { + // 正常输入:1000 行 3 关键词 1 个失败品牌行,payload 与 chunk 统计正确。 + List rows = + fixture.generateRows(1000, KEYWORDS, 1, 0, 0); + CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200); + + assertEquals(1000, rows.size(), "1000 行全部生成"); + assertEquals(1000, metrics.rowCount(), "采样行数一致"); + assertEquals(5, metrics.chunkCount(), "200 行一个 chunk 共 5 个"); + assertTrue(metrics.payloadBytes() > 0, "payload 非空"); + assertEquals(1, metrics.failedBrandRows(), "1 行失败品牌"); + assertEquals(0, metrics.queryFailedBrandRows(), "无查询失败品牌"); + assertEquals(0, metrics.blankAsinRows(), "无空 ASIN"); + assertEquals(0, metrics.blankBrandRows(), "无空品牌"); + assertTrue(rows.getFirst().getAsin().startsWith("B0"), "ASIN 确定性生成"); + assertTrue(rows.getFirst().getKeyword() != null && !rows.getFirst().getKeyword().isBlank(), "关键词非空"); + } + + @Test + void test_task_041_chunk_brand_normal_multiple_items() { + // 批量场景:10k 行 5 关键词多 chunk,行数不丢失且按关键词循环顺序稳定。 + List keywords = List.of("a", "b", "c", "d", "e"); + List rows = + fixture.generateRows(10000, keywords, 500, 300, 200); + CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 500); + + assertEquals(10000, metrics.rowCount(), "10k 行不丢失"); + assertEquals(20, metrics.chunkCount(), "500 行一个 chunk 共 20 个"); + assertEquals(500, metrics.failedBrandRows(), "失败品牌行数稳定"); + assertEquals(300, metrics.queryFailedBrandRows(), "查询失败品牌行数稳定"); + assertEquals(200, metrics.blankAsinRows(), "空 ASIN 行数稳定"); + for (int i = 0; i < rows.size(); i++) { + assertEquals(keywords.get(i % keywords.size()), rows.get(i).getKeyword(), "关键词循环分配"); + } + } + + @Test + void test_task_041_chunk_brand_normal_repeated_operation_is_idempotent() { + // 幂等:同一输入两次生成,行数与 payload 字节数完全一致,ASIN 可重复。 + List first = + fixture.generateRows(5000, KEYWORDS, 50, 25, 10); + List second = + fixture.generateRows(5000, KEYWORDS, 50, 25, 10); + + assertEquals(first.size(), second.size(), "行数幂等"); + assertEquals(first.get(0).getAsin(), second.get(0).getAsin(), "首行 ASIN 幂等"); + assertEquals(first.get(4999).getAsin(), second.get(4999).getAsin(), "末行 ASIN 幂等"); + CollectDataPerfFixture.Metrics m1 = fixture.samplePayload(first, 1000); + CollectDataPerfFixture.Metrics m2 = fixture.samplePayload(second, 1000); + assertEquals(m1.payloadBytes(), m2.payloadBytes(), "payload 字节幂等"); + assertEquals(m1.chunkCount(), m2.chunkCount(), "chunk 数幂等"); + assertEquals(m1.failedBrandRows(), m2.failedBrandRows(), "失败品牌统计幂等"); + } + + @Test + void test_task_041_chunk_brand_boundary_empty_input() { + // 空输入:0 行返回空集合,采样统计全零且无 chunk。 + List rows = fixture.generateRows(0, KEYWORDS, 0, 0, 0); + CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200); + + assertEquals(0, rows.size(), "空输入 0 行"); + assertEquals(0, metrics.rowCount(), "采样行数 0"); + assertEquals(0, metrics.chunkCount(), "空输入无 chunk"); + assertEquals(0, metrics.payloadBytes(), "空输入无 payload"); + assertEquals(0, metrics.failedBrandRows(), "空输入无失败品牌"); + assertEquals(0, metrics.queryFailedBrandRows(), "空输入无查询失败品牌"); + assertEquals(0, metrics.blankAsinRows(), "空输入无空 ASIN"); + } + + @Test + void test_task_041_chunk_brand_boundary_single_item() { + // 单元素:1 行 1 关键词单 chunk,不依赖批量路径,全部统计可算。 + List rows = fixture.generateRows(1, List.of("only"), 0, 0, 0); + CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200); + + assertEquals(1, rows.size(), "单行"); + assertEquals(1, metrics.chunkCount(), "单行一个 chunk"); + assertEquals(1, metrics.rowCount(), "采样行数 1"); + assertEquals(0, metrics.failedBrandRows(), "单行有效品牌"); + assertTrue(metrics.payloadBytes() > 0, "单行 payload 非空"); + assertEquals("only", rows.getFirst().getKeyword(), "单关键词正确"); + assertEquals("FBA", rows.getFirst().getDeliveryMethod(), "单行配送方式确定"); + } + + @Test + void test_task_041_chunk_brand_boundary_limit_and_overflow() { + // 上限/超限:10k 行最大值可执行;超限行数、负计数、品牌合计超行数被拒绝。 + List maxRows = fixture.generateRows(10000, KEYWORDS, 0, 0, 0); + assertEquals(10000, maxRows.size(), "最大行数可执行"); + + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(10001, KEYWORDS, 0, 0, 0), "超限行数被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(100, KEYWORDS, -1, 0, 0), "负失败品牌计数被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(100, KEYWORDS, 60, 50, 0), "品牌合计超行数被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(100, KEYWORDS, 0, 0, 101), "空 ASIN 计数超行数被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(100, List.of(), 0, 0, 0), "空关键词集合被拒绝"); + } + + @Test + void test_task_041_chunk_brand_invalid_input_rejected() { + // 非法输入:null 集合、null 行列表、非正 chunkSize、负行数抛可识别异常。 + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(-1, KEYWORDS, 0, 0, 0), "负行数被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.generateRows(100, null, 0, 0, 0), "null 关键词集合被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.samplePayload(null, 200), "null 行列表被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.samplePayload(List.of(), 0), "非正 chunkSize 被拒绝"); + assertThrows(IllegalArgumentException.class, + () -> fixture.samplePayload(List.of(), -5), "负 chunkSize 被拒绝"); + } + + @Test + void test_task_041_chunk_brand_dependency_failure_releases_resources() throws Exception { + // 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果; + // 恢复后同一输入可重新采样且结果一致。 + ObjectMapper spy = org.mockito.Mockito.spy(new ObjectMapper()); + org.mockito.Mockito.doAnswer(invocation -> { + throw new java.io.IOException("serializer down"); + }).when(spy).writeValueAsBytes(org.mockito.ArgumentMatchers.any()); + CollectDataPerfFixture failingFixture = new CollectDataPerfFixture(spy); + + List rows = fixture.generateRows(500, KEYWORDS, 10, 5, 3); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> failingFixture.samplePayload(rows, 100), "序列化失败必须抛出"); + assertTrue(ex.getMessage().contains("采样序列化失败"), "异常消息可识别"); + + CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 100); + assertEquals(500, metrics.rowCount(), "恢复后采样行数完整"); + assertEquals(5, metrics.chunkCount(), "恢复后 chunk 数正确"); + assertEquals(10, metrics.failedBrandRows(), "恢复后失败品牌统计正确"); + assertEquals(5, metrics.queryFailedBrandRows(), "恢复后查询失败统计正确"); + assertEquals(3, metrics.blankAsinRows(), "恢复后空 ASIN 统计正确"); + } +}