|
|
@@ -12,7 +12,9 @@ import com.tzld.videoVector.dao.mapper.pgVector.ext.MaterialDeconstructResultMap
|
|
|
import com.tzld.videoVector.model.entity.ArticleMatch;
|
|
|
import com.tzld.videoVector.model.entity.MaterialMatch;
|
|
|
import com.tzld.videoVector.model.entity.VideoDetail;
|
|
|
+import com.tzld.videoVector.model.entity.VideoMatch;
|
|
|
import com.tzld.videoVector.model.param.MatchTopNVideoParam;
|
|
|
+import com.tzld.videoVector.model.param.recall.MatchByMaterialIdParam;
|
|
|
import com.tzld.videoVector.model.param.recall.MatchByTextParam;
|
|
|
import com.tzld.videoVector.model.param.recall.MatchByVideoIdParam;
|
|
|
import com.tzld.videoVector.model.po.pgVector.ArticleDeconstructResult;
|
|
|
@@ -23,6 +25,7 @@ import com.tzld.videoVector.model.vo.VideoMatchResult;
|
|
|
import com.tzld.videoVector.model.vo.recall.AIUnderstandingVO;
|
|
|
import com.tzld.videoVector.model.vo.recall.DeconstructPointsVO;
|
|
|
import com.tzld.videoVector.model.vo.recall.ArticleDetailVO;
|
|
|
+import com.tzld.videoVector.model.vo.recall.MaterialBasicVO;
|
|
|
import com.tzld.videoVector.model.vo.recall.MaterialDetailVO;
|
|
|
import com.tzld.videoVector.model.vo.recall.RecallResultVO;
|
|
|
import com.tzld.videoVector.model.vo.recall.VideoBasicVO;
|
|
|
@@ -30,11 +33,17 @@ import com.tzld.videoVector.model.vo.recall.VideoMatchEnrichedVO;
|
|
|
import com.tzld.videoVector.service.ArticleVectorStoreService;
|
|
|
import com.tzld.videoVector.service.EmbeddingService;
|
|
|
import com.tzld.videoVector.service.MaterialVectorStoreService;
|
|
|
+import com.tzld.videoVector.service.VectorStoreService;
|
|
|
import com.tzld.videoVector.service.VideoSearchService;
|
|
|
import com.tzld.videoVector.service.recall.VectorRecallTestService;
|
|
|
import com.tzld.videoVector.util.Md5Util;
|
|
|
+import com.tzld.videoVector.util.RedisUtils;
|
|
|
+import com.tzld.videoVector.util.VectorUtils;
|
|
|
+import com.tzld.videoVector.model.po.pgVector.MaterialVector;
|
|
|
+import com.google.common.collect.Lists;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.util.CollectionUtils;
|
|
|
@@ -45,9 +54,11 @@ import javax.annotation.Resource;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.Collections;
|
|
|
import java.util.HashMap;
|
|
|
+import java.util.HashSet;
|
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
+import java.util.Objects;
|
|
|
import java.util.Set;
|
|
|
import java.util.Comparator;
|
|
|
import java.util.concurrent.CompletableFuture;
|
|
|
@@ -93,6 +104,15 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
|
|
|
@Autowired
|
|
|
private EmbeddingService embeddingService;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private VectorStoreService vectorStoreService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private RedisUtils redisUtils;
|
|
|
+
|
|
|
+ @Value("${video.detail.metrics.days:7}")
|
|
|
+ private int metricsDays;
|
|
|
+
|
|
|
private static final String SOURCE_AIGC = "aigc_deconstruct";
|
|
|
|
|
|
/** source_type → 中文来源标签 */
|
|
|
@@ -1021,6 +1041,369 @@ public class VectorRecallTestServiceImpl implements VectorRecallTestService {
|
|
|
return buildResult(videoItems, Collections.emptyList(), Collections.emptyList());
|
|
|
}
|
|
|
|
|
|
+ // ====================================================================
|
|
|
+ // matchByMaterialId — 素材ID跨模态召回
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public RecallResultVO matchByMaterialId(MatchByMaterialIdParam param) {
|
|
|
+ RecallResultVO empty = emptyResult();
|
|
|
+ if (param == null || !StringUtils.hasText(param.getMaterialId())) {
|
|
|
+ log.info("matchByMaterialId: materialId 为空");
|
|
|
+ return empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ String materialId = param.getMaterialId().trim();
|
|
|
+ int topN = param.getTopN() != null && param.getTopN() > 0 ? param.getTopN() : 50;
|
|
|
+
|
|
|
+ List<String> configCodes;
|
|
|
+ if (StringUtils.hasText(param.getConfigCode())) {
|
|
|
+ configCodes = Collections.singletonList(param.getConfigCode());
|
|
|
+ } else {
|
|
|
+ configCodes = materialVectorStoreService.getDistinctConfigCodes(materialId);
|
|
|
+ if (configCodes.isEmpty()) {
|
|
|
+ log.info("matchByMaterialId: materialId={} 无向量数据", materialId);
|
|
|
+ return empty;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("matchByMaterialId: materialId={}, topN={}, configCodes={}", materialId, topN, configCodes);
|
|
|
+
|
|
|
+ int candidateTopN = Math.max(topN * VectorConstants.MULTI_POINT_RECALL_CANDIDATE_FACTOR,
|
|
|
+ VectorConstants.MULTI_POINT_RECALL_MIN_CANDIDATES);
|
|
|
+
|
|
|
+ List<VideoMatchEnrichedVO> allResults = Collections.synchronizedList(new ArrayList<>());
|
|
|
+ List<CompletableFuture<Void>> allFutures = new ArrayList<>();
|
|
|
+
|
|
|
+ for (String configCode : configCodes) {
|
|
|
+ List<MaterialVector> vectors = materialVectorStoreService.getVectorsByMaterialId(materialId, configCode);
|
|
|
+ if (vectors.isEmpty()) continue;
|
|
|
+
|
|
|
+ // 取第一个点向量的 raw embedding
|
|
|
+ String rawEmbedding = vectors.get(0).getEmbedding();
|
|
|
+ if (!StringUtils.hasText(rawEmbedding)) continue;
|
|
|
+
|
|
|
+ List<Float> queryVector = VectorUtils.parseVectorString(rawEmbedding);
|
|
|
+ if (queryVector == null || queryVector.isEmpty()) continue;
|
|
|
+
|
|
|
+ final String cc = configCode;
|
|
|
+ final List<Float> qv = queryVector;
|
|
|
+ final int ctn = candidateTopN;
|
|
|
+ final int tn = topN;
|
|
|
+
|
|
|
+ // 视频召回:同维度搜索,不跨 configCode
|
|
|
+ allFutures.add(CompletableFuture.runAsync(() -> {
|
|
|
+ try {
|
|
|
+ List<VideoMatch> matches = vectorStoreService.searchTopN(cc, qv, ctn);
|
|
|
+ List<VideoMatch> deduped = deduplicateVideoMatches(matches, tn);
|
|
|
+ log.info("matchByMaterialId 视频搜索 cc={}: {} 条, 去重后 {} 条",
|
|
|
+ cc, matches != null ? matches.size() : 0, deduped.size());
|
|
|
+ if (!deduped.isEmpty()) {
|
|
|
+ allResults.addAll(enrichVideoMatchesFromVectorStore(deduped, cc));
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("matchByMaterialId 视频搜索失败 configCode={}: {}", cc, e.getMessage());
|
|
|
+ }
|
|
|
+ }, RECALL_EXECUTOR));
|
|
|
+
|
|
|
+ // 素材召回(排除自身)
|
|
|
+ allFutures.add(CompletableFuture.runAsync(() -> {
|
|
|
+ try {
|
|
|
+ List<MaterialMatch> matches = materialVectorStoreService.searchTopN(cc, qv, ctn);
|
|
|
+ matches = matches.stream()
|
|
|
+ .filter(m -> !materialId.equals(m.getMaterialId()))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<MaterialMatch> deduped = deduplicateMaterialMatches(matches, tn);
|
|
|
+ if (!deduped.isEmpty()) {
|
|
|
+ allResults.addAll(enrichMaterialMatches(deduped, cc));
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("matchByMaterialId 素材搜索失败 configCode={}: {}", cc, e.getMessage());
|
|
|
+ }
|
|
|
+ }, RECALL_EXECUTOR));
|
|
|
+
|
|
|
+ // 文章召回
|
|
|
+ allFutures.add(CompletableFuture.runAsync(() -> {
|
|
|
+ try {
|
|
|
+ List<ArticleMatch> matches = articleVectorStoreService.searchTopN(cc, qv, ctn);
|
|
|
+ List<ArticleMatch> deduped = deduplicateArticleMatches(matches, tn);
|
|
|
+ if (!deduped.isEmpty()) {
|
|
|
+ allResults.addAll(enrichArticleMatches(deduped, cc));
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("matchByMaterialId 文章搜索失败 configCode={}: {}", cc, e.getMessage());
|
|
|
+ }
|
|
|
+ }, RECALL_EXECUTOR));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 等待并行搜索完成
|
|
|
+ for (CompletableFuture<Void> future : allFutures) {
|
|
|
+ try {
|
|
|
+ future.get(30, TimeUnit.SECONDS);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("matchByMaterialId 并行搜索等待异常: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 跨 configCode 去重:同一 (id, modality) 保留最高分
|
|
|
+ List<VideoMatchEnrichedVO> merged = deduplicateCrossConfigCode(allResults);
|
|
|
+
|
|
|
+ // 按模态拆分,各自独立排序截断到 topN(避免跨模态分数竞争,视频分数可能天然低于素材自身匹配)
|
|
|
+ List<VideoMatchEnrichedVO> videoItems = merged.stream()
|
|
|
+ .filter(it -> it.getModality() == Modality.VIDEO)
|
|
|
+ .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
|
|
|
+ Comparator.nullsLast(Comparator.reverseOrder())))
|
|
|
+ .limit(topN)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<VideoMatchEnrichedVO> materialItems = merged.stream()
|
|
|
+ .filter(it -> it.getModality() == Modality.MATERIAL)
|
|
|
+ .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
|
|
|
+ Comparator.nullsLast(Comparator.reverseOrder())))
|
|
|
+ .limit(topN)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<VideoMatchEnrichedVO> articleItems = merged.stream()
|
|
|
+ .filter(it -> it.getModality() == Modality.ARTICLE)
|
|
|
+ .sorted(Comparator.comparing(VideoMatchEnrichedVO::getScore,
|
|
|
+ Comparator.nullsLast(Comparator.reverseOrder())))
|
|
|
+ .limit(topN)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ log.info("matchByMaterialId 按模态截断后: video={}, material={}, article={}",
|
|
|
+ videoItems.size(), materialItems.size(), articleItems.size());
|
|
|
+
|
|
|
+ // 把输入素材自身加入结果(score=1.0, modality=MATERIAL)
|
|
|
+ VideoMatchEnrichedVO selfItem = enrichSelfMaterial(materialId, configCodes.isEmpty() ? null : configCodes.get(0));
|
|
|
+ if (selfItem != null) {
|
|
|
+ materialItems.add(0, selfItem);
|
|
|
+ }
|
|
|
+
|
|
|
+ RecallResultVO result = buildResult(videoItems, materialItems, articleItems);
|
|
|
+ log.info("matchByMaterialId 完成: total={}, video={}, material={}, article={}",
|
|
|
+ result.getTotal(), result.getVideoCount(), result.getMaterialCount(), result.getArticleCount());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 视频向量搜索结果去重:同一 videoId 保留最高分
|
|
|
+ */
|
|
|
+ private List<VideoMatch> deduplicateVideoMatches(List<VideoMatch> matches, int topN) {
|
|
|
+ if (CollectionUtils.isEmpty(matches)) return Collections.emptyList();
|
|
|
+ Map<Long, VideoMatch> deduped = new LinkedHashMap<>();
|
|
|
+ for (VideoMatch m : matches) {
|
|
|
+ if (m == null || m.getVideoId() == null) continue;
|
|
|
+ VideoMatch existing = deduped.get(m.getVideoId());
|
|
|
+ if (existing == null || m.getScore() > existing.getScore()) {
|
|
|
+ deduped.put(m.getVideoId(), m);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return deduped.values().stream().limit(topN).collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 跨 configCode 去重:同一 (id, modality) 保留最高分
|
|
|
+ */
|
|
|
+ private List<VideoMatchEnrichedVO> deduplicateCrossConfigCode(List<VideoMatchEnrichedVO> items) {
|
|
|
+ if (CollectionUtils.isEmpty(items)) return Collections.emptyList();
|
|
|
+ Map<String, VideoMatchEnrichedVO> deduped = new LinkedHashMap<>();
|
|
|
+ for (VideoMatchEnrichedVO item : items) {
|
|
|
+ if (item == null || item.getModality() == null) continue;
|
|
|
+ String key = keyOf(item);
|
|
|
+ VideoMatchEnrichedVO existing = deduped.get(key);
|
|
|
+ if (existing == null || (item.getScore() != null &&
|
|
|
+ (existing.getScore() == null || item.getScore() > existing.getScore()))) {
|
|
|
+ deduped.put(key, item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new ArrayList<>(deduped.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ private String keyOf(VideoMatchEnrichedVO item) {
|
|
|
+ String idPart = item.getMaterialId() != null ? "mat:" + item.getMaterialId()
|
|
|
+ : item.getArticleId() != null ? "art:" + item.getArticleId()
|
|
|
+ : "vid:" + item.getId();
|
|
|
+ return item.getModality() + ":" + idPart;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 vectorStore(video_vectors)搜索结果 enrich,对齐 VideoSearchServiceImpl 的 enrichVideoDetail + enrichDeconstruct
|
|
|
+ */
|
|
|
+ private List<VideoMatchEnrichedVO> enrichVideoMatchesFromVectorStore(List<VideoMatch> matches, String configCode) {
|
|
|
+ if (CollectionUtils.isEmpty(matches)) return Collections.emptyList();
|
|
|
+
|
|
|
+ List<Long> videoIdList = matches.stream()
|
|
|
+ .map(VideoMatch::getVideoId)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .distinct()
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (videoIdList.isEmpty()) return Collections.emptyList();
|
|
|
+
|
|
|
+ // 1. 视频基础信息(标题、封面、视频地址),按 100 条分批避免 API 超时
|
|
|
+ Map<Long, VideoDetail> videoDetails = new HashMap<>();
|
|
|
+ for (List<Long> batch : Lists.partition(videoIdList, 100)) {
|
|
|
+ Map<Long, VideoDetail> batchResult = videoApiService.getVideoDetail(new HashSet<>(batch));
|
|
|
+ if (batchResult != null) {
|
|
|
+ videoDetails.putAll(batchResult);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 批量读取视频运营指标: video:detail:{days}d:{videoId}
|
|
|
+ Map<Long, Map<String, Object>> metricsCache = new HashMap<>();
|
|
|
+ try {
|
|
|
+ List<String> metricsKeys = videoIdList.stream()
|
|
|
+ .map(id -> VectorConstants.VIDEO_DETAIL_DAYS_KEY_PREFIX + metricsDays + "d:" + id)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<String> metricsValues = redisUtils.mGet(metricsKeys);
|
|
|
+ if (metricsValues != null) {
|
|
|
+ for (int i = 0; i < videoIdList.size() && i < metricsValues.size(); i++) {
|
|
|
+ String json = metricsValues.get(i);
|
|
|
+ if (StringUtils.hasText(json)) {
|
|
|
+ try {
|
|
|
+ Map<String, Object> detail = JSONObject.parseObject(json, Map.class);
|
|
|
+ if (detail != null && !detail.isEmpty()) {
|
|
|
+ metricsCache.put(videoIdList.get(i), detail);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.debug("解析视频指标失败 videoId={}", videoIdList.get(i));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("批量读取视频指标失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 批量读取解构缓存: recall:vid_decode:{videoId}
|
|
|
+ Map<Long, Map<String, Object>> deconstructCache = new HashMap<>();
|
|
|
+ try {
|
|
|
+ List<String> decodeKeys = videoIdList.stream()
|
|
|
+ .map(id -> REDIS_KEY_DECODE_PREFIX + id)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<String> decodeValues = redisUtils.mGet(decodeKeys);
|
|
|
+ if (decodeValues != null) {
|
|
|
+ for (int i = 0; i < videoIdList.size() && i < decodeValues.size(); i++) {
|
|
|
+ String json = decodeValues.get(i);
|
|
|
+ if (StringUtils.hasText(json)) {
|
|
|
+ JSONObject obj = JSON.parseObject(json);
|
|
|
+ Map<String, Object> flat = obj != null ? buildFlatDeconstruct(obj) : null;
|
|
|
+ if (flat != null && !flat.isEmpty()) {
|
|
|
+ deconstructCache.put(videoIdList.get(i), flat);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("批量读取解构缓存失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 组装 VideoMatchEnrichedVO
|
|
|
+ List<VideoMatchEnrichedVO> items = new ArrayList<>(matches.size());
|
|
|
+ for (VideoMatch m : matches) {
|
|
|
+ if (m == null || m.getVideoId() == null) continue;
|
|
|
+ VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
|
|
|
+ vo.setId(m.getVideoId());
|
|
|
+ vo.setModality(Modality.VIDEO);
|
|
|
+ vo.setConfigCode(configCode);
|
|
|
+ vo.setScore(m.getScore());
|
|
|
+
|
|
|
+ VideoDetail vd = videoDetails.get(m.getVideoId());
|
|
|
+ if (vd != null) {
|
|
|
+ vo.setTitle(vd.getTitle());
|
|
|
+ vo.setVideoUrl(vd.getVideoPath());
|
|
|
+ vo.setCover(vd.getCover());
|
|
|
+ }
|
|
|
+
|
|
|
+ // videoDetail map: 合并运营指标 + 解构(对齐 VideoSearchServiceImpl 输出格式)
|
|
|
+ Map<String, Object> detailMap = new LinkedHashMap<>();
|
|
|
+ Map<String, Object> metrics = metricsCache.get(m.getVideoId());
|
|
|
+ if (metrics != null) {
|
|
|
+ detailMap.putAll(metrics);
|
|
|
+ }
|
|
|
+ Map<String, Object> deconstruct = deconstructCache.get(m.getVideoId());
|
|
|
+ if (deconstruct != null) {
|
|
|
+ detailMap.put("deconstruct", deconstruct);
|
|
|
+ }
|
|
|
+ vo.setVideoDetail(detailMap.isEmpty() ? null : detailMap);
|
|
|
+
|
|
|
+ applyCompatibilityFields(vo);
|
|
|
+ items.add(vo);
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建输入素材自身的 enriched 条目(score=1.0)
|
|
|
+ */
|
|
|
+ private VideoMatchEnrichedVO enrichSelfMaterial(String materialId, String configCode) {
|
|
|
+ Map<String, MaterialDeconstructResult> rows = loadMaterialDeconstructRows(Collections.singletonList(materialId));
|
|
|
+ MaterialDeconstructResult row = rows.get(materialId);
|
|
|
+
|
|
|
+ JSONObject raw = row != null ? parseResultJson(row) : null;
|
|
|
+ MaterialBasicMeta basic = raw != null ? extractMaterialBasicMeta(raw) : null;
|
|
|
+ Map<String, Object> deconstructFlat = raw != null ? buildDeconstructFromRaw(raw) : null;
|
|
|
+
|
|
|
+ VideoMatchEnrichedVO vo = new VideoMatchEnrichedVO();
|
|
|
+ vo.setMaterialId(materialId);
|
|
|
+ try {
|
|
|
+ vo.setId(Long.parseLong(materialId));
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
+ }
|
|
|
+ vo.setModality(Modality.MATERIAL);
|
|
|
+ vo.setConfigCode(StringUtils.hasText(configCode) ? configCode : null);
|
|
|
+ vo.setScore(1.0);
|
|
|
+
|
|
|
+ if (basic != null) {
|
|
|
+ vo.setTitle(basic.title);
|
|
|
+ applyMaterialImagesAndCover(vo, basic.imagesJson);
|
|
|
+ }
|
|
|
+
|
|
|
+ MaterialDetailVO detail = new MaterialDetailVO();
|
|
|
+ if (basic != null) {
|
|
|
+ detail.setTitle(basic.title);
|
|
|
+ }
|
|
|
+ if (vo.getImageList() != null) {
|
|
|
+ detail.setImageCount(vo.getImageList().size());
|
|
|
+ }
|
|
|
+ Short sourceType = row != null ? row.getSourceType() : null;
|
|
|
+ detail.setSource(mapSourceTypeToLabel(sourceType));
|
|
|
+ detail.setDeconstruct(deconstructFlat);
|
|
|
+ vo.setMaterialDetail(detail);
|
|
|
+
|
|
|
+ applyCompatibilityFields(vo);
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================================================
|
|
|
+ // materialDetail — 素材详情预览
|
|
|
+ // ====================================================================
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public MaterialBasicVO getMaterialDetail(String materialId) {
|
|
|
+ if (!StringUtils.hasText(materialId)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String trimmed = materialId.trim();
|
|
|
+ Map<String, MaterialDeconstructResult> rows = loadMaterialDeconstructRows(Collections.singletonList(trimmed));
|
|
|
+ MaterialDeconstructResult row = rows.get(trimmed);
|
|
|
+ if (row == null) {
|
|
|
+ log.info("getMaterialDetail: materialId={} 不存在", trimmed);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ JSONObject raw = parseResultJson(row);
|
|
|
+ MaterialBasicMeta basic = raw != null ? extractMaterialBasicMeta(raw) : null;
|
|
|
+
|
|
|
+ MaterialBasicVO vo = new MaterialBasicVO();
|
|
|
+ vo.setMaterialId(trimmed);
|
|
|
+ if (basic != null) {
|
|
|
+ vo.setTitle(basic.title);
|
|
|
+ }
|
|
|
+ // 取第一张图片作为预览
|
|
|
+ List<String> images = basic != null && basic.imagesJson != null
|
|
|
+ ? parseImages(basic.imagesJson) : Collections.emptyList();
|
|
|
+ vo.setImageUrl(!images.isEmpty() ? images.get(0) : null);
|
|
|
+
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
@Override
|
|
|
public DeconstructPointsVO getDeconstructPoints(Long videoId) {
|
|
|
if (videoId == null || videoId <= 0L) {
|