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

Merge branch 'cooperation_video_candidate_pool_improved_lld_0509' of Server/growth-manager into master

liulidong 13 часов назад
Родитель
Сommit
64c4362667

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

@@ -44,6 +44,11 @@ public interface ContentPlatformDemandVideoMapperExt {
      * dimension:等值过滤;dimensionExclude:排除该 dimension(包含 NULL 视为通过);
      * dimension:等值过滤;dimensionExclude:排除该 dimension(包含 NULL 视为通过);
      * demandFilterSortStrategyLike:对 demand_filter_sort_strategy 做 LIKE 过滤;
      * demandFilterSortStrategyLike:对 demand_filter_sort_strategy 做 LIKE 过滤;
      * excludeSelfTitle=true 时过滤掉 video.title == demand_content_title 的行。
      * excludeSelfTitle=true 时过滤掉 video.title == demand_content_title 的行。
+     * excludeCategories:要剔除的 category 列表(SQL `NOT IN`,NULL 视为通过);为空/null 不做品类过滤。
+     *   身份相关规则由 Service 层 `resolveExcludeCategories` 决定:
+     *   - 渠道「公众号合作-Daily-自选」对所有身份返回空(白名单)
+     *   - 内部/代理:["节日祝福"]
+     *   - 合作方/其他:["早中晚好","节日祝福"]
      */
      */
     List<ContentPlatformDemandVideo> selectForRecommend(@Param("dt") String dt,
     List<ContentPlatformDemandVideo> selectForRecommend(@Param("dt") String dt,
                                                        @Param("channelName") String channelName,
                                                        @Param("channelName") String channelName,
@@ -58,10 +63,23 @@ public interface ContentPlatformDemandVideoMapperExt {
                                                        @Param("matchMethod") String matchMethod,
                                                        @Param("matchMethod") String matchMethod,
                                                        @Param("crowdPackage") String crowdPackage,
                                                        @Param("crowdPackage") String crowdPackage,
                                                        @Param("limit") int limit,
                                                        @Param("limit") int limit,
-                                                       @Param("excludeSelfTitle") boolean excludeSelfTitle);
+                                                       @Param("excludeSelfTitle") boolean excludeSelfTitle,
+                                                       @Param("excludeCategories") List<String> excludeCategories);
 
 
     List<String> selectDistinctCrowdPackages(@Param("dt") String dt, @Param("channelName") String channelName);
     List<String> selectDistinctCrowdPackages(@Param("dt") String dt, @Param("channelName") String channelName);
 
 
+    /**
+     * 搜索候选白名单:dt 最新分区下,该入口(channel + 入口维度)demand_strategy IN ('人群需求', '优质相似') 且 rov >= 0.03 的视频行。
+     * crowdSegment == "泛人群" 时翻译成 (NULL OR ''/'-'/'null'),与 selectForRecommend 一致。
+     * ORDER BY rov DESC, id ASC,便于上层 putIfAbsent 直接取 max rov 代表行。
+     * 小程序投流:crowdSegment=#{crowdPackage},channelLevel3=null
+     * 公众号投流-稳定:crowdSegment=null,channelLevel3=#{ghName}
+     */
+    List<ContentPlatformDemandVideo> selectSearchWhitelist(@Param("dt") String dt,
+                                                            @Param("channelName") String channelName,
+                                                            @Param("crowdSegment") String crowdSegment,
+                                                            @Param("channelLevel3") String channelLevel3);
+
     List<ContentPlatformDemandVideo> selectActiveVideos(@Param("dt") String dt);
     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);
     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/XcxPlanListParam.java

@@ -13,6 +13,9 @@ public class XcxPlanListParam extends PageParam {
     @ApiModelProperty(value = "标题")
     @ApiModelProperty(value = "标题")
     private String title;
     private String title;
 
 
+    @ApiModelProperty(value = "备注(精确等值)")
+    private String remark;
+
     @ApiModelProperty(value = "创建时间开始")
     @ApiModelProperty(value = "创建时间开始")
     private Long createTimestampStart;
     private Long createTimestampStart;
 
 

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

@@ -22,9 +22,6 @@ public class XcxPlanItemExportVO {
     @ExcelProperty("视频id")
     @ExcelProperty("视频id")
     private Long videoId;
     private Long videoId;
 
 
-    @ExcelProperty("rootSourceId")
-    private String rootSourceId;
-
     @ExcelProperty("视频标题")
     @ExcelProperty("视频标题")
     private String title;
     private String title;
 
 
@@ -34,6 +31,9 @@ public class XcxPlanItemExportVO {
     @ExcelProperty("推送链接")
     @ExcelProperty("推送链接")
     private String pageUrl;
     private String pageUrl;
 
 
+    @ExcelProperty("rootSourceId")
+    private String rootSourceId;
+
     @ExcelProperty("备注")
     @ExcelProperty("备注")
     private String remark;
     private String remark;
 
 

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

@@ -639,15 +639,21 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     private static final String MATCH_METHOD_PRIOR_SCENE = "场景已看视频";
     private static final String MATCH_METHOD_PRIOR_SCENE = "场景已看视频";
     /** match_method 取值,prior / posterior 池识别用 */
     /** match_method 取值,prior / posterior 池识别用 */
     private static final String MATCH_METHOD_PRIOR = "视频库_解构特征_向量相似匹配";
     private static final String MATCH_METHOD_PRIOR = "视频库_解构特征_向量相似匹配";
-    /** prior/posterior 池视频近 7 日 rov 下限(质量过滤):0.02 在 prior 池 0513 全量保留 ~41%,
-     * cdjh 优质相似 0514 验证保留 ~54%,量大有意义;
-     * priorScene 池基数小(单 channel 几十~百条),仍保持 >0(在 groupAndTopK 内兜底)。 */
-    private static final double DEMAND_MIN_ROV = 0.02;
+    /** prior/posterior 池视频近 7 日 rov 下限(质量过滤):统一与 priorScene sceneSumRov 一致,0.03。 */
+    private static final double DEMAND_MIN_ROV = 0.03;
+    /** priorScene 池(场景已看视频)的 sceneSumRov 下限(质量过滤):与 rov 下限同口径 0.03。 */
+    private static final double PRIOR_SCENE_MIN_SUM_ROV = 0.03;
     /** channel_name 映射:企微/小程序 type 直推,公众号入口按 ghName 反查 demand 表(见 resolveChannelName)。 */
     /** channel_name 映射:企微/小程序 type 直推,公众号入口按 ghName 反查 demand 表(见 resolveChannelName)。 */
     private static final String CHANNEL_NAME_QW           = "群/企微合作-稳定";
     private static final String CHANNEL_NAME_QW           = "群/企微合作-稳定";
     private static final String CHANNEL_NAME_XCX          = "小程序投流-稳定";
     private static final String CHANNEL_NAME_XCX          = "小程序投流-稳定";
+    private static final String CHANNEL_NAME_GZH_TOULIU   = "公众号投流-稳定";
     private static final String CHANNEL_NAME_GZH_JIZHUAN  = "公众号合作-即转-稳定";
     private static final String CHANNEL_NAME_GZH_JIZHUAN  = "公众号合作-即转-稳定";
     private static final String CHANNEL_NAME_GZH_DAILY    = "公众号合作-Daily-自选";
     private static final String CHANNEL_NAME_GZH_DAILY    = "公众号合作-Daily-自选";
+    /** 搜索入口仅对这两个渠道放开:小程序投流(按人群包)+ 公众号投流-稳定(按公众号)。 */
+    private static final Set<String> CHANNELS_ALLOW_SEARCH = new HashSet<>(Arrays.asList(
+            CHANNEL_NAME_XCX, CHANNEL_NAME_GZH_TOULIU));
+    /** 搜索入口仅对 INTERNAL(2 自营)/ AGENT(3 代理) 类型账号放开。 */
+    private static final Set<Integer> USER_TYPES_ALLOW_SEARCH = new HashSet<>(Arrays.asList(2, 3));
     /**
     /**
      * 合作类渠道:demand.crowd_segment 语义=合作方代码(yy/szhx/cdjh/...),与登录账号 ContentPlatformAccount.channel 同义,可作为过滤条件。
      * 合作类渠道:demand.crowd_segment 语义=合作方代码(yy/szhx/cdjh/...),与登录账号 ContentPlatformAccount.channel 同义,可作为过滤条件。
      * 其余渠道(投流-稳定/服务号投流):demand.crowd_segment 语义=人群分组标签(R50*xx/回流xx),与登录账号合作方无关,绝不可用 user.channel 过滤。
      * 其余渠道(投流-稳定/服务号投流):demand.crowd_segment 语义=人群分组标签(R50*xx/回流xx),与登录账号合作方无关,绝不可用 user.channel 过滤。
@@ -703,12 +709,28 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 推荐池剔除的 category 列表(身份相关)。
+     * - 渠道「公众号合作-Daily-自选」:所有身份返回空(白名单语义保留,等价于不做 NOT IN)
+     * - 其他渠道:内部(2)/代理(3) 仅过滤「节日祝福」;合作方(1)/其他 过滤「早中晚好」+「节日祝福」
+     * 仅作用于推荐池(selectForRecommend);搜索路径维持现状不做 category 过滤。
+     */
+    private List<String> resolveExcludeCategories(String channelName, ContentPlatformAccount user) {
+        if (CHANNEL_NAME_GZH_DAILY.equals(channelName)) return Collections.emptyList();
+        Integer type = user == null ? null : user.getType();
+        boolean privileged = Objects.equals(type, ContentPlatformAccountTypeEnum.INTERNAL.getVal())
+                || Objects.equals(type, ContentPlatformAccountTypeEnum.AGENT.getVal());
+        return privileged
+                ? Collections.singletonList("节日祝福")
+                : Arrays.asList("早中晚好", "节日祝福");
+    }
+
     @Override
     @Override
     public Page<VideoContentItemVO> getVideoContentList(VideoContentListParam param) {
     public Page<VideoContentItemVO> getVideoContentList(VideoContentListParam param) {
         ContentPlatformAccount user = LoginUserContext.getUser();
         ContentPlatformAccount user = LoginUserContext.getUser();
-        // 如果 title 有内容,调用 manager 平台接口搜索
+        // title 非空 → 走 demand 池白名单 + 向量召回交集搜索(切掉管理平台关键词 fallback,只对 type∈{2,3} + 投流类渠道放开)
         if (StringUtils.hasText(param.getTitle())) {
         if (StringUtils.hasText(param.getTitle())) {
-            return getVideoContentListByTitleV2(param);
+            return searchByTitleInDemandPool(param, user);
         }
         }
         String source = param.getSource();
         String source = param.getSource();
         if (SOURCE_PRIOR.equalsIgnoreCase(source)) {
         if (SOURCE_PRIOR.equalsIgnoreCase(source)) {
@@ -1010,14 +1032,15 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
+        List<String> excludeCategories = resolveExcludeCategories(channelName, user);
         // priorScene 池新识别:demand_strategy='人群需求' AND match_method='场景已看视频'(0519+ 起,旧 demand_strategy='人群需求-场景' 已迁走)
         // priorScene 池新识别:demand_strategy='人群需求' AND match_method='场景已看视频'(0519+ 起,旧 demand_strategy='人群需求-场景' 已迁走)
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false);
+                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false, excludeCategories);
         // 跨渠道退化:仅合作类渠道(企微/即转/Daily-自选)主查 crowd_segment 0 行时去掉合作方代码再试一次,
         // 跨渠道退化:仅合作类渠道(企微/即转/Daily-自选)主查 crowd_segment 0 行时去掉合作方代码再试一次,
         // 仍保留 ghName,避免公众号入口结果跨账号串号。投流/服务号类渠道 crowdSegment 已是 null,主查与本路径等价,不会进入。
         // 仍保留 ghName,避免公众号入口结果跨账号串号。投流/服务号类渠道 crowdSegment 已是 null,主查与本路径等价,不会进入。
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false);
+                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false, excludeCategories);
         }
         }
         // 1. 同 video_id 取 total_rov 最大的代表行(SQL 已排序,putIfAbsent 保留首次)
         // 1. 同 video_id 取 total_rov 最大的代表行(SQL 已排序,putIfAbsent 保留首次)
         LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
         LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
@@ -1025,10 +1048,11 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             if (r.getVideoId() == null) continue;
             if (r.getVideoId() == null) continue;
             bestPerVideo.putIfAbsent(r.getVideoId(), r);
             bestPerVideo.putIfAbsent(r.getVideoId(), r);
         }
         }
-        // 2. 过滤 rov<=0/null
+        // 2. 过滤 rov<=0/null,以及 sceneSumRov < PRIOR_SCENE_MIN_SUM_ROV(场景关联弱视频砍掉)
         List<ContentPlatformDemandVideo> filtered = new ArrayList<>(bestPerVideo.size());
         List<ContentPlatformDemandVideo> filtered = new ArrayList<>(bestPerVideo.size());
         for (ContentPlatformDemandVideo r : bestPerVideo.values()) {
         for (ContentPlatformDemandVideo r : bestPerVideo.values()) {
             if (r.getRov() == null || r.getRov() <= 0) continue;
             if (r.getRov() == null || r.getRov() <= 0) continue;
+            if (r.getSceneSumRov() == null || r.getSceneSumRov() < PRIOR_SCENE_MIN_SUM_ROV) continue;
             filtered.add(r);
             filtered.add(r);
         }
         }
         // 3. 按 sceneSumRov DESC 排序,次级 total_rov DESC
         // 3. 按 sceneSumRov DESC 排序,次级 total_rov DESC
@@ -1081,13 +1105,14 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
+        List<String> excludeCategories = resolveExcludeCategories(channelName, user);
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false);
+                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false, excludeCategories);
 
 
         // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
         // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false);
+                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false, excludeCategories);
         }
         }
 
 
         rows = rows.stream()
         rows = rows.stream()
@@ -1157,18 +1182,19 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
+        List<String> excludeCategories = resolveExcludeCategories(channelName, user);
         // posterior 池加 match_method='视频库_解构特征_向量相似匹配' 兜底,防止未来上游对优质相似分量出别的 match_method 值后污染本池
         // posterior 池加 match_method='视频库_解构特征_向量相似匹配' 兜底,防止未来上游对优质相似分量出别的 match_method 值后污染本池
         // 优质相似池:drive_dimension_time 一律不限制(含主查与退化路径),避免仅「昨日」窗口召回过少。
         // 优质相似池:drive_dimension_time 一律不限制(含主查与退化路径),避免仅「昨日」窗口召回过少。
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true);
+                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true, excludeCategories);
 
 
         // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
         // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
         if (channelName != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true);
+                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true, excludeCategories);
         }
         }
 
 
-        // 近 7 日 rov 下限,与 prior 池一致(cdjh 0514 验证 ≥0.02 保留 ~54%)
+        // 近 7 日 rov 下限,与 prior 池一致(DEMAND_MIN_ROV,统一到 0.03)
         rows = rows.stream()
         rows = rows.stream()
                 .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)
                 .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)
                 .collect(Collectors.toList());
                 .collect(Collectors.toList());
@@ -2016,6 +2042,89 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 搜索:在当前入口的 demand 白名单(rov >= 0.03)内做 title LIKE 关键字命中,按 rov DESC 排序。
+     * 门控:
+     *   - 用户身份 type ∈ {2 自营, 3 代理},否则返回空
+     *   - 渠道 ∈ {小程序投流-稳定, 公众号投流-稳定},否则返回空
+     *   - 小程序投流必填 crowdPackage,公众号投流必填 ghName,否则返回空
+     * 流程:
+     *   1. demand 白名单(channel + crowdSegment/channelLevel3 + demand_strategy IN (人群需求,优质相似) + rov>=0.03)
+     *      SQL 已 ORDER BY rov DESC, id ASC
+     *   2. 同 video_id 去重:putIfAbsent 取 max rov 代表行 —— 与搜索 rov DESC 排序对齐,
+     *      与 priorScene 池(取 max sceneSumRov 代表行)有意区别;同 dt 内稳定,跨 dt 重灌可能换代表行。
+     *   3. title.trim().toLowerCase().contains(query) 关键字命中
+     *   4. 排序:rov DESC,次级 total_rov DESC,score 字段清空避免前端歧义
+     *   5. 按 pageNum/pageSize 内存切片返回
+     */
+    private Page<VideoContentItemVO> searchByTitleInDemandPool(VideoContentListParam param, ContentPlatformAccount user) {
+        Page<VideoContentItemVO> empty = new Page<>(param.getPageNum(), param.getPageSize());
+        empty.setTotalSize(0);
+        empty.setObjs(new ArrayList<>());
+
+        if (user == null || !USER_TYPES_ALLOW_SEARCH.contains(user.getType())) {
+            return empty;
+        }
+        String channelName = resolveChannelName(param);
+        if (channelName == null || !CHANNELS_ALLOW_SEARCH.contains(channelName)) {
+            return empty;
+        }
+        boolean isXcx = CHANNEL_NAME_XCX.equals(channelName);
+        String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
+        String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
+        if (isXcx && crowdPackage == null) return empty;
+        if (!isXcx && ghName == null) return empty;
+
+        String kw = param.getTitle() == null ? "" : param.getTitle().trim().toLowerCase();
+        if (kw.isEmpty()) return empty;
+
+        String dt = demandVideoMapperExt.getMaxDt(channelName);
+        if (!StringUtils.hasText(dt)) return empty;
+
+        List<ContentPlatformDemandVideo> whitelist = demandVideoMapperExt.selectSearchWhitelist(
+                dt, channelName, isXcx ? crowdPackage : null, isXcx ? null : ghName);
+        if (CollectionUtils.isEmpty(whitelist)) return empty;
+
+        // SQL 已 ORDER BY rov DESC,putIfAbsent 即拿到 max rov 代表行(设计意图:与排序键一致)
+        LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
+        for (ContentPlatformDemandVideo r : whitelist) {
+            if (r.getVideoId() == null) continue;
+            bestPerVideo.putIfAbsent(r.getVideoId(), r);
+        }
+
+        List<VideoContentItemVO> hits = new ArrayList<>();
+        for (ContentPlatformDemandVideo demand : bestPerVideo.values()) {
+            if (demand.getTitle() == null) continue;
+            if (!demand.getTitle().toLowerCase().contains(kw)) continue;
+            VideoContentItemVO vo = buildDemandVideoContentItemVOList(Collections.singletonList(demand)).get(0);
+            vo.setSearchSource("keyword");
+            vo.setScore(null);
+            hits.add(vo);
+        }
+
+        if (hits.isEmpty()) return empty;
+
+        // rov DESC,次级 total_rov DESC 作稳定兜底
+        hits.sort((a, b) -> {
+            double ra = a.getRov() == null ? 0d : a.getRov();
+            double rb = b.getRov() == null ? 0d : b.getRov();
+            int c = Double.compare(rb, ra);
+            if (c != 0) return c;
+            double ta = a.getTotalRov() == null ? 0d : a.getTotalRov();
+            double tb = b.getTotalRov() == null ? 0d : b.getTotalRov();
+            return Double.compare(tb, ta);
+        });
+
+        int pageNum = Math.max(1, param.getPageNum());
+        int pageSize = param.getPageSize() > 0 ? param.getPageSize() : 10;
+        Page<VideoContentItemVO> page = new Page<>(pageNum, pageSize);
+        page.setTotalSize(hits.size());
+        int from = Math.min((pageNum - 1) * pageSize, hits.size());
+        int to = Math.min(pageNum * pageSize, hits.size());
+        page.setObjs(new ArrayList<>(hits.subList(from, to)));
+        return page;
+    }
+
     private List<VideoContentItemVO> buildDemandVideoContentItemVOList(List<ContentPlatformDemandVideo> videoList) {
     private List<VideoContentItemVO> buildDemandVideoContentItemVOList(List<ContentPlatformDemandVideo> videoList) {
         if (CollectionUtils.isEmpty(videoList)) {
         if (CollectionUtils.isEmpty(videoList)) {
             return new ArrayList<>();
             return new ArrayList<>();

+ 38 - 2
api-module/src/main/resources/mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.xml

@@ -110,8 +110,12 @@
                match_text, title, cover, video, experiment_id, scene_sum_rov, status, create_timestamp, update_timestamp
                match_text, title, cover, video, experiment_id, scene_sum_rov, status, create_timestamp, update_timestamp
         FROM content_platform_demand_video
         FROM content_platform_demand_video
         WHERE dt = #{dt} AND status = 1
         WHERE dt = #{dt} AND status = 1
-        <if test="channelName == null || channelName != '公众号合作-Daily-自选'">
-        AND (category IS NULL OR category NOT IN ('早中晚好','节日祝福'))
+        <if test="excludeCategories != null and excludeCategories.size() > 0">
+            AND (category IS NULL OR category NOT IN
+            <foreach collection="excludeCategories" item="c" open="(" close=")" separator=",">
+                #{c}
+            </foreach>
+            )
         </if>
         </if>
         <if test="channelName != null and channelName != ''">
         <if test="channelName != null and channelName != ''">
             AND channel_name = #{channelName}
             AND channel_name = #{channelName}
@@ -160,6 +164,38 @@
         LIMIT #{limit}
         LIMIT #{limit}
     </select>
     </select>
 
 
+    <select id="selectSearchWhitelist" resultType="com.tzld.piaoquan.api.model.po.contentplatform.ContentPlatformDemandVideo">
+        SELECT id, dt, channel_name, channel_level3, crowd_segment, dimension, point_type, standard_element,
+               category_name, category, 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,
+               match_method, match_video_filter, match_sort,
+               video_id, config_code, score, sim, rov,
+               match_text, title, cover, video, experiment_id, scene_sum_rov, status, create_timestamp, update_timestamp
+        FROM content_platform_demand_video
+        WHERE dt = #{dt} AND status = 1
+          AND channel_name = #{channelName}
+          AND demand_strategy IN ('人群需求', '优质相似')
+          AND video_id IS NOT NULL
+          AND rov IS NOT NULL
+          AND rov &gt;= 0.03
+        <if test="crowdSegment != null and crowdSegment != ''">
+            <choose>
+                <when test='crowdSegment == "泛人群"'>
+                    AND (crowd_segment IS NULL OR crowd_segment = '' OR crowd_segment = '-' OR crowd_segment = 'null')
+                </when>
+                <otherwise>
+                    AND crowd_segment = #{crowdSegment}
+                </otherwise>
+            </choose>
+        </if>
+        <if test="channelLevel3 != null and channelLevel3 != ''">
+            AND channel_level3 = #{channelLevel3}
+        </if>
+        ORDER BY rov DESC, id ASC
+    </select>
+
     <select id="selectDistinctCrowdPackages" resultType="java.lang.String">
     <select id="selectDistinctCrowdPackages" resultType="java.lang.String">
         SELECT DISTINCT
         SELECT DISTINCT
             CASE
             CASE

+ 6 - 0
api-module/src/main/resources/mapper/contentplatform/ext/ContentPlatformPlanMapperExt.xml

@@ -443,6 +443,9 @@
             and id in (select plan_id from content_platform_xcx_plan_video where title like concat('%', #{param.title},
             and id in (select plan_id from content_platform_xcx_plan_video where title like concat('%', #{param.title},
             '%'))
             '%'))
         </if>
         </if>
+        <if test="param.remark != null and param.remark != ''">
+            and remark = #{param.remark}
+        </if>
         <if test="param.createTimestampStart != null">
         <if test="param.createTimestampStart != null">
             and create_timestamp &gt; #{param.createTimestampStart}
             and create_timestamp &gt; #{param.createTimestampStart}
         </if>
         </if>
@@ -464,6 +467,9 @@
             and id in (select plan_id from content_platform_xcx_plan_video where title like concat('%', #{param.title},
             and id in (select plan_id from content_platform_xcx_plan_video where title like concat('%', #{param.title},
             '%'))
             '%'))
         </if>
         </if>
+        <if test="param.remark != null and param.remark != ''">
+            and remark = #{param.remark}
+        </if>
         <if test="param.createTimestampStart != null">
         <if test="param.createTimestampStart != null">
             and create_timestamp &gt; #{param.createTimestampStart}
             and create_timestamp &gt; #{param.createTimestampStart}
         </if>
         </if>