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();
}
}