Просмотр исходного кода

解构结果缓存例行任务刷新

wangyunpeng 13 часов назад
Родитель
Сommit
b3ae913b59

+ 9 - 0
core/src/main/java/com/tzld/videoVector/common/constant/VectorConstants.java

@@ -30,6 +30,15 @@ public interface VectorConstants {
     /** 视频解构 Redis Key 前缀,格式: recall:vid_decode:{videoId},由 script/sync_decode_to_redis.py 写入 */
     String VID_DECODE_KEY_PREFIX = "recall:vid_decode:";
 
+    /** 视频解构缓存 ID 追踪 SET,记录所有已缓存 decode 的 videoId */
+    String VID_DECODE_IDS_SET_KEY = "recall:vid_decode:id_set";
+
+    /** 视频AI理解 Redis Key 前缀,格式: video:ai_understanding:{videoId} */
+    String AI_OLD_UNDERSTANDING_KEY_PREFIX = "video:old:ai_understanding:";
+
+    /** 视频AI理解缓存 ID 追踪 SET,记录所有已缓存的 videoId,用于清除逻辑 */
+    String AI_OLD_UNDERSTANDING_IDS_SET_KEY = "video:old:ai_understanding:id_set";
+
     // ========================== 批处理参数 ==========================
 
     /** 每页查询数量 */

+ 216 - 0
core/src/main/java/com/tzld/videoVector/job/AiUnderstandingSyncJob.java

@@ -0,0 +1,216 @@
+package com.tzld.videoVector.job;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.tzld.videoVector.common.constant.VectorConstants;
+import com.tzld.videoVector.dao.mapper.pgVector.ext.VideoVectorMapperExt;
+import com.tzld.videoVector.util.OdpsUtil;
+import com.tzld.videoVector.util.RedisUtils;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/**
+ * 视频AI理解信息同步任务
+ * <p>
+ * 功能:
+ * 1. 查询 video_vectors 表中所有 video_id(去重)
+ * 2. 分批从 ODPS result_log 获取老解构结果(r.json 格式)
+ * 3. 提取"内容选题"、"视频主题"、"视频关键词"、"视频口播"
+ * 4. 写入 Redis 缓存(key: video:ai_understanding:{videoId})
+ * 5. 清除逻辑:先获取上次缓存的ID集合,本次同步后删除不再存在的旧ID缓存
+ */
+@Slf4j
+@Component
+public class AiUnderstandingSyncJob {
+
+    @Resource
+    private VideoVectorMapperExt videoVectorMapperExt;
+
+    @Resource
+    private RedisUtils redisUtils;
+
+    /** ODPS SQL IN 子句批次大小 */
+    private static final int ODPS_BATCH_SIZE = 1000;
+
+    /**
+     * 同步视频AI理解信息到 Redis
+     * XxlJob handler: syncAiUnderstandingJob
+     */
+    @XxlJob("syncAiUnderstandingJob")
+    public ReturnT<String> syncAiUnderstandingJob(String param) {
+        log.info("开始执行视频AI理解信息同步任务, param: {}", param);
+
+        try {
+            // 1. 查询 video_vectors 表中所有不重复的 video_id
+            List<Long> allVideoIds = videoVectorMapperExt.selectAllDistinctVideoIds();
+            if (CollectionUtils.isEmpty(allVideoIds)) {
+                log.info("video_vectors 表中无数据,跳过");
+                return ReturnT.SUCCESS;
+            }
+            log.info("查询到 {} 个不重复的 video_id", allVideoIds.size());
+
+            // 2. 获取上次缓存的 videoId 集合(用于清除逻辑)
+            Set<String> previousCachedIds = redisUtils.sMembers(VectorConstants.AI_OLD_UNDERSTANDING_IDS_SET_KEY);
+            if (previousCachedIds == null) {
+                previousCachedIds = Collections.emptySet();
+            }
+            log.info("上次缓存的 AI 理解 videoId 数量: {}", previousCachedIds.size());
+
+            // 3. 分批查询 result_log 并写入 Redis
+            AtomicInteger totalSuccess = new AtomicInteger(0);
+            AtomicInteger totalFail = new AtomicInteger(0);
+            Set<String> currentCachedIds = Collections.synchronizedSet(new HashSet<>());
+
+            for (int i = 0; i < allVideoIds.size(); i += ODPS_BATCH_SIZE) {
+                int end = Math.min(i + ODPS_BATCH_SIZE, allVideoIds.size());
+                List<Long> batchIds = allVideoIds.subList(i, end);
+                processBatch(batchIds, totalSuccess, totalFail, currentCachedIds, i, end);
+            }
+
+            // 4. 清除逻辑:删除本次不再存在的旧缓存
+            Set<String> toRemove = new HashSet<>(previousCachedIds);
+            toRemove.removeAll(currentCachedIds);
+            if (!toRemove.isEmpty()) {
+                log.info("清除 {} 个不再存在的AI理解缓存", toRemove.size());
+                for (String oldId : toRemove) {
+                    try {
+                        redisUtils.del(VectorConstants.AI_OLD_UNDERSTANDING_KEY_PREFIX + oldId);
+                        redisUtils.sRemove(VectorConstants.AI_OLD_UNDERSTANDING_IDS_SET_KEY, oldId);
+                    } catch (Exception e) {
+                        log.error("清除旧缓存失败, videoId={}: {}", oldId, e.getMessage());
+                    }
+                }
+            }
+
+            log.info("视频AI理解信息同步任务完成,总成功: {}, 总失败: {}, 清除旧缓存: {}",
+                    totalSuccess.get(), totalFail.get(), toRemove.size());
+            return ReturnT.SUCCESS;
+        } catch (Exception e) {
+            log.error("视频AI理解信息同步任务执行失败: {}", e.getMessage(), e);
+            return new ReturnT<>(ReturnT.FAIL_CODE, "任务执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 处理单批次:查询 ODPS result_log 并写入 Redis
+     */
+    private void processBatch(List<Long> batchIds, AtomicInteger totalSuccess, AtomicInteger totalFail,
+                              Set<String> currentCachedIds, int batchStart, int batchEnd) {
+        try {
+            String idsStr = batchIds.stream()
+                    .map(String::valueOf)
+                    .collect(Collectors.joining(","));
+
+            // 查询 result_log(取最新一条数据)
+            String sql = String.format(
+                    "SELECT video_id, data FROM loghubods.result_log " +
+                            "WHERE video_id IN (%s) AND dt > 20240001;", idsStr);
+
+            OdpsUtil.getOdpsDataStream(sql, record -> {
+                try {
+                    String videoIdStr = record.getString("video_id");
+                    if (!StringUtils.hasText(videoIdStr)) {
+                        return;
+                    }
+                    Long videoId = Long.parseLong(videoIdStr.trim());
+
+                    String data = record.getString("data");
+                    if (!StringUtils.hasText(data)) {
+                        return;
+                    }
+
+                    // 解析并提取字段
+                    JSONObject cacheObj = extractAiUnderstanding(videoId, data);
+                    if (cacheObj == null) {
+                        return;
+                    }
+
+                    // 写入 Redis(不设置过期时间,通过每日任务清除逻辑手动控制删除)
+                    String redisKey = VectorConstants.AI_OLD_UNDERSTANDING_KEY_PREFIX + videoId;
+                    redisUtils.set(redisKey, cacheObj.toJSONString(), 0);
+
+                    // 记录到 SET 中
+                    redisUtils.sAdd(VectorConstants.AI_OLD_UNDERSTANDING_IDS_SET_KEY, videoIdStr);
+                    currentCachedIds.add(videoIdStr);
+                    totalSuccess.incrementAndGet();
+                } catch (Exception e) {
+                    log.error("处理 result_log 记录失败: {}", e.getMessage());
+                    totalFail.incrementAndGet();
+                }
+            });
+
+            log.info("批次 {}-{} 处理完成", batchStart, batchEnd);
+        } catch (Exception e) {
+            log.error("批次 {}-{} 查询ODPS失败: {}", batchStart, batchEnd, e.getMessage(), e);
+            totalFail.addAndGet(batchIds.size());
+        }
+    }
+
+    /**
+     * 从 result_log 的 data 字段中提取 AI 理解信息
+     * data 结构参考 r.json:
+     * - "一、基础信息"."内容选题"
+     * - "一、基础信息"."视频主题"
+     * - "一、基础信息"."视频关键词"
+     * - "五、音画细节"."视频口播"
+     *
+     * @param videoId 视频ID
+     * @param data    result_log 的 data JSON 字符串
+     * @return 缓存 JSON 对象,全部字段为空时返回 null
+     */
+    private JSONObject extractAiUnderstanding(Long videoId, String data) {
+        try {
+            JSONObject json = JSON.parseObject(data);
+            if (json == null) {
+                return null;
+            }
+
+            String contentTopic = null;
+            String videoTheme = null;
+            String videoKeywords = null;
+            String videoNarration = null;
+
+            // 提取"一、基础信息"中的字段
+            JSONObject basicInfo = json.getJSONObject("一、基础信息");
+            if (basicInfo != null) {
+                contentTopic = basicInfo.getString("内容选题");
+                videoTheme = basicInfo.getString("视频主题");
+                videoKeywords = basicInfo.getString("视频关键词");
+            }
+
+            // 提取"五、音画细节"中的"视频口播"
+            JSONObject audioVisual = json.getJSONObject("五、音画细节");
+            if (audioVisual != null) {
+                videoNarration = audioVisual.getString("视频口播");
+            }
+
+            // 全部字段为空则不缓存
+            if (!StringUtils.hasText(contentTopic)
+                    && !StringUtils.hasText(videoTheme)
+                    && !StringUtils.hasText(videoKeywords)
+                    && !StringUtils.hasText(videoNarration)) {
+                return null;
+            }
+
+            JSONObject cacheObj = new JSONObject();
+            cacheObj.put("videoId", videoId);
+            cacheObj.put("contentTopic", contentTopic);
+            cacheObj.put("videoTheme", videoTheme);
+            cacheObj.put("videoKeywords", videoKeywords);
+            cacheObj.put("videoNarration", videoNarration);
+            return cacheObj;
+        } catch (Exception e) {
+            log.error("解析 result_log data 失败, videoId={}: {}", videoId, e.getMessage());
+            return null;
+        }
+    }
+}

+ 83 - 1
core/src/main/java/com/tzld/videoVector/job/VideoVectorJob.java

@@ -1,6 +1,7 @@
 package com.tzld.videoVector.job;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.aliyun.odps.data.Record;
 import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue;
@@ -18,8 +19,10 @@ import com.tzld.videoVector.model.po.pgVector.DeconstructVectorConfigExample;
 import com.tzld.videoVector.service.DeconstructService;
 import com.tzld.videoVector.service.EmbeddingService;
 import com.tzld.videoVector.service.VectorStoreService;
+import com.tzld.videoVector.service.VideoSearchService;
 import com.tzld.videoVector.util.Md5Util;
 import com.tzld.videoVector.util.OdpsUtil;
+import com.tzld.videoVector.util.RedisUtils;
 import com.tzld.videoVector.util.VectorUtils;
 import com.xxl.job.core.biz.model.ReturnT;
 import com.xxl.job.core.handler.annotation.XxlJob;
@@ -64,6 +67,18 @@ public class VideoVectorJob {
     @Resource
     private VideoTitleVectorJob videoTitleVectorJob;
 
+    @Resource
+    private AiUnderstandingSyncJob aiUnderstandingSyncJob;
+
+    @Resource
+    private VideoSearchService videoSearchService;
+
+    @Resource
+    private RedisUtils redisUtils;
+
+    /** 本次 Job 执行中已缓存 decode 的 videoId,避免多配置下重复写入 */
+    private final Set<Long> decodeCachedInThisRun = ConcurrentHashMap.newKeySet();
+
 
     @ApolloJsonValue("${aigc.deconstruct.task.ids:[46, 57, 58]}")
     private List<Integer> aigcDeconstructTaskIds;
@@ -665,6 +680,9 @@ public class VideoVectorJob {
                 return;
             }
 
+            // 尝试缓存 decode 结果
+            tryCacheDecodeResult(videoId, dataContent);
+
             List<String> texts = extractTextsFromDataContent(dataContent, config);
             if (CollectionUtils.isEmpty(texts)) {
                 log.debug("videoId={} 配置 {} 未提取到选题文本,跳过", videoId, configCode);
@@ -851,6 +869,8 @@ public class VideoVectorJob {
 
                 if (!notPassedIds.isEmpty()) {
                     vectorStoreService.deleteBatch(configCode, notPassedIds);
+                    // 同步删除 decode 缓存
+                    deleteDecodeCacheBatch(notPassedIds);
                     totalRemoved += notPassedIds.size();
                     log.info("配置 {} 移除审核不通过的视频 {} 个: {}", configCode, notPassedIds.size(), notPassedIds);
                 }
@@ -1134,7 +1154,8 @@ public class VideoVectorJob {
                 new AbstractMap.SimpleEntry<>("vectorVideoJob", () -> vectorVideoJob(param)),
                 new AbstractMap.SimpleEntry<>("aigcVideoVectorJob", () -> aigcVideoVectorJob(param)),
                 new AbstractMap.SimpleEntry<>("resultLogVideoVectorJob", () -> resultLogVideoVectorJob(param)),
-                new AbstractMap.SimpleEntry<>("videoTitleVectorJob", () -> videoTitleVectorJob.videoTitleVectorJob(param))
+                new AbstractMap.SimpleEntry<>("videoTitleVectorJob", () -> videoTitleVectorJob.videoTitleVectorJob(param)),
+                new AbstractMap.SimpleEntry<>("syncAiUnderstandingJob", () -> aiUnderstandingSyncJob.syncAiUnderstandingJob(param))
         );
 
         boolean hasFailure = false;
@@ -1175,4 +1196,65 @@ public class VideoVectorJob {
         }
         return false;
     }
+
+    // ========================== Decode 缓存相关方法 ==========================
+
+    /**
+     * 尝试将解构结果解析并缓存到 Redis (JSONObject 版本)
+     * 仅在本次 Job 运行中未缓存过时执行
+     */
+    private void tryCacheDecodeResult(Long videoId, JSONObject rawJson) {
+        if (videoId == null || rawJson == null) {
+            return;
+        }
+        if (decodeCachedInThisRun.contains(videoId)) {
+            return;
+        }
+        try {
+            JSONObject decoded = videoSearchService.parseDecodeResult(rawJson);
+            if (!isValidDecodeResult(decoded)) {
+                return;
+            }
+            String redisKey = VectorConstants.VID_DECODE_KEY_PREFIX + videoId;
+            redisUtils.addVal(redisKey, decoded.toJSONString());
+            // 记录到追踪 SET
+            redisUtils.sAdd(VectorConstants.VID_DECODE_IDS_SET_KEY, String.valueOf(videoId));
+            decodeCachedInThisRun.add(videoId);
+            log.debug("decode 缓存写入成功,videoId={}", videoId);
+        } catch (Exception e) {
+            log.warn("tryCacheDecodeResult 失败,videoId={}, error={}", videoId, e.getMessage());
+        }
+    }
+
+    /**
+     * 批量删除 decode 缓存
+     */
+    private void deleteDecodeCacheBatch(Set<Long> videoIds) {
+        if (videoIds == null || videoIds.isEmpty()) {
+            return;
+        }
+        try {
+            for (Long videoId : videoIds) {
+                String redisKey = VectorConstants.VID_DECODE_KEY_PREFIX + videoId;
+                redisUtils.del(redisKey);
+                redisUtils.sRemove(VectorConstants.VID_DECODE_IDS_SET_KEY, String.valueOf(videoId));
+            }
+            log.info("删除 decode 缓存 {} 个", videoIds.size());
+        } catch (Exception e) {
+            log.error("删除 decode 缓存失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 校验 decode 解析结果是否有效(topic 或 highValuePoints 至少一个非空)
+     */
+    private boolean isValidDecodeResult(JSONObject decoded) {
+        if (decoded == null) {
+            return false;
+        }
+        boolean hasTopic = StringUtils.hasText(decoded.getString("topic"));
+        JSONArray points = decoded.getJSONArray("highValuePoints");
+        boolean hasPoints = points != null && !points.isEmpty();
+        return hasTopic || hasPoints;
+    }
 }

+ 10 - 0
core/src/main/java/com/tzld/videoVector/service/VideoSearchService.java

@@ -53,4 +53,14 @@ public interface VideoSearchService {
      * @return configCode -> configName 的映射
      */
     Map<String, String> getAllConfigCodes();
+
+    /**
+     * 解析解构原始 JSON,提取 topic + highValuePoints
+     * 支持 AIGC dataContent (a.json) 和 ODPS raw_result (v.json) 两种结构
+     * 内部先归一化,再调用 parseRawDeconstruct
+     *
+     * @param rawJson 解构原始 JSON
+     * @return {"topic": "...", "highValuePoints": [...]},解析失败返回 null
+     */
+    JSONObject parseDecodeResult(JSONObject rawJson);
 }

+ 139 - 0
core/src/main/java/com/tzld/videoVector/service/impl/VideoSearchServiceImpl.java

@@ -1167,4 +1167,143 @@ public class VideoSearchServiceImpl implements VideoSearchService {
         }
         return out;
     }
+
+    @Override
+    public JSONObject parseDecodeResult(JSONObject rawJson) {
+        if (rawJson == null) {
+            return null;
+        }
+        try {
+            if (isOdpsFormat(rawJson)) {
+                return parseDecodeFromOdps(rawJson);
+            } else {
+                return parseDecodeFromAigc(rawJson);
+            }
+        } catch (Exception e) {
+            log.error("parseDecodeResult 解析失败: {}", e.getMessage(), e);
+            return null;
+        }
+    }
+
+    // ========================== 格式检测 ==========================
+
+    /**
+     * 判断解构 JSON 是否为 ODPS raw_result 格式(v.json)
+     * 特征:顶层包含 point_label / final_normalization / point_contribution 等嵌套容器节点
+     */
+    private boolean isOdpsFormat(JSONObject raw) {
+        return raw.containsKey("point_label")
+                || raw.containsKey("final_normalization")
+                || raw.containsKey("point_contribution");
+    }
+
+    // ========================== ODPS 格式提取 (v.json) ==========================
+
+    /**
+     * 从 ODPS raw_result (v.json) 提取 decode 缓存数据
+     * 字段全部嵌套在 final_normalization / point_label / point_contribution 等父节点下
+     */
+    private JSONObject parseDecodeFromOdps(JSONObject raw) {
+        JSONObject flat = new JSONObject();
+        JSONObject fn = raw.getJSONObject("final_normalization");
+
+        // ① 最终选题: final_normalization.topic_fusion_result.最终选题
+        if (fn != null) {
+            JSONObject tfr = fn.getJSONObject("topic_fusion_result");
+            if (tfr != null) {
+                flat.put("最终选题", tfr.getJSONObject("最终选题"));
+            }
+            // ② inspiration_final_result / keypoint_final / purpose_final_result
+            flat.put("inspiration_final_result", fn.getJSONObject("inspiration_final_result"));
+            flat.put("keypoint_final", fn.getJSONObject("keypoint_final"));
+            flat.put("purpose_final_result", fn.getJSONObject("purpose_final_result"));
+        }
+
+        // ③ contribution_results: point_contribution.contribution_results
+        JSONObject pc = raw.getJSONObject("point_contribution");
+        if (pc != null) {
+            flat.put("contribution_results", pc.getJSONArray("contribution_results"));
+        }
+
+        // ④ 灵感点/关键点/目的点: point_label 优先,point_classification 备选
+        JSONObject pl = raw.getJSONObject("point_label");
+        JSONObject pcl = raw.getJSONObject("point_classification");
+        for (String pointType : new String[]{"灵感点", "关键点", "目的点"}) {
+            JSONArray arr = (pl != null) ? pl.getJSONArray(pointType) : null;
+            if (arr == null && pcl != null) {
+                arr = pcl.getJSONArray(pointType);
+            }
+            if (arr != null) {
+                flat.put(pointType, arr);
+            }
+        }
+
+        return parseRawDeconstruct(flat);
+    }
+
+    // ========================== AIGC 格式提取 (a.json) ==========================
+
+    /**
+     * 从 AIGC dataContent (a.json) 提取 decode 缓存数据
+     * 所有字段在顶层,但缺少 contribution_results → 需要从分词结果生成默认贡献度
+     */
+    private JSONObject parseDecodeFromAigc(JSONObject raw) {
+        JSONObject flat = new JSONObject();
+
+        // ① 最终选题: 顶层
+        flat.put("最终选题", raw.getJSONObject("最终选题"));
+
+        // ② inspiration_final_result / keypoint_final / purpose_final_result: 顶层
+        flat.put("inspiration_final_result", raw.getJSONObject("inspiration_final_result"));
+        flat.put("keypoint_final", raw.getJSONObject("keypoint_final"));
+        flat.put("purpose_final_result", raw.getJSONObject("purpose_final_result"));
+
+        // ③ 灵感点/关键点/目的点: 顶层数组
+        for (String pointType : new String[]{"灵感点", "关键点", "目的点"}) {
+            JSONArray arr = raw.getJSONArray(pointType);
+            if (arr != null) {
+                flat.put(pointType, arr);
+            }
+        }
+
+        // ④ AIGC 格式无 contribution_results,从分词结果生成默认贡献度 1.0
+        JSONArray defaultContrib = buildDefaultContribution(flat);
+        if (!defaultContrib.isEmpty()) {
+            flat.put("contribution_results", defaultContrib);
+        }
+
+        return parseRawDeconstruct(flat);
+    }
+
+    /**
+     * 当解构结果缺少 contribution_results 时(AIGC dataContent 格式),
+     * 从灵感点/关键点/目的点的分词结果中提取所有词,赋予默认贡献度 1.0
+     */
+    private JSONArray buildDefaultContribution(JSONObject normalized) {
+        JSONArray result = new JSONArray();
+        Set<String> seen = new HashSet<>();
+        for (String pointType : new String[]{"灵感点", "关键点", "目的点"}) {
+            JSONArray points = normalized.getJSONArray(pointType);
+            if (points == null) continue;
+            for (int i = 0; i < points.size(); i++) {
+                JSONObject p = points.getJSONObject(i);
+                if (p == null) continue;
+                JSONArray words = p.getJSONArray("分词结果");
+                if (words == null) continue;
+                for (int j = 0; j < words.size(); j++) {
+                    JSONObject w = words.getJSONObject(j);
+                    if (w == null) continue;
+                    String word = w.getString("词");
+                    if (word != null && !seen.contains(word)) {
+                        seen.add(word);
+                        JSONObject row = new JSONObject();
+                        row.put("词", word);
+                        row.put("贡献度", 1.0);
+                        result.add(row);
+                    }
+                }
+            }
+        }
+        return result;
+    }
 }

+ 27 - 17
core/src/main/java/com/tzld/videoVector/service/recall/impl/VectorRecallTestServiceImpl.java

@@ -166,23 +166,45 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         if (videoId == null || videoId <= 0L) {
             return null;
         }
-        // 数据源切换: 复用 syncVideoDetailJob 写入的 Redis (key: video:detail:{videoId}),
-        // 字段从中文维度名映射到 VO. result_log 同步链路尚未建, 留给后续如需"视频口播"再补.
         if (stringRedisTemplate == null) {
             log.info("getAiUnderstanding: redisTemplate not available, returning null. videoId={}", videoId);
             return null;
         }
         try {
+            // 优先从 AI 理解专用缓存读取(syncAiUnderstandingJob 从 result_log 同步)
             String json = stringRedisTemplate.opsForValue()
+                    .get(VectorConstants.AI_OLD_UNDERSTANDING_KEY_PREFIX + videoId);
+            if (StringUtils.hasText(json)) {
+                JSONObject detail = JSON.parseObject(json);
+                String topic = detail.getString("contentTopic");
+                String theme = detail.getString("videoTheme");
+                String keywords = detail.getString("videoKeywords");
+                String narration = detail.getString("videoNarration");
+                if (!StringUtils.hasText(topic)
+                        && !StringUtils.hasText(theme)
+                        && !StringUtils.hasText(keywords)
+                        && !StringUtils.hasText(narration)) {
+                    return null;
+                }
+                AIUnderstandingVO vo = new AIUnderstandingVO();
+                vo.setVideoId(videoId);
+                vo.setContentTopic(topic);
+                vo.setVideoTheme(theme);
+                vo.setVideoKeywords(keywords);
+                vo.setVideoNarration(narration);
+                return vo;
+            }
+
+            // 降级:从 video:detail 缓存读取(syncVideoDetailJob 从维度表同步,无视频口播)
+            String detailJson = stringRedisTemplate.opsForValue()
                     .get(VectorConstants.VIDEO_DETAIL_KEY_PREFIX + videoId);
-            if (!StringUtils.hasText(json)) {
+            if (!StringUtils.hasText(detailJson)) {
                 return null;
             }
-            JSONObject detail = JSON.parseObject(json);
+            JSONObject detail = JSON.parseObject(detailJson);
             String topic = detail.getString("内容选题");
             String theme = detail.getString("视频主题");
             String keywords = detail.getString("视频关键词");
-            // 三个核心字段全空 → 视为无 AI 理解数据, 返回 null 避免前端展示空壳
             if (!StringUtils.hasText(topic)
                     && !StringUtils.hasText(theme)
                     && !StringUtils.hasText(keywords)) {
@@ -193,7 +215,6 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
             vo.setContentTopic(topic);
             vo.setVideoTheme(theme);
             vo.setVideoKeywords(keywords);
-            // "视频口播" 不在 video_dimension_detail_add_column 维度字段里, 暂留 null
             vo.setVideoNarration(null);
             return vo;
         } catch (Exception e) {
@@ -203,17 +224,6 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
         }
     }
 
-    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
      *

+ 4 - 0
core/src/main/java/com/tzld/videoVector/util/RedisUtils.java

@@ -233,6 +233,10 @@ public class RedisUtils {
         return redisTemplate.opsForSet().isMember(key, val);
     }
 
+    public Long sRemove(String key, String... vals) {
+        return redisTemplate.opsForSet().remove(key, (Object[]) vals);
+    }
+
     public void setBit(String key, long val, boolean flag) {
         redisTemplate.opsForValue().setBit(key, val, flag);
     }

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

@@ -26,6 +26,9 @@ public class XxlJobController {
     @Autowired
     private VideoTitleVectorJob videoTitleVectorJob;
 
+    @Autowired
+    private AiUnderstandingSyncJob aiUnderstandingSyncJob;
+
     // ==================== 视频向量化任务 ====================
 
     @GetMapping("/vectorVideoJob")
@@ -88,4 +91,12 @@ public class XxlJobController {
         return CommonResponse.success();
     }
 
+    // ==================== 老AI理解度同步任务 ====================
+
+    @GetMapping("/syncAiUnderstandingJob")
+    public CommonResponse<Void> syncAiUnderstandingJob() {
+        aiUnderstandingSyncJob.syncAiUnderstandingJob(null);
+        return CommonResponse.success();
+    }
+
 }