瀏覽代碼

视频推荐接口支持三路穿插(先验/后验/全局热门)+ source 单源筛选

- VideoContentListParam 新增 source 字段(prior/posterior/hot/空)
- VideoContentItemVO 新增 source 字段
- ContentPlatformDemandVideoMapperExt 新增 selectForRecommend(dt, crowdSegment, demandStrategy, limit)
- getVideoContentList 改造为按 source 路由:单源走对应 fetcher,缺省走 3 路 1/1/1 严格穿插 + 跨路 video_id 去重(先到先得 prior > posterior > hot)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 1 月之前
父節點
當前提交
92d17b9626

+ 9 - 0
api-module/src/main/java/com/tzld/piaoquan/api/dao/mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.java

@@ -27,6 +27,15 @@ public interface ContentPlatformDemandVideoMapperExt {
 
     String getMaxDt();
 
+    /**
+     * 推荐场景候选池查询:按 demand_strategy 取指定 crowd_segment 的候选视频,按 score DESC 取前 N 行。
+     * 不在 SQL 层做 video_id 去重,调用方在 Java 侧按 video_id 保留首条(即得分最高的一条)。
+     */
+    List<ContentPlatformDemandVideo> selectForRecommend(@Param("dt") String dt,
+                                                       @Param("crowdSegment") String crowdSegment,
+                                                       @Param("demandStrategy") String demandStrategy,
+                                                       @Param("limit") int limit);
+
     List<ContentPlatformDemandVideo> selectActiveVideos(@Param("dt") String dt);
 
     int updateStatusByVideoId(@Param("videoId") Long videoId, @Param("dt") String dt, @Param("status") Integer status, @Param("updateTimestamp") Long updateTimestamp);

+ 3 - 0
api-module/src/main/java/com/tzld/piaoquan/api/model/param/contentplatform/VideoContentListParam.java

@@ -18,4 +18,7 @@ public class VideoContentListParam extends PageParam {
 
     @ApiModelProperty(value = "排序 0-平台推荐 1-行业裂变率 2-本渠道裂变率 3-推荐指数")
     private Integer sort = 0;
+
+    @ApiModelProperty(value = "数据来源: prior-先验需求 / posterior-后验需求 / hot-全局热门 / 空-全部穿插")
+    private String source;
 }

+ 3 - 0
api-module/src/main/java/com/tzld/piaoquan/api/model/vo/contentplatform/VideoContentItemVO.java

@@ -86,4 +86,7 @@ public class VideoContentItemVO {
 
     @ApiModelProperty(value = "实验id")
     private String experimentId;
+
+    @ApiModelProperty(value = "数据来源: prior / posterior / hot")
+    private String source;
 }

+ 133 - 11
api-module/src/main/java/com/tzld/piaoquan/api/service/contentplatform/impl/ContentPlatformPlanServiceImpl.java

@@ -606,6 +606,13 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         return gzhPlanMapper.selectByExample(example);
     }
 
+    private static final int RECOMMEND_CANDIDATE_LIMIT = 500;
+    private static final String DEMAND_STRATEGY_PRIOR = "先验需求";
+    private static final String DEMAND_STRATEGY_POSTERIOR = "后验需求";
+    private static final String SOURCE_PRIOR = "prior";
+    private static final String SOURCE_POSTERIOR = "posterior";
+    private static final String SOURCE_HOT = "hot";
+
     @Override
     public Page<VideoContentItemVO> getVideoContentList(VideoContentListParam param) {
         ContentPlatformAccount user = LoginUserContext.getUser();
@@ -613,25 +620,140 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         if (StringUtils.hasText(param.getTitle())) {
             return getVideoContentListByTitle(param);
         }
-        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
-        int offset = (param.getPageNum() - 1) * param.getPageSize();
-        String dt = planMapperExt.getVideoMaxDt();
-        String datastatDt = planMapperExt.getVideoDatastatMaxDt();
-        int count = planMapperExt.getVideoCount(param, dt, videoMinScore);
-        result.setTotalSize(count);
-        if (count == 0) {
+        String source = param.getSource();
+        if (SOURCE_PRIOR.equalsIgnoreCase(source)) {
+            return getSingleSourcePage(param, user, SOURCE_PRIOR);
+        }
+        if (SOURCE_POSTERIOR.equalsIgnoreCase(source)) {
+            return getSingleSourcePage(param, user, SOURCE_POSTERIOR);
+        }
+        if (SOURCE_HOT.equalsIgnoreCase(source)) {
+            return getSingleSourcePage(param, user, SOURCE_HOT);
+        }
+        return getInterleavedPage(param, user);
+    }
+
+    /**
+     * 单一来源分页:从对应候选池取最多 N 条,按候选池顺序分页。
+     */
+    private Page<VideoContentItemVO> getSingleSourcePage(VideoContentListParam param, ContentPlatformAccount user, String source) {
+        List<VideoContentItemVO> candidates;
+        if (SOURCE_PRIOR.equals(source)) {
+            candidates = fetchPriorCandidates(user, RECOMMEND_CANDIDATE_LIMIT);
+        } else if (SOURCE_POSTERIOR.equals(source)) {
+            candidates = fetchPosteriorCandidates(user, RECOMMEND_CANDIDATE_LIMIT);
+        } else {
+            candidates = fetchHotCandidates(param, user, RECOMMEND_CANDIDATE_LIMIT);
+        }
+        for (VideoContentItemVO v : candidates) {
+            v.setSource(source);
+        }
+        return paginateCandidates(param, candidates);
+    }
+
+    /**
+     * 三路 1/1/1 严格穿插 + 跨路 video_id 去重。
+     * 缺路顺位补;先到先得(顺序:prior > posterior > hot)。
+     */
+    private Page<VideoContentItemVO> getInterleavedPage(VideoContentListParam param, ContentPlatformAccount user) {
+        List<VideoContentItemVO> prior = fetchPriorCandidates(user, RECOMMEND_CANDIDATE_LIMIT);
+        List<VideoContentItemVO> posterior = fetchPosteriorCandidates(user, RECOMMEND_CANDIDATE_LIMIT);
+        List<VideoContentItemVO> hot = fetchHotCandidates(param, user, RECOMMEND_CANDIDATE_LIMIT);
+        for (VideoContentItemVO v : prior) v.setSource(SOURCE_PRIOR);
+        for (VideoContentItemVO v : posterior) v.setSource(SOURCE_POSTERIOR);
+        for (VideoContentItemVO v : hot) v.setSource(SOURCE_HOT);
+
+        List<List<VideoContentItemVO>> pools = Arrays.asList(prior, posterior, hot);
+        int[] pointers = new int[3];
+        boolean[] exhausted = new boolean[3];
+        Set<Long> emittedIds = new HashSet<>();
+        List<VideoContentItemVO> merged = new ArrayList<>();
+
+        int idx = 0;
+        while (!(exhausted[0] && exhausted[1] && exhausted[2])) {
+            int cur = idx % 3;
+            idx++;
+            if (exhausted[cur]) {
+                continue;
+            }
+            List<VideoContentItemVO> pool = pools.get(cur);
+            while (pointers[cur] < pool.size() && emittedIds.contains(pool.get(pointers[cur]).getVideoId())) {
+                pointers[cur]++;
+            }
+            if (pointers[cur] < pool.size()) {
+                VideoContentItemVO item = pool.get(pointers[cur]++);
+                emittedIds.add(item.getVideoId());
+                merged.add(item);
+            } else {
+                exhausted[cur] = true;
+            }
+        }
+        return paginateCandidates(param, merged);
+    }
+
+    private Page<VideoContentItemVO> paginateCandidates(VideoContentListParam param, List<VideoContentItemVO> all) {
+        int pageSize = param.getPageSize();
+        int pageNum = param.getPageNum();
+        Page<VideoContentItemVO> result = new Page<>(pageNum, pageSize);
+        result.setTotalSize(all.size());
+        if (all.isEmpty()) {
             result.setObjs(new ArrayList<>());
             return result;
         }
+        int from = Math.min((pageNum - 1) * pageSize, all.size());
+        int to = Math.min(pageNum * pageSize, all.size());
+        result.setObjs(new ArrayList<>(all.subList(from, to)));
+        return result;
+    }
+
+    private List<VideoContentItemVO> fetchPriorCandidates(ContentPlatformAccount user, int limit) {
+        return fetchDemandCandidates(user, DEMAND_STRATEGY_PRIOR, limit);
+    }
+
+    private List<VideoContentItemVO> fetchPosteriorCandidates(ContentPlatformAccount user, int limit) {
+        return fetchDemandCandidates(user, DEMAND_STRATEGY_POSTERIOR, limit);
+    }
+
+    private List<VideoContentItemVO> fetchDemandCandidates(ContentPlatformAccount user, String demandStrategy, int limit) {
+        String dt = demandVideoMapperExt.getMaxDt();
+        if (!StringUtils.hasText(dt)) {
+            return new ArrayList<>();
+        }
+        String crowdSegment = user.getChannel();
+        // 超量拉取,再按 video_id 去重保留首条(即得分最高的一条)
+        List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(dt, crowdSegment, demandStrategy, limit * 3);
+        if (CollectionUtils.isEmpty(rows)) {
+            return new ArrayList<>();
+        }
+        LinkedHashMap<Long, ContentPlatformDemandVideo> distinct = new LinkedHashMap<>();
+        for (ContentPlatformDemandVideo row : rows) {
+            if (row.getVideoId() == null) {
+                continue;
+            }
+            if (!distinct.containsKey(row.getVideoId())) {
+                distinct.put(row.getVideoId(), row);
+                if (distinct.size() >= limit) {
+                    break;
+                }
+            }
+        }
+        return buildDemandVideoContentItemVOList(new ArrayList<>(distinct.values()));
+    }
+
+    private List<VideoContentItemVO> fetchHotCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
+        String dt = planMapperExt.getVideoMaxDt();
+        String datastatDt = planMapperExt.getVideoDatastatMaxDt();
+        if (!StringUtils.hasText(dt)) {
+            return new ArrayList<>();
+        }
         String sort = getVideoContentListSort(param.getSort());
         String type = getVideoContentListType(param.getType());
         String channel = getVideoContentListChannel(param.getSort(), user.getChannel());
         String strategy = param.getSort() == 3 ? "recommend" : "normal";
         List<ContentPlatformVideo> videoList = planMapperExt.getVideoList(param, dt, datastatDt, type, channel, strategy,
-                videoMinScore, offset, param.getPageSize(), sort);
-        List<VideoContentItemVO> list = buildVideoContentItemVOList(videoList, type, "sum", user.getChannel(), datastatDt);
-        result.setObjs(list);
-        return result;
+                videoMinScore, 0, limit, sort);
+        List<VideoContentItemVO> result = buildVideoContentItemVOList(videoList, type, "sum", user.getChannel(), datastatDt);
+        return result == null ? new ArrayList<>() : result;
     }
 
     /**

+ 20 - 0
api-module/src/main/resources/mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.xml

@@ -74,6 +74,26 @@
         SELECT MAX(dt) FROM content_platform_demand_video WHERE status = 1
     </select>
 
+    <select id="selectForRecommend" resultType="com.tzld.piaoquan.api.model.po.contentplatform.ContentPlatformDemandVideo">
+        SELECT id, dt, channel_name, crowd_segment, dimension, point_type, standard_element,
+               category_name, demand_id, crowd_package, conversion_target, partner, account, scene_value,
+               demand_strategy, drive_dimension_time, demand_filter_sort_strategy, demand_type,
+               demand_content_id, demand_content_title, demand_content_topic,
+               crowd_count, video_count, visit_uv, uv_ratio, total_rov, online_action, match_experiment_id,
+               video_id, config_code, score, sim, rov,
+               match_text, title, cover, video, experiment_id, status, create_timestamp, update_timestamp
+        FROM content_platform_demand_video
+        WHERE dt = #{dt} AND status = 1
+        <if test="crowdSegment != null and crowdSegment != ''">
+            AND crowd_segment = #{crowdSegment}
+        </if>
+        <if test="demandStrategy != null and demandStrategy != ''">
+            AND demand_strategy = #{demandStrategy}
+        </if>
+        ORDER BY score DESC
+        LIMIT #{limit}
+    </select>
+
     <select id="selectActiveVideos" resultType="com.tzld.piaoquan.api.model.po.contentplatform.ContentPlatformDemandVideo">
         SELECT DISTINCT video_id
         FROM content_platform_demand_video