/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 前置校验(任一不满足返回空页)
- 用户类型白名单:
user.type 必须在 {2(自营), 3(代理)} 中
- 渠道名白名单:
resolveChannelName() 解析出的 DemandChannel 必须 .allowSearch() 为 true(XCX / GZH_TOULIU)
- 入口维度必填:
- 小程序投流 (
DemandChannel.XCX) → crowdPackage 必填
- 公众号投流 (
DemandChannel.GZH_TOULIU) → ghName 必填
- 关键词非空: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 配置:
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)均执行:
- video_id 去重:同一 video_id 只出现一次
- 归一化标题去重:通过
TitleNormalizer(去 emoji/空白/全半角转换)后,同标题只保留先出现的
- 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 平台接口) |