# `/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 平台接口) |
