Parcourir la source

视频增加表现--code-review

luojunhui il y a 2 jours
Parent
commit
3c4257216a

+ 247 - 0
server/src/test/java/MaterialQualitySyncJobIntegrationTest.java

@@ -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;
+    }
+}

+ 87 - 0
server/src/test/java/MaterialVectorEmbedIntegrationTest.java

@@ -0,0 +1,87 @@
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialVectorMapperExt;
+import com.tzld.videoVector.job.MaterialVectorJob;
+import com.xxl.job.core.biz.model.ReturnT;
+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.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 素材向量化集成测试:仅 vectorMaterialJob,不同步解构结果。
+ * <p>
+ * 运行前改下面两个常量即可,无需配 VM Options。
+ */
+@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 MaterialVectorEmbedIntegrationTest {
+
+    /** 改为 true 后才会连 RDS / DashScope 执行 */
+    private static final boolean RUN_LIVE_TEST = true;
+
+    /**
+     * 本次最多向量化素材数。
+     * <=0 表示全量(扫完 material_deconstruct_result 里所有未向量化的素材)。
+     */
+    private static final int MAX_MATERIAL_COUNT = 0;
+
+    private static final String SOURCE_AIGC = "aigc_deconstruct";
+    private static final String CONFIG_VIDEO_TOPIC = "VIDEO_TOPIC";
+
+    @Autowired
+    private MaterialVectorJob materialVectorJob;
+
+    @Autowired
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
+
+    @Autowired
+    private MaterialVectorMapperExt materialVectorMapperExt;
+
+    @Test
+    void vectorMaterialJob_embedsAllFromDeconstructResult() {
+        Assumptions.assumeTrue(RUN_LIVE_TEST, "将 RUN_LIVE_TEST 改为 true 后运行");
+
+        String param = MAX_MATERIAL_COUNT > 0 ? String.valueOf(MAX_MATERIAL_COUNT) : null;
+        log.info(">>> 向量化集成测试开始, maxMaterialCount={}", param == null ? "全量" : param);
+
+        List<String> deconstructIds = materialDeconstructResultMapperExt
+                .selectMaterialIdsBySourcePaged(SOURCE_AIGC, 0, 1);
+        assertFalse(deconstructIds.isEmpty(),
+                "material_deconstruct_result 无数据,请先完成解构同步");
+
+        Set<String> vectorIdsBefore = new HashSet<>(materialVectorMapperExt.selectAllMaterialIds(CONFIG_VIDEO_TOPIC));
+        log.info("执行前 VIDEO_TOPIC 向量 materialId 数: {}", vectorIdsBefore.size());
+
+        ReturnT<String> result = materialVectorJob.vectorMaterialJob(param);
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode(),
+                "vectorMaterialJob 应成功, msg=" + (result == null ? null : result.getMsg()));
+
+        Set<String> vectorIdsAfter = new HashSet<>(materialVectorMapperExt.selectAllMaterialIds(CONFIG_VIDEO_TOPIC));
+        log.info("执行后 VIDEO_TOPIC 向量 materialId 数: {} (before={})",
+                vectorIdsAfter.size(), vectorIdsBefore.size());
+
+        assertTrue(vectorIdsAfter.size() >= vectorIdsBefore.size(),
+                "material_vectors 向量数不应减少");
+        assertTrue(!vectorIdsAfter.isEmpty() || !vectorIdsBefore.isEmpty(),
+                "material_vectors 应有向量记录");
+    }
+}

+ 109 - 0
server/src/test/java/MaterialVectorJobIntegrationTest.java

@@ -0,0 +1,109 @@
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialVectorMapperExt;
+import com.tzld.videoVector.job.MaterialVectorJob;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.xxl.job.core.biz.model.ReturnT;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+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 org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.util.CollectionUtils;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * MaterialVectorJob 集成测试:sync + vectorize(runOnce)。
+ * <p>
+ * 运行前改下面两个常量即可,无需配 VM Options。
+ */
+@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 MaterialVectorJobIntegrationTest {
+
+    /** 改为 true 后才会连 RDS / AIGC / DashScope 执行 */
+    private static final boolean RUN_LIVE_TEST = false;
+
+    /** 本次最多向量化素材数;sync 阶段仍全量,仅 vectorize 阶段限量 */
+    private static final int MAX_MATERIAL_COUNT = 5;
+
+    private static final String SOURCE_AIGC = "aigc_deconstruct";
+    private static final String CONFIG_VIDEO_TOPIC = "VIDEO_TOPIC";
+
+    @Autowired
+    private MaterialVectorJob materialVectorJob;
+
+    @Autowired
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
+
+    @Autowired
+    private MaterialVectorMapperExt materialVectorMapperExt;
+
+    @Autowired
+    private MaterialVectorStoreService materialVectorStoreService;
+
+    @BeforeEach
+    void injectTaskSourceMap() {
+        Map<String, Short> taskSourceMap = new HashMap<>();
+        taskSourceMap.put("67", (short) 1);
+        taskSourceMap.put("69", (short) 2);
+        ReflectionTestUtils.setField(materialVectorJob, "aigcMaterialTaskSourceMap", taskSourceMap);
+        ReflectionTestUtils.setField(materialVectorJob, "defaultSourceType", (short) 2);
+    }
+
+    @Test
+    void runOnce_writesDeconstructResultAndMaterialVectors() {
+        Assumptions.assumeTrue(RUN_LIVE_TEST, "将 RUN_LIVE_TEST 改为 true 后运行");
+
+        log.info(">>> 集成测试开始, maxMaterialCount={}", MAX_MATERIAL_COUNT);
+
+        Set<String> vectorIdsBefore = new HashSet<>(materialVectorMapperExt.selectAllMaterialIds(CONFIG_VIDEO_TOPIC));
+        log.info("执行前 VIDEO_TOPIC 向量 materialId 数: {}", vectorIdsBefore.size());
+
+        ReturnT<String> result = materialVectorJob.runOnce(MAX_MATERIAL_COUNT);
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode(),
+                "runOnce 应成功, msg=" + (result == null ? null : result.getMsg()));
+
+        List<String> deconstructMaterialIds = materialDeconstructResultMapperExt
+                .selectMaterialIdsBySourcePaged(SOURCE_AIGC, 0, MAX_MATERIAL_COUNT);
+        assertFalse(CollectionUtils.isEmpty(deconstructMaterialIds),
+                "material_deconstruct_result 应有同步记录,请确认 AIGC task 67/69 有数据且 PG 表已建");
+
+        log.info("material_deconstruct_result 样本 materialIds: {}", deconstructMaterialIds);
+
+        boolean hasNewOrExistingVector = false;
+        for (String materialId : deconstructMaterialIds) {
+            if (materialVectorStoreService.exists(CONFIG_VIDEO_TOPIC, materialId)) {
+                hasNewOrExistingVector = true;
+                log.info("materialId={} 已有 VIDEO_TOPIC 向量", materialId);
+            }
+        }
+
+        Set<String> vectorIdsAfter = new HashSet<>(materialVectorMapperExt.selectAllMaterialIds(CONFIG_VIDEO_TOPIC));
+        log.info("执行后 VIDEO_TOPIC 向量 materialId 数: {} (before={})",
+                vectorIdsAfter.size(), vectorIdsBefore.size());
+
+        assertTrue(hasNewOrExistingVector || vectorIdsAfter.size() > vectorIdsBefore.size(),
+                "material_vectors 应有 VIDEO_TOPIC 向量写入;若全部素材已向量化,可增大 MAX_MATERIAL_COUNT 后重试");
+    }
+}

+ 64 - 0
server/src/test/java/MaterialVectorJobIntegrationTestApp.java

@@ -0,0 +1,64 @@
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+
+/**
+ * MaterialVectorJob 集成测试专用 Spring Boot 入口。
+ * <p>
+ * 与 {@link com.tzld.videoVector.MaterialEmbeddingTestRunner} 使用相同的精简 ComponentScan,
+ * 但不包含 CommandLineRunner(避免测试启动时 System.exit)。
+ */
+@SpringBootApplication(excludeName = {
+        "com.tzld.commons.aliyun.log.AliyunLogAutoConfiguration"
+})
+@ComponentScan(
+        basePackages = "com.tzld.videoVector",
+        excludeFilters = {
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.Application"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.MaterialEmbeddingTestRunner"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.MaterialEmbeddingTestRunner\\$TestKickoffRunner"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.MaterialVectorEmbedOnlyRunner"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.MaterialVectorEmbedOnlyRunner\\$EmbedKickoffRunner"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.XxlJobConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.AliOssConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.util\\.AliOssFileTool"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.SwaggerConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.WebMvcConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.config\\.SchedulingConfig"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.filter\\..*"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.aop\\..*"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.controller\\..*"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoTitleVectorJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.AiUnderstandingSyncJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.ChannelDemandMatchJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoDetailSyncJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.job\\.VideoVectorJob"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.impl\\.MaterialSearchServiceImpl"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.impl\\.VideoSearchServiceImpl"),
+                @ComponentScan.Filter(type = FilterType.REGEX,
+                        pattern = "com\\.tzld\\.videoVector\\.service\\.recall\\..*")
+        }
+)
+public class MaterialVectorJobIntegrationTestApp {
+}

+ 194 - 0
server/src/test/java/MaterialVectorJobTest.java

@@ -0,0 +1,194 @@
+import com.alibaba.fastjson.JSONObject;
+import com.tzld.videoVector.api.AigcApiService;
+import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.job.MaterialVectorJob;
+import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
+import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
+import com.tzld.videoVector.service.EmbeddingService;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.xxl.job.core.biz.model.ReturnT;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * MaterialVectorJob 单元测试(Mock 外部依赖,不连 DB / AIGC / DashScope)
+ */
+@ExtendWith(MockitoExtension.class)
+@Slf4j
+public class MaterialVectorJobTest {
+
+    private static final String INTERNAL_MATERIAL_ID = "1001";
+    private static final String EXTERNAL_MATERIAL_ID = "d41d8cd98f00b204e9800998ecf8427e";
+    private static final long TASK_INSTANCE_ID = 9001L;
+
+    @Mock
+    private DeconstructVectorConfigMapper vectorConfigMapper;
+
+    @Mock
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
+
+    @Mock
+    private MaterialVectorStoreService materialVectorStoreService;
+
+    @Mock
+    private EmbeddingService embeddingService;
+
+    @Mock
+    private AigcApiService aigcApiService;
+
+    @InjectMocks
+    private MaterialVectorJob materialVectorJob;
+
+    @BeforeEach
+    void setUp() {
+        Map<String, Short> taskSourceMap = new HashMap<>();
+        taskSourceMap.put("67", (short) 1);
+        taskSourceMap.put("69", (short) 2);
+        ReflectionTestUtils.setField(materialVectorJob, "aigcMaterialTaskSourceMap", taskSourceMap);
+        ReflectionTestUtils.setField(materialVectorJob, "defaultSourceType", (short) 2);
+    }
+
+    @Test
+    void syncMaterialDeconstructJob_skipsWhenTaskMapEmpty() {
+        ReflectionTestUtils.setField(materialVectorJob, "aigcMaterialTaskSourceMap", Collections.emptyMap());
+
+        ReturnT<String> result = materialVectorJob.syncMaterialDeconstructJob(null);
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+        verify(aigcApiService, never()).getTaskInputList(anyInt());
+        verify(materialDeconstructResultMapperExt, never()).batchInsertIgnore(any());
+    }
+
+    @Test
+    void syncMaterialDeconstructJob_insertsExternalMaterialMd5Id() {
+        AigcApiService.AigcTaskInput input = new AigcApiService.AigcTaskInput();
+        input.setBizUniqueId(EXTERNAL_MATERIAL_ID);
+        input.setTaskInstanceId(TASK_INSTANCE_ID);
+        when(aigcApiService.getTaskInputList(67)).thenReturn(Collections.singletonList(input));
+        when(aigcApiService.getTaskInputList(69)).thenReturn(Collections.emptyList());
+        when(materialDeconstructResultMapperExt.selectExistingMaterialIds(eq("aigc_deconstruct"), any()))
+                .thenReturn(Collections.emptyList());
+        JSONObject dataContent = new JSONObject();
+        dataContent.put("topic", "测试素材主题");
+        when(aigcApiService.getTaskCallbackDetail(TASK_INSTANCE_ID)).thenReturn(dataContent);
+        when(materialDeconstructResultMapperExt.batchInsertIgnore(any())).thenReturn(1);
+
+        ReturnT<String> result = materialVectorJob.syncMaterialDeconstructJob(null);
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<MaterialDeconstructResult>> captor = ArgumentCaptor.forClass(List.class);
+        verify(materialDeconstructResultMapperExt).batchInsertIgnore(captor.capture());
+        MaterialDeconstructResult saved = captor.getValue().get(0);
+        assertEquals(EXTERNAL_MATERIAL_ID, saved.getMaterialId());
+        assertEquals("aigc_deconstruct", saved.getSource());
+        assertEquals((short) 1, saved.getSourceType().shortValue());
+        assertTrue(saved.getResult().contains("测试素材主题"));
+    }
+
+    @Test
+    void vectorMaterialJob_returnsSuccessWhenNoConfig() {
+        when(vectorConfigMapper.selectByExample(any())).thenReturn(Collections.emptyList());
+
+        ReturnT<String> result = materialVectorJob.vectorMaterialJob("5");
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+        verify(materialDeconstructResultMapperExt, never()).selectMaterialIdsBySourcePaged(anyString(), anyInt(), anyInt());
+    }
+
+    @Test
+    void vectorMaterialJob_embedsAndStoresMaterialVector() {
+        DeconstructVectorConfig config = buildTopicConfig();
+        when(vectorConfigMapper.selectByExample(any())).thenReturn(Collections.singletonList(config));
+        when(materialDeconstructResultMapperExt.selectMaterialIdsBySourcePaged(eq("aigc_deconstruct"), eq(0), anyInt()))
+                .thenReturn(Collections.singletonList(INTERNAL_MATERIAL_ID));
+        when(materialVectorStoreService.existsByIds(eq("VIDEO_TOPIC"), anyCollection()))
+                .thenReturn(Collections.emptySet());
+
+        MaterialDeconstructResult deconstructResult = new MaterialDeconstructResult();
+        deconstructResult.setMaterialId(INTERNAL_MATERIAL_ID);
+        deconstructResult.setSourceType((short) 2);
+        JSONObject dataContent = new JSONObject();
+        dataContent.put("topic", "刘伯温预言素材");
+        deconstructResult.setResult(dataContent.toJSONString());
+        when(materialDeconstructResultMapperExt.selectResultsByMaterialIds(eq("aigc_deconstruct"), any()))
+                .thenReturn(Collections.singletonList(deconstructResult));
+
+        List<Float> vector = Arrays.asList(0.1f, 0.2f, 0.3f, 0.4f);
+        when(materialVectorStoreService.getVectorByTextHash(anyString(), eq("VIDEO_TOPIC"))).thenReturn(null);
+        when(embeddingService.embed(anyString(), eq(config))).thenReturn(vector);
+
+        ReturnT<String> result = materialVectorJob.vectorMaterialJob("1");
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+        verify(materialVectorStoreService).save(
+                eq("VIDEO_TOPIC"),
+                eq(INTERNAL_MATERIAL_ID),
+                eq(vector),
+                eq("刘伯温预言素材"),
+                eq((short) 2)
+        );
+    }
+
+    @Test
+    void materialJob_runsSyncThenVectorize() {
+        ReflectionTestUtils.setField(materialVectorJob, "aigcMaterialTaskSourceMap", Collections.emptyMap());
+        when(vectorConfigMapper.selectByExample(any())).thenReturn(Collections.emptyList());
+
+        ReturnT<String> result = materialVectorJob.materialJob("3");
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+        verify(vectorConfigMapper, times(1)).selectByExample(any());
+    }
+
+    @Test
+    void runOnce_runsFullPipelineWithMaxLimit() {
+        ReflectionTestUtils.setField(materialVectorJob, "aigcMaterialTaskSourceMap", Collections.emptyMap());
+        when(vectorConfigMapper.selectByExample(any())).thenReturn(Collections.emptyList());
+
+        ReturnT<String> result = materialVectorJob.runOnce(2);
+
+        assertEquals(ReturnT.SUCCESS_CODE, result.getCode());
+        verify(aigcApiService, never()).getTaskInputList(anyInt());
+        verify(vectorConfigMapper, times(1)).selectByExample(any());
+    }
+
+    private DeconstructVectorConfig buildTopicConfig() {
+        DeconstructVectorConfig config = new DeconstructVectorConfig();
+        config.setConfigCode("VIDEO_TOPIC");
+        config.setSourceField("aigc_deconstruct");
+        config.setSourcePath("$.topic");
+        config.setEnabled((short) 1);
+        config.setPriority(1);
+        return config;
+    }
+}