Jelajahi Sumber

素材新增阅读指标

luojunhui 1 hari lalu
induk
melakukan
e81a2550c5

+ 27 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/pgVector/ext/MaterialQualityMapperExt.java

@@ -0,0 +1,27 @@
+package com.tzld.videoVector.dao.mapper.pgVector.ext;
+
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * material_quality 自定义 Mapper
+ */
+public interface MaterialQualityMapperExt {
+
+    /**
+     * 批量 upsert(ON CONFLICT DO UPDATE)
+     */
+    int batchUpsert(@Param("list") List<MaterialQuality> list);
+
+    /**
+     * 按 materialId 批量查询质量分
+     */
+    List<MaterialQuality> selectByMaterialIds(@Param("materialIds") List<String> materialIds);
+
+    /**
+     * 分页查询所有 materialId(用于增量同步遍历)
+     */
+    List<String> selectAllMaterialIds(@Param("offset") int offset, @Param("limit") int limit);
+}

+ 343 - 0
core/src/main/java/com/tzld/videoVector/job/MaterialQualitySyncJob.java

@@ -0,0 +1,343 @@
+package com.tzld.videoVector.job;
+
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt;
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
+import com.tzld.videoVector.util.MaterialQualityCalculator;
+import com.tzld.videoVector.util.OdpsUtil;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 素材质量评分同步 Job
+ * 从 ODPS loghubods.touliu_creative_data 拉取投放表现数据,
+ * 计算综合质量分,写入 pgVector material_quality 表
+ */
+@Component
+public class MaterialQualitySyncJob {
+
+    private static final Logger log = LoggerFactory.getLogger(MaterialQualitySyncJob.class);
+
+    private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final int BATCH_SIZE = 200;
+
+    // 创意状态过滤:null/空 = 不限制;指定值如 "有效" 则过滤
+    // 通过 param: creativeStatus=有效 传入
+
+    @Resource
+    private MaterialQualityMapperExt materialQualityMapperExt;
+
+    /**
+     * 同步素材质量评分(每日凌晨执行)
+     *
+     * 可选参数(逗号分隔):
+     * costThreshold=100  — 置信度消耗阈值,默认50
+     * dryRun=true        — 仅打印计算结果,不写入DB
+     */
+    @XxlJob("syncMaterialQualityJob")
+    public ReturnT<String> syncMaterialQualityJob(String param) {
+        log.info("===== syncMaterialQualityJob 开始, param: {} =====", param);
+
+        double costThreshold = parseParamDouble(param, "costThreshold", 50.0);
+        boolean dryRun = parseParamBool(param, "dryRun");
+        String creativeStatus = parseParamString(param, "creativeStatus", null);
+        boolean probeStatus = parseParamBool(param, "probeStatus");
+        String startDtStr = parseParamString(param, "startDt", null);
+
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+
+        // 回刷模式: backfill=20260501 → 从昨天往前逐日滑动到 20260501
+        String backfillStr = parseParamString(param, "backfill", null);
+        if (backfillStr != null && !backfillStr.isEmpty()) {
+            return backfillHistory(backfillStr, yesterday, costThreshold, creativeStatus, dryRun);
+        }
+
+        LocalDate day7Ago = yesterday.minusDays(6);
+        LocalDate day30Ago;
+        if (startDtStr != null && !startDtStr.isEmpty()) {
+            day30Ago = LocalDate.parse(startDtStr, DT_FMT);
+        } else {
+            day30Ago = yesterday.minusDays(29);
+        }
+        String dt = yesterday.format(DT_FMT);
+
+        log.info("时间窗口: 7天=[{}, {}], 30天=[{}, {}], 消耗阈值: {}, creativeStatus={}, dt={}",
+                day7Ago, yesterday, day30Ago, yesterday, costThreshold,
+                creativeStatus != null ? creativeStatus : "不限制", dt);
+
+        if (probeStatus) {
+            probeCreativeStatus(day30Ago, yesterday);
+            return ReturnT.SUCCESS;
+        }
+
+        if (!probeTableHasData(day30Ago, yesterday)) {
+            log.error("ODPS 表无数据,请检查: 1) dt 分区格式是否正确 2) 时间范围内是否有数据");
+            return ReturnT.FAIL;
+        }
+
+        return doSync(day30Ago, yesterday, day7Ago, dt, costThreshold, creativeStatus, dryRun);
+    }
+
+    /** 单次同步 */
+    private ReturnT<String> doSync(LocalDate day30Ago, LocalDate endDt, LocalDate day7Ago,
+                                    String dt, double costThreshold, String creativeStatus, boolean dryRun) {
+        List<MaterialQuality> rawList = new ArrayList<>();
+        String sql = buildOdpsSql(day30Ago, endDt, day7Ago, creativeStatus);
+        log.info("ODPS SQL: {}", sql);
+        System.err.println("===== ODPS SQL =====\n" + sql + "\n====================");
+
+        long odpsCount;
+        try {
+            odpsCount = OdpsUtil.getOdpsDataStream(sql, record -> {
+                MaterialQuality mq = new MaterialQuality();
+                mq.setMaterialId(record.getString("material_id"));
+                mq.setCost7d(toDouble(record.getDouble("cost_7d")));
+                mq.setTargetConversion7d(toLong(record.getBigint("target_conversions_7d")));
+                mq.setTotalConversion7d(toLong(record.getBigint("total_conversions_7d")));
+                mq.setRevenue7d(toDouble(record.getDouble("revenue_7d")));
+                mq.setT0ViralCount7d(toLong(record.getBigint("t0_viral_count_7d")));
+                mq.setT0ViralRate7d(toDouble(record.getDouble("t0_viral_rate_7d")));
+                mq.setMiniProgramOpenRate7d(toDouble(record.getDouble("mini_program_open_rate_7d")));
+                mq.setFirstUv7d(toLong(record.getBigint("first_uv_7d")));
+                mq.setShareCount7d(toLong(record.getBigint("share_count_7d")));
+                mq.setCost30d(toDouble(record.getDouble("cost_30d")));
+                mq.setTargetConversion30d(toLong(record.getBigint("target_conversions_30d")));
+                mq.setAdOptimizationGoal(record.getString("ad_optimization_goal"));
+                mq.setPackageName(record.getString("package_name"));
+                mq.setAdStatus(record.getString("ad_status"));
+                mq.setCreativeStatus(record.getString("creative_status"));
+                mq.setDt(dt);
+                synchronized (rawList) { rawList.add(mq); }
+            });
+        } catch (Exception e) {
+            log.error("ODPS 查询异常: {}", e.getMessage(), e);
+            return ReturnT.FAIL;
+        }
+
+        log.info("ODPS 读取完成, dt={}, 记录数: {}", dt, odpsCount);
+        if (rawList.isEmpty()) {
+            log.warn("ODPS 未返回任何素材数据, dt={}", dt);
+            return ReturnT.FAIL;
+        }
+
+        MaterialQualityCalculator.calculateAll(rawList, costThreshold);
+
+        if (dryRun) {
+            log.info("===== DRY RUN 模式, 不写入DB, dt={} =====", dt);
+            printTopBottom(rawList);
+            return ReturnT.SUCCESS;
+        }
+
+        int totalUpserted = 0;
+        for (int i = 0; i < rawList.size(); i += BATCH_SIZE) {
+            int end = Math.min(i + BATCH_SIZE, rawList.size());
+            totalUpserted += materialQualityMapperExt.batchUpsert(rawList.subList(i, end));
+        }
+        log.info("===== dt={} 完成, upserted: {} =====", dt, totalUpserted);
+        return ReturnT.SUCCESS;
+    }
+
+    /** 回刷历史:从 endDt 往前逐日滑动到 backfillFromStr,每日统计近7天数据 */
+    private ReturnT<String> backfillHistory(String backfillFromStr, LocalDate endDt,
+                                             double costThreshold, String creativeStatus, boolean dryRun) {
+        LocalDate cursor = endDt;
+        LocalDate backfillFrom = LocalDate.parse(backfillFromStr, DT_FMT);
+        int total = 0;
+        int days = 0;
+
+        log.info("===== 回刷开始: {} → {} =====", endDt, backfillFrom);
+        while (!cursor.isBefore(backfillFrom)) {
+            String dt = cursor.format(DT_FMT);
+            LocalDate day7Ago = cursor.minusDays(6);
+            LocalDate day30Ago = cursor.minusDays(29);
+            log.info("--- 回刷 dt={}, 7天窗口=[{}, {}] ---", dt, day7Ago, cursor);
+
+            try {
+                ReturnT<String> result = doSync(day30Ago, cursor, day7Ago, dt, costThreshold, creativeStatus, dryRun);
+                if (ReturnT.SUCCESS_CODE == result.getCode()) {
+                    days++;
+                }
+            } catch (Exception e) {
+                log.error("回刷 dt={} 失败: {}", dt, e.getMessage(), e);
+            }
+            total++;
+            cursor = cursor.minusDays(1);
+
+            if (total % 10 == 0) {
+                log.info("回刷进度: 已处理 {} 天, 成功 {} 天, 当前 cursor={}", total, days, cursor);
+            }
+        }
+        log.info("===== 回刷完成: 共 {} 天, 成功 {} 天 =====", total, days);
+        return ReturnT.SUCCESS;
+    }
+
+    // ===== ODPS SQL 构建 =====
+
+    /**
+     * 使用 30 天窗口做条件聚合,同时产出 7 天和 30 天指标
+     *
+     * @param creativeStatus 创意状态过滤,null/空 = 不限制
+     */
+    private String buildOdpsSql(LocalDate day30Ago, LocalDate yesterday, LocalDate day7Ago,
+                                String creativeStatus) {
+        String start = day30Ago.format(DT_FMT);
+        String end = yesterday.format(DT_FMT);
+        String d7 = day7Ago.format(DT_FMT);
+
+        StringBuilder sql = new StringBuilder();
+        sql.append("SELECT\n")
+           .append("  `创意id` AS material_id,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `消耗` ELSE 0 END) AS cost_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `目标转化量` ELSE 0 END) AS target_conversions_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `总转化量` ELSE 0 END) AS total_conversions_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `收入` ELSE 0 END) AS revenue_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `t0裂变数` ELSE 0 END) AS t0_viral_count_7d,\n")
+           .append("  AVG(CASE WHEN dt >= '").append(d7).append("' THEN `t0裂变率` ELSE NULL END) AS t0_viral_rate_7d,\n")
+           .append("  AVG(CASE WHEN dt >= '").append(d7).append("' THEN `小程序打开率` ELSE NULL END) AS mini_program_open_rate_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `首层uv` ELSE 0 END) AS first_uv_7d,\n")
+           .append("  SUM(CASE WHEN dt >= '").append(d7).append("' THEN `首层分享次数` ELSE 0 END) AS share_count_7d,\n")
+           .append("  SUM(`消耗`) AS cost_30d,\n")
+           .append("  SUM(`目标转化量`) AS target_conversions_30d,\n")
+           .append("  MAX(`广告优化目标`) AS ad_optimization_goal,\n")
+           .append("  MAX(`package_name`) AS package_name,\n")
+           .append("  MAX(`广告状态`) AS ad_status,\n")
+           .append("  MAX(`创意状态`) AS creative_status\n")
+           .append("FROM loghubods.touliu_creative_data\n")
+           .append("WHERE dt >= '").append(start).append("' AND dt <= '").append(end).append("'\n");
+
+        if (creativeStatus != null && !creativeStatus.isEmpty()) {
+            sql.append("  AND `创意状态` = '").append(creativeStatus).append("'\n");
+        }
+
+        sql.append("GROUP BY `创意id`\n")
+           .append("HAVING SUM(`消耗`) > 0");
+
+        return sql.toString();
+    }
+
+    /**
+     * 探查表是否有数据,同时打印 dt 范围帮助确认分区格式
+     *
+     * @return true = 表在目标时间范围内有数据
+     */
+    private boolean probeTableHasData(LocalDate day30Ago, LocalDate yesterday) {
+        // 1. 全表探查(dt 范围 + 总行数)
+        String metaSql = "SELECT MIN(dt) AS min_dt, MAX(dt) AS max_dt, COUNT(*) AS total "
+                + "FROM loghubods.touliu_creative_data";
+        log.info("探针-全表 SQL: {}", metaSql);
+        try {
+            long metaCount = OdpsUtil.getOdpsDataStream(metaSql, record -> {
+                log.info("  全表: min_dt=[{}], max_dt=[{}], total={}",
+                        record.getString("min_dt"), record.getString("max_dt"),
+                        record.getBigint("total"));
+            });
+            log.info("探针-全表 返回 {} 行", metaCount);
+        } catch (Exception e) {
+            log.error("探针-全表 查询失败: {}", e.getMessage(), e);
+        }
+
+        // 2. 目标时间范围探查
+        String start = day30Ago.format(DT_FMT);
+        String end = yesterday.format(DT_FMT);
+        String rangeSql = "SELECT COUNT(*) AS total FROM loghubods.touliu_creative_data "
+                + "WHERE dt >= '" + start + "' AND dt <= '" + end + "'";
+        log.info("探针-范围 SQL (dt >= {}, dt <= {}): {}", start, end, rangeSql);
+        try {
+            OdpsUtil.getOdpsDataStream(rangeSql, record -> {
+                long total = record.getBigint("total") != null ? record.getBigint("total") : 0;
+                log.info("  时间范围行数: {}", total);
+            });
+        } catch (Exception e) {
+            log.error("探针-范围 查询失败: {}", e.getMessage(), e);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * 探针查询:查看 ODPS 表中创意状态值分布
+     */
+    private void probeCreativeStatus(LocalDate day30Ago, LocalDate yesterday) {
+        String sql = "SELECT `创意状态`, COUNT(*) AS cnt, COUNT(DISTINCT `创意id`) AS creative_cnt "
+                   + "FROM loghubods.touliu_creative_data "
+                   + "WHERE dt >= '" + day30Ago.format(DT_FMT) + "' AND dt <= '" + yesterday.format(DT_FMT) + "' "
+                   + "GROUP BY `创意状态` ORDER BY cnt DESC LIMIT 20";
+
+        log.info("探针 SQL: {}", sql);
+        OdpsUtil.getOdpsDataStream(sql, record -> {
+            log.info("  创意状态=[{}], 记录数={}, 创意数={}",
+                    record.getString("创意状态"),
+                    record.getBigint("cnt"),
+                    record.getBigint("creative_cnt"));
+        });
+    }
+
+    // ===== 辅助方法 =====
+
+    private static Double toDouble(Double v) { return v == null ? 0.0 : v; }
+    private static Long toLong(Long v) { return v == null ? 0L : v; }
+
+    private static String parseParamString(String param, String key, String defaultValue) {
+        if (param == null || param.isEmpty()) return defaultValue;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                return kv[1].trim();
+            }
+        }
+        return defaultValue;
+    }
+
+    private static double parseParamDouble(String param, String key, double defaultValue) {
+        if (param == null || param.isEmpty()) return defaultValue;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                try { return Double.parseDouble(kv[1].trim()); } catch (NumberFormatException e) { }
+            }
+        }
+        return defaultValue;
+    }
+
+    private static boolean parseParamBool(String param, String key) {
+        if (param == null || param.isEmpty()) return false;
+        for (String part : param.split(",")) {
+            String[] kv = part.trim().split("=", 2);
+            if (kv.length == 2 && kv[0].trim().equals(key)) {
+                return "true".equalsIgnoreCase(kv[1].trim());
+            }
+        }
+        return false;
+    }
+
+    private static void printTopBottom(List<MaterialQuality> list) {
+        List<MaterialQuality> sorted = new ArrayList<>(list);
+        sorted.sort((a, b) -> Double.compare(
+                b.getQualityScore() == null ? 0 : b.getQualityScore(),
+                a.getQualityScore() == null ? 0 : a.getQualityScore()));
+
+        int show = Math.min(5, sorted.size());
+        log.info("===== Top {} 高质量素材 =====", show);
+        for (int i = 0; i < show; i++) {
+            MaterialQuality mq = sorted.get(i);
+            log.info("[{}] materialId={}, qualityScore={}, confidence={}, cost7d={}, conv7d={}",
+                    i + 1, mq.getMaterialId(), mq.getQualityScore(), mq.getConfidence(),
+                    mq.getCost7d(), mq.getTargetConversion7d());
+        }
+        log.info("===== Bottom {} 低质量素材 =====", show);
+        for (int i = sorted.size() - 1; i >= Math.max(0, sorted.size() - show); i--) {
+            MaterialQuality mq = sorted.get(i);
+            log.info("[{}] materialId={}, qualityScore={}, confidence={}, cost7d={}, conv7d={}",
+                    sorted.size() - i, mq.getMaterialId(), mq.getQualityScore(), mq.getConfidence(),
+                    mq.getCost7d(), mq.getTargetConversion7d());
+        }
+    }
+}

+ 55 - 0
core/src/main/java/com/tzld/videoVector/model/param/RecallMaterialScoreParam.java

@@ -0,0 +1,55 @@
+package com.tzld.videoVector.model.param;
+
+import java.util.List;
+
+/**
+ * 素材质量加权召回请求参数
+ */
+public class RecallMaterialScoreParam {
+
+    /** 素材ID:以指定素材的向量去召回相似素材(优先级最高) */
+    private String materialId;
+
+    /** 查询文本(与 queryVector 二选一) */
+    private String queryText;
+
+    /** 直接传入查询向量(优先级高于 queryText) */
+    private List<Float> queryVector;
+
+    /** 向量配置编码,默认 VIDEO_TOPIC */
+    private String configCode;
+
+    /** 素材来源类型:null=全部, 1=外部合作, 2=内部素材 */
+    private Short sourceType;
+
+    /** 返回数量,默认 10 */
+    private Integer topN;
+
+    /** 召回放大倍数,默认 3(先取 topN*expansionFactor 个候选,加权排序后取 topN) */
+    private Integer expansionFactor;
+
+    /** 相关性权重 alpha,默认 0.7(1-alpha 为质量分权重) */
+    private Double alpha;
+
+    /** 最小相似度阈值,低于此值的候选直接过滤 */
+    private Double simMin;
+
+    public String getMaterialId() { return materialId; }
+    public void setMaterialId(String materialId) { this.materialId = materialId; }
+    public String getQueryText() { return queryText; }
+    public void setQueryText(String queryText) { this.queryText = queryText; }
+    public List<Float> getQueryVector() { return queryVector; }
+    public void setQueryVector(List<Float> queryVector) { this.queryVector = queryVector; }
+    public String getConfigCode() { return configCode; }
+    public void setConfigCode(String configCode) { this.configCode = configCode; }
+    public Short getSourceType() { return sourceType; }
+    public void setSourceType(Short sourceType) { this.sourceType = sourceType; }
+    public Integer getTopN() { return topN; }
+    public void setTopN(Integer topN) { this.topN = topN; }
+    public Integer getExpansionFactor() { return expansionFactor; }
+    public void setExpansionFactor(Integer expansionFactor) { this.expansionFactor = expansionFactor; }
+    public Double getAlpha() { return alpha; }
+    public void setAlpha(Double alpha) { this.alpha = alpha; }
+    public Double getSimMin() { return simMin; }
+    public void setSimMin(Double simMin) { this.simMin = simMin; }
+}

+ 110 - 0
core/src/main/java/com/tzld/videoVector/model/po/pgVector/MaterialQuality.java

@@ -0,0 +1,110 @@
+package com.tzld.videoVector.model.po.pgVector;
+
+import java.util.Date;
+
+/**
+ * 素材质量评分表(对应 pgVector 库 material_quality)
+ * 数据来源:ODPS loghubods.touliu_creative_data
+ */
+public class MaterialQuality {
+
+    private Long id;
+    private String materialId;
+
+    // 7天汇总指标
+    private Double cost7d;
+    private Long targetConversion7d;
+    private Long totalConversion7d;
+    private Double revenue7d;
+    private Long t0ViralCount7d;
+    private Double t0ViralRate7d;
+    private Double miniProgramOpenRate7d;
+    private Long firstUv7d;
+    private Long shareCount7d;
+
+    // 30天兜底指标
+    private Double cost30d;
+    private Long targetConversion30d;
+
+    // 分维度得分
+    private Double conversionEfficiencyScore;
+    private Double revenueScore;
+    private Double viralScore;
+    private Double engagementScore;
+
+    // 综合评分
+    private Double qualityScore;
+    private Double confidence;
+
+    // 广告维度标签
+    private String adOptimizationGoal;
+    private String packageName;
+    private String adStatus;
+    private String creativeStatus;
+
+    private String dt;       // 统计截止日期 yyyyMMdd
+
+    private Date createTime;
+    private Date updateTime;
+
+    // ===== 非持久化字段(召回时使用) =====
+    private Double sim;       // 余弦相似度
+    private Double finalScore; // sim + quality 加权分
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getMaterialId() { return materialId; }
+    public void setMaterialId(String materialId) { this.materialId = materialId; }
+    public Double getCost7d() { return cost7d; }
+    public void setCost7d(Double cost7d) { this.cost7d = cost7d; }
+    public Long getTargetConversion7d() { return targetConversion7d; }
+    public void setTargetConversion7d(Long targetConversion7d) { this.targetConversion7d = targetConversion7d; }
+    public Long getTotalConversion7d() { return totalConversion7d; }
+    public void setTotalConversion7d(Long totalConversion7d) { this.totalConversion7d = totalConversion7d; }
+    public Double getRevenue7d() { return revenue7d; }
+    public void setRevenue7d(Double revenue7d) { this.revenue7d = revenue7d; }
+    public Long getT0ViralCount7d() { return t0ViralCount7d; }
+    public void setT0ViralCount7d(Long t0ViralCount7d) { this.t0ViralCount7d = t0ViralCount7d; }
+    public Double getT0ViralRate7d() { return t0ViralRate7d; }
+    public void setT0ViralRate7d(Double t0ViralRate7d) { this.t0ViralRate7d = t0ViralRate7d; }
+    public Double getMiniProgramOpenRate7d() { return miniProgramOpenRate7d; }
+    public void setMiniProgramOpenRate7d(Double miniProgramOpenRate7d) { this.miniProgramOpenRate7d = miniProgramOpenRate7d; }
+    public Long getFirstUv7d() { return firstUv7d; }
+    public void setFirstUv7d(Long firstUv7d) { this.firstUv7d = firstUv7d; }
+    public Long getShareCount7d() { return shareCount7d; }
+    public void setShareCount7d(Long shareCount7d) { this.shareCount7d = shareCount7d; }
+    public Double getCost30d() { return cost30d; }
+    public void setCost30d(Double cost30d) { this.cost30d = cost30d; }
+    public Long getTargetConversion30d() { return targetConversion30d; }
+    public void setTargetConversion30d(Long targetConversion30d) { this.targetConversion30d = targetConversion30d; }
+    public Double getConversionEfficiencyScore() { return conversionEfficiencyScore; }
+    public void setConversionEfficiencyScore(Double conversionEfficiencyScore) { this.conversionEfficiencyScore = conversionEfficiencyScore; }
+    public Double getRevenueScore() { return revenueScore; }
+    public void setRevenueScore(Double revenueScore) { this.revenueScore = revenueScore; }
+    public Double getViralScore() { return viralScore; }
+    public void setViralScore(Double viralScore) { this.viralScore = viralScore; }
+    public Double getEngagementScore() { return engagementScore; }
+    public void setEngagementScore(Double engagementScore) { this.engagementScore = engagementScore; }
+    public Double getQualityScore() { return qualityScore; }
+    public void setQualityScore(Double qualityScore) { this.qualityScore = qualityScore; }
+    public Double getConfidence() { return confidence; }
+    public void setConfidence(Double confidence) { this.confidence = confidence; }
+    public String getAdOptimizationGoal() { return adOptimizationGoal; }
+    public void setAdOptimizationGoal(String adOptimizationGoal) { this.adOptimizationGoal = adOptimizationGoal; }
+    public String getPackageName() { return packageName; }
+    public void setPackageName(String packageName) { this.packageName = packageName; }
+    public String getAdStatus() { return adStatus; }
+    public void setAdStatus(String adStatus) { this.adStatus = adStatus; }
+    public String getCreativeStatus() { return creativeStatus; }
+    public void setCreativeStatus(String creativeStatus) { this.creativeStatus = creativeStatus; }
+    public String getDt() { return dt; }
+    public void setDt(String dt) { this.dt = dt; }
+    public Date getCreateTime() { return createTime; }
+    public void setCreateTime(Date createTime) { this.createTime = createTime; }
+    public Date getUpdateTime() { return updateTime; }
+    public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
+    public Double getSim() { return sim; }
+    public void setSim(Double sim) { this.sim = sim; }
+    public Double getFinalScore() { return finalScore; }
+    public void setFinalScore(Double finalScore) { this.finalScore = finalScore; }
+}

+ 98 - 0
core/src/main/java/com/tzld/videoVector/model/vo/MaterialQualityVO.java

@@ -0,0 +1,98 @@
+package com.tzld.videoVector.model.vo;
+
+/**
+ * 素材质量统计信息(enrich 接口返回)
+ */
+public class MaterialQualityVO {
+
+    private String materialId;
+    private String dt;          // 统计截止日期
+
+    // 7天投放指标
+    private Double cost7d;
+    private Long targetConversion7d;
+    private Long totalConversion7d;
+    private Double revenue7d;
+    private Long t0ViralCount7d;
+    private Double t0ViralRate7d;
+    private Double miniProgramOpenRate7d;
+    private Long firstUv7d;
+    private Long shareCount7d;
+
+    // 30天兜底指标
+    private Double cost30d;
+    private Long targetConversion30d;
+
+    // 效率派生指标
+    private Double cpa7d;            // 7天单次转化成本 = cost7d / targetConversion7d
+    private Double roas7d;           // 7天ROAS = revenue7d / cost7d
+
+    // 分维度得分
+    private Double conversionEfficiencyScore;
+    private Double revenueScore;
+    private Double viralScore;
+    private Double engagementScore;
+
+    // 综合评分
+    private Double qualityScore;
+    private Double confidence;
+
+    // 标签
+    private String adOptimizationGoal;
+    private String packageName;
+    private String adStatus;
+    private String creativeStatus;
+
+    // ===== getters/setters =====
+
+    public String getMaterialId() { return materialId; }
+    public void setMaterialId(String materialId) { this.materialId = materialId; }
+    public String getDt() { return dt; }
+    public void setDt(String dt) { this.dt = dt; }
+    public Double getCost7d() { return cost7d; }
+    public void setCost7d(Double cost7d) { this.cost7d = cost7d; }
+    public Long getTargetConversion7d() { return targetConversion7d; }
+    public void setTargetConversion7d(Long targetConversion7d) { this.targetConversion7d = targetConversion7d; }
+    public Long getTotalConversion7d() { return totalConversion7d; }
+    public void setTotalConversion7d(Long totalConversion7d) { this.totalConversion7d = totalConversion7d; }
+    public Double getRevenue7d() { return revenue7d; }
+    public void setRevenue7d(Double revenue7d) { this.revenue7d = revenue7d; }
+    public Long getT0ViralCount7d() { return t0ViralCount7d; }
+    public void setT0ViralCount7d(Long t0ViralCount7d) { this.t0ViralCount7d = t0ViralCount7d; }
+    public Double getT0ViralRate7d() { return t0ViralRate7d; }
+    public void setT0ViralRate7d(Double t0ViralRate7d) { this.t0ViralRate7d = t0ViralRate7d; }
+    public Double getMiniProgramOpenRate7d() { return miniProgramOpenRate7d; }
+    public void setMiniProgramOpenRate7d(Double miniProgramOpenRate7d) { this.miniProgramOpenRate7d = miniProgramOpenRate7d; }
+    public Long getFirstUv7d() { return firstUv7d; }
+    public void setFirstUv7d(Long firstUv7d) { this.firstUv7d = firstUv7d; }
+    public Long getShareCount7d() { return shareCount7d; }
+    public void setShareCount7d(Long shareCount7d) { this.shareCount7d = shareCount7d; }
+    public Double getCost30d() { return cost30d; }
+    public void setCost30d(Double cost30d) { this.cost30d = cost30d; }
+    public Long getTargetConversion30d() { return targetConversion30d; }
+    public void setTargetConversion30d(Long targetConversion30d) { this.targetConversion30d = targetConversion30d; }
+    public Double getCpa7d() { return cpa7d; }
+    public void setCpa7d(Double cpa7d) { this.cpa7d = cpa7d; }
+    public Double getRoas7d() { return roas7d; }
+    public void setRoas7d(Double roas7d) { this.roas7d = roas7d; }
+    public Double getConversionEfficiencyScore() { return conversionEfficiencyScore; }
+    public void setConversionEfficiencyScore(Double conversionEfficiencyScore) { this.conversionEfficiencyScore = conversionEfficiencyScore; }
+    public Double getRevenueScore() { return revenueScore; }
+    public void setRevenueScore(Double revenueScore) { this.revenueScore = revenueScore; }
+    public Double getViralScore() { return viralScore; }
+    public void setViralScore(Double viralScore) { this.viralScore = viralScore; }
+    public Double getEngagementScore() { return engagementScore; }
+    public void setEngagementScore(Double engagementScore) { this.engagementScore = engagementScore; }
+    public Double getQualityScore() { return qualityScore; }
+    public void setQualityScore(Double qualityScore) { this.qualityScore = qualityScore; }
+    public Double getConfidence() { return confidence; }
+    public void setConfidence(Double confidence) { this.confidence = confidence; }
+    public String getAdOptimizationGoal() { return adOptimizationGoal; }
+    public void setAdOptimizationGoal(String adOptimizationGoal) { this.adOptimizationGoal = adOptimizationGoal; }
+    public String getPackageName() { return packageName; }
+    public void setPackageName(String packageName) { this.packageName = packageName; }
+    public String getAdStatus() { return adStatus; }
+    public void setAdStatus(String adStatus) { this.adStatus = adStatus; }
+    public String getCreativeStatus() { return creativeStatus; }
+    public void setCreativeStatus(String creativeStatus) { this.creativeStatus = creativeStatus; }
+}

+ 156 - 0
core/src/main/java/com/tzld/videoVector/model/vo/RecallMaterialScoreVO.java

@@ -0,0 +1,156 @@
+package com.tzld.videoVector.model.vo;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 素材质量加权召回响应
+ */
+public class RecallMaterialScoreVO {
+
+    private List<ScoredMaterial> items;
+    private int total;
+
+    public List<ScoredMaterial> getItems() { return items; }
+    public void setItems(List<ScoredMaterial> items) { this.items = items; }
+    public int getTotal() { return total; }
+    public void setTotal(int total) { this.total = total; }
+
+    public static RecallMaterialScoreVO of(List<ScoredMaterial> items) {
+        RecallMaterialScoreVO vo = new RecallMaterialScoreVO();
+        vo.setItems(items);
+        vo.setTotal(items != null ? items.size() : 0);
+        return vo;
+    }
+
+    /**
+     * 单个素材评分明细(含完整投放统计数据)
+     */
+    public static class ScoredMaterial {
+
+        // 基础标识
+        private String materialId;
+        private String videoId;     // modality=VIDEO 时使用
+        private String articleId;   // modality=ARTICLE 时使用
+        private String modality;    // VIDEO / MATERIAL / ARTICLE
+        private String configCode;
+        private String text;            // 向量化原文
+        private String dt;              // 统计截止日期
+        private String title;           // 素材标题(从解构结果提取)
+        private String cover;           // 素材封面(第一张图片)
+        private List<String> imageList; // 素材图片列表
+        private Map<String, Object> deconstruct; // 解构信息
+
+        // 评分
+        private Double sim;             // 余弦相似度 [0, 1]
+        private Double qualityScore;    // 素材质量分 [0, 1]
+        private Double confidence;      // 质量分置信度 [0, 1]
+        private Double finalScore;      // 综合得分 = alpha*sim + (1-alpha)*qualityScore
+
+        // 质量分维度明细
+        private Double conversionEfficiencyScore;
+        private Double revenueScore;
+        private Double viralScore;
+        private Double engagementScore;
+
+        // 7天投放指标
+        private Double cost7d;
+        private Long targetConversion7d;
+        private Long totalConversion7d;
+        private Double revenue7d;
+        private Long t0ViralCount7d;
+        private Double t0ViralRate7d;
+        private Double miniProgramOpenRate7d;
+        private Long firstUv7d;
+        private Long shareCount7d;
+
+        // 30天兜底指标
+        private Double cost30d;
+        private Long targetConversion30d;
+
+        // 效率派生指标
+        private Double cpa7d;            // 7天单次转化成本
+        private Double roas7d;           // 7天ROAS
+
+        // 标签
+        private String adOptimizationGoal;
+        private String packageName;
+        private String adStatus;
+        private String creativeStatus;
+
+        // ===== getters/setters =====
+
+        public String getMaterialId() { return materialId; }
+        public void setMaterialId(String materialId) { this.materialId = materialId; }
+        public String getVideoId() { return videoId; }
+        public void setVideoId(String videoId) { this.videoId = videoId; }
+        public String getArticleId() { return articleId; }
+        public void setArticleId(String articleId) { this.articleId = articleId; }
+        public String getModality() { return modality; }
+        public void setModality(String modality) { this.modality = modality; }
+        public String getConfigCode() { return configCode; }
+        public void setConfigCode(String configCode) { this.configCode = configCode; }
+        public String getText() { return text; }
+        public void setText(String text) { this.text = text; }
+        public String getDt() { return dt; }
+        public void setDt(String dt) { this.dt = dt; }
+        public String getTitle() { return title; }
+        public void setTitle(String title) { this.title = title; }
+        public String getCover() { return cover; }
+        public void setCover(String cover) { this.cover = cover; }
+        public List<String> getImageList() { return imageList; }
+        public void setImageList(List<String> imageList) { this.imageList = imageList; }
+        public Map<String, Object> getDeconstruct() { return deconstruct; }
+        public void setDeconstruct(Map<String, Object> deconstruct) { this.deconstruct = deconstruct; }
+        public Double getSim() { return sim; }
+        public void setSim(Double sim) { this.sim = sim; }
+        public Double getQualityScore() { return qualityScore; }
+        public void setQualityScore(Double qualityScore) { this.qualityScore = qualityScore; }
+        public Double getConfidence() { return confidence; }
+        public void setConfidence(Double confidence) { this.confidence = confidence; }
+        public Double getFinalScore() { return finalScore; }
+        public void setFinalScore(Double finalScore) { this.finalScore = finalScore; }
+        public Double getConversionEfficiencyScore() { return conversionEfficiencyScore; }
+        public void setConversionEfficiencyScore(Double conversionEfficiencyScore) { this.conversionEfficiencyScore = conversionEfficiencyScore; }
+        public Double getRevenueScore() { return revenueScore; }
+        public void setRevenueScore(Double revenueScore) { this.revenueScore = revenueScore; }
+        public Double getViralScore() { return viralScore; }
+        public void setViralScore(Double viralScore) { this.viralScore = viralScore; }
+        public Double getEngagementScore() { return engagementScore; }
+        public void setEngagementScore(Double engagementScore) { this.engagementScore = engagementScore; }
+        public Double getCost7d() { return cost7d; }
+        public void setCost7d(Double cost7d) { this.cost7d = cost7d; }
+        public Long getTargetConversion7d() { return targetConversion7d; }
+        public void setTargetConversion7d(Long targetConversion7d) { this.targetConversion7d = targetConversion7d; }
+        public Long getTotalConversion7d() { return totalConversion7d; }
+        public void setTotalConversion7d(Long totalConversion7d) { this.totalConversion7d = totalConversion7d; }
+        public Double getRevenue7d() { return revenue7d; }
+        public void setRevenue7d(Double revenue7d) { this.revenue7d = revenue7d; }
+        public Long getT0ViralCount7d() { return t0ViralCount7d; }
+        public void setT0ViralCount7d(Long t0ViralCount7d) { this.t0ViralCount7d = t0ViralCount7d; }
+        public Double getT0ViralRate7d() { return t0ViralRate7d; }
+        public void setT0ViralRate7d(Double t0ViralRate7d) { this.t0ViralRate7d = t0ViralRate7d; }
+        public Double getMiniProgramOpenRate7d() { return miniProgramOpenRate7d; }
+        public void setMiniProgramOpenRate7d(Double miniProgramOpenRate7d) { this.miniProgramOpenRate7d = miniProgramOpenRate7d; }
+        public Long getFirstUv7d() { return firstUv7d; }
+        public void setFirstUv7d(Long firstUv7d) { this.firstUv7d = firstUv7d; }
+        public Long getShareCount7d() { return shareCount7d; }
+        public void setShareCount7d(Long shareCount7d) { this.shareCount7d = shareCount7d; }
+        public Double getCost30d() { return cost30d; }
+        public void setCost30d(Double cost30d) { this.cost30d = cost30d; }
+        public Long getTargetConversion30d() { return targetConversion30d; }
+        public void setTargetConversion30d(Long targetConversion30d) { this.targetConversion30d = targetConversion30d; }
+        public Double getCpa7d() { return cpa7d; }
+        public void setCpa7d(Double cpa7d) { this.cpa7d = cpa7d; }
+        public Double getRoas7d() { return roas7d; }
+        public void setRoas7d(Double roas7d) { this.roas7d = roas7d; }
+        public String getAdOptimizationGoal() { return adOptimizationGoal; }
+        public void setAdOptimizationGoal(String adOptimizationGoal) { this.adOptimizationGoal = adOptimizationGoal; }
+        public String getPackageName() { return packageName; }
+        public void setPackageName(String packageName) { this.packageName = packageName; }
+        public String getAdStatus() { return adStatus; }
+        public void setAdStatus(String adStatus) { this.adStatus = adStatus; }
+        public String getCreativeStatus() { return creativeStatus; }
+        public void setCreativeStatus(String creativeStatus) { this.creativeStatus = creativeStatus; }
+    }
+}

+ 5 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/MaterialDetailVO.java

@@ -36,4 +36,9 @@ public class MaterialDetailVO {
      * 解构(与视频 deconstruct 子结构对齐:topic + 灵感点/关键点/目的点 及其实质)
      */
     private Map<String, Object> deconstruct;
+
+    /**
+     * 投放质量/表现数据(来源于 material_quality 表,仅内部素材有此数据)
+     */
+    private Map<String, Object> quality;
 }

+ 20 - 0
core/src/main/java/com/tzld/videoVector/service/MaterialSearchService.java

@@ -2,7 +2,10 @@ package com.tzld.videoVector.service;
 
 import com.tzld.videoVector.model.param.MaterialMatchParam;
 import com.tzld.videoVector.model.param.MaterialSubmitParam;
+import com.tzld.videoVector.model.param.RecallMaterialScoreParam;
 import com.tzld.videoVector.model.vo.MaterialMatchResult;
+import com.tzld.videoVector.model.vo.MaterialQualityVO;
+import com.tzld.videoVector.model.vo.RecallMaterialScoreVO;
 
 import java.util.List;
 
@@ -29,4 +32,21 @@ public interface MaterialSearchService {
      * @return 匹配结果列表
      */
     List<MaterialMatchResult> matchTopNMaterial(MaterialMatchParam param);
+
+    /**
+     * 素材质量加权召回
+     * 在向量相似度的基础上,融合素材投放表现(转化率、ROAS、裂变率等)做综合排序
+     *
+     * @param param 召回参数
+     * @return 综合评分排序的素材列表
+     */
+    RecallMaterialScoreVO recallMaterialWithQuality(RecallMaterialScoreParam param);
+
+    /**
+     * 根据 material_id 获取素材的投放统计信息和质量评分
+     *
+     * @param materialId 素材ID
+     * @return 质量统计信息,不存在时返回 null
+     */
+    MaterialQualityVO getMaterialQuality(String materialId);
 }

+ 475 - 0
core/src/main/java/com/tzld/videoVector/service/impl/MaterialSearchServiceImpl.java

@@ -5,17 +5,32 @@ import com.tzld.videoVector.common.constant.VectorConstants;
 import com.tzld.videoVector.dao.mapper.pgVector.DeconstructContentMapper;
 import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.ContentVectorMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt;
+import com.tzld.videoVector.model.entity.ArticleMatch;
+import com.tzld.videoVector.model.entity.MaterialMatch;
+import com.tzld.videoVector.model.entity.VideoMatch;
 import com.tzld.videoVector.model.param.MaterialMatchParam;
 import com.tzld.videoVector.model.param.MaterialSubmitParam;
+import com.tzld.videoVector.model.param.RecallMaterialScoreParam;
 import com.tzld.videoVector.model.po.pgVector.ContentVector;
 import com.tzld.videoVector.model.po.pgVector.DeconstructContent;
 import com.tzld.videoVector.model.po.pgVector.DeconstructContentExample;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
+import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
 import com.tzld.videoVector.model.vo.MaterialMatchResult;
+import com.tzld.videoVector.model.vo.MaterialQualityVO;
+import com.tzld.videoVector.model.vo.RecallMaterialScoreVO;
+import com.tzld.videoVector.model.vo.RecallMaterialScoreVO.ScoredMaterial;
+import com.tzld.videoVector.service.ArticleVectorStoreService;
 import com.tzld.videoVector.service.DeconstructService;
 import com.tzld.videoVector.service.EmbeddingService;
 import com.tzld.videoVector.service.MaterialSearchService;
+import com.tzld.videoVector.service.MaterialVectorStoreService;
+import com.tzld.videoVector.service.VectorStoreService;
 import com.tzld.videoVector.service.VectorizeService;
 import com.tzld.videoVector.util.Md5Util;
 import com.tzld.videoVector.util.VectorUtils;
@@ -58,6 +73,26 @@ public class MaterialSearchServiceImpl implements MaterialSearchService {
     @Resource
     private ContentVectorMapperExt contentVectorMapperExt;
 
+    @Resource
+    private MaterialVectorStoreService materialVectorStoreService;
+
+    @Resource
+    private VectorStoreService vectorStoreService;
+
+    @Resource
+    private ArticleVectorStoreService articleVectorStoreService;
+
+    @Resource
+    private MaterialQualityMapperExt materialQualityMapperExt;
+
+    @Resource
+    private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
+
+    // 默认参数
+    private static final double DEFAULT_ALPHA = 0.7;
+    private static final double DEFAULT_SIM_MIN = 0.7;
+    private static final int DEFAULT_EXPANSION_FACTOR = 3;
+
     // ================================================================ 入库
     @Override
     public String submitMaterial(MaterialSubmitParam param) {
@@ -266,8 +301,448 @@ public class MaterialSearchServiceImpl implements MaterialSearchService {
         return result;
     }
 
+    // ================================================================ 质量加权召回
+    @Override
+    public RecallMaterialScoreVO recallMaterialWithQuality(RecallMaterialScoreParam param) {
+        if (param == null) {
+            log.error("recallMaterialWithQuality 参数为空");
+            return RecallMaterialScoreVO.of(Collections.emptyList());
+        }
+
+        int topN = param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10;
+        int expansionFactor = param.getExpansionFactor() != null && param.getExpansionFactor() > 0
+                ? param.getExpansionFactor() : DEFAULT_EXPANSION_FACTOR;
+        double alpha = param.getAlpha() != null ? param.getAlpha() : DEFAULT_ALPHA;
+        double simMin = param.getSimMin() != null ? param.getSimMin() : DEFAULT_SIM_MIN;
+        String configCode = param.getConfigCode();
+        if (!StringUtils.hasText(configCode)) {
+            configCode = DEFAULT_CONFIG_CODE;
+        }
+
+        int candidateSize = topN * expansionFactor;
+        log.info("质量加权召回开始, configCode={}, topN={}, expansionFactor={}, alpha={}, simMin={}, sourceType={}",
+                configCode, topN, expansionFactor, alpha, simMin, param.getSourceType());
+
+        // Step 1: 解析查询向量
+        List<Float> queryVector = resolveQueryVectorForRecall(param);
+        if (queryVector == null || queryVector.isEmpty()) {
+            log.error("质量加权召回:无法获取查询向量");
+            return RecallMaterialScoreVO.of(Collections.emptyList());
+        }
+
+        // Step 2: 跨模态向量召回(视频 + 素材 + 长文),每种模态独立 topN
+        List<ScoredMaterial> allItems = new ArrayList<>();
+
+        // —— 视频 ——
+        List<VideoMatch> videoCandidates = vectorStoreService.searchTopN(configCode, queryVector, candidateSize);
+        if (videoCandidates != null && !videoCandidates.isEmpty()) {
+            allItems.addAll(buildModalityItems(configCode, "VIDEO", videoCandidates, topN, alpha, simMin));
+            log.info("视频召回: 候选 {} 条, 返回", videoCandidates.size());
+        }
+
+        // —— 素材 ——
+        Short sourceType = param.getSourceType();
+        List<MaterialMatch> materialCandidates;
+        if (sourceType != null) {
+            materialCandidates = materialVectorStoreService.searchTopNBySource(configCode, queryVector, candidateSize, sourceType);
+        } else {
+            materialCandidates = materialVectorStoreService.searchTopN(configCode, queryVector, candidateSize);
+        }
+        if (materialCandidates != null && !materialCandidates.isEmpty()) {
+            allItems.addAll(buildMaterialItems(configCode, materialCandidates, topN, alpha, simMin));
+            log.info("素材召回: 候选 {} 条", materialCandidates.size());
+        }
+
+        // —— 长文 ——
+        List<ArticleMatch> articleCandidates = articleVectorStoreService.searchTopN(configCode, queryVector, candidateSize);
+        if (articleCandidates != null && !articleCandidates.isEmpty()) {
+            allItems.addAll(buildModalityItems(configCode, "ARTICLE", articleCandidates, topN, alpha, simMin));
+            log.info("长文召回: 候选 {} 条", articleCandidates.size());
+        }
+
+        log.info("跨模态质量加权召回完成, 共 {} 条", allItems.size());
+        return RecallMaterialScoreVO.of(allItems);
+    }
+
+    // ================================================================ 跨模态 Item 构建
+
+    /** 视频/长文:仅用 sim 评分(质量分暂无) */
+    private <T> List<ScoredMaterial> buildModalityItems(String configCode, String modality,
+                                                         List<T> candidates, int topN,
+                                                         double alpha, double simMin) {
+        List<ScoredMaterial> list = new ArrayList<>();
+        for (T m : candidates) {
+            double sim = getMatchScore(m);
+            if (sim < simMin) continue;
+            ScoredMaterial item = new ScoredMaterial();
+            item.setConfigCode(configCode);
+            item.setModality(modality);
+            item.setSim(round4(sim));
+            item.setQualityScore(0.5);
+            item.setConfidence(0.0);
+            item.setFinalScore(round4(sim)); // 仅用 sim
+            if (m instanceof VideoMatch) {
+                VideoMatch vm = (VideoMatch) m;
+                item.setVideoId(String.valueOf(vm.getVideoId()));
+                item.setMaterialId(item.getVideoId());
+                item.setText(vm.getText());
+            } else if (m instanceof ArticleMatch) {
+                ArticleMatch am = (ArticleMatch) m;
+                item.setArticleId(am.getArticleId());
+                item.setMaterialId(item.getArticleId());
+                item.setText(am.getText());
+            }
+            list.add(item);
+        }
+        list.sort(Comparator.comparingDouble(ScoredMaterial::getFinalScore).reversed());
+        return list.stream().limit(topN).collect(Collectors.toList());
+    }
+
+    /** 素材:sim + quality 复合评分,含完整投放数据和元数据 */
+    private List<ScoredMaterial> buildMaterialItems(String configCode, List<MaterialMatch> candidates,
+                                                     int topN, double alpha, double simMin) {
+        List<String> materialIds = candidates.stream()
+                .map(MaterialMatch::getMaterialId).distinct().collect(Collectors.toList());
+        Map<String, MaterialQuality> qualityMap = batchGetMaterialQuality(materialIds);
+        Map<String, JSONObject> deconstructMap = batchGetMaterialDeconstruct(materialIds);
+
+        List<ScoredMaterial> list = new ArrayList<>();
+        for (MaterialMatch m : candidates) {
+            double sim = m.getScore();
+            if (sim < simMin) continue;
+
+            MaterialQuality mq = qualityMap.get(m.getMaterialId());
+            double qualityScore;
+            double confidence;
+            if (mq != null && mq.getConfidence() != null && mq.getConfidence() > 0.3) {
+                qualityScore = mq.getQualityScore() != null ? mq.getQualityScore() : 0.5;
+                confidence = mq.getConfidence();
+            } else {
+                qualityScore = 0.5;
+                confidence = 0;
+            }
+            double finalScore = alpha * sim + (1 - alpha) * qualityScore;
+
+            ScoredMaterial item = new ScoredMaterial();
+            item.setModality("MATERIAL");
+            item.setMaterialId(m.getMaterialId());
+            item.setConfigCode(configCode);
+            item.setText(m.getText());
+            item.setSim(round4(sim));
+            item.setQualityScore(round4(qualityScore));
+            item.setConfidence(round4(confidence));
+            item.setFinalScore(round4(finalScore));
+
+            // 元数据
+            JSONObject deconstructJson = deconstructMap.get(m.getMaterialId());
+            if (deconstructJson != null) {
+                item.setTitle(extractMaterialTitle(deconstructJson));
+                List<String> images = extractMaterialImages(deconstructJson);
+                if (images != null && !images.isEmpty()) {
+                    item.setImageList(images);
+                    item.setCover(images.get(0));
+                }
+                item.setDeconstruct(extractDeconstruct(deconstructJson));
+            }
+
+            // 质量数据
+            if (mq != null) {
+                item.setDt(mq.getDt());
+                item.setConversionEfficiencyScore(mq.getConversionEfficiencyScore());
+                item.setRevenueScore(mq.getRevenueScore());
+                item.setViralScore(mq.getViralScore());
+                item.setEngagementScore(mq.getEngagementScore());
+                item.setCost7d(mq.getCost7d());
+                item.setTargetConversion7d(mq.getTargetConversion7d());
+                item.setTotalConversion7d(mq.getTotalConversion7d());
+                item.setRevenue7d(mq.getRevenue7d());
+                item.setT0ViralCount7d(mq.getT0ViralCount7d());
+                item.setT0ViralRate7d(mq.getT0ViralRate7d());
+                item.setMiniProgramOpenRate7d(mq.getMiniProgramOpenRate7d());
+                item.setFirstUv7d(mq.getFirstUv7d());
+                item.setShareCount7d(mq.getShareCount7d());
+                item.setCost30d(mq.getCost30d());
+                item.setTargetConversion30d(mq.getTargetConversion30d());
+                Double cost = mq.getCost7d();
+                if (cost != null && cost > 0) {
+                    Long conv = mq.getTargetConversion7d();
+                    if (conv != null && conv > 0) item.setCpa7d(round4(cost / conv));
+                    Double rev = mq.getRevenue7d();
+                    if (rev != null && rev > 0) item.setRoas7d(round4(rev / cost));
+                }
+                item.setAdOptimizationGoal(mq.getAdOptimizationGoal());
+                item.setPackageName(mq.getPackageName());
+                item.setAdStatus(mq.getAdStatus());
+                item.setCreativeStatus(mq.getCreativeStatus());
+            }
+            list.add(item);
+        }
+        list.sort(Comparator.comparingDouble(ScoredMaterial::getFinalScore).reversed());
+        return list.stream().limit(topN).collect(Collectors.toList());
+    }
+
+    /** 从不同类型的 Match 中提取相似度分数 */
+    private double getMatchScore(Object match) {
+        if (match instanceof VideoMatch) return ((VideoMatch) match).getScore();
+        if (match instanceof MaterialMatch) return ((MaterialMatch) match).getScore();
+        if (match instanceof ArticleMatch) return ((ArticleMatch) match).getScore();
+        return 0;
+    }
+
+    // ================================================================ 素材质量查询
+    @Override
+    public MaterialQualityVO getMaterialQuality(String materialId) {
+        if (!StringUtils.hasText(materialId)) {
+            return null;
+        }
+
+        List<MaterialQuality> list = materialQualityMapperExt.selectByMaterialIds(
+                Collections.singletonList(materialId));
+        if (list == null || list.isEmpty()) {
+            log.info("素材质量信息不存在, materialId={}", materialId);
+            return null;
+        }
+
+        MaterialQuality mq = list.get(0);
+        MaterialQualityVO vo = new MaterialQualityVO();
+        vo.setMaterialId(mq.getMaterialId());
+        vo.setDt(mq.getDt());
+
+        // 7天投放指标
+        vo.setCost7d(mq.getCost7d());
+        vo.setTargetConversion7d(mq.getTargetConversion7d());
+        vo.setTotalConversion7d(mq.getTotalConversion7d());
+        vo.setRevenue7d(mq.getRevenue7d());
+        vo.setT0ViralCount7d(mq.getT0ViralCount7d());
+        vo.setT0ViralRate7d(mq.getT0ViralRate7d());
+        vo.setMiniProgramOpenRate7d(mq.getMiniProgramOpenRate7d());
+        vo.setFirstUv7d(mq.getFirstUv7d());
+        vo.setShareCount7d(mq.getShareCount7d());
+
+        // 30天兜底
+        vo.setCost30d(mq.getCost30d());
+        vo.setTargetConversion30d(mq.getTargetConversion30d());
+
+        // 效率派生指标
+        Double cost = mq.getCost7d();
+        if (cost != null && cost > 0) {
+            Long conv = mq.getTargetConversion7d();
+            vo.setCpa7d(conv != null && conv > 0 ? round4(cost / conv) : null);
+            Double rev = mq.getRevenue7d();
+            vo.setRoas7d(rev != null && rev > 0 ? round4(rev / cost) : null);
+        }
+
+        // 分维度得分
+        vo.setConversionEfficiencyScore(mq.getConversionEfficiencyScore());
+        vo.setRevenueScore(mq.getRevenueScore());
+        vo.setViralScore(mq.getViralScore());
+        vo.setEngagementScore(mq.getEngagementScore());
+
+        // 综合评分
+        vo.setQualityScore(mq.getQualityScore());
+        vo.setConfidence(mq.getConfidence());
+
+        // 标签
+        vo.setAdOptimizationGoal(mq.getAdOptimizationGoal());
+        vo.setPackageName(mq.getPackageName());
+        vo.setAdStatus(mq.getAdStatus());
+        vo.setCreativeStatus(mq.getCreativeStatus());
+
+        return vo;
+    }
+
     // ================================================================ 私有方法
 
+    /**
+     * 为质量召回解析查询向量
+     * 优先级:materialId > queryVector > queryText
+     */
+    private List<Float> resolveQueryVectorForRecall(RecallMaterialScoreParam param) {
+        String configCode = param.getConfigCode() != null ? param.getConfigCode() : DEFAULT_CONFIG_CODE;
+
+        // 1. 通过 materialId 查该素材的向量,作为 query vector 去搜相似素材
+        if (StringUtils.hasText(param.getMaterialId())) {
+            List<MaterialVector> vectors = materialVectorStoreService.getVectorsByMaterialId(
+                    param.getMaterialId(), configCode);
+            if (vectors != null && !vectors.isEmpty()) {
+                String embedding = vectors.get(0).getEmbedding();
+                if (StringUtils.hasText(embedding)) {
+                    List<Float> queryVector = VectorUtils.parseVectorString(embedding);
+                    if (queryVector != null && !queryVector.isEmpty()) {
+                        log.info("使用 materialId={} 的向量作为查询向量, configCode={}, 维度={}",
+                                param.getMaterialId(), configCode, queryVector.size());
+                        return queryVector;
+                    }
+                }
+            }
+            log.warn("materialId={} 在 configCode={} 下无向量,回退到其他方式", param.getMaterialId(), configCode);
+        }
+
+        // 2. 直接传入的 queryVector
+        if (param.getQueryVector() != null && !param.getQueryVector().isEmpty()) {
+            return param.getQueryVector();
+        }
+
+        // 3. queryText embedding
+        if (StringUtils.hasText(param.getQueryText())) {
+            String textHash = Md5Util.encoderByMd5(param.getQueryText());
+            if (StringUtils.hasText(textHash)) {
+                List<Float> cached = materialVectorStoreService.getVectorByTextHash(textHash, configCode);
+                if (cached != null && !cached.isEmpty()) {
+                    return cached;
+                }
+            }
+            DeconstructVectorConfig config = getVectorConfigByCode(configCode);
+            return embeddingService.embed(param.getQueryText(), config);
+        }
+        return null;
+    }
+
+    /**
+     * 批量查询素材解构结果,返回 materialId -> 解析后的 JSON
+     */
+    private Map<String, JSONObject> batchGetMaterialDeconstruct(List<String> materialIds) {
+        if (CollectionUtils.isEmpty(materialIds)) {
+            return Collections.emptyMap();
+        }
+        try {
+            List<MaterialDeconstructResult> rows = materialDeconstructResultMapperExt
+                    .selectResultsByMaterialIds("aigc_deconstruct", materialIds);
+            if (rows == null || rows.isEmpty()) {
+                return Collections.emptyMap();
+            }
+            Map<String, JSONObject> map = new HashMap<>();
+            for (MaterialDeconstructResult row : rows) {
+                if (row.getMaterialId() == null || !StringUtils.hasText(row.getResult())) {
+                    continue;
+                }
+                try {
+                    JSONObject json = JSONObject.parseObject(row.getResult());
+                    map.put(row.getMaterialId(), json);
+                } catch (Exception e) {
+                    log.warn("解析 material_deconstruct_result JSON 失败, materialId={}", row.getMaterialId());
+                }
+            }
+            return map;
+        } catch (Exception e) {
+            log.error("批量查询 material_deconstruct_result 失败: {}", e.getMessage(), e);
+            return Collections.emptyMap();
+        }
+    }
+
+    /** 从解构 JSON 中提取素材标题 */
+    private String extractMaterialTitle(JSONObject json) {
+        String[] paths = {"target_post.title", "title", "标题", "contentTitle", "素材标题",
+                "input.title", "content.title", "最终选题.name", "最终选题.title"};
+        for (String path : paths) {
+            String val = getJsonPath(json, path);
+            if (StringUtils.hasText(val)) return val;
+        }
+        return null;
+    }
+
+    /** 从解构 JSON 中提取素材图片列表 */
+    @SuppressWarnings("unchecked")
+    private List<String> extractMaterialImages(JSONObject json) {
+        String[] paths = {"target_post.images", "target_post.imageList", "images",
+                "imageList", "input.images", "input.imageList"};
+        for (String path : paths) {
+            Object val = getJsonPathValue(json, path);
+            if (val instanceof List) {
+                List<String> list = (List<String>) val;
+                if (!list.isEmpty()) return list;
+            }
+        }
+        return null;
+    }
+
+    /** 按点号分隔路径从 JSON 中取字符串值 */
+    private String getJsonPath(JSONObject json, String path) {
+        String[] keys = path.split("\\.");
+        Object current = json;
+        for (String key : keys) {
+            if (current instanceof JSONObject) {
+                current = ((JSONObject) current).get(key);
+            } else {
+                return null;
+            }
+        }
+        return current != null ? current.toString() : null;
+    }
+
+    /** 从解构 JSON 中提取解构层级(选题 + 灵感点/关键点/目的点) */
+    private Map<String, Object> extractDeconstruct(JSONObject json) {
+        Map<String, Object> result = new HashMap<>();
+        // 选题
+        String topic = extractMaterialTitle(json);
+        if (StringUtils.hasText(topic)) {
+            result.put("topic", topic);
+        }
+        // 灵感点/关键点/目的点 — 提取名称列表
+        extractPointNames(json, result, "灵感点");
+        extractPointNames(json, result, "关键点");
+        extractPointNames(json, result, "目的点");
+        return result.isEmpty() ? null : result;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void extractPointNames(JSONObject json, Map<String, Object> result, String key) {
+        Object val = json.get(key);
+        if (val instanceof com.alibaba.fastjson.JSONArray) {
+            List<String> names = new ArrayList<>();
+            for (Object item : (com.alibaba.fastjson.JSONArray) val) {
+                if (item instanceof JSONObject) {
+                    String name = ((JSONObject) item).getString("name");
+                    if (name != null) names.add(name);
+                } else if (item instanceof String) {
+                    names.add((String) item);
+                }
+            }
+            if (!names.isEmpty()) result.put(key, names);
+        }
+    }
+
+    private Object getJsonPathValue(JSONObject json, String path) {
+        String[] keys = path.split("\\.");
+        Object current = json;
+        for (String key : keys) {
+            if (current instanceof JSONObject) {
+                current = ((JSONObject) current).get(key);
+            } else {
+                return null;
+            }
+        }
+        return current;
+    }
+
+    /**
+     * 批量查询 material_quality
+     */
+    private Map<String, MaterialQuality> batchGetMaterialQuality(List<String> materialIds) {
+        if (CollectionUtils.isEmpty(materialIds)) {
+            return Collections.emptyMap();
+        }
+        try {
+            List<MaterialQuality> list = materialQualityMapperExt.selectByMaterialIds(materialIds);
+            if (list == null || list.isEmpty()) {
+                return Collections.emptyMap();
+            }
+            Map<String, MaterialQuality> map = new HashMap<>();
+            for (MaterialQuality mq : list) {
+                map.put(mq.getMaterialId(), mq);
+            }
+            return map;
+        } catch (Exception e) {
+            log.error("批量查询 material_quality 失败, ids={}, error={}",
+                    materialIds, e.getMessage(), e);
+            return Collections.emptyMap();
+        }
+    }
+
+    private static double round4(double v) {
+        return Math.round(v * 10000.0) / 10000.0;
+    }
+
     /**
      * 解析查询向量
      * 优先级:queryVector > channelContentId 历史向量 > queryText embedding

+ 117 - 3
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -9,6 +9,7 @@ import com.tzld.videoVector.common.enums.Modality;
 import com.tzld.videoVector.dao.mapper.pgVector.DeconstructVectorConfigMapper;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.ArticleDeconstructResultMapperExt;
 import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMapperExt;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt;
 import com.tzld.videoVector.model.entity.ArticleMatch;
 import com.tzld.videoVector.model.entity.MaterialMatch;
 import com.tzld.videoVector.model.entity.VideoDetail;
@@ -22,6 +23,7 @@ import com.tzld.videoVector.model.po.pgVector.ArticleDeconstructResult;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfig;
 import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
 import com.tzld.videoVector.model.po.pgVector.MaterialDeconstructResult;
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
 import com.tzld.videoVector.model.vo.VideoMatchResult;
 import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
 import com.tzld.videoVector.model.vo.recall.ArticleBasicVO;
@@ -92,6 +94,9 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
     @Autowired
     private MaterialDeconstructResultMapperExt materialDeconstructResultMapperExt;
 
+    @Autowired
+    private MaterialQualityMapperExt materialQualityMapperExt;
+
     @Autowired
     private ArticleVectorStoreService articleVectorStoreService;
 
@@ -250,6 +255,8 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 .collect(Collectors.toList());
     }
 
+    private static final double COMPOSITE_ALPHA = 0.6;
+
     private List<VideoMatchEnrichedVO> limitEnrichedItemsByScore(List<VideoMatchEnrichedVO> items, int topN) {
         if (CollectionUtils.isEmpty(items) || topN <= 0 || items.size() <= topN) {
             return items == null ? Collections.emptyList() : items;
@@ -261,6 +268,32 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 .collect(Collectors.toList());
     }
 
+    /** 素材专用:先按综合分(α*sim + (1-α)*qualityScore)排序再取topN */
+    private List<VideoMatchEnrichedVO> limitMaterialByComposite(List<VideoMatchEnrichedVO> items, int topN) {
+        if (CollectionUtils.isEmpty(items) || topN <= 0 || items.size() <= topN) {
+            return items == null ? Collections.emptyList() : items;
+        }
+        return items.stream()
+                .sorted(Comparator.comparingDouble(item -> {
+                    double sim = item.getScore() != null ? item.getScore() : 0;
+                    double qs = getQualityScore(item);
+                    return -(COMPOSITE_ALPHA * sim + (1 - COMPOSITE_ALPHA) * qs);
+                }))
+                .limit(topN)
+                .collect(Collectors.toList());
+    }
+
+    private double getQualityScore(VideoMatchEnrichedVO item) {
+        if (item.getMaterialDetail() == null || item.getMaterialDetail().getQuality() == null) {
+            return 0.5;
+        }
+        Object qs = item.getMaterialDetail().getQuality().get("qualityScore");
+        if (qs instanceof Number) {
+            return ((Number) qs).doubleValue();
+        }
+        return 0.5;
+    }
+
     /**
      * 素材文本召回:material_vectors → material_deconstruct_result
      */
@@ -286,7 +319,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                         }
                         log.info("素材召回(rawVector) 去重后({}条): {}, configCode={}",
                                 matches.size(), matchSample, configCode);
-                        return limitEnrichedItemsByScore(enrichMaterialMatches(matches, configCode), topN);
+                        return limitMaterialByComposite(enrichMaterialMatches(matches, configCode), topN);
                     }
                     log.info("素材召回(rawVector) 无结果, configCode={}", configCode);
                     return Collections.emptyList();
@@ -307,7 +340,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                         }
                         log.info("素材召回(parsed vector缓存) 去重后({}条): {}, configCode={}",
                                 matches.size(), matchSample, configCode);
-                        return limitEnrichedItemsByScore(enrichMaterialMatches(matches, configCode), topN);
+                        return limitMaterialByComposite(enrichMaterialMatches(matches, configCode), topN);
                     }
                     log.info("素材召回(parsed vector缓存) 无结果, configCode={}", configCode);
                     return Collections.emptyList();
@@ -332,7 +365,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 matchSample.add(m.getMaterialId() + ":" + String.format("%.4f", m.getScore()));
             }
             log.info("素材召回(embedding API) 去重后({}条): {}, configCode={}", matches.size(), matchSample, configCode);
-            return limitEnrichedItemsByScore(enrichMaterialMatches(matches, configCode), topN);
+            return limitMaterialByComposite(enrichMaterialMatches(matches, configCode), topN);
         } catch (Exception e) {
             log.error("素材召回 material_vectors 异常: {}", e.getMessage(), e);
             return Collections.emptyList();
@@ -460,6 +493,7 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
                 .filter(java.util.Objects::nonNull)
                 .collect(Collectors.toList());
         Map<String, MaterialDeconstructResult> rowByMaterialId = loadMaterialDeconstructRows(materialIds);
+        Map<String, MaterialQuality> qualityByMaterialId = loadMaterialQualityRows(materialIds);
 
         List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
         for (MaterialMatch m : matches) {
@@ -488,6 +522,13 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
             MaterialDetailVO detail = new MaterialDetailVO();
             fillMaterialDetailVO(detail, basic, row, deconstructFlat, m.getSourceType());
             fillMaterialDetailImageCount(detail, vo.getImageList());
+
+            // 填充投放质量数据
+            MaterialQuality mq = qualityByMaterialId.get(m.getMaterialId());
+            if (mq != null) {
+                detail.setQuality(buildQualityMap(mq));
+            }
+
             vo.setMaterialDetail(detail);
 
             applyCompatibilityFields(vo);
@@ -787,6 +828,79 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         return result;
     }
 
+    private Map<String, Object> buildQualityMap(MaterialQuality mq) {
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("dt", mq.getDt());
+        map.put("qualityScore", mq.getQualityScore());
+        map.put("confidence", mq.getConfidence());
+        map.put("conversionEfficiencyScore", mq.getConversionEfficiencyScore());
+        map.put("revenueScore", mq.getRevenueScore());
+        map.put("viralScore", mq.getViralScore());
+        map.put("engagementScore", mq.getEngagementScore());
+        map.put("cost7d", mq.getCost7d());
+        map.put("targetConversion7d", mq.getTargetConversion7d());
+        map.put("totalConversion7d", mq.getTotalConversion7d());
+        map.put("revenue7d", mq.getRevenue7d());
+        map.put("t0ViralCount7d", mq.getT0ViralCount7d());
+        map.put("t0ViralRate7d", mq.getT0ViralRate7d());
+        map.put("miniProgramOpenRate7d", mq.getMiniProgramOpenRate7d());
+        map.put("firstUv7d", mq.getFirstUv7d());
+        map.put("shareCount7d", mq.getShareCount7d());
+        map.put("cost30d", mq.getCost30d());
+        map.put("targetConversion30d", mq.getTargetConversion30d());
+        // 派生指标
+        Long firstUv = mq.getFirstUv7d();
+        Long totalConv = mq.getTotalConversion7d();
+        Long viralCount = mq.getT0ViralCount7d();
+        Double rev = mq.getRevenue7d();
+
+        // 消耗优先7天,不足时用30天兜底
+        Double cost = mq.getCost7d();
+        boolean use30d = cost == null || cost < 50;
+        if (use30d && mq.getCost30d() != null && mq.getCost30d() > 0) {
+            cost = mq.getCost30d();
+        }
+        map.put("use30d", use30d);
+
+        // 打开率(CTR) = 首层uv / 总转化量
+        if (firstUv != null && firstUv > 0 && totalConv != null && totalConv > 0) {
+            map.put("ctr7d", Math.round((double) firstUv / totalConv * 10000.0) / 10000.0);
+        }
+        // 裂变率 = t0裂变数 / 首层uv
+        if (viralCount != null && viralCount > 0 && firstUv != null && firstUv > 0) {
+            map.put("viralRate7d", Math.round((double) viralCount / firstUv * 10000.0) / 10000.0);
+        }
+        // ROI = 收入 / 消耗
+        if (cost != null && cost > 0 && rev != null && rev > 0) {
+            map.put("roi7d", Math.round(rev / cost * 10000.0) / 10000.0);
+        }
+        map.put("cost7d", cost);
+        map.put("adOptimizationGoal", mq.getAdOptimizationGoal());
+        map.put("packageName", mq.getPackageName());
+        map.put("adStatus", mq.getAdStatus());
+        map.put("creativeStatus", mq.getCreativeStatus());
+        return map;
+    }
+
+    private Map<String, MaterialQuality> loadMaterialQualityRows(List<String> materialIds) {
+        if (CollectionUtils.isEmpty(materialIds)) {
+            return Collections.emptyMap();
+        }
+        Map<String, MaterialQuality> result = new HashMap<>();
+        try {
+            List<MaterialQuality> rows = materialQualityMapperExt.selectByMaterialIds(materialIds);
+            if (CollectionUtils.isEmpty(rows)) return result;
+            for (MaterialQuality row : rows) {
+                if (row != null && row.getMaterialId() != null) {
+                    result.putIfAbsent(row.getMaterialId(), row);
+                }
+            }
+        } catch (Exception e) {
+            log.error("批量加载 material_quality 失败: {}", e.getMessage(), e);
+        }
+        return result;
+    }
+
     private JSONObject parseResultJson(MaterialDeconstructResult row) {
         if (row == null || !StringUtils.hasText(row.getResult())) {
             return null;

+ 180 - 0
core/src/main/java/com/tzld/videoVector/util/MaterialQualityCalculator.java

@@ -0,0 +1,180 @@
+package com.tzld.videoVector.util;
+
+import com.tzld.videoVector.model.po.pgVector.MaterialQuality;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 素材质量分计算工具
+ * 基于投放表现数据(消耗、转化、收入、裂变、互动)计算综合质量分
+ */
+public class MaterialQualityCalculator {
+
+    private static final Logger log = LoggerFactory.getLogger(MaterialQualityCalculator.class);
+
+    // 各维度默认权重(CTR 50%, 裂变 30%, ROI 20%)
+    private static final double W_CTR = 0.50;
+    private static final double W_VIRAL = 0.30;
+    private static final double W_ROI = 0.20;
+
+    // 置信度相关
+    private static final double DEFAULT_COST_THRESHOLD = 50.0;  // 7天消耗阈值
+    private static final double DEFAULT_PRIOR = 0.5;            // 贝叶斯先验分
+
+    /**
+     * 批量计算质量分(会修改 list 中每个 MaterialQuality 的分数字段)
+     *
+     * @param list          原始指标列表(ODPS 聚合后的原始数据)
+     * @param costThreshold 置信度消耗阈值,7天消耗 >= 此值视为有置信度
+     */
+    public static void calculateAll(List<MaterialQuality> list, double costThreshold) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+
+        int totalCount = list.size();
+        log.info("开始计算质量分,素材总数: {}, 消耗阈值: {}", totalCount, costThreshold);
+
+        // Step 1: 计算每个素材的原始维度值
+        List<DimValues> dimValuesList = new ArrayList<>(totalCount);
+        for (MaterialQuality mq : list) {
+            DimValues dv = new DimValues();
+            dv.materialId = mq.getMaterialId();
+            dv.cost7d = nullToZero(mq.getCost7d());
+
+            // CTR(打开率) = 首层uv / 总转化量
+            // 裂变率 = t0裂变数 / 首层uv
+            // ROI = 收入 / 消耗
+            double firstUv = nullToZero(mq.getFirstUv7d());
+            double totalConv = nullToZero(mq.getTotalConversion7d());
+            double viralCount = nullToZero(mq.getT0ViralCount7d());
+
+            if (firstUv > 0 && totalConv > 0) {
+                dv.ctr = firstUv / totalConv;
+            }
+            if (viralCount > 0 && firstUv > 0) {
+                dv.viralRate = viralCount / firstUv;
+            }
+            if (dv.cost7d > 0) {
+                dv.roi = nullToZero(mq.getRevenue7d()) / dv.cost7d;
+            }
+
+            dimValuesList.add(dv);
+        }
+
+        // Step 2: 计算各维度百分位排名
+        computePercentileRanks(dimValuesList, dv -> dv.ctr, (dv, r) -> dv.ctrPct = r);
+        computePercentileRanks(dimValuesList, dv -> dv.viralRate, (dv, r) -> dv.viralPct = r);
+        computePercentileRanks(dimValuesList, dv -> dv.roi, (dv, r) -> dv.roiPct = r);
+
+        // Step 3: 加权计算综合分 + 置信度衰减
+        int lowConfCount = 0;
+        int noDataCount = 0;
+
+        for (int i = 0; i < list.size(); i++) {
+            MaterialQuality mq = list.get(i);
+            DimValues dv = dimValuesList.get(i);
+
+            double cost = dv.cost7d;
+            double confidence = Math.min(cost / costThreshold, 1.0);
+
+            if (cost <= 0) {
+                noDataCount++;
+            } else if (confidence < 1.0) {
+                lowConfCount++;
+            }
+
+            // 动态调整权重:roi 为 0 时将其权重分配给 CTR 和裂变
+            double wCtr = W_CTR;
+            double wViral = W_VIRAL;
+            double wRoi = W_ROI;
+
+            if (dv.roi <= 0 && dv.cost7d > 0) {
+                double redist = W_ROI / (W_CTR + W_VIRAL);
+                wCtr += W_ROI * redist;
+                wViral += W_ROI * redist;
+                wRoi = 0;
+            }
+
+            double rawScore = wCtr * dv.ctrPct
+                            + wViral * dv.viralPct
+                            + wRoi * dv.roiPct;
+
+            double qualityScore = confidence * rawScore + (1 - confidence) * DEFAULT_PRIOR;
+
+            mq.setConversionEfficiencyScore(round2(dv.ctrPct));    // CTR 百分位
+            mq.setViralScore(round2(dv.viralPct));                 // 裂变率 百分位
+            mq.setEngagementScore(round2(dv.roiPct));              // ROI 百分位
+            mq.setQualityScore(round2(qualityScore));
+            mq.setConfidence(round2(confidence));
+        }
+
+        log.info("质量分计算完成, 总素材: {}, 无消耗数据: {}, 低于置信度阈值: {}",
+                totalCount, noDataCount, lowConfCount);
+    }
+
+    // ===== 百分位排名计算 =====
+
+    @FunctionalInterface
+    private interface ValueExtractor {
+        double extract(DimValues dv);
+    }
+
+    @FunctionalInterface
+    private interface RankSetter {
+        void set(DimValues dv, double rank);
+    }
+
+    private static void computePercentileRanks(List<DimValues> list,
+                                               ValueExtractor getter,
+                                               RankSetter setter) {
+        int n = list.size();
+        // 按维度值升序排列
+        List<DimValues> sorted = list.stream()
+                .sorted(Comparator.comparingDouble(getter::extract))
+                .collect(Collectors.toList());
+
+        // 相同值赋予相同百分位(取平均 rank)
+        for (int i = 0; i < n; ) {
+            double val = getter.extract(sorted.get(i));
+            int j = i;
+            while (j < n && getter.extract(sorted.get(j)) == val) {
+                j++;
+            }
+            // [i, j) 区间内的值相同,取平均 rank
+            double avgRank = (i + j - 1) / 2.0;
+            double pct = (n > 1) ? avgRank / (n - 1) : 0.5;
+            for (int k = i; k < j; k++) {
+                setter.set(sorted.get(k), pct);
+            }
+            i = j;
+        }
+    }
+
+    // ===== 辅助方法 =====
+
+    private static double nullToZero(Double v) { return v == null ? 0 : v; }
+    private static double nullToZero(Long v) { return v == null ? 0 : v; }
+
+    private static double round2(double v) {
+        return Math.round(v * 100.0) / 100.0;
+    }
+
+    /**
+     * 内部类:各维度的原始值 + 百分位排名
+     */
+    private static class DimValues {
+        String materialId;
+        double cost7d;
+        double ctr;         // 打开率 = 首层uv / 总转化量
+        double viralRate;   // 裂变率 = t0裂变数 / 首层uv
+        double roi;         // ROI = 收入 / 消耗
+
+        double ctrPct = 0.5;
+        double viralPct = 0.5;
+        double roiPct = 0.5;
+    }
+}

+ 8 - 2
core/src/main/java/com/tzld/videoVector/util/OdpsUtil.java

@@ -25,6 +25,12 @@ public class OdpsUtil {
     private static final String ENDPOINT = "http://service.odps.aliyun.com/api";
     private static final String PROJECT = "loghubods";
 
+    private static String ensureSemicolon(String sql) {
+        if (sql == null) return null;
+        String trimmed = sql.trim();
+        return trimmed.endsWith(";") ? trimmed : trimmed + ";";
+    }
+
     private static Odps getOdps() {
         Account account = new AliyunAccount(ACCESS_ID, ACCESS_KEY);
         Odps odps = new Odps(account);
@@ -37,7 +43,7 @@ public class OdpsUtil {
         Odps odps = getOdps();
         Instance i;
         try {
-            i = SQLTask.run(odps, sql);
+            i = SQLTask.run(odps, ensureSemicolon(sql));
             i.waitForSuccess();
             return SQLTask.getResult(i);
         } catch (OdpsException e) {
@@ -60,7 +66,7 @@ public class OdpsUtil {
     public static long getOdpsDataStream(String sql, Consumer<Record> recordConsumer) {
         Odps odps = getOdps();
         try {
-            Instance instance = SQLTask.run(odps, sql);
+            Instance instance = SQLTask.run(odps, ensureSemicolon(sql));
             instance.waitForSuccess();
 
             InstanceTunnel tunnel = new InstanceTunnel(odps);

+ 104 - 0
core/src/main/resources/mapper/pgVector/ext/MaterialQualityMapperExt.xml

@@ -0,0 +1,104 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialQualityMapperExt">
+
+    <resultMap id="BaseResultMap" type="com.tzld.videoVector.model.po.pgVector.MaterialQuality">
+        <id column="id" property="id"/>
+        <result column="material_id" property="materialId"/>
+        <result column="cost_7d" property="cost7d"/>
+        <result column="target_conversion_7d" property="targetConversion7d"/>
+        <result column="total_conversion_7d" property="totalConversion7d"/>
+        <result column="revenue_7d" property="revenue7d"/>
+        <result column="t0_viral_count_7d" property="t0ViralCount7d"/>
+        <result column="t0_viral_rate_7d" property="t0ViralRate7d"/>
+        <result column="mini_program_open_rate_7d" property="miniProgramOpenRate7d"/>
+        <result column="first_uv_7d" property="firstUv7d"/>
+        <result column="share_count_7d" property="shareCount7d"/>
+        <result column="cost_30d" property="cost30d"/>
+        <result column="target_conversion_30d" property="targetConversion30d"/>
+        <result column="conversion_efficiency_score" property="conversionEfficiencyScore"/>
+        <result column="revenue_score" property="revenueScore"/>
+        <result column="viral_score" property="viralScore"/>
+        <result column="engagement_score" property="engagementScore"/>
+        <result column="quality_score" property="qualityScore"/>
+        <result column="confidence" property="confidence"/>
+        <result column="ad_optimization_goal" property="adOptimizationGoal"/>
+        <result column="package_name" property="packageName"/>
+        <result column="ad_status" property="adStatus"/>
+        <result column="creative_status" property="creativeStatus"/>
+        <result column="dt" property="dt"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <insert id="batchUpsert">
+        INSERT INTO material_quality (
+            material_id,
+            cost_7d, target_conversion_7d, total_conversion_7d, revenue_7d,
+            t0_viral_count_7d, t0_viral_rate_7d, mini_program_open_rate_7d,
+            first_uv_7d, share_count_7d,
+            cost_30d, target_conversion_30d,
+            conversion_efficiency_score, revenue_score, viral_score, engagement_score,
+            quality_score, confidence,
+            ad_optimization_goal, package_name, ad_status, creative_status,
+            dt, create_time, update_time
+        )
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+        (
+            #{item.materialId},
+            #{item.cost7d}, #{item.targetConversion7d}, #{item.totalConversion7d}, #{item.revenue7d},
+            #{item.t0ViralCount7d}, #{item.t0ViralRate7d}, #{item.miniProgramOpenRate7d},
+            #{item.firstUv7d}, #{item.shareCount7d},
+            #{item.cost30d}, #{item.targetConversion30d},
+            #{item.conversionEfficiencyScore}, #{item.revenueScore}, #{item.viralScore}, #{item.engagementScore},
+            #{item.qualityScore}, #{item.confidence},
+            #{item.adOptimizationGoal}, #{item.packageName}, #{item.adStatus}, #{item.creativeStatus},
+            #{item.dt}, NOW(), NOW()
+        )
+        </foreach>
+        ON CONFLICT (material_id, dt)
+        DO UPDATE SET
+            cost_7d = EXCLUDED.cost_7d,
+            target_conversion_7d = EXCLUDED.target_conversion_7d,
+            total_conversion_7d = EXCLUDED.total_conversion_7d,
+            revenue_7d = EXCLUDED.revenue_7d,
+            t0_viral_count_7d = EXCLUDED.t0_viral_count_7d,
+            t0_viral_rate_7d = EXCLUDED.t0_viral_rate_7d,
+            mini_program_open_rate_7d = EXCLUDED.mini_program_open_rate_7d,
+            first_uv_7d = EXCLUDED.first_uv_7d,
+            share_count_7d = EXCLUDED.share_count_7d,
+            cost_30d = EXCLUDED.cost_30d,
+            target_conversion_30d = EXCLUDED.target_conversion_30d,
+            conversion_efficiency_score = EXCLUDED.conversion_efficiency_score,
+            revenue_score = EXCLUDED.revenue_score,
+            viral_score = EXCLUDED.viral_score,
+            engagement_score = EXCLUDED.engagement_score,
+            quality_score = EXCLUDED.quality_score,
+            confidence = EXCLUDED.confidence,
+            ad_optimization_goal = EXCLUDED.ad_optimization_goal,
+            package_name = EXCLUDED.package_name,
+            ad_status = EXCLUDED.ad_status,
+            creative_status = EXCLUDED.creative_status,
+            dt = EXCLUDED.dt,
+            update_time = NOW()
+    </insert>
+
+    <select id="selectByMaterialIds" resultMap="BaseResultMap">
+        SELECT DISTINCT ON (material_id) *
+        FROM material_quality
+        WHERE material_id IN
+        <foreach collection="materialIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+        ORDER BY material_id, dt DESC
+    </select>
+
+    <select id="selectAllMaterialIds" resultType="java.lang.String">
+        SELECT DISTINCT ON (material_id) material_id
+        FROM material_quality
+        ORDER BY material_id, dt DESC
+        LIMIT #{limit} OFFSET #{offset}
+    </select>
+
+</mapper>

+ 22 - 0
server/src/main/java/com/tzld/videoVector/controller/MaterialController.java

@@ -3,7 +3,10 @@ package com.tzld.videoVector.controller;
 import com.tzld.videoVector.common.base.CommonResponse;
 import com.tzld.videoVector.model.param.MaterialMatchParam;
 import com.tzld.videoVector.model.param.MaterialSubmitParam;
+import com.tzld.videoVector.model.param.RecallMaterialScoreParam;
 import com.tzld.videoVector.model.vo.MaterialMatchResult;
+import com.tzld.videoVector.model.vo.MaterialQualityVO;
+import com.tzld.videoVector.model.vo.RecallMaterialScoreVO;
 import com.tzld.videoVector.service.MaterialSearchService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import org.springframework.web.bind.annotation.PathVariable;
+
 import java.util.List;
 
 /**
@@ -38,4 +43,21 @@ public class MaterialController {
     public CommonResponse<List<MaterialMatchResult>> matchTopN(@RequestBody MaterialMatchParam param) {
         return CommonResponse.success(materialSearchService.matchTopNMaterial(param));
     }
+
+    /**
+     * 素材质量加权召回
+     * 在向量相似度的基础上,融合素材投放表现(转化率、ROAS、裂变率等)做综合排序
+     */
+    @PostMapping("/recallWithQuality")
+    public CommonResponse<RecallMaterialScoreVO> recallWithQuality(@RequestBody RecallMaterialScoreParam param) {
+        return CommonResponse.success(materialSearchService.recallMaterialWithQuality(param));
+    }
+
+    /**
+     * 素材质量统计信息查询(根据 material_id)
+     */
+    @PostMapping("/quality/{materialId}")
+    public CommonResponse<MaterialQualityVO> getQuality(@PathVariable String materialId) {
+        return CommonResponse.success(materialSearchService.getMaterialQuality(materialId));
+    }
 }

+ 11 - 0
server/src/main/java/com/tzld/videoVector/controller/XxlJobController.java

@@ -32,6 +32,9 @@ public class XxlJobController {
     @Autowired
     private ChannelDemandMatchJob channelDemandMatchJob;
 
+    @Autowired
+    private MaterialQualitySyncJob materialQualitySyncJob;
+
     // ==================== 视频向量化任务 ====================
 
     @GetMapping("/vectorVideoJob")
@@ -136,4 +139,12 @@ public class XxlJobController {
         return CommonResponse.success();
     }
 
+    // ==================== 素材质量评分同步任务 ====================
+
+    @GetMapping("/syncMaterialQualityJob")
+    public CommonResponse<Void> syncMaterialQualityJob() {
+        materialQualitySyncJob.syncMaterialQualityJob(null);
+        return CommonResponse.success();
+    }
+
 }