# `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 强过滤 |
| `DEMAND_MIN_ROV` | `0.02` | prior / posterior 池近 7 日 rov 下限(priorScene 池基数小,仍 >0) |
| `CHANNEL_NAME_GZH` | `"公众号合作-即转-稳定"` | `type=0` 自动回复 / ghName fallback |
| `CHANNEL_NAME_GZH_DAILY` | `"公众号合作-Daily-自选"` | `type=1` 服务号推送 / `type=4` 公众号推送 |
| `CHANNEL_NAME_QW`  | `"群/企微合作-稳定"`     | 企微入口对应 `channel_name` 强过滤值 |
| `PRIOR_GROUP_KEEP_RATIO` | `0.5` | prior 池"特征组"按 total_rov 分位保留比例 |
| `POSTERIOR_GROUP_KEEP_RATIO` | `0.5` | posterior 池 demand_content_id 组按 total_rov 中位数及以上保留 |

> 优质相似池 SQL **不**加 `drive_dimension_time` 条件(主查与 ghName/跨渠道退化路径一致),可包含「昨日」以外驱动时间档位的数据。

公共强过滤（所有 demand 池 SQL）：`dt = max(dt)` AND `status = 1` AND `crowd_segment = user.channel` AND `channel_name = resolveChannelName(param)`(可空)。  
例外：`channel_name = '公众号合作-Daily-自选'` 时不加「早中晚好 / 节日祝福」`category` 黑名单条件（其它渠道仍过滤）。

### 2.1 `resolveChannelName(param)` — 入口 → `channel_name` 映射

为避免同一 `crowd_segment` 在公众号系与企微系都存在(已确认 `gzyhc` / `wxm` 双渠道并存)时,选企微入口却拉到公众号数据,**3 个 demand 池 SQL 在源头加 `channel_name` 强过滤**。映射依据 `VideoContentListParam`:

| `param.type` | 含义 | 映射 `channel_name` |
|---|---|---|
| `0` 自动回复 | 公众号系-即转 | `CHANNEL_NAME_GZH` |
| `1` 服务号推送 / `4` 公众号推送 | 公众号系-Daily | `CHANNEL_NAME_GZH_DAILY` |
| `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）

**目的：取所有"优质相似"行(不再按 `demand_filter_sort_strategy` 子分类),按需求组的 total_rov 中位数砍掉群体表现弱的需求,组内取 top K。**

> 与旧版差异:旧版"绝对高效率%"(A 段)无条件优先于"相对裂变率%"(B 段)。新版统一按 `total_rov DESC, score DESC` 排,段间优先级取消;由于 B 段(相对裂变率)群体强度普遍低于 A 段,中位数裁剪后大概率被砍掉,这是已知且接受的行为。

单段查询:

```sql
SELECT ... WHERE ... AND channel_name=:resolvedChannelName(可空)
  AND demand_strategy='优质相似'
  -- 不加 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
```

**`drive_dimension_time`**: 优质相似池**一律不限制**(主查与退化路径均不传该维度),与公众号/渠道粒度无关。

退化阶梯:主查询空且 `ghName` 非空 → 去 `ghName` 重查;仍空且 `channel_name` 非空 → 再去 `crowd_segment` 重查(跨渠道兜底)。`channel_name` 强过滤始终保留。

**[新] 近 7 日 rov 下限(`DEMAND_MIN_ROV=0.02`)**:
- 与 prior 池一致,过滤掉视频 rov < 0.02 的低质量近期表现
- cdjh 优质相似 0514 全量验证保留 ~54%,priorScene 池基数小不加该过滤

**[新] 需求组分位裁剪 (`retainTopGroupsByTotalRov`,`keepRatio=POSTERIOR_GROUP_KEEP_RATIO=0.5`)**:
- 按 `demand_content_id` 分组,取每组 `max(total_rov)`(需求组的群体强度)
- 按组 total_rov DESC 排,**保留 top ⌈N×50%⌉ 个需求组**(即中位数及以上)
- 组数 < 2 时全部保留,避免空/单组数据被意外裁掉

> 设计动机:同一 `demand_strategy='优质相似'` 里,低 total_rov 的 demand 会带回很多 sim=1.0 的克隆视频稀释推荐,先在需求组层面砍一刀比在视频层面砍更稳定。

进 `groupAndTopK`：
- 分组键：`demand_content_id`
- **过滤** `rov <= 0` 或 null
- **过滤** `excludeSelfTitle=true` → 用 `TitleNormalizer.isSelfTitle` 跳过自标题
- 组排序：组内最大 `total_rov` DESC
- 组内排序：`score` DESC,去重,每组最多 3 条

最后 `concatDedup(out, [], limit)` 用 `videoId` + `TitleNormalizer.normalize(title)` 双键去重,截到 10000。

最终顺序：**保留 top 50% 需求组内,组按总 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<videoId>` 跨池去重，场景优先（先到先得）
- 一侧用完后，另一侧剩余按原顺序追加

每条 VO 设 `source='prior'`，然后 `paginateCandidates` 切片。

---

## 6. 「已发优质相似」单源（`source=posterior`）

```java
list = fetchPosteriorCandidates(...)   // 顺序 = total_rov DESC, score DESC (单段)
```

每条 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   传播头部       优质相似(不按 drive_dimension_time 过滤) SQL 默认
  (代表需求 total_rov 选最大)        组 total_rov 中位数及以上            (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) |
