wangyunpeng 1 день назад
Родитель
Сommit
b6debe1f25
13 измененных файлов с 907 добавлено и 243 удалено
  1. 5 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/ContentPlatformAccountTypeEnum.java
  2. 48 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandChannelEnum.java
  3. 20 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandMatchMethodEnum.java
  4. 19 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandStrategyEnum.java
  5. 19 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandTypeEnum.java
  6. 45 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/IndustryTypeEnum.java
  7. 21 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/PriorDimensionEnum.java
  8. 32 0
      api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/VideoContentSource.java
  9. 1 0
      api-module/src/main/java/com/tzld/piaoquan/api/dao/mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.java
  10. 8 2
      api-module/src/main/java/com/tzld/piaoquan/api/model/param/contentplatform/VideoContentListParam.java
  11. 236 241
      api-module/src/main/java/com/tzld/piaoquan/api/service/contentplatform/impl/ContentPlatformPlanServiceImpl.java
  12. 3 0
      api-module/src/main/resources/mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.xml
  13. 450 0
      docs/videoContentList-flow.md

+ 5 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/ContentPlatformAccountTypeEnum.java

@@ -26,4 +26,9 @@ public enum ContentPlatformAccountTypeEnum {
         }
         return other;
     }
+
+    /** 搜索入口仅对自营/代理类型账号放开 */
+    public boolean allowSearch() {
+        return this == INTERNAL || this == AGENT;
+    }
 }

+ 48 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandChannelEnum.java

@@ -0,0 +1,48 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * 需求渠道枚举,对应 content_platform_demand_video.channel_name
+ */
+@Getter
+public enum DemandChannelEnum {
+
+    QW("群/企微合作-稳定"),
+    XCX("小程序投流-稳定"),
+    GZH_TOULIU("公众号投流-稳定"),
+    GZH_JIZHUAN("公众号合作-即转-稳定"),
+    GZH_DAILY("公众号合作-Daily-自选");
+
+    /** 搜索入口白名单 */
+    public static final Set<DemandChannelEnum> SEARCH_ALLOWED = EnumSet.of(XCX, GZH_TOULIU);
+
+    /** 合作类渠道: crowd_segment 语义=合作方代码,可用 user.channel 过滤 */
+    public static final Set<DemandChannelEnum> PARTNER_CROWD = EnumSet.of(QW, GZH_JIZHUAN, GZH_DAILY);
+
+    private final String value;
+
+    DemandChannelEnum(String value) {
+        this.value = value;
+    }
+
+    /** 字符串反查枚举,未匹配返回 null */
+    public static DemandChannelEnum fromValue(String value) {
+        if (value == null) return null;
+        for (DemandChannelEnum c : values()) {
+            if (c.value.equals(value)) return c;
+        }
+        return null;
+    }
+
+    public boolean isPartner() {
+        return PARTNER_CROWD.contains(this);
+    }
+
+    public boolean allowSearch() {
+        return SEARCH_ALLOWED.contains(this);
+    }
+}

+ 20 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandMatchMethodEnum.java

@@ -0,0 +1,20 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+/**
+ * 需求匹配方法论枚举,对应 content_platform_demand_video.match_method
+ */
+@Getter
+public enum DemandMatchMethodEnum {
+
+    SCENE("场景已看视频"),
+    VECTOR_SIMILARITY("视频库_解构特征_向量相似匹配"),
+    PRECISION("视频库_解构特征点_精准匹配");
+
+    private final String value;
+
+    DemandMatchMethodEnum(String value) {
+        this.value = value;
+    }
+}

+ 19 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandStrategyEnum.java

@@ -0,0 +1,19 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+/**
+ * 需求策略枚举,对应 content_platform_demand_video.demand_strategy
+ */
+@Getter
+public enum DemandStrategyEnum {
+
+    PRIOR("人群需求"),
+    POSTERIOR("优质相似");
+
+    private final String value;
+
+    DemandStrategyEnum(String value) {
+        this.value = value;
+    }
+}

+ 19 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/DemandTypeEnum.java

@@ -0,0 +1,19 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+/**
+ * 人群需求特征点类型枚举,对应 content_platform_demand_video.demand_type
+ */
+@Getter
+public enum DemandTypeEnum {
+
+    STANDARD("特征点"),
+    GENERALIZED("特征点泛化");
+
+    private final String value;
+
+    DemandTypeEnum(String value) {
+        this.value = value;
+    }
+}

+ 45 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/IndustryTypeEnum.java

@@ -0,0 +1,45 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.Getter;
+
+/**
+ * 行业类型枚举
+ */
+@Getter
+public enum IndustryTypeEnum {
+
+    AUTO_REPLY(0, "自动回复", true),
+    FWH_PUSH(1, "服务号推送", true),
+    QW_GROUP(2, "企微-社群", false),
+    QW_AUTO_REPLY(3, "企微-自动回复", false),
+    GZH_PUSH(4, "公众号推送", true),
+    XCX(5, "小程序投流", false),
+
+    OTHER(999, "", false);
+
+    private final int val;
+    private final String description;
+    /** 是否为公众号入口(type ∈ {0,1,4}) */
+    private final boolean gzh;
+
+    IndustryTypeEnum(int val, String description, boolean gzh) {
+        this.val = val;
+        this.description = description;
+        this.gzh = gzh;
+    }
+
+    @JsonValue
+    public int getVal() {
+        return val;
+    }
+
+    @JsonCreator
+    public static IndustryTypeEnum from(int val) {
+        for (IndustryTypeEnum t : values()) {
+            if (t.val == val) return t;
+        }
+        return OTHER;
+    }
+}

+ 21 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/PriorDimensionEnum.java

@@ -0,0 +1,21 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+/**
+ * 人群需求维度枚举,对应 content_platform_demand_video.dimension
+ */
+@Getter
+public enum PriorDimensionEnum {
+
+    PREMIUM("传播的头部"),
+    GROWTH("增长的头部"),
+    DISTRIBUTION("传播的分发"),
+    GROWTH_DISTRIBUTION("增长的分发");
+
+    private final String value;
+
+    PriorDimensionEnum(String value) {
+        this.value = value;
+    }
+}

+ 32 - 0
api-module/src/main/java/com/tzld/piaoquan/api/common/enums/contentplatform/VideoContentSource.java

@@ -0,0 +1,32 @@
+package com.tzld.piaoquan.api.common.enums.contentplatform;
+
+import lombok.Getter;
+
+/**
+ * 视频内容列表数据来源枚举
+ */
+@Getter
+public enum VideoContentSource {
+
+    /** 人群需求(粉丝喜欢) */
+    PRIOR("prior"),
+    /** 优质相似 */
+    POSTERIOR("posterior"),
+    /** 全局热门 */
+    HOT("hot");
+
+    private final String value;
+
+    VideoContentSource(String value) {
+        this.value = value;
+    }
+
+    /** 从请求参数(source 字段)反查,忽略大小写,未匹配返回 null */
+    public static VideoContentSource fromParam(String source) {
+        if (source == null) return null;
+        for (VideoContentSource s : values()) {
+            if (s.value.equalsIgnoreCase(source)) return s;
+        }
+        return null;
+    }
+}

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

@@ -61,6 +61,7 @@ public interface ContentPlatformDemandVideoMapperExt {
                                                        @Param("driveDimensionTime") String driveDimensionTime,
                                                        @Param("category") String category,
                                                        @Param("matchMethod") String matchMethod,
+                                                       @Param("demandType") String demandType,
                                                        @Param("crowdPackage") String crowdPackage,
                                                        @Param("limit") int limit,
                                                        @Param("excludeSelfTitle") boolean excludeSelfTitle,

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

@@ -1,5 +1,6 @@
 package com.tzld.piaoquan.api.model.param.contentplatform;
 
+import com.tzld.piaoquan.api.common.enums.contentplatform.IndustryTypeEnum;
 import com.tzld.piaoquan.api.model.param.PageParam;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -13,8 +14,13 @@ public class VideoContentListParam extends PageParam {
     @ApiModelProperty(value = "品类")
     private String category;
 
-    @ApiModelProperty(value = "行业 0-自动回复、1-服务号推送、2-企微-社群、3-企微-自动回复、4-公众号推送")
-    private Integer type = 999;
+    @ApiModelProperty(value = "行业类型: 0-自动回复、1-服务号推送、2-企微-社群、3-企微-自动回复、4-公众号推送、5-小程序投流")
+    private Integer type = IndustryTypeEnum.OTHER.getVal();
+
+    /** 行业类型枚举转换 */
+    public IndustryTypeEnum getIndustryType() {
+        return IndustryTypeEnum.from(type == null ? 999 : type);
+    }
 
     @ApiModelProperty(value = "排序 0-平台推荐 1-行业裂变率 2-本渠道裂变率 3-推荐指数")
     private Integer sort = 0;

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

@@ -149,6 +149,22 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     @Value("${small_page_url}")
     private String GET_SMALL_PAGE_URL;
 
+    /** prior/posterior 池视频近 7 日 rov 下限(质量过滤) */
+    @Value("${demand.min.rov:0.03}")
+    private double demandMinRov;
+
+    /** priorScene 池(场景已看视频)的 sceneSumRov 下限(质量过滤) */
+    @Value("${demand.prior.scene.min.sum.rov:0.03}")
+    private double priorSceneMinSumRov;
+
+    /** prior 池按(point_type, standard_element)分组后,保留 total_rov 排名 top K 的比例 */
+    @Value("${demand.prior.group.keep.ratio:0.5}")
+    private double priorGroupKeepRatio;
+
+    /** posterior 池按 demand_content_id 分组后,保留 total_rov 排名 top K 的比例 */
+    @Value("${demand.posterior.group.keep.ratio:0.5}")
+    private double posteriorGroupKeepRatio;
+
     @Override
     public Page<GzhPlanItemVO> gzhPlanList(GzhPlanListParam param) {
         ContentPlatformAccount loginAccount = LoginUserContext.getUser();
@@ -616,80 +632,81 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     private static final int DEMAND_CANDIDATE_LIMIT = 10000;
     private static final int HOT_CANDIDATE_LIMIT = 10000;
     private static final int TOP_K_PER_DEMAND = 3;
-    private static final String DEMAND_STRATEGY_PRIOR = "人群需求";
-    private static final String DEMAND_STRATEGY_POSTERIOR = "优质相似";
-    private static final String PRIOR_PREMIUM_DIMENSION = "传播的头部";
-    /** 第三路池:dimension='增长的头部',与 PRIOR_PREMIUM_DIMENSION 语义不同(增长强度 vs 传播强度)。 */
-    private static final String GROWTH_PREMIUM_DIMENSION = "增长的头部";
-    /** match_method 取值,priorScene 池识别用(0519+ 起替代旧 demand_strategy='人群需求-场景') */
-    private static final String MATCH_METHOD_PRIOR_SCENE = "场景已看视频";
-    /** match_method 取值,prior / posterior 池识别用 */
-    private static final String MATCH_METHOD_PRIOR = "视频库_解构特征_向量相似匹配";
-    /** 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)。 */
-    private static final String CHANNEL_NAME_QW           = "群/企微合作-稳定";
-    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_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 语义=人群分组标签(R50*xx/回流xx),与登录账号合作方无关,绝不可用 user.channel 过滤。
+     * 人群需求池配置:{dimension, demandType, matchMethod, isScene}
      */
-    private static final Set<String> CHANNELS_USE_PARTNER_AS_CROWD = new HashSet<>(Arrays.asList(
-            CHANNEL_NAME_QW, CHANNEL_NAME_GZH_JIZHUAN, CHANNEL_NAME_GZH_DAILY));
-    private static final double PRIOR_GROUP_KEEP_RATIO = 0.5;
-    /** posterior 按 demand_content_id 分组后保留 total_rov 排名前 50% 的需求组,
-     * 砍掉群体表现弱的需求,避免低 total_rov 的 demand 带回来的相似变体稀释结果。 */
-    private static final double POSTERIOR_GROUP_KEEP_RATIO = 0.5;
-    private static final String SOURCE_PRIOR = "prior";
-    private static final String SOURCE_POSTERIOR = "posterior";
-    private static final String SOURCE_HOT = "hot";
+    private static final List<PriorPoolConfig> PRIOR_POOL_CONFIGS = buildPriorPoolConfigs();
+
+    private static List<PriorPoolConfig> buildPriorPoolConfigs() {
+        return Arrays.asList(
+                // 策略1: 人群需求*场景已看
+                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.SCENE, true),
+                // 策略2: 人群需求*特征点*向量匹配
+                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                // 策略3: 人群需求*特征点泛化*向量匹配
+                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
+                // 策略4: 人群需求*特征点泛化*精准匹配
+                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false),
+                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false),
+                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false)
+        );
+    }
+
+    /**
+     * 人群需求单池配置
+     */
+    private static class PriorPoolConfig {
+        final PriorDimensionEnum dimension;
+        final DemandTypeEnum demandType;
+        final DemandMatchMethodEnum matchMethod;
+        final boolean scene;
+
+        PriorPoolConfig(PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod, boolean scene) {
+            this.dimension = dimension;
+            this.demandType = demandType;
+            this.matchMethod = matchMethod;
+            this.scene = scene;
+        }
+    }
 
     /**
      * 推导 channel_name(人群_渠道) 作为 demand 池强过滤。
      * 信号优先级:
-     *   1. 企微入口(type=2/3) → 群/企微合作-稳定
-     *   2. 小程序投流(type=5) → 小程序投流-稳定
-     *   3. 公众号入口(type=0/1/4)或仅传 ghName:按 ghName 反查 demand 表 channel_level3 → channel_name
-     *      ─ 业务约定一个公众号只归属一个 channel(合作-即转 / 合作-Daily-自选 / 投流-稳定 三选一),
-     *        反查失败(新账号或上游当天没产)返回 null,不限 channel_name(保留兜底行为)。
-     *   4. 否则 null,不限 channel_name。
-     * 反查每次实时查表(已规避索引/缓存改造),首次请求 30~660ms,后续受 InnoDB buffer pool 缓存。
+     * 1. 企微入口(type=2/3) → QW
+     * 2. 小程序投流(type=5) → XCX
+     * 3. 公众号入口(type=0/1/4)或仅传 ghName:按 ghName 反查 demand 表 channel_level3 → channel_name
+     * ─ 业务约定一个公众号只归属一个 channel(合作-即转 / 合作-Daily-自选 / 投流-稳定 三选一),
+     * 反查失败(新账号或上游当天没产)返回 null,不限 channel_name(保留兜底行为)。
+     * 4. 否则 null,不限 channel_name。
      */
-    private String resolveChannelName(VideoContentListParam param) {
-        Integer type = param.getType();
-        if (type != null) {
-            if (type == 2 || type == 3) return CHANNEL_NAME_QW;
-            if (type == 5) return CHANNEL_NAME_XCX;
+    private DemandChannelEnum resolveChannelName(VideoContentListParam param) {
+        IndustryTypeEnum type = param.getIndustryType();
+        if (type != null && type != IndustryTypeEnum.OTHER) {
+            if (type == IndustryTypeEnum.QW_GROUP || type == IndustryTypeEnum.QW_AUTO_REPLY) return DemandChannelEnum.QW;
+            if (type == IndustryTypeEnum.XCX) return DemandChannelEnum.XCX;
         }
         String ghName = param.getGhName();
         if (StringUtils.hasText(ghName)) {
             String dt = demandVideoMapperExt.getMaxDt(null);
             if (StringUtils.hasText(dt)) {
-                return demandVideoMapperExt.selectChannelNameByGh(dt, ghName);
+                return DemandChannelEnum.fromValue(demandVideoMapperExt.selectChannelNameByGh(dt, ghName));
             }
         }
         return null;
     }
 
     /**
-     * 仅对"合作类"渠道(企微/即转/Daily-自选)用 user.channel 作为 demand.crowd_segment 过滤;
+     * 仅对"合作类"渠道用 user.channel 作为 demand.crowd_segment 过滤;
      * 投流/服务号类渠道一律 null(crowd_segment 是人群分组标签,与合作方无关)。
-     * 解决:0526 之前 user.channel 被无条件喂给所有渠道,投流类零命中后触发跨渠道退化,
-     * 退化把 ghName 一并丢掉,导致公众号入口结果串号到其他公众号。
      */
-    private String resolveCrowdSegment(String channelName, ContentPlatformAccount user) {
-        if (user == null) return null;
-        if (channelName != null && CHANNELS_USE_PARTNER_AS_CROWD.contains(channelName)) {
+    private String resolveCrowdSegment(DemandChannelEnum channel, ContentPlatformAccount user) {
+        if (user == null || channel == null) return null;
+        if (channel.isPartner()) {
             return user.getChannel();
         }
         return null;
@@ -697,12 +714,11 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 推荐池剔除的 category 列表(身份相关)。
-     * - 渠道「公众号合作-Daily-自选」:所有身份返回空(白名单语义保留,等价于不做 NOT IN)
-     * - 其他渠道:内部(2)/代理(3) 仅过滤「节日祝福」;合作方(1)/其他 过滤「早中晚好」+「节日祝福」
-     * 仅作用于推荐池(selectForRecommend);搜索路径维持现状不做 category 过滤。
+     * - GZH_DAILY:所有身份返回空(白名单语义保留)
+     * - 其他:内部(2)/代理(3) 仅过滤「节日祝福」;合作方(1)/其他 过滤「早中晚好」+「节日祝福」
      */
-    private List<String> resolveExcludeCategories(String channelName, ContentPlatformAccount user) {
-        if (CHANNEL_NAME_GZH_DAILY.equals(channelName)) return Collections.emptyList();
+    private List<String> resolveExcludeCategories(DemandChannelEnum channel, ContentPlatformAccount user) {
+        if (DemandChannelEnum.GZH_DAILY == channel) return Collections.emptyList();
         Integer type = user == null ? null : user.getType();
         boolean privileged = Objects.equals(type, ContentPlatformAccountTypeEnum.INTERNAL.getVal())
                 || Objects.equals(type, ContentPlatformAccountTypeEnum.AGENT.getVal());
@@ -713,9 +729,9 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 曝光下发过滤的纯逻辑(可单测,全渠道统一口径):
-     *   - minExposurePv 为 null 或 <=0:关闭过滤,全放行;
-     *   - match_exposure_pv 为 NULL(无曝光数据,如「场景已看视频」池):放行,不受影响;
-     *   - 有曝光数据:要求 > minExposurePv 才下发。
+     * - minExposurePv 为 null 或 <=0:关闭过滤,全放行;
+     * - match_exposure_pv 为 NULL(无曝光数据,如「场景已看视频」池):放行,不受影响;
+     * - 有曝光数据:要求 > minExposurePv 才下发。
      */
     static boolean passesExposureFilter(Long matchExposurePv, Long minExposurePv) {
         if (minExposurePv == null || minExposurePv <= 0) {
@@ -731,15 +747,15 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         if (StringUtils.hasText(param.getTitle())) {
             return searchByTitleInDemandPool(param, user);
         }
-        String source = param.getSource();
-        if (SOURCE_PRIOR.equalsIgnoreCase(source)) {
-            return getSingleSourcePage(param, user, SOURCE_PRIOR);
+        VideoContentSource source = VideoContentSource.fromParam(param.getSource());
+        if (VideoContentSource.PRIOR == source) {
+            return getSingleSourcePage(param, user, source);
         }
-        if (SOURCE_POSTERIOR.equalsIgnoreCase(source)) {
-            return getSingleSourcePage(param, user, SOURCE_POSTERIOR);
+        if (VideoContentSource.POSTERIOR == source) {
+            return getSingleSourcePage(param, user, source);
         }
-        if (SOURCE_HOT.equalsIgnoreCase(source)) {
-            return getSingleSourcePage(param, user, SOURCE_HOT);
+        if (VideoContentSource.HOT == source) {
+            return getSingleSourcePage(param, user, source);
         }
         return getInterleavedPage(param, user);
     }
@@ -748,34 +764,35 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
      * 单一来源分页:与穿插使用同一套候选构建逻辑(人群需求/优质相似各 2 阶段、组内 score top K),
      * 再按 pageNum/pageSize 在内存中分页。totalSize = 去重后总数。
      */
-    private Page<VideoContentItemVO> getSingleSourcePage(VideoContentListParam param, ContentPlatformAccount user, String source) {
-        if (SOURCE_HOT.equals(source)) {
+    private Page<VideoContentItemVO> getSingleSourcePage(VideoContentListParam param, ContentPlatformAccount user, VideoContentSource source) {
+        if (VideoContentSource.HOT == source) {
             return getHotSourcePaged(param, user);
         }
         List<VideoContentItemVO> list;
-        if (SOURCE_PRIOR.equals(source)) {
-            // 粉丝喜欢:
-            //   公众号入口(type∈{0,1,4}):3 池 — priorScene + prior(传播头部) + growth(增长头部),每位独立等概率抽 + seed=nanoTime
-            //   企微入口  (type∈{2,3}):2 池 — priorScene + prior(传播头部),严格 1:1 交替(无随机)
-            boolean isGzh = isGzhEntryType(param.getType());
-            ExecutorService executor = Executors.newFixedThreadPool(isGzh ? 3 : 2);
+        if (VideoContentSource.PRIOR == source) {
+            // 人群需求池并行拉取, 按 PRIOR_POOL_CONFIGS 配置
+            int poolCount = PRIOR_POOL_CONFIGS.size();
+            ExecutorService executor = Executors.newFixedThreadPool(poolCount);
             try {
-                Future<List<VideoContentItemVO>> fScene = executor.submit(
-                        () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT));
-                Future<List<VideoContentItemVO>> fPrior = executor.submit(
-                        () -> fetchPriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT));
-                Future<List<VideoContentItemVO>> fGrowth = isGzh ? executor.submit(
-                        () -> fetchPriorGrowthCandidates(param, user, DEMAND_CANDIDATE_LIMIT)) : null;
+                List<Future<List<VideoContentItemVO>>> futures = new ArrayList<>(poolCount);
+                for (PriorPoolConfig cfg : PRIOR_POOL_CONFIGS) {
+                    futures.add(executor.submit(cfg.scene
+                            ? () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT, cfg.dimension, cfg.demandType, cfg.matchMethod)
+                            : () -> fetchPriorPool(param, user, DEMAND_CANDIDATE_LIMIT, cfg.dimension, cfg.demandType, cfg.matchMethod)));
+                }
 
                 int timeoutSeconds = 30;
-                List<VideoContentItemVO> scene = getQuietly(fScene, timeoutSeconds);
-                List<VideoContentItemVO> prior = getQuietly(fPrior, timeoutSeconds);
-                if (isGzh) {
-                    List<VideoContentItemVO> growth = getQuietly(fGrowth, timeoutSeconds);
-                    list = interleavePriorPoolsRandom(scene, prior, growth, user);
-                } else {
-                    list = interleavePriorWithScene(scene, prior);
+                List<List<VideoContentItemVO>> pools = new ArrayList<>(poolCount);
+                for (Future<List<VideoContentItemVO>> f : futures) {
+                    pools.add(getQuietly(f, timeoutSeconds));
+                }
+                for (List<VideoContentItemVO> pool : pools) {
+                    for (VideoContentItemVO v : pool) v.setSource(VideoContentSource.PRIOR.getValue());
                 }
+
+                long userSeed = user.getId() == null ? 0L : user.getId();
+                long seed = System.nanoTime() ^ userSeed;
+                list = interleaveMultiPools(pools, new Random(seed), 1);
             } finally {
                 executor.shutdown();
             }
@@ -783,17 +800,17 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             list = fetchPosteriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT);
         }
         for (VideoContentItemVO v : list) {
-            v.setSource(source);
+            v.setSource(source.getValue());
         }
         return paginateCandidates(param, list);
     }
 
     /**
      * 3 池每位独立等概率抽样(公众号入口用):
-     *  - 池: [scene, prior(传播头部), growth(增长头部)]
-     *  - 每个输出位置在「未耗尽池」中等概率抽 1,从该池头部取下一条
-     *  - seed = nanoTime ^ userId:每次接口请求都换 seed,第一条来自哪一路每次都不同
-     *  - 跨池 video_id / 标题去重;翻页 P1/P2 不保证序列一致(刷新即换排)
+     * - 池: [scene, prior(传播头部), growth(增长头部)]
+     * - 每个输出位置在「未耗尽池」中等概率抽 1,从该池头部取下一条
+     * - seed = nanoTime ^ userId:每次接口请求都换 seed,第一条来自哪一路每次都不同
+     * - 跨池 video_id / 标题去重;翻页 P1/P2 不保证序列一致(刷新即换排)
      */
     private List<VideoContentItemVO> interleavePriorPoolsRandom(List<VideoContentItemVO> scene,
                                                                 List<VideoContentItemVO> prior,
@@ -806,8 +823,8 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 企微入口用:priorScene 与 prior 池严格 1:1 交替输出(无随机):
-     *  - 起始池固定 scene,交替 1:1 各取 1 条
-     *  - 跨池 video_id / 标题去重;一侧用完后,剩余按原顺序追加输出,不丢数据
+     * - 起始池固定 scene,交替 1:1 各取 1 条
+     * - 跨池 video_id / 标题去重;一侧用完后,剩余按原顺序追加输出,不丢数据
      */
     private List<VideoContentItemVO> interleavePriorWithScene(List<VideoContentItemVO> scene,
                                                               List<VideoContentItemVO> prior) {
@@ -830,9 +847,9 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 通用 N 池随机穿插:
-     *  - maxBlockSize=1 → 每位独立等概率从所有未耗尽池抽 1(允许连续同源)
-     *  - maxBlockSize=K(>=2) → 块大小 1~K 随机,块间切「其他未耗尽池」(避免连续同源)
-     *  - 跨池 video_id / 标题去重;某池跳过去重后耗尽即标记 exhausted
+     * - maxBlockSize=1 → 每位独立等概率从所有未耗尽池抽 1(允许连续同源)
+     * - maxBlockSize=K(>=2) → 块大小 1~K 随机,块间切「其他未耗尽池」(避免连续同源)
+     * - 跨池 video_id / 标题去重;某池跳过去重后耗尽即标记 exhausted
      */
     private List<VideoContentItemVO> interleaveMultiPools(List<List<VideoContentItemVO>> pools,
                                                           Random rng,
@@ -929,7 +946,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             return result;
         }
         String sort = getVideoContentListSort(param.getSort());
-        String type = getVideoContentListType(param.getType());
+        String type = param.getIndustryType().getDescription();
         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,
@@ -939,7 +956,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             list = new ArrayList<>();
         }
         for (VideoContentItemVO v : list) {
-            v.setSource(SOURCE_HOT);
+            v.setSource(VideoContentSource.HOT.getValue());
         }
         result.setObjs(list);
         return result;
@@ -947,48 +964,42 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 默认 tab 随机穿插 + 跨路 video_id 去重。
-     * 公众号入口 5 池: priorScene / prior(传播头部) / priorGrowth(增长头部) / posterior / hot
-     * 企微入口   4 池: priorScene / prior(传播头部) / posterior / hot(沿用旧逻辑,不加 growth)
-     * prior 类(scene/prior/growth)对外都标 source='prior'(粉丝喜欢)。
+     * 人群需求池(source='prior') + 优质相似(source='posterior') + 热门(source='hot')
      * 每步在未耗尽的池中等概率随机选一个,从该池头部取下一条(池内顺序由 fetcher 决定)。
      * 用 (userId ^ 当天日期) 作为种子,保证同一用户当天翻页顺序一致、刷新一致。
      */
     private Page<VideoContentItemVO> getInterleavedPage(VideoContentListParam param, ContentPlatformAccount user) {
-        boolean isGzh = isGzhEntryType(param.getType());
-        int poolCount = isGzh ? 5 : 4;
-        ExecutorService executor = Executors.newFixedThreadPool(poolCount);
+        int priorCount = PRIOR_POOL_CONFIGS.size();
+        int totalCount = priorCount + 2; // + posterior + hot
+        ExecutorService executor = Executors.newFixedThreadPool(totalCount);
         try {
-            // 各池并行拉取,互不依赖
-            Future<List<VideoContentItemVO>> fPriorScene = executor.submit(
-                    () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT));
-            Future<List<VideoContentItemVO>> fPrior = executor.submit(
-                    () -> fetchPriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT));
+            // prior 池并行拉取
+            List<Future<List<VideoContentItemVO>>> futures = new ArrayList<>(totalCount);
+            for (PriorPoolConfig cfg : PRIOR_POOL_CONFIGS) {
+                futures.add(executor.submit(cfg.scene
+                        ? () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT, cfg.dimension, cfg.demandType, cfg.matchMethod)
+                        : () -> fetchPriorPool(param, user, DEMAND_CANDIDATE_LIMIT, cfg.dimension, cfg.demandType, cfg.matchMethod)));
+            }
+            // posterior + hot
             Future<List<VideoContentItemVO>> fPosterior = executor.submit(
                     () -> fetchPosteriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT));
             Future<List<VideoContentItemVO>> fHot = executor.submit(
                     () -> fetchHotCandidates(param, user, HOT_CANDIDATE_LIMIT));
-            Future<List<VideoContentItemVO>> fPriorGrowth = isGzh ? executor.submit(
-                    () -> fetchPriorGrowthCandidates(param, user, DEMAND_CANDIDATE_LIMIT)) : null;
+            futures.add(fPosterior);
+            futures.add(fHot);
 
             int timeoutSeconds = 30;
-            List<VideoContentItemVO> priorScene  = getQuietly(fPriorScene, timeoutSeconds);
-            List<VideoContentItemVO> prior       = getQuietly(fPrior, timeoutSeconds);
-            List<VideoContentItemVO> posterior   = getQuietly(fPosterior, timeoutSeconds);
-            List<VideoContentItemVO> hot         = getQuietly(fHot, timeoutSeconds);
-
-            for (VideoContentItemVO v : priorScene) v.setSource(SOURCE_PRIOR);
-            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;
-            if (isGzh) {
-                List<VideoContentItemVO> priorGrowth = getQuietly(fPriorGrowth, timeoutSeconds);
-                for (VideoContentItemVO v : priorGrowth) v.setSource(SOURCE_PRIOR);
-                pools = Arrays.asList(priorScene, prior, priorGrowth, posterior, hot);
-            } else {
-                pools = Arrays.asList(priorScene, prior, posterior, hot);
+            List<List<VideoContentItemVO>> pools = new ArrayList<>(totalCount);
+            for (int i = 0; i < futures.size(); i++) {
+                pools.add(getQuietly(futures.get(i), timeoutSeconds));
             }
+            // source 标记: 前 priorCount 个池为 prior, 倒数第2为 posterior, 最后为 hot
+            for (int i = 0; i < priorCount; i++) {
+                for (VideoContentItemVO v : pools.get(i)) v.setSource(VideoContentSource.PRIOR.getValue());
+            }
+            for (VideoContentItemVO v : pools.get(priorCount)) v.setSource(VideoContentSource.POSTERIOR.getValue());
+            for (VideoContentItemVO v : pools.get(priorCount + 1)) v.setSource(VideoContentSource.HOT.getValue());
+
             int N = pools.size();
             int[] pointers = new int[N];
             boolean[] exhausted = new boolean[N];
@@ -1003,7 +1014,10 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             while (true) {
                 boolean allExhausted = true;
                 for (boolean e : exhausted) {
-                    if (!e) { allExhausted = false; break; }
+                    if (!e) {
+                        allExhausted = false;
+                        break;
+                    }
                 }
                 if (allExhausted) break;
 
@@ -1061,33 +1075,38 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     }
 
     /**
-     * 人群需求-场景池: demand_strategy='人群需求-场景'
+     * 人群需求-场景池: 按 dimension + demandType + matchMethod 组合过滤
      * 退化策略: ghName 非空且查不到数据 → 退回渠道粒度(不限 channel_level3)。
      * 后处理:
-     *   1. 同 video_id 仅保留 total_rov 最大的代表行(利用 SQL 已按 total_rov DESC, score DESC 排好,首次即最大)
-     *   2. 过滤 rov 为 null 或 <=0(视频近 7 日无表现)
-     *   3. 输出顺序按 sceneSumRov DESC,相同再按 total_rov DESC 兜底
+     * 1. 同 video_id 仅保留 total_rov 最大的代表行(利用 SQL 已按 total_rov DESC, score DESC 排好,首次即最大)
+     * 2. 过滤 rov 为 null 或 <=0(视频近 7 日无表现)
+     * 3. 输出顺序按 sceneSumRov DESC,相同再按 total_rov DESC 兜底
      */
-    private List<VideoContentItemVO> fetchPriorSceneCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
-        String channelName = resolveChannelName(param);
-        String dt = demandVideoMapperExt.getMaxDt(channelName);
+    private List<VideoContentItemVO> fetchPriorSceneCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit,
+                                                               PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod) {
+        DemandChannelEnum channel = resolveChannelName(param);
+        String dt = demandVideoMapperExt.getMaxDt(channel == null ? null : channel.getValue());
         if (!StringUtils.hasText(dt)) {
             return new ArrayList<>();
         }
-        String crowdSegment = resolveCrowdSegment(channelName, user);
+        String crowdSegment = resolveCrowdSegment(channel, user);
         String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : 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='人群需求-场景' 已迁走)
+        List<String> excludeCategories = resolveExcludeCategories(channel, user);
+        String ch = channel == null ? null : channel.getValue();
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false, excludeCategories);
-        // 跨渠道退化:仅合作类渠道(企微/即转/Daily-自选)主查 crowd_segment 0 行时去掉合作方代码再试一次,
+                dt, ch, crowdSegment, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
+                null, null, ghName, null, category,
+                matchMethod.getValue(), demandType.getValue(), crowdPackage, limit, false, excludeCategories);
+        // 跨渠道退化:仅合作类渠道主查 crowd_segment 0 行时去掉合作方代码再试一次,
         // 仍保留 ghName,避免公众号入口结果跨账号串号。投流/服务号类渠道 crowdSegment 已是 null,主查与本路径等价,不会进入。
-        if (channelName != null && crowdSegment != null && rows.isEmpty()) {
+        if (channel != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR_SCENE, crowdPackage, limit, false, excludeCategories);
+                    dt, ch, null, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
+                    null, null, ghName, null, category,
+                    matchMethod.getValue(), demandType.getValue(), crowdPackage, limit, false, excludeCategories);
         }
         // 1. 同 video_id 取 total_rov 最大的代表行(SQL 已排序,putIfAbsent 保留首次)
         LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
@@ -1095,11 +1114,11 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             if (r.getVideoId() == null) continue;
             bestPerVideo.putIfAbsent(r.getVideoId(), r);
         }
-        // 2. 过滤 rov<=0/null,以及 sceneSumRov < PRIOR_SCENE_MIN_SUM_ROV(场景关联弱视频砍掉)
+        // 2. 过滤 rov<=0/null,以及 sceneSumRov < priorSceneMinSumRov(场景关联弱视频砍掉)
         List<ContentPlatformDemandVideo> filtered = new ArrayList<>(bestPerVideo.size());
         for (ContentPlatformDemandVideo r : bestPerVideo.values()) {
             if (r.getRov() == null || r.getRov() <= 0) continue;
-            if (r.getSceneSumRov() == null || r.getSceneSumRov() < PRIOR_SCENE_MIN_SUM_ROV) continue;
+            if (r.getSceneSumRov() == null || r.getSceneSumRov() < priorSceneMinSumRov) continue;
             // 曝光下发过滤:场景已看视频池 match_exposure_pv 100% 为 NULL,此处恒放行;保留以统一全池口径。
             if (!passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv)) continue;
             filtered.add(r);
@@ -1118,54 +1137,46 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     }
 
     /**
-     * 人群需求池(prior):dimension='传播的头部'。
-     * 复用 fetchPriorDimensionCandidates 的 pipeline,仅 dimension 不同。
-     */
-    private List<VideoContentItemVO> fetchPriorCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
-        return fetchPriorDimensionCandidates(param, user, limit, PRIOR_PREMIUM_DIMENSION);
-    }
-
-    /**
-     * 人群需求池(priorGrowth):dimension='增长的头部'。
-     * 与 prior 池(传播头部)分开各算各的 top 50% 分位,二者跨池 video_id 去重在 interleave 阶段处理。
+     * 人群需求池通用 pipeline(非 scene 池,即策略2/3/4 共 9 池):
+     * 1. SQL: demand_strategy='人群需求' + dimension + demandType + matchMethod
+     * 2. 退化: ghName 无数据 → 去 ghName; crowd_segment 在对侧渠道 0 行 → 去 crowd_segment
+     * 3. 近 7 日 rov >= demandMinRov 过滤
+     * 4. 按 (point_type, standard_element) 分组,按 total_rov 分位保留 top 50%
+     * 5. 组按 total_rov DESC、组内 score DESC, top K=3
+     * 6. 截断到 limit
      */
-    private List<VideoContentItemVO> fetchPriorGrowthCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
-        return fetchPriorDimensionCandidates(param, user, limit, GROWTH_PREMIUM_DIMENSION);
-    }
-
-    /**
-     * 人群需求池共用 pipeline,按 dimension 切分:
-     *   1. SQL: demand_strategy='人群需求' + dimension=<指定> + match_method='视频库_解构特征_向量相似匹配'
-     *   2. 退化:ghName 无数据 → 去 ghName;crowd_segment 在对侧渠道 0 行 → 去 crowd_segment
-     *   3. 近 7 日 rov >= DEMAND_MIN_ROV 过滤
-     *   4. 按 (point_type, standard_element) 分组,按 total_rov 分位保留 top 50%
-     *   5. 组按 total_rov DESC、组内 score DESC,top K=3
-     *   6. 截断到 limit
-     */
-    private List<VideoContentItemVO> fetchPriorDimensionCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit, String dimension) {
-        String channelName = resolveChannelName(param);
-        String dt = demandVideoMapperExt.getMaxDt(channelName);
+    private List<VideoContentItemVO> fetchPriorPool(VideoContentListParam param, ContentPlatformAccount user, int limit,
+                                                    PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod) {
+        DemandChannelEnum channel = resolveChannelName(param);
+        String dt = demandVideoMapperExt.getMaxDt(channel == null ? null : channel.getValue());
         if (!StringUtils.hasText(dt)) {
             return new ArrayList<>();
         }
-        String crowdSegment = resolveCrowdSegment(channelName, user);
+        String crowdSegment = resolveCrowdSegment(channel, user);
         String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
         int fetchLimit = Math.max(limit * 3, DEMAND_CANDIDATE_LIMIT);
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
-        List<String> excludeCategories = resolveExcludeCategories(channelName, user);
+        List<String> excludeCategories = resolveExcludeCategories(channel, user);
+        String ch = channel == null ? null : channel.getValue();
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false, excludeCategories);
+                dt, ch, crowdSegment, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
+                null, null, ghName, null, category,
+                matchMethod.getValue(), demandType.getValue(), crowdPackage, fetchLimit, false,
+                excludeCategories);
 
-        // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
-        if (channelName != null && crowdSegment != null && rows.isEmpty()) {
+        // 跨渠道退化:仅合作类渠道触发,保留 ghName 防串号。
+        if (channel != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_PRIOR, dimension, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, false, excludeCategories);
+                    dt, ch, null, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
+                    null, null, ghName, null, category,
+                    matchMethod.getValue(), demandType.getValue(), crowdPackage, fetchLimit, false,
+                    excludeCategories);
         }
 
         rows = rows.stream()
-                .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)
+                .filter(r -> r.getRov() != null && r.getRov() >= demandMinRov)
                 .filter(r -> passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv))
                 .collect(Collectors.toList());
 
@@ -1173,7 +1184,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
                 (r.getPointType() == null ? "" : r.getPointType()) + "\u0001"
                         + (r.getStandardElement() == null ? "" : r.getStandardElement());
 
-        rows = retainTopGroupsByTotalRov(rows, keyFn, PRIOR_GROUP_KEEP_RATIO);
+        rows = retainTopGroupsByTotalRov(rows, keyFn, priorGroupKeepRatio);
 
         List<VideoContentItemVO> out = groupAndTopK(rows, keyFn, TOP_K_PER_DEMAND, false);
         if (out.size() > limit) {
@@ -1221,32 +1232,38 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
      * 跨组用 video_id + 归一化标题去重,截到 limit。
      */
     private List<VideoContentItemVO> fetchPosteriorCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
-        String channelName = resolveChannelName(param);
-        String dt = demandVideoMapperExt.getMaxDt(channelName);
+        DemandChannelEnum channel = resolveChannelName(param);
+        String dt = demandVideoMapperExt.getMaxDt(channel == null ? null : channel.getValue());
         if (!StringUtils.hasText(dt)) {
             return new ArrayList<>();
         }
-        String crowdSegment = resolveCrowdSegment(channelName, user);
+        String crowdSegment = resolveCrowdSegment(channel, user);
         String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
         int fetchLimit = Math.max(limit * 3, DEMAND_CANDIDATE_LIMIT);
 
         String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
-        List<String> excludeCategories = resolveExcludeCategories(channelName, user);
+        List<String> excludeCategories = resolveExcludeCategories(channel, user);
+        String ch = channel == null ? null : channel.getValue();
         // posterior 池加 match_method='视频库_解构特征_向量相似匹配' 兜底,防止未来上游对优质相似分量出别的 match_method 值后污染本池
-        // 优质相似池:drive_dimension_time 一律不限制(含主查与退化路径),避免仅「昨日」窗口召回过少。
         List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
-                dt, channelName, crowdSegment, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true, excludeCategories);
+                dt, ch, crowdSegment, DemandStrategyEnum.POSTERIOR.getValue(),
+                null, null, null, ghName, null,
+                category, DemandMatchMethodEnum.VECTOR_SIMILARITY.getValue(), null, crowdPackage,
+                fetchLimit, true, excludeCategories);
 
         // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
-        if (channelName != null && crowdSegment != null && rows.isEmpty()) {
+        if (channel != null && crowdSegment != null && rows.isEmpty()) {
             rows = demandVideoMapperExt.selectForRecommend(
-                    dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, null, ghName, null, category, MATCH_METHOD_PRIOR, crowdPackage, fetchLimit, true, excludeCategories);
+                    dt, ch, null, DemandStrategyEnum.POSTERIOR.getValue(),
+                    null, null, null, ghName, null,
+                    category, DemandMatchMethodEnum.VECTOR_SIMILARITY.getValue(), null, crowdPackage,
+                    fetchLimit, true, excludeCategories);
         }
 
-        // 近 7 日 rov 下限,与 prior 池一致(DEMAND_MIN_ROV,统一到 0.03)
+        // 近 7 日 rov 下限,与 prior 池一致(demandMinRov,统一到 0.03)
         rows = rows.stream()
-                .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)
+                .filter(r -> r.getRov() != null && r.getRov() >= demandMinRov)
                 .filter(r -> passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv))
                 .collect(Collectors.toList());
 
@@ -1255,7 +1272,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
         // 按 demand_content_id 的 total_rov 中位数过滤:保留中位数及以上(top 50%)的 demand 组,
         // 砍掉群体表现弱的需求,避免低 total_rov 的 demand 带回来的相似变体稀释结果。
-        rows = retainTopGroupsByTotalRov(rows, keyFn, POSTERIOR_GROUP_KEEP_RATIO);
+        rows = retainTopGroupsByTotalRov(rows, keyFn, posteriorGroupKeepRatio);
 
         List<VideoContentItemVO> out = groupAndTopK(rows, keyFn, TOP_K_PER_DEMAND, true);
         // 单段也要去归一化标题重复(同段内运营把同内容上传成多 video_id 的情况)
@@ -1264,10 +1281,10 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     /**
      * 行级按 keyFn 分组:
-     *   1. 组按"组内最大 total_rov" 倒序
-     *   2. 组内按 score 倒序,组内 video_id 去重,最多取 topK 条
-     *   3. excludeSelfTitle=true 时先在 Java 端用 TitleNormalizer 过滤自标题行
-     *   4. rov 为 null 或 <=0 的行视为"近 7 日无表现",直接丢弃不入池
+     * 1. 组按"组内最大 total_rov" 倒序
+     * 2. 组内按 score 倒序,组内 video_id 去重,最多取 topK 条
+     * 3. excludeSelfTitle=true 时先在 Java 端用 TitleNormalizer 过滤自标题行
+     * 4. rov 为 null 或 <=0 的行视为"近 7 日无表现",直接丢弃不入池
      */
     private List<VideoContentItemVO> groupAndTopK(List<ContentPlatformDemandVideo> rows,
                                                   Function<ContentPlatformDemandVideo, String> keyFn,
@@ -1337,7 +1354,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             return new ArrayList<>();
         }
         String sort = getVideoContentListSort(param.getSort());
-        String type = getVideoContentListType(param.getType());
+        String type = param.getIndustryType().getDescription();
         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,
@@ -1511,29 +1528,6 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         return result;
     }
 
-    /** type ∈ {0 自动回复, 1 服务号推送, 4 公众号推送} 为公众号入口;{2,3} 为企微。type=null/其他 视为非公众号(走企微稳定逻辑)。 */
-    private boolean isGzhEntryType(Integer type) {
-        if (type == null) return false;
-        return type == 0 || type == 1 || type == 4;
-    }
-
-    private String getVideoContentListType(Integer type) {
-        switch (type) {
-            case 0:
-                return "自动回复";
-            case 1:
-                return "服务号推送";
-            case 2:
-                return "企微-社群";
-            case 3:
-                return "企微-自动回复";
-            case 4:
-                return "公众号推送";
-            default:
-                return "";
-        }
-    }
-
     private String getVideoContentListChannel(Integer sort, String channel) {
         if (sort == 2 || sort == 3) {
             return channel;
@@ -1634,7 +1628,7 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             return result;
         }
         String sort = getVideoContentListSort(param.getSort());
-        String type = getVideoContentListType(param.getType());
+        String type = param.getIndustryType().getDescription();
         String channel = getVideoContentListChannel(param.getSort(), user.getChannel());
         List<ContentPlatformVideo> videoList = planMapperExt.getUploadVideoList(param, user.getId(), datastatDt,
                 type, channel, "normal", offset, param.getPageSize(), sort);
@@ -2096,31 +2090,31 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     /**
      * 搜索:在当前入口的 demand 白名单(rov >= 0.03)内做 title LIKE 关键字命中,按 rov DESC 排序。
      * 门控:
-     *   - 用户身份 type ∈ {2 自营, 3 代理},否则返回空
-     *   - 渠道 ∈ {小程序投流-稳定, 公众号投流-稳定},否则返回空
-     *   - 小程序投流必填 crowdPackage,公众号投流必填 ghName,否则返回空
+     * - 用户身份 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 内存切片返回
+     * 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())) {
+        if (user == null || !ContentPlatformAccountTypeEnum.from(user.getType()).allowSearch()) {
             return empty;
         }
-        String channelName = resolveChannelName(param);
-        if (channelName == null || !CHANNELS_ALLOW_SEARCH.contains(channelName)) {
+        DemandChannelEnum channel = resolveChannelName(param);
+        if (channel == null || !channel.allowSearch()) {
             return empty;
         }
-        boolean isXcx = CHANNEL_NAME_XCX.equals(channelName);
+        boolean isXcx = DemandChannelEnum.XCX == channel;
         String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
         String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
         if (isXcx && crowdPackage == null) return empty;
@@ -2129,11 +2123,12 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         String kw = param.getTitle() == null ? "" : param.getTitle().trim().toLowerCase();
         if (kw.isEmpty()) return empty;
 
-        String dt = demandVideoMapperExt.getMaxDt(channelName);
+        String ch = channel.getValue();
+        String dt = demandVideoMapperExt.getMaxDt(ch);
         if (!StringUtils.hasText(dt)) return empty;
 
         List<ContentPlatformDemandVideo> whitelist = demandVideoMapperExt.selectSearchWhitelist(
-                dt, channelName, isXcx ? crowdPackage : null, isXcx ? null : ghName);
+                dt, ch, isXcx ? crowdPackage : null, isXcx ? null : ghName);
         if (CollectionUtils.isEmpty(whitelist)) return empty;
 
         // SQL 已 ORDER BY rov DESC,putIfAbsent 即拿到 max rov 代表行(设计意图:与排序键一致)
@@ -2442,11 +2437,11 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
 
     @Override
     public List<String> getXcxAudiencePackageList() {
-        String dt = demandVideoMapperExt.getMaxDt(CHANNEL_NAME_XCX);
+        String dt = demandVideoMapperExt.getMaxDt(DemandChannelEnum.XCX.getValue());
         if (!StringUtils.hasText(dt)) {
             return Collections.emptyList();
         }
-        return demandVideoMapperExt.selectDistinctCrowdPackages(dt, CHANNEL_NAME_XCX);
+        return demandVideoMapperExt.selectDistinctCrowdPackages(dt, DemandChannelEnum.XCX.getValue());
     }
 
     private List<XcxPlanItemVO> buildXcxPlanItemVOList(List<ContentPlatformXcxPlan> planList) {

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

@@ -156,6 +156,9 @@
         <if test="matchMethod != null and matchMethod != ''">
             AND match_method = #{matchMethod}
         </if>
+        <if test="demandType != null and demandType != ''">
+            AND demand_type = #{demandType}
+        </if>
         <if test="crowdPackage != null and crowdPackage != ''">
             <choose>
                 <when test='crowdPackage == "泛人群"'>

+ 450 - 0
docs/videoContentList-flow.md

@@ -0,0 +1,450 @@
+# `/contentPlatform/plan/videoContentList` 接口流程文档
+
+## 1. 概述
+
+| 项目 | 说明 |
+|------|------|
+| 接口路径 | `POST /contentPlatform/plan/videoContentList` |
+| 接口说明 | 发布内容视频列表(分页查询) |
+| Controller | `ContentPlatformPlanController.java:63` |
+| Service | `ContentPlatformPlanServiceImpl.getVideoContentList()` |
+| 请求参数 | `VideoContentListParam extends PageParam` |
+| 返回类型 | `CommonResponse<Page<VideoContentItemVO>>` |
+
+## 2. 请求参数 (VideoContentListParam)
+
+| 字段 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `title` | String | null | 搜索关键词,**非空时触发标题搜索路径** |
+| `source` | String | null | 数据来源:`"prior"` / `"posterior"` / `"hot"` / 空(全部穿插) |
+| `type` | Integer | 999 | 入口类型:0=自动回复、1=服务号推送、2=企微-社群、3=企微-自动回复、4=公众号推送、5=小程序投流 |
+| `sort` | Integer | 0 | 排序:0=平台推荐、1=行业裂变率、2=本渠道裂变率、3=推荐指数 |
+| `category` | String | null | 品类过滤 |
+| `ghName` | String | null | 公众号名称(映射 `demand.channel_level3`),仅 prior/posterior 路使用 |
+| `crowdPackage` | String | null | 人群包(映射 `demand.crowd_package`),小程序投流入口使用 |
+| `pageNum` | int | (继承) | 页码 |
+| `pageSize` | int | (继承) | 每页条数 |
+
+## 3. 总体路由逻辑
+
+```
+getVideoContentList(param, user)
+│
+├─ [param.title 非空] ──────────────────► 路径A: searchByTitleInDemandPool()
+│
+├─ [param.source == "prior"] ───────────► 路径B: getSingleSourcePage(SOURCE_PRIOR)
+│
+├─ [param.source == "posterior"] ───────► 路径C: getSingleSourcePage(SOURCE_POSTERIOR)
+│
+├─ [param.source == "hot"] ─────────────► 路径D: getSingleSourcePage(SOURCE_HOT) → getHotSourcePaged()
+│
+└─ [param.source 为空或其他] ───────────► 路径E: getInterleavedPage()  默认全部穿插
+```
+
+---
+
+## 4. 路径 A:标题搜索 `searchByTitleInDemandPool()`
+
+### 4.1 前置校验(任一不满足返回空页)
+
+1. **用户类型白名单**:`user.type` 必须在 `{2(自营), 3(代理)}` 中
+2. **渠道名白名单**:`resolveChannelName()` 解析出的 `DemandChannel` 必须 `.allowSearch()` 为 true(`XCX` / `GZH_TOULIU`)
+3. **入口维度必填**:
+   - 小程序投流 (`DemandChannel.XCX`) → `crowdPackage` 必填
+   - 公众号投流 (`DemandChannel.GZH_TOULIU`) → `ghName` 必填
+4. **关键词非空**:title 去除空白后不能为空
+
+### 4.2 搜索流程
+
+```
+searchByTitleInDemandPool(param, user)
+│
+├─ 1. resolveChannelName(param) → DemandChannel
+│     ├─ type ∈ {2,3} → DemandChannel.QW
+│     ├─ type == 5    → DemandChannel.XCX
+│     └─ 其他(0/1/4) + ghName 非空 → DemandChannel.fromValue(查 demand 表)
+│
+├─ 2. 取最新分区 dt = demandVideoMapperExt.getMaxDt(channel.getValue())
+│
+├─ 3. 查白名单候选集
+│     └─ SQL: selectSearchWhitelist(dt, channelName, crowdSegment, channelLevel3)
+│         │  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 >= 0.03
+│         │    [crowdPackage/crowdSegment 过滤, "泛人群" 做 NULL/空串特殊映射]
+│         │    [channelLevel3 = ghName 过滤(仅公众号入口)]
+│         │  ORDER BY rov DESC, id ASC
+│
+├─ 4. 同 video_id 去重(取 max rov 代表行,SQL 已排序,putIfAbsent 保留首次)
+│
+├─ 5. Java 端关键词匹配(title.toLowerCase().contains(kw))
+│
+├─ 6. 排序:rov DESC → total_rov DESC
+│
+└─ 7. 内存分页返回
+```
+
+---
+
+## 5. 枚举类型
+
+### 5.1 PriorDimension — 维度
+
+| 枚举 | 值 |
+|------|-----|
+| `PREMIUM` | `"传播的头部"` |
+| `GROWTH` | `"增长的头部"` |
+| `DISTRIBUTION` | `"传播的分发"` |
+| `GROWTH_DISTRIBUTION` | `"增长的分发"` |
+
+### 5.2 PriorPointType — 特征点类型
+
+| 枚举 | 值 |
+|------|-----|
+| `STANDARD` | `"特征点"` |
+| `GENERALIZED` | `"特征点泛化"` |
+
+### 5.3 DemandMatchMethod — 匹配方法
+
+| 枚举 | 值 |
+|------|-----|
+| `SCENE` | `"场景已看视频"` |
+| `VECTOR_SIMILARITY` | `"视频库_解构特征_向量相似匹配"` |
+| `PRECISION` | `"视频库_解构特征点_精准匹配"` |
+
+### 5.4 DemandStrategy — 需求策略
+
+| 枚举 | 值 |
+|------|-----|
+| `PRIOR` | `"人群需求"` |
+| `POSTERIOR` | `"优质相似"` |
+
+### 5.5 DemandChannel — 需求渠道
+
+| 枚举 | 值 | `isPartner()` | `allowSearch()` |
+|------|-----|:---:|:---:|
+| `QW` | `"群/企微合作-稳定"` | ✔ | |
+| `XCX` | `"小程序投流-稳定"` | | ✔ |
+| `GZH_TOULIU` | `"公众号投流-稳定"` | | ✔ |
+| `GZH_JIZHUAN` | `"公众号合作-即转-稳定"` | ✔ | |
+| `GZH_DAILY` | `"公众号合作-Daily-自选"` | ✔ | |
+
+- `SEARCH_ALLOWED` (EnumSet) = `{XCX, GZH_TOULIU}`
+- `PARTNER_CROWD` (EnumSet) = `{QW, GZH_JIZHUAN, GZH_DAILY}`
+- `fromValue(String)` 反查,未匹配返回 `null`
+
+### 5.6 ContentPlatformAccountTypeEnum — 账号类型
+
+| 枚举 | val | 说明 | `allowSearch()` |
+|------|:---:|------|:---:|
+| `PARTNER` | 1 | 合作方 | |
+| `INTERNAL` | 2 | 内部账号 | ✔ |
+| `AGENT` | 3 | 代理商 | ✔ |
+| `other` | 999 | 其他 | |
+
+- `from(int)` 反查,未匹配返回 `other`
+- `allowSearch()`: 搜索入口仅对自营/代理放开
+
+---
+
+## 6. 路径 B:单源 — 人群需求 `source="prior"`
+
+### 6.1 13 池统一架构
+
+4 维度 × (1 scene + 3 策略组) = 13 个候选池,通过 `PriorPoolConfig` 配置:
+
+```java
+PriorPoolConfig(dimension, pointType, matchMethod, isScene)
+```
+
+| 策略 | 池数 | dimension | pointType | matchMethod | 后处理 |
+|------|:---:|-----------|-----------|-------------|--------|
+| 1. 场景已看 | 1 | PREMIUM | STANDARD | SCENE | scene 管线 |
+| 2. 特征点*向量匹配 | 4 | PREMIUM / GROWTH / DISTRIBUTION / GROWTH_DISTRIBUTION | STANDARD | VECTOR_SIMILARITY | prior 管线 |
+| 3. 特征点泛化*向量匹配 | 4 | PREMIUM / GROWTH / DISTRIBUTION / GROWTH_DISTRIBUTION | GENERALIZED | VECTOR_SIMILARITY | prior 管线 |
+| 4. 特征点泛化*精准匹配 | 4 | PREMIUM / GROWTH / DISTRIBUTION / GROWTH_DISTRIBUTION | GENERALIZED | PRECISION | prior 管线 |
+
+### 6.2 流程
+
+```
+getSingleSourcePage(param, user, SOURCE_PRIOR)
+│
+├─ PRIOR_POOL_CONFIGS.size() = 13,遍历 PriorPoolConfig 列表
+├─ 每请求独立 ExecutorService(nThreads=13),try-finally shutdown
+│
+├─ 13 池并行拉取:
+│   ├─ cfg.scene? → fetchPriorSceneCandidates(param, user, limit, cfg.dimension, cfg.pointType, cfg.matchMethod)
+│   └─          → fetchPriorPool(param, user, limit, cfg.dimension, cfg.pointType, cfg.matchMethod)
+│
+├─ getQuietly(30s 超时) 等待全部
+├─ 标记 source = "prior"
+│
+├─ interleaveMultiPools(pools, Random(nanoTime^userId), maxBlockSize=1)
+│   └─ 每位等概率从未耗尽池中随机抽取,跨池 video_id + 归一化标题去重
+│
+└─ paginateCandidates(): 内存分页
+```
+
+### 6.3 Scene 池后处理 (`fetchPriorSceneCandidates`)
+
+```
+resolveChannelName(param) → DemandChannel
+resolveCrowdSegment(channel, user)
+↓
+SQL: selectForRecommend()
+│   demand_strategy=DemandStrategy.PRIOR.getValue()  | dimension.getValue()
+│   pointType.getValue()  | matchMethod=DemandMatchMethod.SCENE.getValue()
+│   [channel_name / crowd_segment / channel_level3 / category / crowdPackage]
+│   [excludeCategories]
+│   ORDER BY total_rov DESC, score DESC
+│
+├─ 跨渠道退化: channel.isPartner() && crowdSegment 非空 0 行 → 去 crowdSegment 重查
+├─ 1. 同 video_id 取 total_rov 最大的代表行 (putIfAbsent)
+├─ 2. 过滤 rov <=0/null, sceneSumRov < 0.03
+├─ 3. 曝光下发过滤
+└─ 4. 排序: sceneSumRov DESC → total_rov DESC
+```
+
+### 6.4 Prior 管线 (`fetchPriorPool`)
+
+```
+resolveChannelName(param) → DemandChannel
+resolveCrowdSegment(channel, user)
+↓
+SQL: selectForRecommend()
+│   demand_strategy=DemandStrategy.PRIOR.getValue()  | dimension.getValue()
+│   pointType.getValue()  | matchMethod.getValue()
+│   fetchLimit = max(limit*3, 10000)
+│
+├─ 跨渠道退化: channel.isPartner() && crowdSegment 非空 0 行 → 去 crowdSegment 重查
+├─ 1. 过滤 rov < 0.03 (DEMAND_MIN_ROV)
+├─ 2. 曝光下发过滤
+├─ 3. 按 (point_type, standard_element) 分组
+├─ 4. retainTopGroupsByTotalRov(): 保留 total_rov top 50% 的组
+└─ 5. groupAndTopK(): 组按 total_rov DESC、组内 score DESC → top K=3
+```
+
+---
+
+## 7. 路径 C:单源 — 优质相似 `source="posterior"`
+
+### 7.1 流程
+
+```
+getSingleSourcePage(param, user, SOURCE_POSTERIOR)
+  └─ fetchPosteriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT)
+```
+
+### 7.2 fetchPosteriorCandidates 详细
+
+```
+resolveChannelName(param) → DemandChannel
+resolveCrowdSegment(channel, user)
+fetchLimit = max(limit*3, 10000)
+↓
+SQL: selectForRecommend()
+│   demand_strategy=DemandStrategy.POSTERIOR.getValue()
+│   match_method=DemandMatchMethod.VECTOR_SIMILARITY.getValue()
+│   [channel_name / crowd_segment / channel_level3 / category / crowdPackage]
+│   [excludeCategories]
+│   [excludeSelfTitle=true → 排除 title = demand_content_title 的自匹配行]
+│   ORDER BY total_rov DESC, score DESC
+│
+├─ 跨渠道退化: channel.isPartner() && crowdSegment 非空 0 行 → 去 crowdSegment
+├─ 1. 过滤 rov < 0.03 (DEMAND_MIN_ROV)
+├─ 2. 曝光下发过滤
+├─ 3. 按 demand_content_id 分组
+├─ 4. retainTopGroupsByTotalRov(): 保留 total_rov top 50% 的需求组
+└─ 5. groupAndTopK(): 组按 total_rov DESC、组内 score DESC → top K=3(排除自标题)
+```
+
+- 所有结果标记 `source = "posterior"`
+- 内存分页返回
+
+---
+
+## 8. 路径 D:单源 — 热门 `source="hot"`
+
+### 8.1 流程
+
+```
+getSingleSourcePage(param, user, SOURCE_HOT)
+  └─ getHotSourcePaged(param, user)
+```
+
+### 8.2 详细
+
+```
+getHotSourcePaged(param, user)
+│
+├─ 取最新分区 dt = getVideoMaxDt()
+├─ 取统计最新分区 datastatDt = getVideoDatastatMaxDt()
+│
+├─ SQL: getVideoCount(param, dt, videoMinScore)
+│   │  FROM content_platform_video_agg
+│   │  WHERE dt = #{dt} AND status = 1 AND score >= #{minScore}
+│   │    [title LIKE / category = 条件]
+│
+├─ 排序解析 getVideoContentListSort(sort):
+│   ├─ sort=0 → "video.score desc"
+│   ├─ sort=1/2/3 → "datastat.fission_rate desc, video.score desc"
+│   └─ default → "video.score desc"
+│
+├─ type/channel/strategy 解析:
+│   ├─ type: 根据入口类型映射中文名
+│   ├─ channel: sort=2或3 时取 user.channel,否则 "sum"
+│   └─ strategy: sort=3 时 "recommend",否则 "normal"
+│
+├─ SQL: getVideoList()
+│   │  FROM content_platform_video_agg video
+│   │  LEFT JOIN content_platform_video_datastat_agg datastat
+│   │    ON datastat.dt/datastat.type/datastat.channel/datastat.strategy/datastat.video_id
+│   │  WHERE video.dt/datastat 条件
+│   │  ORDER BY ${sort}
+│   │  LIMIT offset, pageSize
+│
+├─ buildVideoContentItemVOList(): 组装 VO
+│   ├─ 补齐缺失封面(调 messageAttachmentService.getVideoDetail 批量获取)
+│   └─ 填充裂变率数据(getTypeChannelVideoDataStatAggList)
+│
+└─ 标记 source = "hot",返回 DB 真分页结果
+```
+
+**注意**:hot 路径是**真正的数据库分页**(SQL LIMIT offset, pageSize),而非内存分页。
+
+---
+
+## 9. 路径 E:默认全部穿插 `source` 为空
+
+### 9.1 15 池统一架构
+
+所有入口类型统一 15 池:13 prior + 1 posterior + 1 hot。
+
+```
+getInterleavedPage(param, user)
+│
+├─ priorCount = PRIOR_POOL_CONFIGS.size() = 13
+├─ totalCount = 13 + 2 = 15
+├─ 每请求独立 ExecutorService(nThreads=15),try-finally shutdown
+│
+├─ 15 池并行拉取:
+│   ├─ prior 13 池: 遍历 PriorPoolConfig,按 scene 标志分派 fetchPriorSceneCandidates / fetchPriorPool
+│   ├─ posterior 1 池: fetchPosteriorCandidates()
+│   └─ hot 1 池: fetchHotCandidates()
+│
+├─ getQuietly(30s 超时) 等待全部
+├─ source 标记: 前 13 池为 "prior",第 14 为 "posterior",第 15 为 "hot"
+│
+├─ 随机穿插算法:
+│   ├─ 种子 = userId ^ 当天日期字符串 hashCode(同用户当天翻页顺序一致)
+│   ├─ 每步从未耗尽池中等概率随机选一个
+│   ├─ 跨池 video_id + 归一化标题去重
+│   └─ 所有池耗尽后停止
+│
+└─ paginateCandidates(): 内存分页
+```
+
+### 9.2 池列表
+
+| 来源 | 池数 | 说明 |
+|------|:---:|------|
+| prior | 13 | 策略1(1) + 策略2(4) + 策略3(4) + 策略4(4) |
+| posterior | 1 | 优质相似 |
+| hot | 1 | 全局热门 |
+| **合计** | **15** | |
+
+---
+
+## 10. 并发策略
+
+| 路径 | 池数 | 线程池 | 生命周期 |
+|------|:---:|--------|---------|
+| prior 单源 | 13 | `Executors.newFixedThreadPool(13)` | 每次请求创建,finally shutdown |
+| posterior 单源 | 1 | 无(直接调用) | — |
+| hot 单源 | 1 | 无(直接调用) | — |
+| 默认穿插 | 15 | `Executors.newFixedThreadPool(15)` | 每次请求创建,finally shutdown |
+
+- 请求间完全隔离,不共享线程池,无阻塞排队
+- 每 Future 30s 超时,`getQuietly()` 异常/超时返回空列表(等价于该池无候选数据)
+- 各池查不同表、不同 SQL 条件,无事务/连接竞争
+
+---
+
+## 11. 关键常量一览
+
+| 常量 | 值 | 说明 |
+|------|-----|------|
+| `DEMAND_CANDIDATE_LIMIT` | 10000 | demand 池候选拉取上限 |
+| `HOT_CANDIDATE_LIMIT` | 10000 | hot 池候选拉取上限 |
+| `TOP_K_PER_DEMAND` | 3 | 每组(demand_content_id)最多保留条数 |
+| `DEMAND_MIN_ROV` | 0.03 | prior/posterior 池 rov 下限 |
+| `PRIOR_SCENE_MIN_SUM_ROV` | 0.03 | priorScene 池 sceneSumRov 下限 |
+| `PRIOR_GROUP_KEEP_RATIO` | 0.5 | prior 池保留 top 50% 的 (point_type, standard_element) 组 |
+| `POSTERIOR_GROUP_KEEP_RATIO` | 0.5 | posterior 池保留 top 50% 的 demand_content_id 组 |
+| `videoMinScore` | Apollo 可配 | hot 池 score 下限 |
+| `demandMinExposurePv` | 2000 (Apollo) | 曝光下发过滤阈值,≤0 关闭 |
+| `videoTitleSearchMaxCount` | 500 (Apollo) | 标题搜索最大条数 |
+
+---
+
+## 12. 涉及数据表
+
+| 表名 | 用途 | 访问方式 |
+|------|------|---------|
+| `content_platform_demand_video` | prior/posterior/搜索 候选池 | `ContentPlatformDemandVideoMapperExt` |
+| `content_platform_video_agg` | hot 池每日视频快照 | `ContentPlatformPlanMapperExt` |
+| `content_platform_video_datastat_agg` | 视频裂变率等统计数据 | `ContentPlatformPlanMapperExt` (LEFT JOIN) |
+
+---
+
+## 13. channel_name 解析规则 (`resolveChannelName` → `DemandChannel`)
+
+| type | ghName | 返回值 |
+|------|--------|------|
+| 2 (企微-社群) | — | `DemandChannel.QW` |
+| 3 (企微-自动回复) | — | `DemandChannel.QW` |
+| 5 (小程序投流) | — | `DemandChannel.XCX` |
+| 0/1/4 (公众号) | 有值 | `DemandChannel.fromValue(查 demand 表 WHERE channel_level3=ghName)` |
+| 0/1/4 (公众号) | null | null |
+| 其他 | — | null |
+
+---
+
+## 14. crowdSegment 解析规则 (`resolveCrowdSegment`)
+
+仅对合作类渠道(`DemandChannel.isPartner()` 为 true → QW / GZH_JIZHUAN / GZH_DAILY)用 `user.channel` 作为 `demand.crowd_segment` 过滤,投流/服务号类渠道一律 null。
+
+---
+
+## 15. 排序模式对照 (`getVideoContentListSort`)
+
+| sort | SQL ORDER BY |
+|:---:|------|
+| 0 | `video.score desc` |
+| 1 | `datastat.fission_rate desc, video.score desc` |
+| 2 | `datastat.fission_rate desc, video.score desc` |
+| 3 | `datastat.fission_rate desc, video.score desc` |
+| default | `video.score desc` |
+
+---
+
+## 16. 去重策略总结
+
+所有涉及多池穿插的路径(路径 B、路径 E)均执行:
+
+1. **video_id 去重**:同一 video_id 只出现一次
+2. **归一化标题去重**:通过 `TitleNormalizer`(去 emoji/空白/全半角转换)后,同标题只保留先出现的
+3. prior/posterior 池内部还有 **自标题去重**:`excludeSelfTitle=true` 时 SQL 排除 `title = demand_content_title` 的行
+
+---
+
+## 17. 退化策略汇总
+
+| 退化场景 | 触发条件 | 退化动作 |
+|----------|----------|---------|
+| 跨渠道 crowdSegment 退化 | `DemandChannel.isPartner()` && crowdSegment 非空 && SQL 返回 0 行 | 去除 crowdSegment 条件重查,**保留 ghName** |
+| ghName 无数据退化 | 传了 ghName 但从 demand 表反查不到 channel_name | `resolveChannelName` 返回 null,不限 channel_name |
+| 向量搜索降级 (路径A 内部) | `getVideoContentListByTitleV2` 向量搜索为空 | 降级到 `getVideoContentListByTitle` 关键词搜索 (manager 平台接口) |