# `videoContentList` 推荐列表排序逻辑 > 接口:`POST /contentPlatform/plan/videoContentList` > 入口:`ContentPlatformPlanServiceImpl.getVideoContentList` > 数据源:`content_platform_demand_video`(人群需求/优质相似/人群需求-场景)+ `content_platform_video`(全局热门) > > 数据组重命名:旧值「先验需求/后验需求/先验需求-场景」→ 新值「人群需求/优质相似/人群需求-场景」(2026-05 改名,本文档已对齐新值)。 > > 适用:当前分支 `cooperation_video_candidate_pool_improved_lld_0509`(含 commit `2860bdce`)。 --- ## 1. 入参 → 路径分发 ``` title 非空 ───────────► 全站搜索 (getVideoContentListByTitle) source=prior ───────────► 单源「粉丝喜欢」(getSingleSourcePage / prior) source=posterior ───────► 单源「已发优质相似」(getSingleSourcePage / posterior) source=hot ───────────► 单源「全局热门」(getHotSourcePaged) source 空(默认) ──────► 四路随机穿插 (getInterleavedPage) ``` > 当前前端:`prior` 和 `posterior` 两个 tab 已 disabled(提示"功能正在完善中"),生产实际只走「全部」(四路穿插)和「全局热门」单源。 --- ## 2. 关键常量 | 常量 | 值 | 含义 | |---|---|---| | `DEMAND_CANDIDATE_LIMIT` | 10000 | 每个 demand 池最大候选条数 | | `HOT_CANDIDATE_LIMIT` | 10000 | hot 池候选条数 | | `TOP_K_PER_DEMAND` | 3 | demand 池组内取前 K | | `DEMAND_STRATEGY_PRIOR` | `"人群需求"` | 人群需求池过滤值(prior,旧名「先验需求」) | | `DEMAND_STRATEGY_PRIOR_SCENE` | `"人群需求-场景"` | 场景池过滤值(旧名「先验需求-场景」) | | `DEMAND_STRATEGY_POSTERIOR` | `"优质相似"` | 优质相似池过滤值(posterior,旧名「后验需求」) | | `PRIOR_PREMIUM_DIMENSION` | `"传播的头部"` | prior 池 dimension 强过滤 | | `PRIOR_MIN_ROV` | `0.02` | prior 池近 7 日 rov 下限(只对 prior 池;priorScene/posterior 仍 >0) | | `CHANNEL_NAME_GZH` | `"公众号合作-即转-稳定"` | 公众号入口对应 `channel_name` 强过滤值 | | `CHANNEL_NAME_QW` | `"群/企微合作-稳定"` | 企微入口对应 `channel_name` 强过滤值 | | `PRIOR_GROUP_KEEP_RATIO` | `0.5` | prior 池"特征组"按 total_rov 分位保留比例 | | `POSTERIOR_FILTER_ABS_LIKE` | `"绝对高效率%"` | posterior 池 A 段 `demand_filter_sort_strategy` LIKE | | `POSTERIOR_FILTER_REL_LIKE` | `"相对裂变率%"` | posterior 池 B 段 `demand_filter_sort_strategy` LIKE | | `POSTERIOR_DRIVE_DIMENSION_TIME` | `"昨日"` | posterior 池强制 `drive_dimension_time` | 公共强过滤(所有 demand 池 SQL):`dt = max(dt)` AND `status = 1` AND `crowd_segment = user.channel` AND `channel_name = resolveChannelName(param)`(可空)。 ### 2.1 `resolveChannelName(param)` — 入口 → `channel_name` 映射 为避免同一 `crowd_segment` 在公众号系与企微系都存在(已确认 `gzyhc` / `wxm` 双渠道并存)时,选企微入口却拉到公众号数据,**3 个 demand 池 SQL 在源头加 `channel_name` 强过滤**。映射依据 `VideoContentListParam`: | `param.type` | 含义 | 映射 `channel_name` | |---|---|---| | `0` 自动回复 / `1` 服务号推送 / `4` 公众号推送 | 公众号系 | `CHANNEL_NAME_GZH` | | `2` 企微-社群 / `3` 企微-自动回复 | 企微系 | `CHANNEL_NAME_QW` | | `999` / 其它 | 不限平台 | 进入 fallback | **Fallback**:`type` 空或不在上表时,若 `param.ghName` 非空(只有公众号入口会带 ghName)→ `CHANNEL_NAME_GZH`;否则返回 `null`(不加 channel_name 过滤,保留旧行为)。 > 设计动机:`crowd_segment` 是按客户(广告主)切的,而同一客户可能同时投公众号 + 企微;数据落库时按"人群_渠道"打 `channel_name`。前端切换入口后 `type` 一定会变,所以让 `type` 当一级路由信号最稳。 --- ## 3. 四个候选池的构造顺序 ### 3.1 `fetchPriorSceneCandidates`(场景池) **目的:用户所属 channel 在"场景"维度命中的人群需求-场景行,按视频近 7 日表现(rov)排序。** ```sql SELECT ... FROM content_platform_demand_video WHERE dt=:maxDt AND status=1 AND crowd_segment=:userChannel AND channel_name=:resolvedChannelName -- 若 resolveChannelName 命中(公众号/企微) AND demand_strategy='人群需求-场景' AND channel_level3=:ghName -- 若传入 ORDER BY total_rov DESC, score DESC LIMIT 10000 ``` 退化阶梯(细→粗): 1. `ghName` 非空但查 0 条 → **去掉 `ghName` 再查一次**(拿全 channel 兜底) 2. 仍为 0 且 `channel_name` 非空 → **再去掉 `crowd_segment`**,只按 `channel_name` 拉(跨渠道兜底,如公众号账号切到企微入口) 3. `channel_name` 在所有退化层**始终保留**(强过滤,跨渠道污染必须先挡掉) 后处理(顺序): 1. 同 `videoId` 去重,保留首次出现(SQL 已按 `total_rov DESC, score DESC` 排序 → 首次 = 该视频的"最强代表需求"行) 2. **过滤** `rov` 为 null 或 ≤0 的视频 3. 重排序:`rov DESC` 主键,`total_rov DESC` 次级 tiebreaker 输出顺序:**视频按 rov(近 7 日表现)DESC**;同 rov 时按代表需求的 total_rov DESC。 > 设计动机:场景需求里同一视频会对应多个特征点,但前端只下发一条;用 total_rov 选"最强代表需求",再以视频自身的 rov 决定整体排序,避免按需求维度排序导致同 dimension/标准要素的视频在列表里扎堆。 --- ### 3.2 `fetchPriorCandidates`(人群需求池,prior) **目的:人群需求里,只取 `dimension='传播的头部'` 维度,并按 channel 内"特征需求强度"分位裁掉弱题材。** 单段查询: ```sql SELECT ... WHERE ... AND channel_name=:resolvedChannelName(可空) AND demand_strategy='人群需求' AND dimension='传播的头部' ... ORDER BY total_rov DESC, score DESC LIMIT 30000 ``` 退化阶梯:`ghName` 非空且 0 条 → 去 `ghName` 重查;仍 0 且 `channel_name` 非空 → 再去 `crowd_segment` 重查(跨渠道兜底)。`channel_name` 始终保留。 **[新] 特征组分位裁剪 (`retainTopGroupsByTotalRov`,`keepRatio=0.5`)**: - 按 `(point_type, standard_element)` 分组,取每组 `max(total_rov)`(即该特征的人群需求强度) - 按组 total_rov DESC 排,**保留 top ⌈N×50%⌉ 个特征组** - 各渠道 total_rov 分布差异大(cdjh 0.003~0.057,xycsd17 0.014~0.037),用分位避免绝对阈值伤弱渠道 > 设计动机:`total_rov` 在 prior 池 = 群体对(point_type, standard_element)特征的需求强度。低 total_rov 说明群体不爱这个题材,把对应视频堆在列表底部没意义,直接剪掉。 进 `groupAndTopK`: - 分组键:`(point_type, standard_element)` - **过滤** `rov <= 0` 或 null(近 7 日无表现) - 不做 selfTitle 过滤 - 组排序:组内最大 `total_rov` DESC - 组内排序:`score` DESC,组内 `videoId` 去重,每组最多 3 条 最后按 `limit=10000` 截断。 最终顺序:**保留 top 50% 特征组内,组按总 ROV,组内按 score。** --- ### 3.3 `fetchPosteriorCandidates`(优质相似池,posterior) **目的:优质相似里,"昨日"驱动的"绝对高效率"先出,再出"相对裂变率"。** A、B 两段独立查询: ```sql -- A 段: demand_filter_sort_strategy LIKE '绝对高效率%' SELECT ... WHERE ... AND channel_name=:resolvedChannelName(可空) AND demand_strategy='优质相似' AND demand_filter_sort_strategy LIKE '绝对高效率%' AND drive_dimension_time='昨日' AND (title IS NULL OR demand_content_title IS NULL OR title <> demand_content_title) ORDER BY total_rov DESC, score DESC LIMIT 30000 -- B 段: demand_filter_sort_strategy LIKE '相对裂变率%' SELECT ... AND demand_filter_sort_strategy LIKE '相对裂变率%' AND drive_dimension_time='昨日' ... ``` 退化阶梯:A、B 都空且 `ghName` 非空 → 去 `ghName` 重查;A、B 仍都空且 `channel_name` 非空 → 再去 `crowd_segment` 重查(跨渠道兜底)。`drive_dimension_time='昨日'` 与 `channel_name` 强过滤始终保留。 每段进 `groupAndTopK`: - 分组键:`demand_content_id` - **过滤** `rov <= 0` 或 null - **过滤** `excludeSelfTitle=true` → 用 `TitleNormalizer.isSelfTitle` 跳过自标题 - 组排序:组内最大 `total_rov` DESC - 组内排序:`score` DESC,去重,每组最多 3 条 A 段 + B 段 顺序拼接 → 跨段 `videoId` 去重 → 截 10000。 最终顺序:**A 段(绝对高效率) 在前;段内组按总 ROV,组内按 score。** --- ### 3.4 `fetchHotCandidates`(热门池) 复用现有 `planMapperExt.getVideoList(...)`: - `dt = videoMaxDt`,`datastatDt = videoDatastatMaxDt` - `sort/type/channel/strategy` 由请求和 `param.getSort()` 派生 - 一次性查前 10000 条,**未分组、未二次过滤** - 顺序由 SQL 决定(一般是 `fission_rate DESC` 或 `score DESC`) --- ## 4. 「全部」模式:四路随机穿插(`getInterleavedPage`) ``` priorScene → 标 source='prior' prior → 标 source='prior' posterior → 标 source='posterior' hot → 标 source='hot' ``` > priorScene 和 prior **对外都是 `source='prior'`**(前端"粉丝喜欢"统一标签);浮层细节通过 `demandStrategy` 字段区分场景。 ### 算法 1. 维护 4 个池的 `pointer[i]` 和 `exhausted[i]`,以及全局 `emittedIds` + `emittedTitles`。 2. 种子:`seed = userId ^ LocalDate.now().toString().hashCode()` - 同一用户同一天翻页/刷新得到的顺序一致 - 跨天/跨用户顺序变化 3. 循环直到 4 池全空: 1. 在未耗尽池中等概率随机选一个 2. 跳过该池里 **video_id 已发** 或 **标题(归一化后)已发** 的候选(`shouldSkipForDedup`) 3. 取出第一条未发的,加入 `merged`、记入 `emittedIds` 和 `emittedTitles` 4. 若该池指针越界,标为 exhausted 4. `paginateCandidates`:`totalSize = merged.size()`,按 `pageNum/pageSize` 内存切片返回。 > 标题去重用 `TitleNormalizer.normalize`(去 emoji/空白/全半角),应对运营把同段内容重复上传成多个 video_id(如 `🔴她走了,台湾再无洪秀柱!` 对应 67396144 / 67812469 两条)。 > 单源 `source=prior` 模式的 `interleavePriorWithScene` 也用同一套(video_id + 标题)去重。 ### 时间复杂度 - 每池 1 次 DB Query - 主循环 O(总池容量),每个池最大 10000 → 上界约 4 万次操作 - 单页响应 = 1 次 maxDt + 4 次 SELECT + 内存穿插 ### 排序稳定性 - 同一用户同一天,所有分页之间顺序一致 - `priorScene` / `prior` / `posterior` 内部相对顺序保留(场景按视频 rov;prior/posterior 按组 total_rov + 组内 score),随机只影响"哪一池先出" --- ## 5. 「粉丝喜欢」单源(`source=prior`) ```java scene = fetchPriorSceneCandidates(...) prior = fetchPriorCandidates(...) list = interleavePriorWithScene(scene, prior) // 严格 1:1 ``` `interleavePriorWithScene`: - 单次循环:先从 `scene` 取一条未发的 → 再从 `prior` 取一条未发的,**严格 1:1 交替** - 用 `seen` 跨池去重,场景优先(先到先得) - 一侧用完后,另一侧剩余按原顺序追加 每条 VO 设 `source='prior'`,然后 `paginateCandidates` 切片。 --- ## 6. 「已发优质相似」单源(`source=posterior`) ```java list = fetchPosteriorCandidates(...) // 顺序 = 绝对高效率段 → 相对裂变率段 ``` 每条 VO 设 `source='posterior'`,`paginateCandidates` 切片。 --- ## 7. 「全局热门」单源(`source=hot`) 不走候选池:复用原 `planMapperExt.getVideoCount + getVideoList` 真分页链路,DB 端 LIMIT/OFFSET。VO `source='hot'`。 --- ## 8. 字段全景:每条 VO 必有 `source` 字段 | `source` | 含义 | 数据来源 | 浮层 demand 字段 | |---|---|---|---| | `prior` | 粉丝喜欢(人群需求-场景 + 人群需求-头部) | `content_platform_demand_video` | 有完整字段,可看 `demandStrategy` 区分 | | `posterior` | 已发优质相似 | `content_platform_demand_video` | 有完整字段 | | `hot` | 全局热门 | `content_platform_video` | 只有基础字段(demand 相关字段为空) | --- ## 9. 一图总览 ``` ┌────────────────────────────────────────────────────────┐ │ 1. 拉 4 个候选池 (每个池都自己分阶段、分组、去重、排序) │ └────────────────────────────────────────────────────────┘ │ ┌───────────────────────┬──┴──┬─────────────────────────────┐ ▼ ▼ ▼ ▼ priorScene(10000) prior(10000) posterior(10000) hot(10000) 视频维度 rov DESC 传播头部 A: 绝对高效率 + 昨日 SQL 默认 (代表需求 total_rov 选最大) B: 相对裂变率 + 昨日 (sort 决定) 组(point_type, standard_element) top3 组(demand_content_id) top3 │ ▼ ┌────────────────────────────────────────────────────────┐ │ 2. 四路随机穿插 │ │ - 种子 = userId ^ 今天 │ │ - 跨池 videoId 去重 │ │ - 池内顺序保留 │ └────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────┐ │ 3. paginateCandidates │ │ totalSize = 全量, 按 pageNum/pageSize 切片 │ └────────────────────────────────────────────────────────┘ ``` --- ## 10. 关键代码位置 | 内容 | 位置 | |---|---| | 主入口路由 | `ContentPlatformPlanServiceImpl.java:626` | | 单源分页 | `ContentPlatformPlanServiceImpl.java:649` | | 单源 hot | `ContentPlatformPlanServiceImpl.java:698` | | 四路穿插 | `ContentPlatformPlanServiceImpl.java:743` | | 场景池 fetcher | `ContentPlatformPlanServiceImpl.java:812` | | 人群需求池 fetcher (prior) | `ContentPlatformPlanServiceImpl.java:840` | | 优质相似池 fetcher (posterior) | `ContentPlatformPlanServiceImpl.java:875` | | `groupAndTopK` 通用排序 | `ContentPlatformPlanServiceImpl.java:912` | | 段间拼接 + 去重 | `ContentPlatformPlanServiceImpl.java:956` | | 热门池 fetcher | `ContentPlatformPlanServiceImpl.java:976` | | Mapper SQL | `mapper/contentplatform/ext/ContentPlatformDemandVideoMapperExt.xml` (selectForRecommend) |