# `/contentPlatform/plan/videoContentList` 接口流程文档 ## 1. 概述 | 项目 | 说明 | |------|------| | 接口路径 | `POST /contentPlatform/plan/videoContentList` | | 接口说明 | 发布内容视频列表(分页查询) | | Controller | `ContentPlatformPlanController.java:63` | | Service | `ContentPlatformPlanServiceImpl.getVideoContentList()` | | 请求参数 | `VideoContentListParam extends PageParam` | | 返回类型 | `CommonResponse>` | ## 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 平台接口) |