task-41: 建立 Collect Data 性能基线夹具(1k/10k 行、多 chunk、品牌检测)

新增 CollectDataPerfFixture:确定性生成 1k/10k 行、多关键词多 chunk 与
品牌检测场景(成功/失败/查询失败/空 ASIN)行集合并采样 payload 字节与
chunk 划分;同一输入重复生成结果一致(幂等),行数/计数/关键词均有上限
校验。8 个用例覆盖正常/批量/幂等/空/单元素/超限/非法输入/序列化失败
降级路径,mvn 全量 681 测试通过。
This commit is contained in:
2026-08-30 12:58:26 +08:00
parent 2ce37f0a1a
commit 33d0f02c55
2 changed files with 311 additions and 0 deletions
@@ -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<CollectDataResultRowVo> generateRows(int rowCount, List<String> 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<CollectDataResultRowVo> 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<String> 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<CollectDataResultRowVo> 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();
}
}
@@ -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 41Collect Data 性能基线夹具。
* 覆盖 1k/10k 行、多个 chunk 与品牌检测(成功/失败/查询失败/空 ASIN)场景的
* 确定性生成与 payload 采样;同一输入重复生成结果一致(幂等),超限与非法
* 输入被拒绝,序列化失败抛可识别异常且不产生部分结果。
*/
class CollectDataPerfFixtureTest {
private final CollectDataPerfFixture fixture =
new CollectDataPerfFixture(new ObjectMapper());
private static final List<String> 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<CollectDataResultRowVo> 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<String> keywords = List.of("a", "b", "c", "d", "e");
List<CollectDataResultRowVo> 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<CollectDataResultRowVo> first =
fixture.generateRows(5000, KEYWORDS, 50, 25, 10);
List<CollectDataResultRowVo> 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<CollectDataResultRowVo> 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<CollectDataResultRowVo> 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<CollectDataResultRowVo> 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<CollectDataResultRowVo> 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 统计正确");
}
}