|
|
@@ -0,0 +1,247 @@
|
|
|
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt;
|
|
|
+import com.tzld.videoVector.job.MaterialQualitySyncJob;
|
|
|
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
|
|
|
+import com.tzld.videoVector.util.MaterialQualityCalculator;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.junit.jupiter.api.Assumptions;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.boot.test.context.SpringBootTest;
|
|
|
+import org.springframework.test.context.ActiveProfiles;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.List;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 素材质量评分同步 集成测试
|
|
|
+ * <p>
|
|
|
+ * 运行前改 RUN_LIVE_TEST = true,需连 ODPS + PG。
|
|
|
+ * 测试内容:
|
|
|
+ * 1. 从 ODPS 拉取投放数据并计算质量分
|
|
|
+ * 2. 写入 material_quality 表
|
|
|
+ * 3. 验证质量分分布等基本合理性
|
|
|
+ */
|
|
|
+@SpringBootTest(
|
|
|
+ classes = MaterialVectorJobIntegrationTestApp.class,
|
|
|
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
|
|
+ properties = {
|
|
|
+ "spring.main.web-application-type=none",
|
|
|
+ "spring.config.location=classpath:/application-test-local.yml"
|
|
|
+ }
|
|
|
+)
|
|
|
+@ActiveProfiles("test-local")
|
|
|
+@Slf4j
|
|
|
+public class MaterialQualitySyncJobIntegrationTest {
|
|
|
+
|
|
|
+ private static final boolean RUN_LIVE_TEST = true;
|
|
|
+
|
|
|
+ /** 验证 Top/Bottom 素材数 */
|
|
|
+ private static final int SHOW_COUNT = 10;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private MaterialQualitySyncJob materialQualitySyncJob;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private MaterialQualityMapperExt materialQualityMapperExt;
|
|
|
+
|
|
|
+ // ========== 回刷历史数据 ==========
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void backfillHistory() {
|
|
|
+ Assumptions.assumeTrue(RUN_LIVE_TEST, "将 RUN_LIVE_TEST 改为 true 后运行");
|
|
|
+
|
|
|
+ // 从昨天开始逐日往前滑动到 20260501,每天统计近7天数据
|
|
|
+ log.info("===== 回刷历史数据开始 =====");
|
|
|
+ materialQualitySyncJob.syncMaterialQualityJob("backfill=20260603");
|
|
|
+ log.info("===== 回刷历史数据完成 =====");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 全流程测试 ==========
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fullSyncAndVerify() {
|
|
|
+ Assumptions.assumeTrue(RUN_LIVE_TEST, "将 RUN_LIVE_TEST 改为 true 后运行");
|
|
|
+
|
|
|
+ log.info("===== 质量分同步集成测试开始 =====");
|
|
|
+
|
|
|
+ // Step 1: 执行同步(dryRun=true 先预览不写入)
|
|
|
+ log.info(">>> Step 1: dryRun 预览");
|
|
|
+ materialQualitySyncJob.syncMaterialQualityJob("dryRun=true");
|
|
|
+
|
|
|
+ // Step 2: 正式同步写入
|
|
|
+ log.info(">>> Step 2: 正式同步写入");
|
|
|
+ materialQualitySyncJob.syncMaterialQualityJob("");
|
|
|
+
|
|
|
+ // Step 3: 从 DB 验证
|
|
|
+ log.info(">>> Step 3: 验证 material_quality 表数据");
|
|
|
+ List<String> allIds = materialQualityMapperExt.selectAllMaterialIds(0, 1000);
|
|
|
+ assertNotNull(allIds, "material_quality 表应返回 materialId 列表");
|
|
|
+ assertFalse(allIds.isEmpty(), "material_quality 表应有数据,请确认 ODPS touliu_creative_data 有数据且 PG 表已建");
|
|
|
+
|
|
|
+ log.info("material_quality 表中共 {} 条记录", allIds.size());
|
|
|
+
|
|
|
+ // 取前 1000 个 ID 查详情
|
|
|
+ List<String> sampleIds = allIds.subList(0, Math.min(allIds.size(), 1000));
|
|
|
+ List<MaterialQuality> qualities = materialQualityMapperExt.selectByMaterialIds(sampleIds);
|
|
|
+ assertNotNull(qualities);
|
|
|
+ assertFalse(qualities.isEmpty(), "应能查到质量分明细");
|
|
|
+
|
|
|
+ log.info("成功查询 {} 条质量分明细", qualities.size());
|
|
|
+
|
|
|
+ // Step 4: 质量分分布验证
|
|
|
+ verifyQualityScoreDistribution(qualities);
|
|
|
+
|
|
|
+ // Step 5: 打印 Top/Bottom 素材
|
|
|
+ printTopBottom(qualities);
|
|
|
+
|
|
|
+ log.info("===== 质量分同步集成测试完成 =====");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 质量分计算逻辑单元验证 ==========
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void calculatorLogic_mixedData() {
|
|
|
+ // 构造测试数据:覆盖高消耗高质量、高消耗低质量、低消耗、零消耗
|
|
|
+ List<MaterialQuality> list = Arrays.asList(
|
|
|
+ buildRaw("M001", 5000.0, 500L, 10000.0, 0.15, 0.08), // 高消耗高转化
|
|
|
+ buildRaw("M002", 3000.0, 30L, 2000.0, 0.02, 0.03), // 高消耗低转化
|
|
|
+ buildRaw("M003", 20.0, 5L, 50.0, 0.30, 0.20), // 低消耗(不可信)
|
|
|
+ buildRaw("M004", 0.0, 0L, 0.0, 0.0, 0.0), // 零消耗(完全不可信)
|
|
|
+ buildRaw("M005", 1000.0, 200L, 5000.0, 0.10, 0.12), // 中等
|
|
|
+ buildRaw("M006", 800.0, 160L, 1600.0, 0.08, 0.06), // 中等偏高
|
|
|
+ buildRaw("M007", 100.0, 2L, 10.0, 0.01, 0.02), // 低转化
|
|
|
+ buildRaw("M008", 2000.0, 400L, 8000.0, 0.20, 0.15), // 高消耗高转化高裂变
|
|
|
+ buildRaw("M009", 50.0, 8L, 100.0, 0.25, 0.18), // 低消耗高裂变
|
|
|
+ buildRaw("M010", 1500.0, 75L, 1500.0, 0.05, 0.04) // 高消耗中等
|
|
|
+ );
|
|
|
+
|
|
|
+ MaterialQualityCalculator.calculateAll(list, 50.0);
|
|
|
+
|
|
|
+ // 高消耗高转化素材应有更高的质量分
|
|
|
+ MaterialQuality m001 = list.get(0); // M001: 高消耗高转化高裂变
|
|
|
+ MaterialQuality m002 = list.get(1); // M002: 高消耗低转化
|
|
|
+ MaterialQuality m004 = list.get(3); // M004: 零消耗
|
|
|
+
|
|
|
+ assertNotNull(m001.getQualityScore());
|
|
|
+ assertNotNull(m002.getQualityScore());
|
|
|
+ assertNotNull(m004.getQualityScore());
|
|
|
+
|
|
|
+ // M001 质量分应高于 M002
|
|
|
+ assertTrue(m001.getQualityScore() > m002.getQualityScore(),
|
|
|
+ String.format("M001(高转化)质量分=%.2f 应高于 M002(低转化)质量分=%.2f",
|
|
|
+ m001.getQualityScore(), m002.getQualityScore()));
|
|
|
+
|
|
|
+ // M004 零消耗 → 置信度为 0 → 质量分 = 先验值 0.5
|
|
|
+ assertTrue(m004.getConfidence() == 0.0 || m004.getQualityScore() == 0.5,
|
|
|
+ String.format("零消耗素材质量分应回退到0.5, 实际 confidence=%.2f, score=%.2f",
|
|
|
+ m004.getConfidence(), m004.getQualityScore()));
|
|
|
+
|
|
|
+ // M008: 高消耗高转化高裂变,应该是最高分之一
|
|
|
+ MaterialQuality m008 = list.get(7);
|
|
|
+ List<MaterialQuality> sorted = new ArrayList<>(list);
|
|
|
+ sorted.sort(Comparator.comparingDouble(
|
|
|
+ m -> m.getQualityScore() == null ? 0 : m.getQualityScore()));
|
|
|
+ MaterialQuality best = sorted.get(sorted.size() - 1);
|
|
|
+ log.info("最高分素材: materialId={}, qualityScore={}, confidence={}, cost7d={}, conv7d={}",
|
|
|
+ best.getMaterialId(), best.getQualityScore(), best.getConfidence(),
|
|
|
+ best.getCost7d(), best.getTargetConversion7d());
|
|
|
+
|
|
|
+ // 所有质量分应在 [0, 1] 范围内
|
|
|
+ for (MaterialQuality mq : list) {
|
|
|
+ double score = mq.getQualityScore();
|
|
|
+ assertTrue(score >= 0 && score <= 1,
|
|
|
+ String.format("质量分=%.4f 应在 [0,1] 范围内, materialId=%s", score, mq.getMaterialId()));
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("质量分计算逻辑验证通过");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 辅助方法 ==========
|
|
|
+
|
|
|
+ private MaterialQuality buildRaw(String materialId, double cost, long conv,
|
|
|
+ double revenue, double viralRate, double openRate) {
|
|
|
+ MaterialQuality mq = new MaterialQuality();
|
|
|
+ mq.setMaterialId(materialId);
|
|
|
+ mq.setCost7d(cost);
|
|
|
+ mq.setTargetConversion7d(conv);
|
|
|
+ mq.setRevenue7d(revenue);
|
|
|
+ mq.setT0ViralRate7d(viralRate);
|
|
|
+ mq.setMiniProgramOpenRate7d(openRate);
|
|
|
+ return mq;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void verifyQualityScoreDistribution(List<MaterialQuality> list) {
|
|
|
+ List<Double> scores = list.stream()
|
|
|
+ .map(MaterialQuality::getQualityScore)
|
|
|
+ .filter(s -> s != null)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ double avg = scores.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
|
|
+ double max = scores.stream().mapToDouble(Double::doubleValue).max().orElse(0);
|
|
|
+ double min = scores.stream().mapToDouble(Double::doubleValue).min().orElse(0);
|
|
|
+
|
|
|
+ long highConf = list.stream()
|
|
|
+ .filter(m -> m.getConfidence() != null && m.getConfidence() >= 1.0)
|
|
|
+ .count();
|
|
|
+ long lowConf = list.stream()
|
|
|
+ .filter(m -> m.getConfidence() != null && m.getConfidence() > 0 && m.getConfidence() < 1.0)
|
|
|
+ .count();
|
|
|
+ long noData = list.stream()
|
|
|
+ .filter(m -> m.getConfidence() == null || m.getConfidence() == 0)
|
|
|
+ .count();
|
|
|
+
|
|
|
+ log.info("质量分分布: avg={}, min={}, max={}, 样本数={}", round2(avg), round2(min), round2(max), scores.size());
|
|
|
+ log.info("置信度分布: 高置信≥1.0={}, 低置信(0,1)={}, 无数据=0={}", highConf, lowConf, noData);
|
|
|
+ log.info("维度分示例: convEff={}, revenue={}, viral={}, engagement={}",
|
|
|
+ list.get(0).getConversionEfficiencyScore(),
|
|
|
+ list.get(0).getRevenueScore(),
|
|
|
+ list.get(0).getViralScore(),
|
|
|
+ list.get(0).getEngagementScore());
|
|
|
+
|
|
|
+ assertTrue(max > 0.5, "应有质量分 > 0.5 的高质量素材, 实际 max=" + round2(max));
|
|
|
+ assertTrue(min < 0.5 || noData > 0,
|
|
|
+ "应有质量分 < 0.5 的低质量素材, 或存在无数据素材(置信度=0), 实际 min=" + round2(min));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void printTopBottom(List<MaterialQuality> list) {
|
|
|
+ List<MaterialQuality> sorted = list.stream()
|
|
|
+ .sorted(Comparator.comparingDouble(
|
|
|
+ m -> m.getQualityScore() == null ? 0 : m.getQualityScore()))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ int show = Math.min(SHOW_COUNT, sorted.size());
|
|
|
+
|
|
|
+ log.info("===== Top {} 高质量素材 =====", show);
|
|
|
+ for (int i = sorted.size() - 1; i >= Math.max(0, sorted.size() - show); i--) {
|
|
|
+ MaterialQuality m = sorted.get(i);
|
|
|
+ logQuality(m, sorted.size() - i);
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("===== Bottom {} 低质量素材 =====", show);
|
|
|
+ for (int i = 0; i < show; i++) {
|
|
|
+ MaterialQuality m = sorted.get(i);
|
|
|
+ logQuality(m, i + 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void logQuality(MaterialQuality m, int rank) {
|
|
|
+ log.info("[#{}] materialId={}, qualityScore={}, confidence={}, cost7d={}, conv7d={}, "
|
|
|
+ + "revenue7d={}, viralRate={}, openRate={}",
|
|
|
+ rank, m.getMaterialId(),
|
|
|
+ round2(m.getQualityScore()), round2(m.getConfidence()),
|
|
|
+ m.getCost7d(), m.getTargetConversion7d(),
|
|
|
+ m.getRevenue7d(), round2(m.getT0ViralRate7d()), round2(m.getMiniProgramOpenRate7d()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double round2(Double v) {
|
|
|
+ if (v == null) return 0;
|
|
|
+ return Math.round(v * 100.0) / 100.0;
|
|
|
+ }
|
|
|
+}
|