Просмотр исходного кода

videoContentList:新增 source=selected 历史已选 tab,其他 tab 剔除当前账号已发布 video,软删除 plan_video 不出现也不过滤。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 16 часов назад
Родитель
Сommit
cb07e644d1

+ 29 - 0
api-module/src/main/java/com/tzld/piaoquan/api/dao/mapper/contentplatform/ext/ContentPlatformPlanMapperExt.java

@@ -4,6 +4,7 @@ import com.tzld.piaoquan.api.model.param.contentplatform.GzhPlanListParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.QwPlanListParam;
 import com.tzld.piaoquan.api.model.param.contentplatform.VideoContentListParam;
 import com.tzld.piaoquan.api.model.po.contentplatform.*;
+import com.tzld.piaoquan.api.model.vo.contentplatform.VideoContentItemVO;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -133,4 +134,32 @@ public interface ContentPlatformPlanMapperExt {
                                               @Param("now") Long now);
 
     List<ContentPlatformGzhPlanVideo> getGzhPushPlanVideoByRootSourceId(@Param("rootSourceId") String rootSourceId, @Param("planType") Integer planType);
+
+    Integer getSelectedQwVideoCount(@Param("loginAccountId") Long loginAccountId,
+                                    @Param("planType") Integer planType,
+                                    @Param("title") String title);
+
+    List<VideoContentItemVO> getSelectedQwVideoList(@Param("loginAccountId") Long loginAccountId,
+                                                    @Param("planType") Integer planType,
+                                                    @Param("title") String title,
+                                                    @Param("offset") int offset,
+                                                    @Param("pageSize") Integer pageSize);
+
+    Integer getSelectedGzhVideoCount(@Param("loginAccountId") Long loginAccountId,
+                                     @Param("planType") Integer planType,
+                                     @Param("title") String title);
+
+    List<VideoContentItemVO> getSelectedGzhVideoList(@Param("loginAccountId") Long loginAccountId,
+                                                     @Param("planType") Integer planType,
+                                                     @Param("title") String title,
+                                                     @Param("offset") int offset,
+                                                     @Param("pageSize") Integer pageSize);
+
+    List<Long> getUserPublishedQwVideoIds(@Param("loginAccountId") Long loginAccountId,
+                                          @Param("planType") Integer planType,
+                                          @Param("limit") Integer limit);
+
+    List<Long> getUserPublishedGzhVideoIds(@Param("loginAccountId") Long loginAccountId,
+                                           @Param("planType") Integer planType,
+                                           @Param("limit") Integer limit);
 }

+ 8 - 1
api-module/src/main/java/com/tzld/piaoquan/api/model/param/contentplatform/VideoContentListParam.java

@@ -1,9 +1,12 @@
 package com.tzld.piaoquan.api.model.param.contentplatform;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.tzld.piaoquan.api.model.param.PageParam;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class VideoContentListParam extends PageParam {
 
@@ -19,9 +22,13 @@ public class VideoContentListParam extends PageParam {
     @ApiModelProperty(value = "排序 0-平台推荐 1-行业裂变率 2-本渠道裂变率 3-推荐指数")
     private Integer sort = 0;
 
-    @ApiModelProperty(value = "数据来源: prior-人群需求 / posterior-优质相似 / hot-全局热门 / 空-全部穿插")
+    @ApiModelProperty(value = "数据来源: prior-人群需求 / posterior-优质相似 / hot-全局热门 / selected-历史已选 / 空-全部穿插")
     private String source;
 
     @ApiModelProperty(value = "公众号名称(对应 demand.channel_level3),仅 prior/posterior 路使用,无数据时退化为渠道粒度")
     private String ghName;
+
+    /** 内部使用:当前登录账号在当前 type 下已发布过的 video_id 子集,用于在 hot/prior/posterior 池里 NOT IN 排除。不暴露给前端,JSON 反序列化时也忽略。 */
+    @JsonIgnore
+    private List<Long> excludeVideoIds;
 }

+ 133 - 1
api-module/src/main/java/com/tzld/piaoquan/api/service/contentplatform/impl/ContentPlatformPlanServiceImpl.java

@@ -631,6 +631,10 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     private static final String SOURCE_PRIOR = "prior";
     private static final String SOURCE_POSTERIOR = "posterior";
     private static final String SOURCE_HOT = "hot";
+    private static final String SOURCE_SELECTED = "selected";
+    /** 单用户历史已发布 video_id 上限:超过 500 条的老视频不再参与 NOT IN 过滤,
+     * 控制 SQL 体积,老视频本来曝光机会就低,丢失精度可接受。 */
+    private static final int EXCLUDE_PUBLISHED_LIMIT = 500;
 
     /**
      * 推导 channel_name(人群_渠道) 作为 demand 池强过滤。
@@ -655,11 +659,23 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
     @Override
     public Page<VideoContentItemVO> getVideoContentList(VideoContentListParam param) {
         ContentPlatformAccount user = LoginUserContext.getUser();
+        String source = param.getSource();
+        // 历史已选自带 title 模糊搜索,优先于通用 title 向量搜索分发
+        if (SOURCE_SELECTED.equalsIgnoreCase(source)) {
+            return getSelectedSourcePaged(param, user);
+        }
+        // 其他 source(含 title 向量搜索路径不参与):预查询当前账号历史已发布 video_id,
+        // 用于 hot/prior/posterior 池排除,避免重复曝光。
+        if (user != null && user.getId() != null && !StringUtils.hasText(param.getTitle())) {
+            List<Long> excludeVideoIds = getUserPublishedVideoIds(user.getId(), param.getType());
+            if (CollectionUtils.isNotEmpty(excludeVideoIds)) {
+                param.setExcludeVideoIds(excludeVideoIds);
+            }
+        }
         // 如果 title 有内容,调用 manager 平台接口搜索
         if (StringUtils.hasText(param.getTitle())) {
             return getVideoContentListByTitleV2(param);
         }
-        String source = param.getSource();
         if (SOURCE_PRIOR.equalsIgnoreCase(source)) {
             return getSingleSourcePage(param, user, SOURCE_PRIOR);
         }
@@ -672,6 +688,114 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
         return getInterleavedPage(param, user);
     }
 
+    /**
+     * 历史已选:返回当前登录账号在当前入口/type 下已经发布过的视频(按 video_id 去重,取最近一次).
+     * 按入口隔离 — 企微入口查 qw_plan_video,公众号入口查 gzh_plan_video.
+     * 排序:MAX(create_timestamp) DESC(最近发布在前).
+     * type 未指定/不在已知范围 → 返回空,前端引导用户先选行业.
+     */
+    private Page<VideoContentItemVO> getSelectedSourcePaged(VideoContentListParam param, ContentPlatformAccount user) {
+        int pageSize = param.getPageSize();
+        int pageNum = param.getPageNum();
+        Page<VideoContentItemVO> result = new Page<>(pageNum, pageSize);
+        if (user == null || user.getId() == null) {
+            result.setTotalSize(0);
+            result.setObjs(new ArrayList<>());
+            return result;
+        }
+        Integer type = param.getType();
+        Long loginAccountId = user.getId();
+        String title = StringUtils.hasText(param.getTitle()) ? param.getTitle() : null;
+        int offset = (pageNum - 1) * pageSize;
+
+        Integer qwPlanType = mapToQwPlanType(type);
+        Integer gzhPlanType = mapToGzhPlanType(type);
+
+        int count;
+        List<VideoContentItemVO> list;
+        if (qwPlanType != null) {
+            count = nullToZero(planMapperExt.getSelectedQwVideoCount(loginAccountId, qwPlanType, title));
+            result.setTotalSize(count);
+            if (count == 0 || offset >= count) {
+                result.setObjs(new ArrayList<>());
+                return result;
+            }
+            list = planMapperExt.getSelectedQwVideoList(loginAccountId, qwPlanType, title, offset, pageSize);
+        } else if (gzhPlanType != null) {
+            count = nullToZero(planMapperExt.getSelectedGzhVideoCount(loginAccountId, gzhPlanType, title));
+            result.setTotalSize(count);
+            if (count == 0 || offset >= count) {
+                result.setObjs(new ArrayList<>());
+                return result;
+            }
+            list = planMapperExt.getSelectedGzhVideoList(loginAccountId, gzhPlanType, title, offset, pageSize);
+        } else {
+            // type 缺失或未知:历史已选不知道走哪张表,直接返回空
+            result.setTotalSize(0);
+            result.setObjs(new ArrayList<>());
+            return result;
+        }
+        if (list == null) {
+            list = new ArrayList<>();
+        }
+        for (VideoContentItemVO v : list) {
+            v.setSource(SOURCE_SELECTED);
+        }
+        result.setObjs(list);
+        return result;
+    }
+
+    /**
+     * 取当前登录账号在指定 type 入口下已发布的 video_id(最多 EXCLUDE_PUBLISHED_LIMIT 条,按发布时间倒序).
+     * type 无法映射时返回空,等同于不过滤.
+     */
+    private List<Long> getUserPublishedVideoIds(Long loginAccountId, Integer type) {
+        Integer qwPlanType = mapToQwPlanType(type);
+        if (qwPlanType != null) {
+            return planMapperExt.getUserPublishedQwVideoIds(loginAccountId, qwPlanType, EXCLUDE_PUBLISHED_LIMIT);
+        }
+        Integer gzhPlanType = mapToGzhPlanType(type);
+        if (gzhPlanType != null) {
+            return planMapperExt.getUserPublishedGzhVideoIds(loginAccountId, gzhPlanType, EXCLUDE_PUBLISHED_LIMIT);
+        }
+        return Collections.emptyList();
+    }
+
+    /** 前端 param.type → qw_plan.type;非企微入口返回 null. */
+    private Integer mapToQwPlanType(Integer type) {
+        if (type == null) return null;
+        if (type == 2) return QwPlanTypeEnum.GROUP.getVal();
+        if (type == 3) return QwPlanTypeEnum.REPLY.getVal();
+        return null;
+    }
+
+    /** 前端 param.type → gzh_plan.type;非公众号入口返回 null. */
+    private Integer mapToGzhPlanType(Integer type) {
+        if (type == null) return null;
+        if (type == 0) return ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal();
+        if (type == 1) return ContentPlatformGzhPlanTypeEnum.FWH_PUSH.getVal();
+        if (type == 4) return ContentPlatformGzhPlanTypeEnum.GZH_PUSH.getVal();
+        return null;
+    }
+
+    private int nullToZero(Integer v) {
+        return v == null ? 0 : v;
+    }
+
+    /**
+     * 内存过滤 demand 池行,剔除当前用户已发布的 video_id.
+     * 在 demand pipeline 早期调用,避免被剔除的视频占用 groupAndTopK 名额.
+     */
+    private List<ContentPlatformDemandVideo> excludePublishedRows(List<ContentPlatformDemandVideo> rows, List<Long> excludeVideoIds) {
+        if (CollectionUtils.isEmpty(rows) || CollectionUtils.isEmpty(excludeVideoIds)) {
+            return rows;
+        }
+        Set<Long> excludeSet = new HashSet<>(excludeVideoIds);
+        return rows.stream()
+                .filter(r -> r.getVideoId() == null || !excludeSet.contains(r.getVideoId()))
+                .collect(Collectors.toList());
+    }
+
     /**
      * 单一来源分页:与穿插使用同一套候选构建逻辑(人群需求/优质相似各 2 阶段、组内 score top K),
      * 再按 pageNum/pageSize 在内存中分页。totalSize = 去重后总数。
@@ -884,6 +1008,8 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
             rows = demandVideoMapperExt.selectForRecommend(
                     dt, channelName, null, DEMAND_STRATEGY_PRIOR_SCENE, null, null, null, null, null, category, limit, false);
         }
+        // 0. 历史已选剔除:用户已发布过的 video_id 不再出现在 priorScene 池中
+        rows = excludePublishedRows(rows, param.getExcludeVideoIds());
         // 1. 同 video_id 取 total_rov 最大的代表行(SQL 已排序,putIfAbsent 保留首次)
         LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
         for (ContentPlatformDemandVideo r : rows) {
@@ -942,6 +1068,9 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
                     dt, channelName, null, DEMAND_STRATEGY_PRIOR, PRIOR_PREMIUM_DIMENSION, null, null, null, null, category, fetchLimit, false);
         }
 
+        // 历史已选剔除:在 rov 过滤和分组裁剪前剔除,避免被排除的视频占用 topK 名额
+        rows = excludePublishedRows(rows, param.getExcludeVideoIds());
+
         // 近 7 日 rov 下限,过滤掉低质量近期表现的视频(0513 验证 ≥0.02 保留 ~41%)
         rows = rows.stream()
                 .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)
@@ -1024,6 +1153,9 @@ public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanServic
                     dt, channelName, null, DEMAND_STRATEGY_POSTERIOR, null, null, null, null, null, category, fetchLimit, true);
         }
 
+        // 历史已选剔除:在 rov 过滤和分组裁剪前剔除,避免被排除的视频占用 topK 名额
+        rows = excludePublishedRows(rows, param.getExcludeVideoIds());
+
         // 近 7 日 rov 下限,与 prior 池一致(cdjh 0514 验证 ≥0.02 保留 ~54%)
         rows = rows.stream()
                 .filter(r -> r.getRov() != null && r.getRov() >= DEMAND_MIN_ROV)

+ 122 - 0
api-module/src/main/resources/mapper/contentplatform/ext/ContentPlatformPlanMapperExt.xml

@@ -108,6 +108,12 @@
         <if test="param.category!= null and param.category!= ''">
             and category = #{param.category}
         </if>
+        <if test="param.excludeVideoIds != null and param.excludeVideoIds.size() > 0">
+            and video_id not in
+            <foreach collection="param.excludeVideoIds" item="item" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+        </if>
     </select>
 
     <select id="getVideoList" resultType="com.tzld.piaoquan.api.model.po.contentplatform.ContentPlatformVideo">
@@ -123,6 +129,12 @@
         <if test="param.category!= null and param.category!= ''">
             and video.category = #{param.category}
         </if>
+        <if test="param.excludeVideoIds != null and param.excludeVideoIds.size() > 0">
+            and video.video_id not in
+            <foreach collection="param.excludeVideoIds" item="item" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+        </if>
         order by ${sort}
         limit #{offset}, #{pageSize}
     </select>
@@ -419,4 +431,114 @@
           and cpgp.status = 1
     </select>
 
+    <select id="getSelectedQwVideoCount" resultType="java.lang.Integer">
+        select count(distinct v.video_id)
+        from content_platform_qw_plan_video v
+        join content_platform_qw_plan p on p.id = v.plan_id
+        where p.create_account_id = #{loginAccountId}
+          and p.type = #{planType}
+          and p.status = 1
+          and v.status = 1
+        <if test="title != null and title != ''">
+            and v.title like concat('%', #{title}, '%')
+        </if>
+    </select>
+
+    <select id="getSelectedQwVideoList"
+            resultType="com.tzld.piaoquan.api.model.vo.contentplatform.VideoContentItemVO">
+        select t.videoId, t.title, t.cover, t.video, t.experimentId
+        from (
+            select v.video_id as videoId,
+                   v.title as title,
+                   v.cover as cover,
+                   v.video as video,
+                   v.experiment_id as experimentId,
+                   v.create_timestamp as latestTs,
+                   row_number() over (partition by v.video_id order by v.create_timestamp desc) as rn
+            from content_platform_qw_plan_video v
+            join content_platform_qw_plan p on p.id = v.plan_id
+            where p.create_account_id = #{loginAccountId}
+              and p.type = #{planType}
+              and p.status = 1
+              and v.status = 1
+            <if test="title != null and title != ''">
+                and v.title like concat('%', #{title}, '%')
+            </if>
+        ) t
+        where t.rn = 1
+        order by t.latestTs desc
+        limit #{offset}, #{pageSize}
+    </select>
+
+    <select id="getSelectedGzhVideoCount" resultType="java.lang.Integer">
+        select count(distinct v.video_id)
+        from content_platform_gzh_plan_video v
+        join content_platform_gzh_plan p on p.id = v.plan_id
+        where p.create_account_id = #{loginAccountId}
+          and p.type = #{planType}
+          and p.status = 1
+          and v.status = 1
+        <if test="title != null and title != ''">
+            and (v.title like concat('%', #{title}, '%')
+                 or v.custom_title like concat('%', #{title}, '%'))
+        </if>
+    </select>
+
+    <select id="getSelectedGzhVideoList"
+            resultType="com.tzld.piaoquan.api.model.vo.contentplatform.VideoContentItemVO">
+        select t.videoId, t.title, t.cover, t.video, t.experimentId
+        from (
+            select v.video_id as videoId,
+                   coalesce(nullif(v.custom_title, ''), v.title) as title,
+                   coalesce(nullif(v.custom_cover, ''), v.cover) as cover,
+                   v.video as video,
+                   v.experiment_id as experimentId,
+                   v.create_timestamp as latestTs,
+                   row_number() over (partition by v.video_id order by v.create_timestamp desc) as rn
+            from content_platform_gzh_plan_video v
+            join content_platform_gzh_plan p on p.id = v.plan_id
+            where p.create_account_id = #{loginAccountId}
+              and p.type = #{planType}
+              and p.status = 1
+              and v.status = 1
+            <if test="title != null and title != ''">
+                and (v.title like concat('%', #{title}, '%')
+                     or v.custom_title like concat('%', #{title}, '%'))
+            </if>
+        ) t
+        where t.rn = 1
+        order by t.latestTs desc
+        limit #{offset}, #{pageSize}
+    </select>
+
+    <select id="getUserPublishedQwVideoIds" resultType="java.lang.Long">
+        select t.video_id from (
+            select v.video_id as video_id, max(v.create_timestamp) as latest_ts
+            from content_platform_qw_plan_video v
+            join content_platform_qw_plan p on p.id = v.plan_id
+            where p.create_account_id = #{loginAccountId}
+              and p.type = #{planType}
+              and p.status = 1
+              and v.status = 1
+            group by v.video_id
+        ) t
+        order by t.latest_ts desc
+        limit #{limit}
+    </select>
+
+    <select id="getUserPublishedGzhVideoIds" resultType="java.lang.Long">
+        select t.video_id from (
+            select v.video_id as video_id, max(v.create_timestamp) as latest_ts
+            from content_platform_gzh_plan_video v
+            join content_platform_gzh_plan p on p.id = v.plan_id
+            where p.create_account_id = #{loginAccountId}
+              and p.type = #{planType}
+              and p.status = 1
+              and v.status = 1
+            group by v.video_id
+        ) t
+        order by t.latest_ts desc
+        limit #{limit}
+    </select>
+
 </mapper>