Przeglądaj źródła

新增向量召回测试接口 + AI理解占位 (基于 a909594)

后端新增 5 个接口(只新增,不改现有逻辑):
- GET  /recallTest/videoDetail        视频基础详情(playCount 占位 "--")
- POST /recallTest/matchByText        文本召回 + 模态感知 enrich
- POST /recallTest/matchByVideoId     视频ID召回 + enrich
- GET  /recallTest/deconstructPoints  解构层级(透传 result_json)
- GET  /recallTest/aiUnderstanding    AI理解(待 DataWorks 同步表就绪,暂返回 null)

新增文件 (14):
- common/enums/Modality.java
- model/po/videoVector/VideoAiUnderstanding.java
- dao/mapper/videoVector/VideoAiUnderstandingMapper.java
- model/vo/recall/{VideoBasicVO,VideoMatchEnrichedVO,RecallResultVO,DeconstructPointsVO,AIUnderstandingVO}.java
- model/param/recall/{MatchByTextParam,MatchByVideoIdParam}.java
- service/recall/VectorRecallTestService.java + impl
- server/.../controller/VectorRecallTestController.java
- core/src/main/resources/sql/video_ai_understanding.sql (本地表DDL)

适配 a909594 基线:
- DeconstructContent* 用 videoVector.deconstruct.* 包(MySQL,无 pgVector)
- matchTopNVideo 返回 List<Object>,用 JSONObject 解析
- contentType/bizType/status 用 Number 兼容 Byte/Short
- 召回结果对 configCode null safe(a909594 不返回此字段)

启动验证: java -jar 21秒 启动成功,Tomcat 8080 OK
接口验证: 7 个接口 (1 healthcheck + 1 现有回归 + 5 新增) 全部 200 + 真实数据
.gitignore: 加入 config-cache/ (Apollo 运行时缓存)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
刘立冬 1 tydzień temu
rodzic
commit
fd4a3a2b6e

+ 3 - 1
.gitignore

@@ -34,4 +34,6 @@ build/
 ### Mac OS ###
 .DS_Store
 
-*.log
+*.log
+### Runtime caches ###
+config-cache/

+ 35 - 0
core/src/main/java/com/tzld/videoVector/common/enums/Modality.java

@@ -0,0 +1,35 @@
+package com.tzld.videoVector.common.enums;
+
+/**
+ * 召回结果模态枚举
+ *
+ * 与 deconstruct_content.content_type 的映射:
+ *   content_type = 3 (视频)  → VIDEO
+ *   content_type = 2 (图文)  → MATERIAL
+ *   content_type = 1 (长文)  → ARTICLE
+ *   content_type 缺省/未知   → VIDEO (用户确认默认按视频处理)
+ */
+public enum Modality {
+    VIDEO,
+    MATERIAL,
+    ARTICLE;
+
+    /**
+     * 用 Number 兼容 Byte / Short 不同 PO 的字段类型
+     */
+    public static Modality fromContentType(Number contentType) {
+        if (contentType == null) {
+            return VIDEO;
+        }
+        int v = contentType.intValue();
+        switch (v) {
+            case 1:
+                return ARTICLE;
+            case 2:
+                return MATERIAL;
+            case 3:
+            default:
+                return VIDEO;
+        }
+    }
+}

+ 22 - 0
core/src/main/java/com/tzld/videoVector/dao/mapper/videoVector/VideoAiUnderstandingMapper.java

@@ -0,0 +1,22 @@
+package com.tzld.videoVector.dao.mapper.videoVector;
+
+import com.tzld.videoVector.model.po.videoVector.VideoAiUnderstanding;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 视频AI理解结果 Mapper
+ * 数据由 DataWorks 离线同步任务从 ODPS loghubods.result_log 抽取后写入。
+ *
+ * MVP 期间表为空(同步Job未实施),selectByVideoId 返回 null,
+ * Service 层据此返回 null 给前端展示"未就绪"占位。
+ */
+public interface VideoAiUnderstandingMapper {
+
+    @Select("SELECT video_id AS videoId, content_topic AS contentTopic, " +
+            "video_theme AS videoTheme, video_keywords AS videoKeywords, " +
+            "video_narration AS videoNarration, raw_data AS rawData, " +
+            "dt, sync_time AS syncTime " +
+            "FROM video_ai_understanding WHERE video_id = #{videoId} LIMIT 1")
+    VideoAiUnderstanding selectByVideoId(@Param("videoId") Long videoId);
+}

+ 23 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByTextParam.java

@@ -0,0 +1,23 @@
+package com.tzld.videoVector.model.param.recall;
+
+import lombok.Data;
+
+/**
+ * 文本召回参数 (Tab2 文本输入)
+ */
+@Data
+public class MatchByTextParam {
+
+    /** 查询文本 */
+    private String queryText;
+
+    /**
+     * 向量配置编码
+     * 当前已支持: VIDEO_TOPIC(选题) / VIDEO_INSPIRATION(灵感点)
+     * 不传则用默认 VIDEO_TOPIC
+     */
+    private String configCode;
+
+    /** 返回 Top-N,默认 10 */
+    private Integer topN = 10;
+}

+ 23 - 0
core/src/main/java/com/tzld/videoVector/model/param/recall/MatchByVideoIdParam.java

@@ -0,0 +1,23 @@
+package com.tzld.videoVector.model.param.recall;
+
+import lombok.Data;
+
+/**
+ * 通过视频/素材ID召回相似 (Tab1 解构树节点点击触发,或"以此视频召回")
+ */
+@Data
+public class MatchByVideoIdParam {
+
+    /** 视频ID 或 channelContentId 数值化 */
+    private Long videoId;
+
+    /**
+     * 向量配置编码
+     * 当前已支持: VIDEO_TOPIC / VIDEO_INSPIRATION
+     * 不传则用默认 VIDEO_TOPIC
+     */
+    private String configCode;
+
+    /** 返回 Top-N,默认 10 */
+    private Integer topN = 10;
+}

+ 41 - 0
core/src/main/java/com/tzld/videoVector/model/po/videoVector/VideoAiUnderstanding.java

@@ -0,0 +1,41 @@
+package com.tzld.videoVector.model.po.videoVector;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 视频AI理解结果(本地缓存表)
+ *
+ * 数据来源: 阿里云 MaxCompute loghubods.result_log,经 DataWorks 同步Job
+ * 抽取关键字段同步到本地 MySQL。本地表先建好结构,数据待同步Job就绪。
+ *
+ * 对应建表DDL见: core/src/main/resources/sql/video_ai_understanding.sql
+ */
+@Data
+public class VideoAiUnderstanding {
+
+    /** 视频ID,主键 */
+    private Long videoId;
+
+    /** 内容选题 */
+    private String contentTopic;
+
+    /** 视频主题 */
+    private String videoTheme;
+
+    /** 视频关键词 */
+    private String videoKeywords;
+
+    /** 视频口播 */
+    private String videoNarration;
+
+    /** 原始 data 字段 JSON 字符串(完整保留) */
+    private String rawData;
+
+    /** 数据所属分区,通常为 yyyyMMddHH */
+    private String dt;
+
+    /** 同步时间 */
+    private Date syncTime;
+}

+ 34 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/AIUnderstandingVO.java

@@ -0,0 +1,34 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import lombok.Data;
+
+/**
+ * AI理解结果 VO
+ *
+ * 数据来源: ODPS loghubods.result_log (大数据,慢) → 经 DataWorks 同步Job
+ * 同步到本地表 video_ai_understanding。
+ *
+ * MVP 期间本地表为空,该 VO 全部为 null,前端展示"AI理解数据未就绪,等待同步Job"。
+ * 严禁后端伪造任何字段返回。
+ */
+@Data
+public class AIUnderstandingVO {
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 内容选题 */
+    private String contentTopic;
+
+    /** 视频主题 */
+    private String videoTheme;
+
+    /** 视频关键词 */
+    private String videoKeywords;
+
+    /** 视频口播 */
+    private String videoNarration;
+
+    /** 数据所属分区(yyyyMMddHH) */
+    private String dt;
+}

+ 48 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/DeconstructPointsVO.java

@@ -0,0 +1,48 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.Data;
+
+/**
+ * 视频/素材的解构层级返回
+ *
+ * 由于外部解构 API 返回的 JSON schema 是动态的(选题/灵感点/目的点/关键点
+ * 字段名和层级深度可能变化),这里采用透传策略: 直接把原始 result_json 解析为
+ * JSONObject 返回给前端,前端用递归树形组件展示,任意叶子节点都可作为查询文本
+ * 触发召回。
+ */
+@Data
+public class DeconstructPointsVO {
+
+    /** 业务侧 ID (videoId / channelContentId) */
+    private Long id;
+
+    /** 业务侧字符串ID,与 deconstruct_content.channel_content_id 对齐 */
+    private String channelContentId;
+
+    /** 解构任务ID */
+    private String taskId;
+
+    /** 内容类型: 1长文 2图文 3视频 */
+    private Integer contentType;
+
+    /** 业务类型: 0选题 1创作 2制作 */
+    private Integer bizType;
+
+    /**
+     * 解构状态: 0待处理 1处理中 2成功 3失败
+     */
+    private Integer status;
+
+    /** 状态文字描述,便于前端展示 */
+    private String statusDesc;
+
+    /** 标题(如有) */
+    private String title;
+
+    /** 解构结果原始 JSON 对象,前端用递归 Tree 渲染 */
+    private JSONObject rawResult;
+
+    /** 失败原因(若有) */
+    private String failureReason;
+}

+ 28 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/RecallResultVO.java

@@ -0,0 +1,28 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 召回结果包装
+ * 前端可按 modality 字段对 items 分组展示模态Tab。
+ */
+@Data
+public class RecallResultVO {
+
+    /** 召回结果(已 enrich,带模态信息) */
+    private List<VideoMatchEnrichedVO> items;
+
+    /** 命中视频数 */
+    private int videoCount;
+
+    /** 命中素材数 */
+    private int materialCount;
+
+    /** 命中长文数 */
+    private int articleCount;
+
+    /** 总条数 */
+    private int total;
+}

+ 28 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/VideoBasicVO.java

@@ -0,0 +1,28 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import lombok.Data;
+
+/**
+ * 视频基础详情 VO (Tab1 视频ID 查询返回)
+ */
+@Data
+public class VideoBasicVO {
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 标题 */
+    private String title;
+
+    /** 视频播放地址 */
+    private String videoUrl;
+
+    /** 封面图 */
+    private String cover;
+
+    /**
+     * 播放量
+     * 长视频API当前不返回播放量字段,真实占位 "--"
+     */
+    private String playCount = "--";
+}

+ 58 - 0
core/src/main/java/com/tzld/videoVector/model/vo/recall/VideoMatchEnrichedVO.java

@@ -0,0 +1,58 @@
+package com.tzld.videoVector.model.vo.recall;
+
+import com.tzld.videoVector.common.enums.Modality;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 单条召回结果(模态感知 enrich 后)
+ *
+ * 召回是多对多对称架构,一次召回可能混合返回视频和素材。
+ * 通过 modality 字段区分,前端按模态分组展示。
+ */
+@Data
+public class VideoMatchEnrichedVO {
+
+    /** 业务ID (视频时为 wx_video.id, 素材时为 channelContentId 数值化) */
+    private Long id;
+
+    /** 模态 */
+    private Modality modality;
+
+    /** 命中的向量配置编码,如 VIDEO_TOPIC / VIDEO_INSPIRATION */
+    private String configCode;
+
+    /** 相似度分数 */
+    private Double score;
+
+    /** 标题 */
+    private String title;
+
+    /** 封面/缩略图 */
+    private String cover;
+
+    /** 视频URL (仅 modality=VIDEO 有效) */
+    private String videoUrl;
+
+    /** 图片列表 (仅 modality=MATERIAL 有效) */
+    private List<String> imageList;
+
+    /** 正文 (仅 modality=ARTICLE 有效) */
+    private String bodyText;
+
+    /** 播放量,占位 "--" */
+    private String playCount;
+
+    /** 曝光量,占位 "--" */
+    private String exposure;
+
+    /** CTR,占位 "--" */
+    private String ctr;
+
+    /** 阅读数,占位 "--" */
+    private String readCount;
+
+    /** ROV,占位 "--" */
+    private String rov;
+}

+ 61 - 0
core/src/main/java/com/tzld/videoVector/service/recall/VectorRecallTestService.java

@@ -0,0 +1,61 @@
+package com.tzld.videoVector.service.recall;
+
+import com.tzld.videoVector.model.param.recall.MatchByTextParam;
+import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
+import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
+import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.RecallResultVO;
+import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
+
+/**
+ * 向量召回测试 Service
+ * 复用现有 VideoSearchService / VideoApiService / DeconstructService,在此层做数据组装。
+ */
+public interface VectorRecallTestService {
+
+    /**
+     * 获取视频基础详情 (Tab1 用)
+     * 调用长视频API
+     *
+     * @param videoId 视频ID
+     * @return VideoBasicVO,视频不存在返回 null
+     */
+    VideoBasicVO getVideoDetail(Long videoId);
+
+    /**
+     * 文本召回 + 模态感知 enrich (Tab2 用)
+     *
+     * @param param 文本召回参数
+     * @return 召回结果(已 enrich)
+     */
+    RecallResultVO matchByText(MatchByTextParam param);
+
+    /**
+     * 获取视频/素材的解构层级
+     * 直接读取 deconstruct_content.result_json,透传给前端递归渲染。
+     *
+     * @param videoId 视频ID 或 channelContentId 数值化
+     * @return 解构层级 VO,记录不存在返回 null
+     */
+    DeconstructPointsVO getDeconstructPoints(Long videoId);
+
+    /**
+     * 通过视频ID召回相似 (Tab1 解构节点点击触发)
+     * 内部转调 matchTopNVideo,以 channelContentId 形式传入。
+     *
+     * @param param 参数
+     * @return 召回结果(已 enrich)
+     */
+    RecallResultVO matchByVideoId(MatchByVideoIdParam param);
+
+    /**
+     * 获取视频AI理解结果 (Tab1 用)
+     *
+     * 数据来源: 本地 MySQL 表 video_ai_understanding (待 DataWorks 同步Job灌数据)。
+     * MVP 期间表是空的,真实查询返回 null,前端展示"未就绪"占位。
+     *
+     * @param videoId 视频ID
+     * @return AI理解结果,无数据返回 null (严禁mock)
+     */
+    AIUnderstandingVO getAiUnderstanding(Long videoId);
+}

+ 419 - 0
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -0,0 +1,419 @@
+package com.tzld.videoVector.service.recall.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.tzld.videoVector.api.VideoApiService;
+import com.tzld.videoVector.common.enums.Modality;
+import com.tzld.videoVector.dao.mapper.videoVector.VideoAiUnderstandingMapper;
+import com.tzld.videoVector.dao.mapper.videoVector.deconstruct.DeconstructContentMapper;
+import com.tzld.videoVector.model.entity.VideoDetail;
+import com.tzld.videoVector.model.param.MatchTopNVideoParam;
+import com.tzld.videoVector.model.param.recall.MatchByTextParam;
+import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
+import com.tzld.videoVector.model.po.videoVector.VideoAiUnderstanding;
+import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContent;
+import com.tzld.videoVector.model.po.videoVector.deconstruct.DeconstructContentExample;
+import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
+import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.RecallResultVO;
+import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
+import com.tzld.videoVector.model.vo.recall.VideoMatchEnrichedVO;
+import com.tzld.videoVector.service.VideoSearchService;
+import com.tzld.videoVector.service.recall.VectorRecallTestService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+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 java.util.stream.Collectors;
+
+/**
+ * 向量召回测试 Service 实现
+ * 核心职责: 调度现有 Service + 数据组装 + 模态感知 enrich
+ * 严禁 mock 任何业务数据。
+ */
+@Slf4j
+@Service
+public class VectorRecallTestServiceImpl implements VectorRecallTestService {
+
+    @Autowired
+    private VideoSearchService videoSearchService;
+
+    @Resource
+    private VideoApiService videoApiService;
+
+    @Autowired
+    private DeconstructContentMapper deconstructContentMapper;
+
+    @Autowired(required = false)
+    private VideoAiUnderstandingMapper videoAiUnderstandingMapper;
+
+    private static final String PLACEHOLDER = "--";
+
+    @Override
+    public VideoBasicVO getVideoDetail(Long videoId) {
+        if (videoId == null || videoId <= 0L) {
+            return null;
+        }
+        Map<Long, VideoDetail> map = videoApiService.getVideoDetail(Collections.singleton(videoId));
+        VideoDetail detail = map.get(videoId);
+        if (detail == null) {
+            log.info("getVideoDetail: video not found, videoId={}", videoId);
+            return null;
+        }
+        VideoBasicVO vo = new VideoBasicVO();
+        vo.setVideoId(videoId);
+        vo.setTitle(detail.getTitle());
+        vo.setVideoUrl(detail.getVideoPath());
+        vo.setCover(detail.getCover());
+        // playCount 长视频API无字段,真实占位 "--"
+        return vo;
+    }
+
+    @Override
+    public RecallResultVO matchByText(MatchByTextParam param) {
+        RecallResultVO empty = emptyResult();
+        if (param == null || !StringUtils.hasText(param.getQueryText())) {
+            log.warn("matchByText: queryText 为空");
+            return empty;
+        }
+
+        // 1. 调用现有召回 Service
+        MatchTopNVideoParam matchParam = new MatchTopNVideoParam();
+        matchParam.setQueryText(param.getQueryText());
+        matchParam.setConfigCode(param.getConfigCode());
+        matchParam.setTopN(param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10);
+
+        List<Object> rawMatches = videoSearchService.matchTopNVideo(matchParam);
+        if (CollectionUtils.isEmpty(rawMatches)) {
+            return empty;
+        }
+
+        // 2. 解析并 enrich
+        return enrich(rawMatches);
+    }
+
+    @Override
+    public RecallResultVO matchByVideoId(MatchByVideoIdParam param) {
+        RecallResultVO empty = emptyResult();
+        if (param == null || param.getVideoId() == null || param.getVideoId() <= 0L) {
+            return empty;
+        }
+
+        // 转调现有 matchTopNVideo,以 channelContentId 字符串形式传入
+        MatchTopNVideoParam matchParam = new MatchTopNVideoParam();
+        matchParam.setChannelContentId(String.valueOf(param.getVideoId()));
+        matchParam.setConfigCode(param.getConfigCode());
+        matchParam.setTopN(param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 10);
+
+        List<Object> rawMatches = videoSearchService.matchTopNVideo(matchParam);
+        if (CollectionUtils.isEmpty(rawMatches)) {
+            return empty;
+        }
+        return enrich(rawMatches);
+    }
+
+    @Override
+    public DeconstructPointsVO getDeconstructPoints(Long videoId) {
+        if (videoId == null || videoId <= 0L) {
+            return null;
+        }
+        // 通过 channelContentId = String(videoId) 查 deconstruct_content
+        Map<String, DeconstructContent> map = queryDeconstructContent(
+                Collections.singletonList(String.valueOf(videoId))
+        );
+        DeconstructContent content = map.get(String.valueOf(videoId));
+        if (content == null) {
+            log.info("getDeconstructPoints: not found, videoId={}", videoId);
+            return null;
+        }
+
+        DeconstructPointsVO vo = new DeconstructPointsVO();
+        vo.setId(videoId);
+        vo.setChannelContentId(content.getChannelContentId());
+        vo.setTaskId(content.getTaskId());
+        // 用 Number → intValue 兼容 Byte (a909594) / Short (master) PO 字段类型
+        vo.setContentType(content.getContentType() == null ? null : content.getContentType().intValue());
+        vo.setBizType(content.getBizType() == null ? null : content.getBizType().intValue());
+        vo.setStatus(content.getStatus() == null ? null : content.getStatus().intValue());
+        vo.setStatusDesc(getStatusDesc(content.getStatus()));
+        vo.setTitle(content.getTitle());
+        vo.setFailureReason(content.getFailureReason());
+
+        if (StringUtils.hasText(content.getResultJson())) {
+            try {
+                vo.setRawResult(JSON.parseObject(content.getResultJson()));
+            } catch (Exception e) {
+                log.warn("getDeconstructPoints: parse resultJson fail, videoId={}, err={}",
+                        videoId, e.getMessage());
+            }
+        }
+        return vo;
+    }
+
+    @Override
+    public AIUnderstandingVO getAiUnderstanding(Long videoId) {
+        if (videoId == null || videoId <= 0L) {
+            return null;
+        }
+        if (videoAiUnderstandingMapper == null) {
+            // 表/Mapper 未就绪(同步Job尚未实施)
+            log.info("getAiUnderstanding: mapper not available, returning null. videoId={}", videoId);
+            return null;
+        }
+        try {
+            VideoAiUnderstanding po = videoAiUnderstandingMapper.selectByVideoId(videoId);
+            if (po == null) {
+                return null;
+            }
+            AIUnderstandingVO vo = new AIUnderstandingVO();
+            vo.setVideoId(po.getVideoId());
+            vo.setContentTopic(po.getContentTopic());
+            vo.setVideoTheme(po.getVideoTheme());
+            vo.setVideoKeywords(po.getVideoKeywords());
+            vo.setVideoNarration(po.getVideoNarration());
+            vo.setDt(po.getDt());
+            return vo;
+        } catch (Exception e) {
+            // 表可能尚未创建(BadSqlGrammarException等),按真实"未就绪"返回 null
+            log.warn("getAiUnderstanding: query failed, table may not exist yet. videoId={}, err={}",
+                    videoId, e.getMessage());
+            return null;
+        }
+    }
+
+    private String getStatusDesc(Number status) {
+        if (status == null) return "未知";
+        switch (status.intValue()) {
+            case 0: return "待处理";
+            case 1: return "处理中";
+            case 2: return "成功";
+            case 3: return "失败";
+            default: return "未知";
+        }
+    }
+
+    /**
+     * 召回结果模态感知 enrich
+     *
+     * 流程:
+     *  - 提取所有 id
+     *  - 查 deconstruct_content WHERE channel_content_id IN (...) 拿 content_type
+     *  - 视频走 VideoApiService 取权威详情
+     *  - 素材/长文用 deconstruct_content 数据
+     *  - 默认按视频处理(用户确认 content_type 缺省语义)
+     */
+    private RecallResultVO enrich(List<Object> rawMatches) {
+        // 解析原始 matches: [{configCode, videoId, score}, ...]
+        List<MatchItem> matches = new ArrayList<>(rawMatches.size());
+        for (Object obj : rawMatches) {
+            JSONObject jo = toJSONObject(obj);
+            if (jo == null) {
+                continue;
+            }
+            Long id = jo.getLong("videoId");
+            if (id == null) {
+                continue;
+            }
+            MatchItem mi = new MatchItem();
+            mi.id = id;
+            mi.configCode = jo.getString("configCode");
+            mi.score = jo.getDouble("score");
+            matches.add(mi);
+        }
+
+        if (matches.isEmpty()) {
+            return emptyResult();
+        }
+
+        // 提取 id 列表(string形式,用于查 channel_content_id)
+        Set<Long> allIds = matches.stream().map(m -> m.id).collect(Collectors.toSet());
+        List<String> idStrings = allIds.stream().map(String::valueOf).collect(Collectors.toList());
+
+        // 查 deconstruct_content
+        Map<String, DeconstructContent> contentByCcid = queryDeconstructContent(idStrings);
+
+        // 收集需要走 VideoApiService 的视频id
+        Set<Long> videoIds = new HashSet<>();
+        Map<Long, Modality> modalityMap = new HashMap<>();
+
+        for (MatchItem m : matches) {
+            DeconstructContent c = contentByCcid.get(String.valueOf(m.id));
+            Modality modality = (c == null) ? Modality.VIDEO : Modality.fromContentType(c.getContentType());
+            modalityMap.put(m.id, modality);
+            if (modality == Modality.VIDEO) {
+                videoIds.add(m.id);
+            }
+        }
+
+        // 批量取视频详情
+        Map<Long, VideoDetail> videoDetails = videoIds.isEmpty()
+                ? Collections.emptyMap()
+                : videoApiService.getVideoDetail(videoIds);
+
+        // 组装 VO
+        List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
+        int videoCount = 0;
+        int materialCount = 0;
+        int articleCount = 0;
+
+        for (MatchItem m : matches) {
+            Modality modality = modalityMap.get(m.id);
+            VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
+            vo.setId(m.id);
+            vo.setModality(modality);
+            vo.setConfigCode(m.configCode);
+            vo.setScore(m.score);
+            vo.setPlayCount(PLACEHOLDER);
+            vo.setExposure(PLACEHOLDER);
+            vo.setCtr(PLACEHOLDER);
+            vo.setReadCount(PLACEHOLDER);
+            vo.setRov(PLACEHOLDER);
+
+            DeconstructContent content = contentByCcid.get(String.valueOf(m.id));
+
+            switch (modality) {
+                case VIDEO:
+                    VideoDetail vd = videoDetails.get(m.id);
+                    if (vd != null) {
+                        vo.setTitle(vd.getTitle());
+                        vo.setVideoUrl(vd.getVideoPath());
+                        vo.setCover(vd.getCover());
+                    } else if (content != null) {
+                        // 长视频API查不到,降级用本地 deconstruct_content
+                        vo.setTitle(content.getTitle());
+                        vo.setVideoUrl(content.getVideoUrl());
+                    }
+                    videoCount++;
+                    break;
+                case MATERIAL:
+                    if (content != null) {
+                        vo.setTitle(content.getTitle());
+                        vo.setImageList(parseImages(content.getImages()));
+                        if (!CollectionUtils.isEmpty(vo.getImageList())) {
+                            vo.setCover(vo.getImageList().get(0));
+                        }
+                    }
+                    materialCount++;
+                    break;
+                case ARTICLE:
+                    if (content != null) {
+                        vo.setTitle(content.getTitle());
+                        vo.setBodyText(content.getBodyText());
+                    }
+                    articleCount++;
+                    break;
+                default:
+                    videoCount++;
+                    break;
+            }
+
+            items.add(vo);
+        }
+
+        RecallResultVO result = new RecallResultVO();
+        result.setItems(items);
+        result.setVideoCount(videoCount);
+        result.setMaterialCount(materialCount);
+        result.setArticleCount(articleCount);
+        result.setTotal(items.size());
+        return result;
+    }
+
+    /**
+     * 按 channelContentId 批量查 deconstruct_content
+     */
+    private Map<String, DeconstructContent> queryDeconstructContent(List<String> channelContentIds) {
+        if (CollectionUtils.isEmpty(channelContentIds)) {
+            return Collections.emptyMap();
+        }
+        try {
+            DeconstructContentExample example = new DeconstructContentExample();
+            example.createCriteria().andChannelContentIdIn(channelContentIds);
+            List<DeconstructContent> list = deconstructContentMapper.selectByExample(example);
+            // channel_content_id 可能重复(同一内容多次解构),保留最新一条
+            Map<String, DeconstructContent> map = new HashMap<>();
+            for (DeconstructContent c : list) {
+                String ccid = c.getChannelContentId();
+                if (ccid == null) {
+                    continue;
+                }
+                DeconstructContent prev = map.get(ccid);
+                if (prev == null || (c.getId() != null && (prev.getId() == null || c.getId() > prev.getId()))) {
+                    map.put(ccid, c);
+                }
+            }
+            return map;
+        } catch (Exception e) {
+            log.error("queryDeconstructContent error: {}", e.getMessage(), e);
+            return Collections.emptyMap();
+        }
+    }
+
+    private JSONObject toJSONObject(Object obj) {
+        if (obj == null) {
+            return null;
+        }
+        if (obj instanceof JSONObject) {
+            return (JSONObject) obj;
+        }
+        try {
+            return JSON.parseObject(JSON.toJSONString(obj));
+        } catch (Exception e) {
+            log.warn("toJSONObject parse fail: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private List<String> parseImages(String imagesJson) {
+        if (!StringUtils.hasText(imagesJson)) {
+            return Collections.emptyList();
+        }
+        try {
+            JSONArray arr = JSON.parseArray(imagesJson);
+            if (arr == null) {
+                return Collections.emptyList();
+            }
+            List<String> result = new ArrayList<>(arr.size());
+            for (int i = 0; i < arr.size(); i++) {
+                String s = arr.getString(i);
+                if (StringUtils.hasText(s)) {
+                    result.add(s);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("parseImages fail, json={}, err={}", imagesJson, e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private RecallResultVO emptyResult() {
+        RecallResultVO vo = new RecallResultVO();
+        vo.setItems(Collections.emptyList());
+        vo.setVideoCount(0);
+        vo.setMaterialCount(0);
+        vo.setArticleCount(0);
+        vo.setTotal(0);
+        return vo;
+    }
+
+    /**
+     * 解析后的单条 match
+     */
+    private static class MatchItem {
+        Long id;
+        String configCode;
+        Double score;
+    }
+}

+ 24 - 0
core/src/main/resources/sql/video_ai_understanding.sql

@@ -0,0 +1,24 @@
+-- 视频AI理解结果(本地缓存表)
+--
+-- 数据来源: 阿里云 MaxCompute loghubods.result_log
+-- 同步方式: DataWorks 离线同步任务,按 dt(yyyyMMddHH) 分区增量抽取并解析 data 字段
+-- 解析路径(参考 ODPS data 字段 JSON):
+--   content_topic    ← data."一、基础信息"."内容选题"
+--   video_theme      ← data."一、基础信息"."视频主题"
+--   video_keywords   ← data."一、基础信息"."视频关键词"
+--   video_narration  ← data."五、音画细节"."视频口播"
+--
+-- 建在 deconstruct-vector MySQL 库(video-vector-server 已使用)
+
+CREATE TABLE IF NOT EXISTS video_ai_understanding (
+    video_id        BIGINT       NOT NULL COMMENT '视频ID',
+    content_topic   TEXT         COMMENT '内容选题',
+    video_theme     TEXT         COMMENT '视频主题',
+    video_keywords  TEXT         COMMENT '视频关键词',
+    video_narration TEXT         COMMENT '视频口播',
+    raw_data        JSON         COMMENT '原始 data 字段 JSON 完整保留',
+    dt              VARCHAR(16)  COMMENT '数据所属分区 yyyyMMddHH',
+    sync_time       DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '同步时间',
+    PRIMARY KEY (video_id),
+    KEY idx_dt (dt)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '视频AI理解结果缓存(同步自 ODPS result_log)';

+ 82 - 0
server/src/main/java/com/tzld/videoVector/controller/VectorRecallTestController.java

@@ -0,0 +1,82 @@
+package com.tzld.videoVector.controller;
+
+import com.tzld.videoVector.common.base.CommonResponse;
+import com.tzld.videoVector.model.param.recall.MatchByTextParam;
+import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
+import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
+import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
+import com.tzld.videoVector.model.vo.recall.RecallResultVO;
+import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
+import com.tzld.videoVector.service.recall.VectorRecallTestService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 向量召回测试 Controller
+ * 提供给前端测试页面使用,只新增接口,不修改现有接口逻辑。
+ *
+ * MVP 不加鉴权,@CrossOrigin 全开。
+ */
+@RestController
+@RequestMapping("/recallTest")
+@CrossOrigin(origins = "*")
+public class VectorRecallTestController {
+
+    @Autowired
+    private VectorRecallTestService vectorRecallTestService;
+
+    /**
+     * 获取视频基础详情 (Tab1)
+     * GET /videoVector/recallTest/videoDetail?videoId=12345
+     */
+    @GetMapping("/videoDetail")
+    public CommonResponse<VideoBasicVO> videoDetail(@RequestParam("videoId") Long videoId) {
+        return CommonResponse.success(vectorRecallTestService.getVideoDetail(videoId));
+    }
+
+    /**
+     * 文本召回 (Tab2)
+     * POST /videoVector/recallTest/matchByText
+     * body: { "queryText": "...", "configCode": "VIDEO_TOPIC", "topN": 10 }
+     */
+    @PostMapping("/matchByText")
+    public CommonResponse<RecallResultVO> matchByText(@RequestBody MatchByTextParam param) {
+        return CommonResponse.success(vectorRecallTestService.matchByText(param));
+    }
+
+    /**
+     * 视频/素材的解构层级 (Tab1 解构树)
+     * GET /videoVector/recallTest/deconstructPoints?videoId=12345
+     * 直接读取 deconstruct_content.result_json,透传给前端递归渲染。
+     */
+    @GetMapping("/deconstructPoints")
+    public CommonResponse<DeconstructPointsVO> deconstructPoints(@RequestParam("videoId") Long videoId) {
+        return CommonResponse.success(vectorRecallTestService.getDeconstructPoints(videoId));
+    }
+
+    /**
+     * 通过视频/素材ID召回相似 (Tab1 解构节点点击触发)
+     * POST /videoVector/recallTest/matchByVideoId
+     * body: { "videoId": 12345, "configCode": "VIDEO_TOPIC", "topN": 10 }
+     */
+    @PostMapping("/matchByVideoId")
+    public CommonResponse<RecallResultVO> matchByVideoId(@RequestBody MatchByVideoIdParam param) {
+        return CommonResponse.success(vectorRecallTestService.matchByVideoId(param));
+    }
+
+    /**
+     * 视频AI理解结果 (Tab1)
+     * GET /videoVector/recallTest/aiUnderstanding?videoId=12345
+     * 数据未就绪时返回 data:null,前端展示"未就绪"占位。严禁mock。
+     */
+    @GetMapping("/aiUnderstanding")
+    public CommonResponse<AIUnderstandingVO> aiUnderstanding(@RequestParam("videoId") Long videoId) {
+        return CommonResponse.success(vectorRecallTestService.getAiUnderstanding(videoId));
+    }
+}