videoContentList-flow.md 17 KB

/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 配置:

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 解析规则 (resolveChannelNameDemandChannel)

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