package com.tzld.piaoquan.api.service.contentplatform.impl;

import com.alibaba.excel.EasyExcel;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Lists;
import com.stuuudy.commons.external.filestorage.enums.EnumFileType;
import com.stuuudy.commons.external.filestorage.enums.EnumPublicBuckets;
import com.tzld.piaoquan.api.common.enums.ExceptionEnum;
import com.tzld.piaoquan.api.common.enums.contentplatform.*;
import com.tzld.piaoquan.api.common.exception.CommonException;
import com.tzld.piaoquan.api.component.*;
import com.tzld.piaoquan.api.dao.mapper.contentplatform.*;
import com.tzld.piaoquan.api.dao.mapper.contentplatform.ext.ContentPlatformDemandVideoMapperExt;
import com.tzld.piaoquan.api.dao.mapper.contentplatform.ext.ContentPlatformPlanMapperExt;
import com.tzld.piaoquan.api.model.bo.AdPutFlowRecordTencent;
import com.tzld.piaoquan.api.model.bo.BucketDataParam;
import com.tzld.piaoquan.api.model.bo.SmallPageUrlDetail;
import com.tzld.piaoquan.api.model.config.LoginUserContext;
import com.tzld.piaoquan.api.model.param.contentplatform.*;
import com.tzld.piaoquan.api.model.po.contentplatform.*;
import com.tzld.piaoquan.api.model.vo.GhDetailVo;
import com.tzld.piaoquan.api.model.vo.contentplatform.GzhPlanItemVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.GzhPlanVideoContentItemVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.QwPlanItemExportVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.QwPlanItemVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.VideoContentItemVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.XcxPlanItemExportVO;
import com.tzld.piaoquan.api.model.vo.contentplatform.XcxPlanItemVO;
import com.tzld.piaoquan.api.service.CgiReplyService;
import com.tzld.piaoquan.api.service.GhDetailService;
import com.tzld.piaoquan.api.service.VideoMultiService;
import com.tzld.piaoquan.api.service.contentplatform.ContentPlatformAccountService;
import com.tzld.piaoquan.api.service.contentplatform.ContentPlatformCooperateAccountService;
import com.tzld.piaoquan.api.service.contentplatform.ContentPlatformPlanService;
import com.tzld.piaoquan.api.util.AliOssFileTool;
import com.tzld.piaoquan.api.util.CdnUtil;
import com.tzld.piaoquan.api.util.TitleNormalizer;
import com.tzld.piaoquan.growth.common.common.enums.GhTypeEnum;
import com.tzld.piaoquan.growth.common.common.enums.StrategyStatusEnum;
import com.tzld.piaoquan.growth.common.dao.mapper.ext.CgiReplyBucketDataMapperExt;
import com.tzld.piaoquan.growth.common.model.bo.VideoDetail;
import com.tzld.piaoquan.growth.common.model.po.CgiReplyBucketData;
import com.tzld.piaoquan.growth.common.model.po.GhDetail;
import com.tzld.piaoquan.growth.common.model.po.Staff;
import com.tzld.piaoquan.growth.common.service.MessageAttachmentService;
import com.tzld.piaoquan.growth.common.utils.DateUtil;
import com.tzld.piaoquan.growth.common.utils.MessageUtil;
import com.tzld.piaoquan.growth.common.utils.RedisUtils;
import com.tzld.piaoquan.growth.common.utils.page.Page;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.time.ZoneId;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;

@Slf4j
@Service
public class ContentPlatformPlanServiceImpl implements ContentPlatformPlanService {

    @Autowired
    private ContentPlatformGzhPlanMapper gzhPlanMapper;
    @Autowired
    private ContentPlatformPlanMapperExt planMapperExt;
    @Autowired
    private ContentPlatformGzhPlanVideoMapper gzhPlanVideoMapper;
    @Autowired
    private ContentPlatformQwPlanMapper qwPlanMapper;
    @Autowired
    private ContentPlatformQwPlanVideoMapper qwPlanVideoMapper;
    @Autowired
    private ContentPlatformXcxPlanMapper xcxPlanMapper;
    @Autowired
    private ContentPlatformXcxPlanVideoMapper xcxPlanVideoMapper;
    @Autowired
    private CgiReplyBucketDataMapperExt cgiReplyBucketDataMapperExt;
    @Autowired
    private ContentPlatformVideoMapper contentPlatformVideoMapper;
    @Autowired
    private ContentPlatformVideoAggMapper contentPlatformVideoAggMapper;
    @Autowired
    private ContentPlatformGzhPlanChangeLogMapper gzhPlanChangeLogMapper;
    @Autowired
    private ContentPlatformVideoDataStatAggMapper videoDataStatAggMapper;
    @Autowired
    private ContentPlatformCooperateAccountService cooperateAccountService;
    @Autowired
    private GhDetailService ghDetailService;
    @Autowired
    private AigcApiService aigcApiService;
    @Autowired
    private VideoApiService videoApiService;
    @Autowired
    private MessageAttachmentService messageAttachmentService;
    @Autowired
    private VlogApiService vlogApiService;
    @Autowired
    private ContentPlatformAccountService accountService;
    @Autowired
    private AdApiService adApiService;
    @Autowired
    private CgiReplyService cgiReplyService;
    @Autowired
    private TouLiuHttpClient touLiuHttpClient;
    @Autowired
    private ManagerApiService managerApiService;
    @Autowired
    private VideoMultiService videoMultiService;

    @Autowired
    private ContentPlatformDemandVideoMapperExt demandVideoMapperExt;

    @Autowired
    private RedisUtils redisUtils;


    @Value("${vlog.share.appType:11}")
    private String shareAppType;

    @Value("${video.min.score:0.0}")
    private Double videoMinScore;

    /**
     * 曝光下发过滤阈值(全渠道、全向量召回池统一生效):match_exposure_pv 有值时要求 > 该阈值才下发。
     * NULL(无曝光数据,如「场景已看视频」池 100% 无此字段)一律放行,不受影响。
     * <=0 表示关闭该过滤(全放行)。Apollo 可调,改阈值不发版。
     */
    @Value("${demand.min.exposure.pv:2000}")
    private Long demandMinExposurePv;

    @Value("${video.title.search.max.count:500}")
    private int videoTitleSearchMaxCount;

    @Value("${small_page_url}")
    private String GET_SMALL_PAGE_URL;

    /** prior/posterior 池视频近 7 日 rov 下限(质量过滤) */
    @Value("${demand.min.rov:0.03}")
    private double demandMinRov;

    /** priorScene 池(场景已看视频)的 sceneSumRov 下限(质量过滤) */
    @Value("${demand.prior.scene.min.sum.rov:0.03}")
    private double priorSceneMinSumRov;

    /** prior 池按(point_type, standard_element)分组后,保留 total_rov 排名 top K 的比例 */
    @Value("${demand.prior.group.keep.ratio:0.5}")
    private double priorGroupKeepRatio;

    /** posterior 池按 demand_content_id 分组后,保留 total_rov 排名 top K 的比例 */
    @Value("${demand.posterior.group.keep.ratio:0.5}")
    private double posteriorGroupKeepRatio;

    @Override
    public Page<GzhPlanItemVO> gzhPlanList(GzhPlanListParam param) {
        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        Page<GzhPlanItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int offset = (param.getPageNum() - 1) * param.getPageSize();
        int count = planMapperExt.getGzhPlanCount(param, loginAccount.getId());
        result.setTotalSize(count);
        if (count == 0) {
            return result;
        }
        List<ContentPlatformGzhPlan> planList = planMapperExt.getGzhPlanList(param,
                loginAccount.getId(), offset, param.getPageSize());
        List<GzhPlanItemVO> list = buildGzhPlanItemVOList(planList);
        result.setObjs(list);
        return result;
    }

    private List<GzhPlanItemVO> buildGzhPlanItemVOList(List<ContentPlatformGzhPlan> planList) {
        if (CollectionUtils.isEmpty(planList)) {
            return null;
        }
        List<Long> accountIds = planList.stream().map(ContentPlatformGzhPlan::getAccountId).collect(Collectors.toList());
        List<ContentPlatformGzhAccount> accountList = cooperateAccountService.getAccountListByIds(accountIds);
        Map<Long, ContentPlatformGzhAccount> accountMap = accountList.stream().collect(Collectors.toMap(ContentPlatformGzhAccount::getId,
                Function.identity()));
        List<Long> planIds = planList.stream().map(ContentPlatformGzhPlan::getId).collect(Collectors.toList());
        List<ContentPlatformGzhPlanVideo> videoList = getGzhPlanVideoList(planIds);
        Map<Long, List<ContentPlatformGzhPlanVideo>> videoMap = videoList.stream()
                .collect(Collectors.groupingBy(ContentPlatformGzhPlanVideo::getPlanId));
        List<String> ghIds = accountList.stream().map(ContentPlatformGzhAccount::getGhId).collect(Collectors.toList());
        List<CgiReplyBucketData> bucketDataList = cgiReplyService.getCgiReplyBucketDataListByGhIds(ghIds, "manual");
        Map<String, Map<Long, CgiReplyBucketData>> bucketDataMap = bucketDataList.stream()
                .collect(Collectors.groupingBy(CgiReplyBucketData::getGhId,
                        Collectors.toMap(CgiReplyBucketData::getMiniVideoId, Function.identity(), (a, b) -> b)));
        List<GzhPlanItemVO> result = new ArrayList<>();
        for (ContentPlatformGzhPlan gzhPlan : planList) {
            GzhPlanItemVO planItemVO = new GzhPlanItemVO();
            planItemVO.setId(gzhPlan.getId());
            planItemVO.setAccountId(gzhPlan.getAccountId());
            ContentPlatformGzhAccount gzhAccount = accountMap.get(gzhPlan.getAccountId());
            if (Objects.nonNull(gzhAccount)) {
                planItemVO.setAccountName(gzhAccount.getName());
            }
            planItemVO.setType(gzhPlan.getType());
            planItemVO.setScene(gzhPlan.getScene());
            planItemVO.setPublishStage(gzhPlan.getPublishStage());
            planItemVO.setSelectVideoType(gzhPlan.getSelectVideoType());
            List<ContentPlatformGzhPlanVideo> videoListByPlanId = videoMap.get(gzhPlan.getId());
            if (CollectionUtils.isNotEmpty(videoListByPlanId)) {
                planItemVO.setVideoCount(videoListByPlanId.size());
                List<String> titleList = videoListByPlanId.stream().map(ContentPlatformPlanService::getVideoTitle)
                        .collect(Collectors.toList());
                planItemVO.setTitle(titleList);
                List<GzhPlanVideoContentItemVO> videoVOList = new ArrayList<>();
                Map<Long, CgiReplyBucketData> bucketDataMapByGhId = bucketDataMap.get(gzhAccount.getGhId());
                if (MapUtils.isEmpty(bucketDataMapByGhId)) {
                    BucketDataParam bucketDataParam = new BucketDataParam();
                    bucketDataParam.setGhId(gzhAccount.getGhId());
                    cgiReplyService.getRgiReplyData(bucketDataParam);
                    List<CgiReplyBucketData> bucketDatas = cgiReplyService.getCgiReplyBucketDataListByGhId(gzhAccount.getGhId(), "manual");
                    bucketDataMapByGhId = bucketDatas.stream().collect(Collectors.toMap(CgiReplyBucketData::getMiniVideoId, Function.identity(), (a, b) -> b));
                }
                for (ContentPlatformGzhPlanVideo video : videoListByPlanId) {
                    GzhPlanVideoContentItemVO videoItemVO = new GzhPlanVideoContentItemVO();
                    videoItemVO.setVideoId(video.getVideoId());
                    videoItemVO.setTitle(video.getTitle());
                    videoItemVO.setCustomTitle(video.getCustomTitle());
                    videoItemVO.setCover(video.getCover());
                    videoItemVO.setCustomCover(video.getCustomCover());
                    videoItemVO.setCustomCoverType(video.getCustomCoverType());
                    videoItemVO.setVideo(video.getVideo());
                    videoItemVO.setPageUrl(video.getPageUrl());
                    videoItemVO.setExperimentId(video.getExperimentId());
                    if (Objects.nonNull(bucketDataMapByGhId) && bucketDataMapByGhId.containsKey(video.getVideoId())
                            && planItemVO.getType().equals(ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal())) {
                        CgiReplyBucketData bucketData = bucketDataMapByGhId.get(video.getVideoId());
                        if (Objects.nonNull(bucketData)) {
                            videoItemVO.setPageUrl(bucketData.getMiniPagePath());
                        }
                    }
                    videoItemVO.setShareCover(getShareCover(planItemVO.getType(), ContentPlatformPlanService.getVideoCover(video)));
                    videoItemVO.setPageUrl(buildCustomPageUrl(videoItemVO, video));
                    videoVOList.add(videoItemVO);
                }
                planItemVO.setVideoList(videoVOList);
            }
            planItemVO.setCreateTimestamp(gzhPlan.getCreateTimestamp());
            result.add(planItemVO);
        }
        return result;
    }

    private String buildCustomPageUrl(GzhPlanVideoContentItemVO videoItemVO, ContentPlatformGzhPlanVideo video) {
        String pageUrl = videoItemVO.getPageUrl();
        String title = StringUtils.hasText(videoItemVO.getCustomTitle()) ? videoItemVO.getCustomTitle() : videoItemVO.getTitle();
        String cover = StringUtils.hasText(videoItemVO.getCustomCover()) ? videoItemVO.getCustomCover() : videoItemVO.getCover();
        pageUrl = videoMultiService.setVideoMultiTitleCoverPagePath(video.getVideoId(), pageUrl, title, cover);
        return pageUrl;
    }

    @Override
    public List<ContentPlatformGzhPlanVideo> getGzhPlanVideoList(List<Long> planIds) {
        ContentPlatformGzhPlanVideoExample example = new ContentPlatformGzhPlanVideoExample();
        example.createCriteria().andPlanIdIn(planIds);
        return gzhPlanVideoMapper.selectByExample(example);
    }

    @Override
    public void gzhPlanDelete(Long id) {
        ContentPlatformGzhPlan plan = gzhPlanMapper.selectByPrimaryKey(id);
        if (Objects.isNull(plan)) {
            throw new CommonException(ExceptionEnum.GZH_PLAN_NOT_EXISTS);
        }
        if (!Objects.equals(LoginUserContext.getUser().getId(), plan.getCreateAccountId())) {
            throw new CommonException(ExceptionEnum.PARAM_ERROR);
        }
        plan.setStatus(0);
        plan.setUpdateTimestamp(System.currentTimeMillis());
        gzhPlanMapper.updateByPrimaryKeySelective(plan);
        if (PublishStageEnum.PLATFORM.getVal() == plan.getPublishStage()) {
            // 调用aigc删除发布计划
            aigcApiService.changePublishPlanStatus(plan.getExternalId(), 0);
        }
    }


    @Override
    public void gzhUpdateStagePublishStatus(Long id, Integer stagePublishStatus) {
        ContentPlatformGzhPlan plan = gzhPlanMapper.selectByPrimaryKey(id);
        if (Objects.isNull(plan)) {
            throw new CommonException(ExceptionEnum.GZH_PLAN_NOT_EXISTS);
        }
        if (!Objects.equals(LoginUserContext.getUser().getId(), plan.getCreateAccountId())) {
            throw new CommonException(ExceptionEnum.PARAM_ERROR);
        }
        plan.setStagePublishStatus(stagePublishStatus);
        plan.setUpdateTimestamp(System.currentTimeMillis());
        gzhPlanMapper.updateByPrimaryKeySelective(plan);
        ContentPlatformGzhAccount gzhAccount = cooperateAccountService.getById(plan.getAccountId());
        if (StringUtils.hasText(plan.getExternalId()) && StringUtils.hasText(gzhAccount.getExternalId())) {
            aigcApiService.changePublishPlanAccountStatus(plan.getExternalId(), gzhAccount.getExternalId(), stagePublishStatus);
        }
    }

    @Override
    public void qwPlanDelete(Long id) {
        ContentPlatformQwPlan plan = qwPlanMapper.selectByPrimaryKey(id);
        if (Objects.isNull(plan)) {
            throw new CommonException(ExceptionEnum.QW_PLAN_NOT_EXISTS);
        }
        if (!Objects.equals(LoginUserContext.getUser().getId(), plan.getCreateAccountId())) {
            throw new CommonException(ExceptionEnum.PARAM_ERROR);
        }
        plan.setStatus(PlanStatusEnum.DELETED.getVal());
        plan.setUpdateTimestamp(System.currentTimeMillis());
        qwPlanMapper.updateByPrimaryKeySelective(plan);
    }

    @Override
    public void gzhPlanSave(GzhPlanSaveParam param) {
        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        List<ContentPlatformGzhPlan> planExistList = planMapperExt.getGzhPlanExists(param.getAccountId(),
                param.getType(), param.getScene(), param.getId());
        if (CollectionUtils.isNotEmpty(planExistList)
                && param.getType() == ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal()) {
            throw new CommonException(ExceptionEnum.GZH_SCENE_PLAN_EXISTS);
        }
        if (param.getVideoList().size() > 3) {
            throw new CommonException(ExceptionEnum.GZH_PLAN_VIDEO_SIZE_EXCEED);
        }
        ContentPlatformGzhAccount account = cooperateAccountService.getById(param.getAccountId());
        if (Objects.isNull(account)) {
            throw new CommonException(ExceptionEnum.GZH_ACCOUNT_NOT_EXISTS);
        }
        if (!StringUtils.hasText(account.getExternalId()) && param.getPublishStage().equals(PublishStageEnum.PLATFORM.getVal())) {
            throw new CommonException(ExceptionEnum.GZH_ACCOUNT_NOT_AUTH);
        }
        Long now = System.currentTimeMillis();
        ContentPlatformGzhPlan gzhPlan = new ContentPlatformGzhPlan();
        gzhPlan.setAccountId(param.getAccountId());
        gzhPlan.setType(param.getType());
        gzhPlan.setScene(param.getScene());
        gzhPlan.setPublishStage(param.getPublishStage());
        gzhPlan.setSelectVideoType(param.getSelectVideoType());
        gzhPlan.setUpdateTimestamp(now);
        List<Long> videoIds = param.getVideoList().stream().map(GzhPlanVideoContentItemParam::getVideoId).collect(Collectors.toList());
        if (Objects.isNull(param.getId())) {
            // 调用aigc创建发布计划
            if (PublishStageEnum.PLATFORM.getVal() == param.getPublishStage()) {
                String externalId = aigcApiService.createPublishPlan(account.getExternalId(), account.getName(), loginAccount.getChannel());
                gzhPlan.setStagePublishStatus(1);
                gzhPlan.setExternalId(externalId);
            }
            gzhPlan.setCreateAccountId(loginAccount.getId());
            gzhPlan.setCreateTimestamp(now);
            planMapperExt.insertGzhPlanReturnId(gzhPlan);
        } else {
            ContentPlatformGzhPlan oldPlan = gzhPlanMapper.selectByPrimaryKey(param.getId());
            if (!Objects.equals(LoginUserContext.getUser().getId(), oldPlan.getCreateAccountId())) {
                throw new CommonException(ExceptionEnum.PARAM_ERROR);
            }
            if (PublishStageEnum.USER.getVal() == param.getPublishStage()) {
                if (StringUtils.hasText(oldPlan.getExternalId())) {
                    gzhPlan.setStagePublishStatus(0);
                    aigcApiService.changePublishPlanStatus(oldPlan.getExternalId(), 0);
                }
            } else {
                if (StringUtils.hasText(oldPlan.getExternalId())) {
                    gzhPlan.setStagePublishStatus(1);
                    aigcApiService.changePublishPlanStatus(oldPlan.getExternalId(), 1);
                } else {
                    String externalId = aigcApiService.createPublishPlan(account.getExternalId(), account.getName(), loginAccount.getChannel());
                    gzhPlan.setStagePublishStatus(1);
                    gzhPlan.setExternalId(externalId);
                }
            }
            gzhPlan.setId(param.getId());
            gzhPlanMapper.updateByPrimaryKeySelective(gzhPlan);
        }
        // 保存视频内容
        saveGzhPlanVideo(param, videoIds, gzhPlan.getId(), account, loginAccount);
        if (param.getType() == ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal()) {
            // 更新cgi_reply_bucket_data
            updateCgiReplyBucketData(account.getGhId());
            // 更新gh_detail
            Map<Long, String> videoIdTestIdMap = param.getVideoList().stream()
                    .filter(o -> StringUtils.hasText(o.getExperimentId()))
                    .collect(Collectors.toMap(GzhPlanVideoContentItemParam::getVideoId, GzhPlanVideoContentItemParam::getExperimentId));
            updateGhDetail(account, param.getSelectVideoType(), videoIds, videoIdTestIdMap);
        }
        // 调用aigc重新拉取视频
        if (param.getType() == ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal()) {
            if (PublishStageEnum.PLATFORM.getVal() == param.getPublishStage()) {
                //new Thread(() -> aigcApiService.refreshGzhAutoReplyMsgData(account.getGhId())).start();
            } else {
                BucketDataParam bucketDataParam = new BucketDataParam();
                bucketDataParam.setGhId(account.getGhId());
                bucketDataParam.setAccountName(account.getName());
                cgiReplyService.getRgiReplyData(bucketDataParam);
            }
        }
    }

    private void updateCgiReplyBucketData(String ghId) {
        cgiReplyBucketDataMapperExt.deleteBucketDataByGhId(ghId);
    }

    private void saveGzhPlanVideo(GzhPlanSaveParam param, List<Long> videoIds, Long planId,
                                  ContentPlatformGzhAccount account, ContentPlatformAccount loginAccount) {
        List<ContentPlatformGzhPlanVideo> existsVideo = getGzhVideoByPlanId(planId);
        List<Long> existsVideoIds = existsVideo.stream().map(ContentPlatformGzhPlanVideo::getVideoId).collect(Collectors.toList());
        // 删除该计划下所有已有视频，后续统一重新插入
        ContentPlatformGzhPlanVideoExample deleteExample = new ContentPlatformGzhPlanVideoExample();
        deleteExample.createCriteria().andPlanIdEqualTo(planId);
        gzhPlanVideoMapper.deleteByExample(deleteExample);
        for (GzhPlanVideoContentItemParam vo : param.getVideoList()) {
            if (!StringUtils.hasText(vo.getCustomTitle())) {
                vo.setCustomTitle(null);
            }
            if (!StringUtils.hasText(vo.getCustomCover())) {
                vo.setCustomCover(null);
            }
            ContentPlatformGzhPlanVideo item = new ContentPlatformGzhPlanVideo();
            item.setPlanId(planId);
            item.setVideoId(vo.getVideoId());
            item.setTitle(vo.getTitle());
            setCustomTitle(item, vo);
            item.setCover(vo.getCover());
            setCustomCover(item, vo);
            item.setCustomCoverType(vo.getCustomCoverType());
            item.setVideo(vo.getVideo());
            if (param.getType() == ContentPlatformGzhPlanTypeEnum.FWH_PUSH.getVal()) {
                String response = touLiuHttpClient.sendAdFlowAddRequest(GET_SMALL_PAGE_URL, String.valueOf(vo.getVideoId()), "fwhdyy",
                        loginAccount.getChannel(), "自动", "公众号", "文章插小程序", "文字和小程序", account.getGhId(), vo.getExperimentId());
                JSONObject json = JSONObject.parseObject(response);
                SmallPageUrlDetail smallPageUrlDetail = json.getJSONObject("data").toJavaObject(SmallPageUrlDetail.class);
                item.setPageUrl(smallPageUrlDetail.getUrl());
            } else if (param.getType() == ContentPlatformGzhPlanTypeEnum.GZH_PUSH.getVal()) {
                String response = touLiuHttpClient.sendAdFlowAddRequest(GET_SMALL_PAGE_URL, String.valueOf(vo.getVideoId()), "longArticles_outer",
                        loginAccount.getChannel(), "自动", "公众号", "文章插小程序", "小程序", account.getGhId(), vo.getExperimentId());
                JSONObject json = JSONObject.parseObject(response);
                SmallPageUrlDetail smallPageUrlDetail = json.getJSONObject("data").toJavaObject(SmallPageUrlDetail.class);
                item.setPageUrl(smallPageUrlDetail.getUrl());
            }
            // 上报多标题封面
            item.setPageUrl(setMultiTitleCoverPagePath(item));
            item.setExperimentId(vo.getExperimentId());
            if (StringUtils.hasText(item.getPageUrl())) {
                String rootSourceId = MessageUtil.getRootSourceId(item.getPageUrl());
                if (StringUtils.hasText(rootSourceId)) {
                    item.setRootSourceId(rootSourceId);
                }
            }
            item.setCreateAccountId(loginAccount.getId());
            item.setCreateTimestamp(System.currentTimeMillis());
            gzhPlanVideoMapper.insert(item);
        }
        if (CollectionUtils.isNotEmpty(param.getVideoList())) {
            saveChangeVideoLog(planId, existsVideoIds, param.getVideoList());
        }
    }

    private String setMultiTitleCoverPagePath(ContentPlatformGzhPlanVideo item) {
        String pageUrl = item.getPageUrl();
        if (StringUtils.hasText(pageUrl)) {
            String title = StringUtils.hasText(item.getCustomTitle()) ? item.getCustomTitle() : item.getTitle();
            String cover = StringUtils.hasText(item.getCustomCover()) ? item.getCustomCover() : item.getCover();
            try {
                pageUrl = videoMultiService.setVideoMultiTitleCoverPagePath(item.getVideoId(), pageUrl, title, cover);
            } catch (Exception e) {
                log.error("设置视频多标题封面失败，videoId={}", item.getVideoId(), e);
            }
            pageUrl = videoMultiService.setVideoMultiTitleCoverPagePath(item.getVideoId(), pageUrl, title, cover);
        }
        return pageUrl;
    }

    private void setCustomTitle(ContentPlatformGzhPlanVideo video, GzhPlanVideoContentItemParam vo) {
        String title = StringUtils.hasText(vo.getCustomTitle()) ? vo.getCustomTitle() : vo.getTitle();
        if (StringUtils.hasText(title)) {
            boolean titleExist = false;
            List<VideoGrowthMultiTitle> list = videoMultiService.getMultiTitleByVideoId(vo.getVideoId());
            for (VideoGrowthMultiTitle multiTitle : list) {
                if (multiTitle.getTitle().equals(title)) {
                    video.setCustomTitleId(multiTitle.getId());
                    titleExist = true;
                    break;
                }
            }
            if (!titleExist) {
                JSONArray multiTitleList = managerApiService.videoMultiTitleListV2(vo.getVideoId());
                String source = "自定义";
                if (!org.springframework.util.CollectionUtils.isEmpty(multiTitleList)) {
                    for (int i = 0; i < multiTitleList.size(); i++) {
                        JSONObject item = multiTitleList.getJSONObject(i);
                        if (item.getString("title").equals(title)) {
                            if (item.getInteger("source") == 0) {
                                source = "原始";
                                break;
                            }
                        }
                    }
                }
                Long titleId = videoMultiService.saveMultiTitle(vo.getVideoId(), source, title);
                video.setCustomTitleId(titleId);
            }
            video.setTitle(title);
        }
    }

    private List<ContentPlatformGzhPlanVideo> gzhPlanVideoListByVideoTitle(Long videoId) {
        ContentPlatformGzhPlanVideoExample example = new ContentPlatformGzhPlanVideoExample();
        example.createCriteria().andVideoIdEqualTo(videoId).andCustomTitleIdIsNotNull();
        return gzhPlanVideoMapper.selectByExample(example);
    }

    private void setCustomCover(ContentPlatformGzhPlanVideo video, GzhPlanVideoContentItemParam vo) {
        String cover = StringUtils.hasText(vo.getCustomCover()) ? vo.getCustomCover() : vo.getCover();
        if (StringUtils.hasText(cover)) {
            boolean customCoverExist = false;
            List<VideoGrowthMultiCover> list = videoMultiService.getMultiCoverByVideoId(vo.getVideoId());
            for (VideoGrowthMultiCover multiCover : list) {
                if (multiCover.getCover().equals(cover)) {
                    video.setCustomCoverId(multiCover.getId());
                    customCoverExist = true;
                    break;
                }
            }
            if (!customCoverExist) {
                JSONArray multiCoverList = managerApiService.videoMultiCoverListV2(vo.getVideoId());
                String source = "自定义";
                if (!org.springframework.util.CollectionUtils.isEmpty(multiCoverList)) {
                    for (int i = 0; i < multiCoverList.size(); i++) {
                        JSONObject item = multiCoverList.getJSONObject(i);
                        if (item.getString("coverUrl").equals(cover)) {
                            if (item.getInteger("source") == 0) {
                                source = "原始";
                                break;
                            }
                        }
                    }
                }
                Long coverId = videoMultiService.saveMultiCover(vo.getVideoId(), source, cover);
                video.setCustomCoverId(coverId);
            }
            video.setCover(cover);
        }
    }

    private List<ContentPlatformGzhPlanVideo> gzhPlanVideoListByVideoCover(Long videoId) {
        ContentPlatformGzhPlanVideoExample example = new ContentPlatformGzhPlanVideoExample();
        example.createCriteria().andVideoIdEqualTo(videoId).andCustomCoverIdIsNotNull();
        return gzhPlanVideoMapper.selectByExample(example);
    }

    private void saveChangeVideoLog(Long planId, List<Long> existsVideoIds, List<GzhPlanVideoContentItemParam> videoList) {
        List<Long> newVideoIds = videoList.stream().map(GzhPlanVideoContentItemParam::getVideoId).collect(Collectors.toList());
        boolean changed = false;
        for (Long newVideoId : newVideoIds) {
            if (!existsVideoIds.contains(newVideoId)) {
                changed = true;
                break;
            }
        }
        if (changed) {
            // 保存修改日志
            ContentPlatformGzhPlanChangeLog log = new ContentPlatformGzhPlanChangeLog();
            log.setPlanId(planId);
            log.setOldPlanIds(JSONObject.toJSONString(existsVideoIds));
            log.setNewPlanIds(JSONObject.toJSONString(newVideoIds));
            log.setCreateTimestamp(System.currentTimeMillis());
            gzhPlanChangeLogMapper.insertSelective(log);
        }
    }

    private List<ContentPlatformGzhPlanVideo> getGzhVideoByPlanId(Long planId) {
        ContentPlatformGzhPlanVideoExample example = new ContentPlatformGzhPlanVideoExample();
        example.createCriteria().andPlanIdEqualTo(planId);
        return gzhPlanVideoMapper.selectByExample(example);
    }

    private void updateGhDetail(ContentPlatformGzhAccount account, Integer selectVideoType, List<Long> videoIds,
                                Map<Long, String> videoIdTestIdMap) {
        ContentPlatformAccount loginUser = LoginUserContext.getUser();
        // 根据 content_platform_account 的 type 判断公众号类型：内部账号 -> 内部公众号，否则 -> 外部公众号
        Integer ghType = Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.INTERNAL.getVal()) ? GhTypeEnum.GH.type : GhTypeEnum.THIRD_PARTY_GH.type;
        GhDetail ghDetail = ghDetailService.getGhDetailByGhIdType(account.getGhId(), ghType);
        GhDetailVo detailVo = new GhDetailVo();
        Integer strategyStatus = selectVideoType == 0 ? StrategyStatusEnum.DEFAULT.status : StrategyStatusEnum.STRATEGY.status;
        if (Objects.nonNull(ghDetail)) {
            BeanUtils.copyProperties(ghDetail, detailVo);
            detailVo.setAccountId(account.getGhId());
            detailVo.setAccountName(account.getName());
            detailVo.setType(ghType);
            detailVo.setChannel(loginUser.getChannel());
            detailVo.setCategory1(account.getContentType());
            detailVo.setStrategyStatus(strategyStatus);
            detailVo.setVideoIds(videoIds);
            detailVo.setAutoreplySendMinigramNum(videoIds.size());
            ghDetailService.updateDetail(detailVo, videoIdTestIdMap);
        } else {
            detailVo.setAccountId(account.getGhId());
            detailVo.setAccountName(account.getName());
            detailVo.setType(ghType);
            detailVo.setChannel(loginUser.getChannel());
            detailVo.setCategory1(account.getContentType());
            detailVo.setStrategyStatus(strategyStatus);
            detailVo.setVideoIds(videoIds);
            detailVo.setAutoreplySendMinigramNum(videoIds.size());
            ghDetailService.addGhDetail(detailVo, videoIdTestIdMap);
        }
    }

    @Override
    public List<ContentPlatformGzhPlan> getGzhPlanListByCooperateAccountId(Long accountId) {
        ContentPlatformGzhPlanExample example = new ContentPlatformGzhPlanExample();
        example.createCriteria().andAccountIdEqualTo(accountId).andStatusEqualTo(PlanStatusEnum.NORMAL.getVal());
        return gzhPlanMapper.selectByExample(example);
    }

    @Override
    public List<ContentPlatformGzhPlan> getGzhPlanListByCreateAccountId(Long createAccountId) {
        ContentPlatformGzhPlanExample example = new ContentPlatformGzhPlanExample();
        example.createCriteria().andCreateAccountIdEqualTo(createAccountId).andStatusEqualTo(PlanStatusEnum.NORMAL.getVal());
        return gzhPlanMapper.selectByExample(example);
    }

    @Override
    public List<ContentPlatformGzhPlan> getGzhPlanListByCooperateAccountIds(List<Long> accountIds, Integer type) {
        ContentPlatformGzhPlanExample example = new ContentPlatformGzhPlanExample();
        example.createCriteria().andAccountIdIn(accountIds).andTypeEqualTo(type);
        return gzhPlanMapper.selectByExample(example);
    }

    private static final int DEMAND_CANDIDATE_LIMIT = 10000;
    private static final int HOT_CANDIDATE_LIMIT = 10000;
    private static final int TOP_K_PER_DEMAND = 3;

    /**
     * 人群需求池配置：{dimension, demandType, matchMethod, isScene}
     */
    private static final List<PriorPoolConfig> PRIOR_POOL_CONFIGS = buildPriorPoolConfigs();

    private static List<PriorPoolConfig> buildPriorPoolConfigs() {
        return Arrays.asList(
                // 策略1: 人群需求*场景已看
                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.SCENE, true),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.SCENE, true),
                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.SCENE, true),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH_DISTRIBUTION, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.SCENE, true),
                // 策略2: 人群需求*特征点*向量匹配
                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH_DISTRIBUTION, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                // 策略3: 人群需求*特征点泛化*向量匹配
                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH_DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.VECTOR_SIMILARITY, false),
                // 策略4: 人群需求*特征点泛化*精准匹配
                new PriorPoolConfig(PriorDimensionEnum.PREMIUM, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false),
                new PriorPoolConfig(PriorDimensionEnum.DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false),
                new PriorPoolConfig(PriorDimensionEnum.GROWTH_DISTRIBUTION, DemandTypeEnum.GENERALIZED, DemandMatchMethodEnum.PRECISION, false)
        );
    }

    /**
     * 按渠道返回人群需求池配置，不同渠道排除特定策略组合。
     */
    private static List<PriorPoolConfig> getPriorPoolConfigs(DemandChannelEnum channel) {
        List<PriorPoolConfig> configs = new ArrayList<>(PRIOR_POOL_CONFIGS);
        if (channel == DemandChannelEnum.GZH_JIZHUAN) {
            // 公众号即转合作：干掉 增长头部*场景已看、传播头部*场景已看
            configs.removeIf(cfg ->
                    (cfg.dimension == PriorDimensionEnum.GROWTH && cfg.demandType == DemandTypeEnum.STANDARD && cfg.matchMethod == DemandMatchMethodEnum.SCENE)
                            || (cfg.dimension == PriorDimensionEnum.PREMIUM && cfg.demandType == DemandTypeEnum.STANDARD && cfg.matchMethod == DemandMatchMethodEnum.SCENE));
        } else if (channel == DemandChannelEnum.XCX) {
            // 小程序投流：干掉 增长头部*向量匹配、增长头部*泛化*精准匹配、传播头部*场景已看
            configs.removeIf(cfg ->
                    (cfg.dimension == PriorDimensionEnum.GROWTH && cfg.demandType == DemandTypeEnum.GENERALIZED && cfg.matchMethod == DemandMatchMethodEnum.PRECISION));
            configs.add(new PriorPoolConfig(PriorDimensionEnum.UNDEFINED, DemandTypeEnum.STANDARD, DemandMatchMethodEnum.PRECISION, true));
        } else if (channel == DemandChannelEnum.QW) {
            // 企微合作：干掉 传播头部*泛化*精准匹配、传播分发*泛化*精准匹配
            configs.removeIf(cfg ->
                    (cfg.dimension == PriorDimensionEnum.PREMIUM && cfg.demandType == DemandTypeEnum.GENERALIZED && cfg.matchMethod == DemandMatchMethodEnum.PRECISION)
                            || (cfg.dimension == PriorDimensionEnum.DISTRIBUTION && cfg.demandType == DemandTypeEnum.GENERALIZED && cfg.matchMethod == DemandMatchMethodEnum.PRECISION));
        }
        return configs;
    }

    /**
     * 人群需求单池配置
     */
    private static class PriorPoolConfig {
        final PriorDimensionEnum dimension;
        final DemandTypeEnum demandType;
        final DemandMatchMethodEnum matchMethod;
        final boolean scene;

        PriorPoolConfig(PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod, boolean scene) {
            this.dimension = dimension;
            this.demandType = demandType;
            this.matchMethod = matchMethod;
            this.scene = scene;
        }
    }

    /**
     * 推导 channel_name(人群_渠道) 作为 demand 池强过滤。
     * 信号优先级:
     * 1. 企微入口(type=2/3) → QW
     * 2. 小程序投流(type=5) → XCX
     * 3. 公众号入口(type=0/1/4)或仅传 ghName:按 ghName 反查 demand 表 channel_level3 → channel_name
     * ─ 业务约定一个公众号只归属一个 channel(合作-即转 / 合作-Daily-自选 / 投流-稳定 三选一),
     * 反查失败(新账号或上游当天没产)返回 null,不限 channel_name(保留兜底行为)。
     * 4. 否则 null,不限 channel_name。
     */
    private DemandChannelEnum resolveChannelName(VideoContentListParam param) {
        IndustryTypeEnum type = param.getIndustryType();
        if (type != null && type != IndustryTypeEnum.OTHER) {
            if (type == IndustryTypeEnum.QW_GROUP || type == IndustryTypeEnum.QW_AUTO_REPLY) return DemandChannelEnum.QW;
            if (type == IndustryTypeEnum.XCX) return DemandChannelEnum.XCX;
        }
        String ghName = param.getGhName();
        if (StringUtils.hasText(ghName)) {
            return DemandChannelEnum.fromValue(demandVideoMapperExt.selectChannelNameByGh(ghName));
        }
        return null;
    }

    /**
     * 仅对"合作类"渠道用 user.channel 作为 demand.crowd_segment 过滤;
     * 投流/服务号类渠道一律 null(crowd_segment 是人群分组标签,与合作方无关)。
     */
    private String resolveCrowdSegment(DemandChannelEnum channel, ContentPlatformAccount user) {
        if (user == null || channel == null) return null;
        if (channel.isPartner()) {
            return user.getChannel();
        }
        return null;
    }

    /**
     * 推荐池剔除的 category 列表(身份相关)。
     * - GZH_DAILY:所有身份返回空(白名单语义保留)
     * - 其他:内部(2)/代理(3) 仅过滤「节日祝福」;合作方(1)/其他 过滤「早中晚好」+「节日祝福」
     */
    private List<String> resolveExcludeCategories(DemandChannelEnum channel, ContentPlatformAccount user) {
        if (DemandChannelEnum.GZH_DAILY == channel) return Collections.emptyList();
        Integer type = user == null ? null : user.getType();
        boolean privileged = Objects.equals(type, ContentPlatformAccountTypeEnum.INTERNAL.getVal())
                || Objects.equals(type, ContentPlatformAccountTypeEnum.AGENT.getVal());
        return privileged
                ? Collections.singletonList("节日祝福")
                : Arrays.asList("早中晚好", "节日祝福");
    }

    /**
     * 曝光下发过滤的纯逻辑(可单测,全渠道统一口径):
     * - minExposurePv 为 null 或 <=0:关闭过滤,全放行;
     * - match_exposure_pv 为 NULL(无曝光数据,如「场景已看视频」池):放行,不受影响;
     * - 有曝光数据:要求 > minExposurePv 才下发。
     */
    static boolean passesExposureFilter(Long matchExposurePv, Long minExposurePv) {
        if (minExposurePv == null || minExposurePv <= 0) {
            return true;
        }
        return matchExposurePv == null || matchExposurePv > minExposurePv;
    }

    static boolean passesSearchDemandRovFilter(String matchMethod, Double rov, Double sceneSumRov,
                                               double demandMinRov, double priorSceneMinSumRov) {
        if (DemandMatchMethodEnum.SCENE.getValue().equals(matchMethod)) {
            return rov != null && rov > 0
                    && sceneSumRov != null && sceneSumRov >= priorSceneMinSumRov;
        }
        return rov != null && rov >= demandMinRov;
    }

    @Override
    public Page<VideoContentItemVO> getVideoContentList(VideoContentListParam param) {
        ContentPlatformAccount user = LoginUserContext.getUser();
        // title 非空 → 走 demand 池白名单 + 向量召回交集搜索(切掉管理平台关键词 fallback,只对 type∈{2,3} + 投流类渠道放开)
        if (StringUtils.hasText(param.getTitle())) {
            return searchByTitleInDemandPool(param, user);
        }
        VideoContentSource source = VideoContentSource.fromParam(param.getSource());
        if (VideoContentSource.PRIOR == source) {
            return getSingleSourcePage(param, user, source);
        }
        if (VideoContentSource.POSTERIOR == source) {
            return getSingleSourcePage(param, user, source);
        }
        if (VideoContentSource.HOT == source) {
            return getSingleSourcePage(param, user, source);
        }
        return getInterleavedPage(param, user);
    }

    /**
     * 单一来源分页：与穿插使用同一套候选构建逻辑（人群需求/优质相似各 2 阶段、组内 score top K），
     * 再按 pageNum/pageSize 在内存中分页。totalSize = 去重后总数。
     */
    private Page<VideoContentItemVO> getSingleSourcePage(VideoContentListParam param, ContentPlatformAccount user, VideoContentSource source) {
        if (VideoContentSource.HOT == source) {
            return getHotSourcePaged(param, user);
        }
        // 杠杆1: channel + dt 每请求只解析一次,传入各池,避免 fan-out 各池重复查 channel/dt
        DemandChannelEnum channel = resolveChannelName(param);
        String dt = demandVideoMapperExt.getMaxDt(channel == null ? null : channel.getValue());
        List<VideoContentItemVO> list;
        if (VideoContentSource.PRIOR == source) {
            // 人群需求池并行拉取, 按渠道差异化配置
            List<PriorPoolConfig> configs = getPriorPoolConfigs(channel);
            int poolCount = configs.size();
            ExecutorService executor = Executors.newFixedThreadPool(poolCount);
            try {
                List<Future<List<VideoContentItemVO>>> futures = new ArrayList<>(poolCount);
                for (PriorPoolConfig cfg : configs) {
                    futures.add(executor.submit(cfg.scene
                            ? () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt, cfg.dimension, cfg.demandType, cfg.matchMethod)
                            : () -> fetchPriorPool(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt, cfg.dimension, cfg.demandType, cfg.matchMethod)));
                }

                int timeoutSeconds = 30;
                List<List<VideoContentItemVO>> pools = new ArrayList<>(poolCount);
                for (Future<List<VideoContentItemVO>> f : futures) {
                    pools.add(getQuietly(f, timeoutSeconds));
                }
                for (List<VideoContentItemVO> pool : pools) {
                    for (VideoContentItemVO v : pool) v.setSource(VideoContentSource.PRIOR.getValue());
                }

                list = interleaveMultiPools(pools);
            } finally {
                executor.shutdown();
            }
        } else {
            list = fetchPosteriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt);
        }
        for (VideoContentItemVO v : list) {
            v.setSource(source.getValue());
        }
        return paginateCandidates(param, list);
    }

    /**
     * 通用 N 池轮转穿插:
     * - 池列表随机打乱后,按新顺序轮转,每池取 1 条,跳过已耗尽的池继续下一轮
     * - 跨池 video_id / 标题去重;某池耗尽后自动从轮转中移除
     */
    private List<VideoContentItemVO> interleaveMultiPools(List<List<VideoContentItemVO>> pools) {
        int n = pools.size();
        // 随机打乱池顺序,轮转遍历
        List<Integer> order = new ArrayList<>(n);
        for (int i = 0; i < n; i++) order.add(i);
        Collections.shuffle(order);

        int[] pointers = new int[n];
        boolean[] exhausted = new boolean[n];
        for (int i = 0; i < n; i++) {
            List<VideoContentItemVO> p = pools.get(order.get(i));
            if (p == null || p.isEmpty()) exhausted[order.get(i)] = true;
        }
        Set<Long> seenIds = new HashSet<>();
        Set<String> seenTitles = new HashSet<>();
        List<VideoContentItemVO> out = new ArrayList<>();

        int pos = 0;
        while (true) {
            // 跳过已耗尽的池
            int started = pos;
            while (exhausted[order.get(pos)]) {
                pos = (pos + 1) % n;
                if (pos == started) return out; // 全部耗尽
            }

            int idx = order.get(pos);
            List<VideoContentItemVO> pool = pools.get(idx);
            while (pointers[idx] < pool.size()) {
                VideoContentItemVO v = pool.get(pointers[idx]++);
                if (tryEmit(v, seenIds, seenTitles, out)) break;
            }
            if (pointers[idx] >= pool.size()) exhausted[idx] = true;

            pos = (pos + 1) % n;
        }
    }

    private boolean tryEmit(VideoContentItemVO v, Set<Long> seenIds, Set<String> seenTitles, List<VideoContentItemVO> out) {
        if (v.getVideoId() == null) return false;
        if (seenIds.contains(v.getVideoId())) return false;
        String nt = TitleNormalizer.normalize(v.getTitle());
        if (!nt.isEmpty() && seenTitles.contains(nt)) return false;
        seenIds.add(v.getVideoId());
        if (!nt.isEmpty()) seenTitles.add(nt);
        out.add(v);
        return true;
    }

    /**
     * Future.get 包装：异常/超时返回空列表，不阻塞整体流程。
     * 语义与"该池无候选数据"一致——不影响其他池结果。
     */
    private List<VideoContentItemVO> getQuietly(Future<List<VideoContentItemVO>> f, int timeoutSeconds) {
        try {
            return f.get(timeoutSeconds, TimeUnit.SECONDS);
        } catch (Exception e) {
            log.warn("fetch candidates timeout or error, fallback to empty list", e);
            return new ArrayList<>();
        }
    }

    /**
     * 单源 hot：复用原 planMapperExt.getVideoCount + getVideoList 真分页链路。
     */
    private Page<VideoContentItemVO> getHotSourcePaged(VideoContentListParam param, ContentPlatformAccount user) {
        int pageSize = param.getPageSize();
        int pageNum = param.getPageNum();
        Page<VideoContentItemVO> result = new Page<>(pageNum, pageSize);
        String dt = planMapperExt.getVideoMaxDt();
        String datastatDt = planMapperExt.getVideoDatastatMaxDt();
        if (!StringUtils.hasText(dt)) {
            result.setTotalSize(0);
            result.setObjs(new ArrayList<>());
            return result;
        }
        int count = planMapperExt.getVideoCount(param, dt, videoMinScore);
        result.setTotalSize(count);
        if (count == 0) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        int offset = (pageNum - 1) * pageSize;
        if (offset >= count) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        String sort = getVideoContentListSort(param.getSort());
        String type = param.getIndustryType().getDescription();
        String channel = getVideoContentListChannel(param.getSort(), user.getChannel());
        String strategy = param.getSort() == 3 ? "recommend" : "normal";
        List<ContentPlatformVideo> videoList = planMapperExt.getVideoList(param, dt, datastatDt, type, channel, strategy,
                videoMinScore, offset, pageSize, sort);
        List<VideoContentItemVO> list = buildVideoContentItemVOList(videoList, type, "sum", user.getChannel(), datastatDt);
        if (list == null) {
            list = new ArrayList<>();
        }
        for (VideoContentItemVO v : list) {
            v.setSource(VideoContentSource.HOT.getValue());
        }
        result.setObjs(list);
        return result;
    }

    /**
     * 默认 tab 随机穿插 + 跨路 video_id 去重。
     * 人群需求池(source='prior') + 优质相似(source='posterior') + 热门(source='hot')
     * 每步在未耗尽的池中等概率随机选一个,从该池头部取下一条(池内顺序由 fetcher 决定)。
     * 用 (userId ^ 当天日期) 作为种子,保证同一用户当天翻页顺序一致、刷新一致。
     */
    private Page<VideoContentItemVO> getInterleavedPage(VideoContentListParam param, ContentPlatformAccount user) {
        // 杠杆1: channel + dt 每请求只解析一次,传入各 demand 池(hot 池用自己的 dt,不受影响)
        DemandChannelEnum channel = resolveChannelName(param);
        String dt = demandVideoMapperExt.getMaxDt(channel == null ? null : channel.getValue());
        // 人群需求池按渠道差异化配置
        List<PriorPoolConfig> priorConfigs = getPriorPoolConfigs(channel);
        int priorCount = priorConfigs.size();
        int totalCount = priorCount + 2; // + posterior + hot
        ExecutorService executor = Executors.newFixedThreadPool(totalCount);
        try {
            // prior 池并行拉取
            List<Future<List<VideoContentItemVO>>> futures = new ArrayList<>(totalCount);
            for (PriorPoolConfig cfg : priorConfigs) {
                futures.add(executor.submit(cfg.scene
                        ? () -> fetchPriorSceneCandidates(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt, cfg.dimension, cfg.demandType, cfg.matchMethod)
                        : () -> fetchPriorPool(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt, cfg.dimension, cfg.demandType, cfg.matchMethod)));
            }
            // posterior + hot
            Future<List<VideoContentItemVO>> fPosterior = executor.submit(
                    () -> fetchPosteriorCandidates(param, user, DEMAND_CANDIDATE_LIMIT, channel, dt));
            Future<List<VideoContentItemVO>> fHot = executor.submit(
                    () -> fetchHotCandidates(param, user, HOT_CANDIDATE_LIMIT));
            futures.add(fPosterior);
            futures.add(fHot);

            int timeoutSeconds = 30;
            List<List<VideoContentItemVO>> pools = new ArrayList<>(totalCount);
            for (int i = 0; i < futures.size(); i++) {
                pools.add(getQuietly(futures.get(i), timeoutSeconds));
            }
            // source 标记: 前 priorCount 个池为 prior, 倒数第2为 posterior, 最后为 hot
            for (int i = 0; i < priorCount; i++) {
                for (VideoContentItemVO v : pools.get(i)) v.setSource(VideoContentSource.PRIOR.getValue());
            }
            for (VideoContentItemVO v : pools.get(priorCount)) v.setSource(VideoContentSource.POSTERIOR.getValue());
            for (VideoContentItemVO v : pools.get(priorCount + 1)) v.setSource(VideoContentSource.HOT.getValue());

            List<VideoContentItemVO> merged = interleaveMultiPools(pools);
            return paginateCandidates(param, merged);
        } finally {
            executor.shutdown();
        }
    }

    private Page<VideoContentItemVO> paginateCandidates(VideoContentListParam param, List<VideoContentItemVO> all) {
        int pageSize = param.getPageSize();
        int pageNum = param.getPageNum();
        Page<VideoContentItemVO> result = new Page<>(pageNum, pageSize);
        result.setTotalSize(all.size());
        if (all.isEmpty()) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        int from = Math.min((pageNum - 1) * pageSize, all.size());
        int to = Math.min(pageNum * pageSize, all.size());
        result.setObjs(new ArrayList<>(all.subList(from, to)));
        return result;
    }

    /**
     * 人群需求-场景池: 按 dimension + demandType + matchMethod 组合过滤。
     * 退化策略: ghName 非空且查不到数据 → 退回渠道粒度(不限 channel_level3)。
     * 后处理:
     * 1. 同 video_id 仅保留 total_rov 最大的代表行(利用 SQL 已按 total_rov DESC, score DESC 排好,首次即最大)
     * 2. 过滤 rov 为 null 或 <=0(视频近 7 日无表现)
     * 3. 输出顺序按 sceneSumRov DESC,相同再按 total_rov DESC 兜底
     */
    private List<VideoContentItemVO> fetchPriorSceneCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit,
                                                               DemandChannelEnum channel, String dt,
                                                               PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod) {
        if (!StringUtils.hasText(dt)) {
            return new ArrayList<>();
        }
        String crowdSegment = resolveCrowdSegment(channel, user);
        String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;

        String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
        String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
        List<String> excludeCategories = resolveExcludeCategories(channel, user);
        String ch = channel == null ? null : channel.getValue();
        List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
                dt, ch, crowdSegment, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
                null, null, ghName, null, category,
                matchMethod.getValue(), demandType.getValue(), crowdPackage, limit, false, excludeCategories);
        // 跨渠道退化:仅合作类渠道主查 crowd_segment 0 行时去掉合作方代码再试一次,
        // 仍保留 ghName,避免公众号入口结果跨账号串号。投流/服务号类渠道 crowdSegment 已是 null,主查与本路径等价,不会进入。
        if (channel != null && crowdSegment != null && rows.isEmpty()) {
            rows = demandVideoMapperExt.selectForRecommend(
                    dt, ch, null, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
                    null, null, ghName, null, category,
                    matchMethod.getValue(), demandType.getValue(), crowdPackage, limit, false, excludeCategories);
        }
        // 1. 同 video_id 取 total_rov 最大的代表行(SQL 已排序,putIfAbsent 保留首次)
        LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
        for (ContentPlatformDemandVideo r : rows) {
            if (r.getVideoId() == null) continue;
            bestPerVideo.putIfAbsent(r.getVideoId(), r);
        }
        // 2. 过滤 rov<=0/null,以及 sceneSumRov < priorSceneMinSumRov(场景关联弱视频砍掉)
        List<ContentPlatformDemandVideo> filtered = new ArrayList<>(bestPerVideo.size());
        for (ContentPlatformDemandVideo r : bestPerVideo.values()) {
            if (r.getRov() == null || r.getRov() <= 0) continue;
            if (r.getSceneSumRov() == null || r.getSceneSumRov() < priorSceneMinSumRov) continue;
            // 曝光下发过滤:场景已看视频池 match_exposure_pv 100% 为 NULL,此处恒放行;保留以统一全池口径。
            if (!passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv)) continue;
            filtered.add(r);
        }
        // 3. 按 sceneSumRov DESC 排序,次级 total_rov DESC
        filtered.sort((a, b) -> {
            int c = Double.compare(
                    b.getSceneSumRov() == null ? 0d : b.getSceneSumRov(),
                    a.getSceneSumRov() == null ? 0d : a.getSceneSumRov());
            if (c != 0) return c;
            return Double.compare(
                    b.getTotalRov() == null ? 0d : b.getTotalRov(),
                    a.getTotalRov() == null ? 0d : a.getTotalRov());
        });
        return buildDemandVideoContentItemVOList(filtered);
    }

    /**
     * 人群需求池通用 pipeline(非 scene 池,即策略2/3/4 共 9 池):
     * 1. SQL: demand_strategy='人群需求' + dimension + demandType + matchMethod
     * 2. 退化: ghName 无数据 → 去 ghName; crowd_segment 在对侧渠道 0 行 → 去 crowd_segment
     * 3. 近 7 日 rov >= demandMinRov 过滤
     * 4. 按 (point_type, standard_element) 分组,按 total_rov 分位保留 top 50%
     * 5. 组按 total_rov DESC、组内 score DESC, top K=3
     * 6. 截断到 limit
     */
    private List<VideoContentItemVO> fetchPriorPool(VideoContentListParam param, ContentPlatformAccount user, int limit,
                                                    DemandChannelEnum channel, String dt,
                                                    PriorDimensionEnum dimension, DemandTypeEnum demandType, DemandMatchMethodEnum matchMethod) {
        if (!StringUtils.hasText(dt)) {
            return new ArrayList<>();
        }
        String crowdSegment = resolveCrowdSegment(channel, user);
        String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
        int fetchLimit = Math.max(limit * 3, DEMAND_CANDIDATE_LIMIT);

        String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
        String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
        List<String> excludeCategories = resolveExcludeCategories(channel, user);
        String ch = channel == null ? null : channel.getValue();
        List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
                dt, ch, crowdSegment, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
                null, null, ghName, null, category,
                matchMethod.getValue(), demandType.getValue(), crowdPackage, fetchLimit, false,
                excludeCategories);

        // 跨渠道退化:仅合作类渠道触发,保留 ghName 防串号。
        if (channel != null && crowdSegment != null && rows.isEmpty()) {
            rows = demandVideoMapperExt.selectForRecommend(
                    dt, ch, null, DemandStrategyEnum.PRIOR.getValue(), dimension.getValue(),
                    null, null, ghName, null, category,
                    matchMethod.getValue(), demandType.getValue(), crowdPackage, fetchLimit, false,
                    excludeCategories);
        }

        rows = rows.stream()
                .filter(r -> r.getRov() != null && r.getRov() >= demandMinRov)
                .filter(r -> passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv))
                .collect(Collectors.toList());

        Function<ContentPlatformDemandVideo, String> keyFn = r ->
                (r.getPointType() == null ? "" : r.getPointType()) + "\u0001"
                        + (r.getStandardElement() == null ? "" : r.getStandardElement());

        rows = retainTopGroupsByTotalRov(rows, keyFn, priorGroupKeepRatio);

        List<VideoContentItemVO> out = groupAndTopK(rows, keyFn, TOP_K_PER_DEMAND, false);
        if (out.size() > limit) {
            out = new ArrayList<>(out.subList(0, limit));
        }
        return out;
    }

    /**
     * 按 channel 内"特征组(由 keyFn 定义)"分位过滤:保留 total_rov 排名 top (keepRatio*100%) 的组。
     * 同组内的 total_rov 在 SQL dimension 过滤后应该是常量,这里取 max 作为组代表,以应对脏数据。
     * 组数不足 2 时全部保留,避免对空/单组数据产生意外裁剪。
     */
    private List<ContentPlatformDemandVideo> retainTopGroupsByTotalRov(
            List<ContentPlatformDemandVideo> rows,
            Function<ContentPlatformDemandVideo, String> keyFn,
            double keepRatio) {
        if (CollectionUtils.isEmpty(rows)) return rows;
        LinkedHashMap<String, Double> groupMax = new LinkedHashMap<>();
        for (ContentPlatformDemandVideo r : rows) {
            String key = keyFn.apply(r);
            double tr = r.getTotalRov() == null ? 0d : r.getTotalRov();
            groupMax.merge(key, tr, Math::max);
        }
        int total = groupMax.size();
        if (total < 2) return rows;
        int keep = Math.max(1, (int) Math.ceil(total * keepRatio));
        if (keep >= total) return rows;

        List<Map.Entry<String, Double>> sorted = new ArrayList<>(groupMax.entrySet());
        sorted.sort((a, b) -> Double.compare(b.getValue(), a.getValue()));
        Set<String> keepKeys = new HashSet<>();
        for (int i = 0; i < keep; i++) keepKeys.add(sorted.get(i).getKey());

        List<ContentPlatformDemandVideo> out = new ArrayList<>(rows.size());
        for (ContentPlatformDemandVideo r : rows) {
            if (keepKeys.contains(keyFn.apply(r))) out.add(r);
        }
        return out;
    }

    /**
     * 优质相似池(posterior):一段查询,不再按 demand_filter_sort_strategy 子分类。
     * 按 demand_content_id 分组,组按 total_rov DESC、组内 score DESC 取前 K;
     * 跨组用 video_id + 归一化标题去重,截到 limit。
     */
    private List<VideoContentItemVO> fetchPosteriorCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit,
                                                              DemandChannelEnum channel, String dt) {
        if (!StringUtils.hasText(dt)) {
            return new ArrayList<>();
        }
        String crowdSegment = resolveCrowdSegment(channel, user);
        String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
        int fetchLimit = Math.max(limit * 3, DEMAND_CANDIDATE_LIMIT);

        String category = StringUtils.hasText(param.getCategory()) ? param.getCategory() : null;
        String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
        List<String> excludeCategories = resolveExcludeCategories(channel, user);
        String ch = channel == null ? null : channel.getValue();
        // posterior 池加 match_method='视频库_解构特征_向量相似匹配' 兜底,防止未来上游对优质相似分量出别的 match_method 值后污染本池
        List<ContentPlatformDemandVideo> rows = demandVideoMapperExt.selectForRecommend(
                dt, ch, crowdSegment, DemandStrategyEnum.POSTERIOR.getValue(),
                null, null, null, ghName, null,
                category, DemandMatchMethodEnum.VECTOR_SIMILARITY.getValue(), null, crowdPackage,
                fetchLimit, true, excludeCategories);

        // 跨渠道退化:见 fetchPriorSceneCandidates 注释。仅合作类渠道触发,保留 ghName 防串号。
        if (channel != null && crowdSegment != null && rows.isEmpty()) {
            rows = demandVideoMapperExt.selectForRecommend(
                    dt, ch, null, DemandStrategyEnum.POSTERIOR.getValue(),
                    null, null, null, ghName, null,
                    category, DemandMatchMethodEnum.VECTOR_SIMILARITY.getValue(), null, crowdPackage,
                    fetchLimit, true, excludeCategories);
        }

        // 近 7 日 rov 下限,与 prior 池一致(demandMinRov,统一到 0.03)
        rows = rows.stream()
                .filter(r -> r.getRov() != null && r.getRov() >= demandMinRov)
                .filter(r -> passesExposureFilter(r.getMatchExposurePv(), demandMinExposurePv))
                .collect(Collectors.toList());

        Function<ContentPlatformDemandVideo, String> keyFn = r ->
                r.getDemandContentId() == null ? "" : r.getDemandContentId();

        // 按 demand_content_id 的 total_rov 中位数过滤:保留中位数及以上(top 50%)的 demand 组,
        // 砍掉群体表现弱的需求,避免低 total_rov 的 demand 带回来的相似变体稀释结果。
        rows = retainTopGroupsByTotalRov(rows, keyFn, posteriorGroupKeepRatio);

        List<VideoContentItemVO> out = groupAndTopK(rows, keyFn, TOP_K_PER_DEMAND, true);
        // 单段也要去归一化标题重复(同段内运营把同内容上传成多 video_id 的情况)
        return concatDedup(out, Collections.emptyList(), limit);
    }

    /**
     * 行级按 keyFn 分组：
     * 1. 组按"组内最大 total_rov" 倒序
     * 2. 组内按 score 倒序，组内 video_id 去重，最多取 topK 条
     * 3. excludeSelfTitle=true 时先在 Java 端用 TitleNormalizer 过滤自标题行
     * 4. rov 为 null 或 <=0 的行视为"近 7 日无表现"，直接丢弃不入池
     */
    private List<VideoContentItemVO> groupAndTopK(List<ContentPlatformDemandVideo> rows,
                                                  Function<ContentPlatformDemandVideo, String> keyFn,
                                                  int topK,
                                                  boolean excludeSelfTitle) {
        if (CollectionUtils.isEmpty(rows)) {
            return new ArrayList<>();
        }
        LinkedHashMap<String, List<ContentPlatformDemandVideo>> groups = new LinkedHashMap<>();
        Map<String, Double> groupRov = new HashMap<>();
        for (ContentPlatformDemandVideo r : rows) {
            if (r.getVideoId() == null) continue;
            if (r.getRov() == null || r.getRov() <= 0) continue;
            if (excludeSelfTitle && TitleNormalizer.isSelfTitle(r.getTitle(), r.getDemandContentTitle())) continue;
            String k = keyFn.apply(r);
            if (k == null) continue;
            groups.computeIfAbsent(k, x -> new ArrayList<>()).add(r);
            double tr = r.getTotalRov() == null ? 0d : r.getTotalRov();
            Double prev = groupRov.get(k);
            if (prev == null || tr > prev) {
                groupRov.put(k, tr);
            }
        }
        List<Map.Entry<String, List<ContentPlatformDemandVideo>>> sortedGroups = new ArrayList<>(groups.entrySet());
        sortedGroups.sort((a, b) -> Double.compare(
                groupRov.getOrDefault(b.getKey(), 0d),
                groupRov.getOrDefault(a.getKey(), 0d)));

        List<ContentPlatformDemandVideo> out = new ArrayList<>();
        for (Map.Entry<String, List<ContentPlatformDemandVideo>> e : sortedGroups) {
            List<ContentPlatformDemandVideo> g = new ArrayList<>(e.getValue());
            g.sort((a, b) -> Double.compare(
                    b.getScore() == null ? 0d : b.getScore(),
                    a.getScore() == null ? 0d : a.getScore()));
            Set<Long> seen = new HashSet<>();
            int taken = 0;
            for (ContentPlatformDemandVideo r : g) {
                if (!seen.add(r.getVideoId())) continue;
                out.add(r);
                if (++taken >= topK) break;
            }
        }
        return buildDemandVideoContentItemVOList(out);
    }

    /**
     * 段间拼接 + video_id + 归一化标题 去重。标题归一化用 TitleNormalizer,
     * 应对运营把同段内容重复上传成多个 video_id 的情况(单源 posterior 也能去掉同标题不同 id 的重复)。
     */
    private List<VideoContentItemVO> concatDedup(List<VideoContentItemVO> a, List<VideoContentItemVO> b, int limit) {
        Set<Long> seenIds = new HashSet<>();
        Set<String> seenTitles = new HashSet<>();
        List<VideoContentItemVO> out = new ArrayList<>();
        for (VideoContentItemVO v : a) {
            if (tryEmit(v, seenIds, seenTitles, out) && out.size() >= limit) return out;
        }
        for (VideoContentItemVO v : b) {
            if (tryEmit(v, seenIds, seenTitles, out) && out.size() >= limit) return out;
        }
        return out;
    }

    private List<VideoContentItemVO> fetchHotCandidates(VideoContentListParam param, ContentPlatformAccount user, int limit) {
        String dt = planMapperExt.getVideoMaxDt();
        String datastatDt = planMapperExt.getVideoDatastatMaxDt();
        if (!StringUtils.hasText(dt)) {
            return new ArrayList<>();
        }
        String sort = getVideoContentListSort(param.getSort());
        String type = param.getIndustryType().getDescription();
        String channel = getVideoContentListChannel(param.getSort(), user.getChannel());
        String strategy = param.getSort() == 3 ? "recommend" : "normal";
        List<ContentPlatformVideo> videoList = planMapperExt.getVideoList(param, dt, datastatDt, type, channel, strategy,
                videoMinScore, 0, limit, sort);
        List<VideoContentItemVO> result = buildVideoContentItemVOList(videoList, type, "sum", user.getChannel(), datastatDt);
        return result == null ? new ArrayList<>() : result;
    }

    /**
     * 按标题通过 manager 平台接口查询视频列表，支持最大查询条数限制
     */
    private Page<VideoContentItemVO> getVideoContentListByTitle(VideoContentListParam param) {
        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int pageSize = param.getPageSize() > 0 ? param.getPageSize() : 10;
        int offset = (param.getPageNum() - 1) * pageSize;

        // 查询前判断：如果请求的偏移量已超出最大条数限制，直接返回，不发起远程查询
        if (offset >= videoTitleSearchMaxCount) {
            result.setTotalSize(videoTitleSearchMaxCount);
            result.setObjs(new ArrayList<>());
            return result;
        }

        JSONObject pageData = managerApiService.searchVideoByTitle(param.getTitle(), param.getPageNum(), pageSize);
        if (pageData == null) {
            result.setTotalSize(0);
            result.setObjs(new ArrayList<>());
            return result;
        }

        int totalSize = pageData.getIntValue("totalSize");
        // 限制最大可查询条数
        int effectiveTotalSize = Math.min(totalSize, videoTitleSearchMaxCount);
        result.setTotalSize(effectiveTotalSize);

        JSONArray objs = pageData.getJSONArray("objs");
        result.setObjs(buildVideoContentItemVOFromRemote(objs));
        return result;
    }

    /**
     * 按标题通过向量搜索接口查询视频列表，并发请求视频详情组装数据返回
     */
    private Page<VideoContentItemVO> getVideoContentListByTitleV2(VideoContentListParam param) {
        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int pageSize = param.getPageSize() > 0 ? param.getPageSize() : 10;

        // 调用向量搜索接口，topN 取 pageSize 的合理上限
        int topN = Math.min(pageSize, 50);
        JSONObject data = managerApiService.recallVideoWithScore(param.getTitle(), topN);

        boolean vectorEmpty = (data == null);
        if (!vectorEmpty) {
            JSONArray items = data.getJSONArray("items");
            vectorEmpty = (items == null || items.isEmpty());
        }

        // 向量搜索为空，降级走关键词搜索
        if (vectorEmpty) {
            Page<VideoContentItemVO> fallbackResult = getVideoContentListByTitle(param);
            // 标识来源为关键词搜索
            if (fallbackResult.getObjs() != null) {
                for (VideoContentItemVO vo : fallbackResult.getObjs()) {
                    vo.setSearchSource("manager");
                    vo.setExperimentId("manager_search");
                }
            }
            return fallbackResult;
        }

        JSONArray items = data.getJSONArray("items");
        int total = data.getIntValue("total");
        int effectiveTotalSize = Math.min(total, videoTitleSearchMaxCount);
        result.setTotalSize(effectiveTotalSize);

        // 分页截取
        int offset = (param.getPageNum() - 1) * pageSize;
        int end = Math.min(offset + pageSize, items.size());
        if (offset >= items.size()) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        List<JSONObject> pageItems = new ArrayList<>();
        for (int i = offset; i < end; i++) {
            pageItems.add(items.getJSONObject(i));
        }

        // 提取 videoId 列表
        List<Long> videoIds = pageItems.stream()
                .map(item -> item.getLong("videoId"))
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());

        // 并发请求视频详情
        Map<Long, VideoDetail> videoDetailMap = new HashMap<>();
        if (CollectionUtils.isNotEmpty(videoIds)) {
            ExecutorService executor = Executors.newFixedThreadPool(videoIds.size() / 20 + 1);
            try {
                List<Future<Map<Long, VideoDetail>>> futures = new ArrayList<>();
                for (List<Long> partition : Lists.partition(videoIds, 20)) {
                    futures.add(executor.submit(() -> {
                        Set<Long> ids = new HashSet<>(partition);
                        return messageAttachmentService.getVideoDetail(ids);
                    }));
                }
                for (Future<Map<Long, VideoDetail>> future : futures) {
                    try {
                        Map<Long, VideoDetail> detailMap = future.get(10, TimeUnit.SECONDS);
                        if (detailMap != null) {
                            videoDetailMap.putAll(detailMap);
                        }
                    } catch (Exception e) {
                        log.error("getVideoContentListByTitle get video detail error", e);
                    }
                }
            } finally {
                executor.shutdown();
            }
        }

        // 组装返回数据
        List<VideoContentItemVO> voList = new ArrayList<>();
        for (JSONObject item : pageItems) {
            Long videoId = item.getLong("videoId");
            VideoContentItemVO vo = new VideoContentItemVO();
            vo.setVideoId(videoId);
            vo.setScore(item.getDouble("score"));
            // 标识来源为向量搜索
            vo.setSearchSource("vector");
            vo.setExperimentId("vector_search");
            // 优先从视频详情中获取标题、封面、视频地址
            VideoDetail detail = videoDetailMap.get(videoId);
            if (detail != null) {
                vo.setTitle(detail.getTitle());
                String cover = detail.getCover();
                if (cover != null && cover.contains("/watermark")) {
                    cover = cover.substring(0, cover.indexOf("/watermark"));
                }
                vo.setCover(cover);
                vo.setVideo(detail.getVideoPath());
                voList.add(vo);
            }
        }
        result.setObjs(voList);
        return result;
    }

    /**
     * 将 manager 接口返回的 objs 数据转换为 VideoContentItemVO 列表
     */
    private List<VideoContentItemVO> buildVideoContentItemVOFromRemote(JSONArray objs) {
        if (objs == null || objs.isEmpty()) {
            return new ArrayList<>();
        }
        List<VideoContentItemVO> result = new ArrayList<>();
        for (int i = 0; i < objs.size(); i++) {
            JSONObject obj = objs.getJSONObject(i);
            VideoContentItemVO item = new VideoContentItemVO();
            item.setVideoId(obj.getLong("id"));
            item.setTitle(obj.getString("title"));
            item.setVideo(obj.getString("transedVideoPath"));
            String cover = obj.getString("selfCoverImgPath");
            if (cover.contains("?")) {
                cover = cover.substring(0, cover.indexOf("?"));
            }
            cover += "?x-oss-process=image/resize,m_fill,w_600,h_480,limit_0/format,jpg/watermark,image_eXNoL3BpYy93YXRlcm1hcmtlci9pY29uX3BsYXlfd2hpdGUucG5nP3gtb3NzLXByb2Nlc3M9aW1hZ2UvcmVzaXplLHdfMTQ0,g_center";
            item.setCover(cover);
            result.add(item);
        }
        return result;
    }

    private String getVideoContentListChannel(Integer sort, String channel) {
        if (sort == 2 || sort == 3) {
            return channel;
        }
        return "sum";
    }

    private String getVideoContentListSort(Integer sort) {
        switch (sort) {
            case 0:
                return "video.score desc";
            case 1:
                return "datastat.fission_rate desc, video.score desc";
            case 2:
                return "datastat.fission_rate desc, video.score desc";
            case 3:
                return "datastat.fission_rate desc, video.score desc";
            default:
                return "video.score desc";
        }
    }

    private List<VideoContentItemVO> buildVideoContentItemVOList(List<ContentPlatformVideo> videoList,
                                                                 String type, String channel, String userChannel, String datastatDt) {
        if (CollectionUtils.isEmpty(videoList)) {
            return null;
        }
        List<Long> videoIds = videoList.stream()
                .map(ContentPlatformVideo::getVideoId).distinct().collect(Collectors.toList());
        List<Long> getCoverVideoIds = videoList.stream()
                .filter(o -> !StringUtils.hasText(o.getCover()))
                .map(ContentPlatformVideo::getVideoId).distinct().collect(Collectors.toList());
        Map<Long, VideoDetail> coverMap = new HashMap<>();
        for (List<Long> partition : Lists.partition(getCoverVideoIds, 20)) {
            Set<Long> ids = new HashSet<>(partition);
            coverMap.putAll(messageAttachmentService.getVideoDetail(ids));
        }
        List<ContentPlatformVideoDataStatAgg> videoDataStatAggList = getTypeChannelVideoDataStatAggList(datastatDt,
                type, Arrays.asList("sum", userChannel), videoIds);
        Map<Long, Double> typeVideoScoreMap = videoDataStatAggList.stream()
                .filter(o -> channel.equals(o.getChannel()) && "normal".equals(o.getStrategy())).collect(Collectors.toMap(
                        ContentPlatformVideoDataStatAgg::getVideoId, ContentPlatformVideoDataStatAgg::getFissionRate));
        Map<Long, Double> channelVideoScoreMap = videoDataStatAggList.stream()
                .filter(o -> userChannel.equals(o.getChannel()) && "normal".equals(o.getStrategy())).collect(Collectors.toMap(
                        ContentPlatformVideoDataStatAgg::getVideoId, ContentPlatformVideoDataStatAgg::getFissionRate));
        Map<Long, Double> recommendTypeVideoScoreMap = videoDataStatAggList.stream()
                .filter(o -> channel.equals(o.getChannel()) && "recommend".equals(o.getStrategy())).collect(Collectors.toMap(
                        ContentPlatformVideoDataStatAgg::getVideoId, ContentPlatformVideoDataStatAgg::getFissionRate));
        Map<Long, Double> recommendChannelVideoScoreMap = videoDataStatAggList.stream()
                .filter(o -> userChannel.equals(o.getChannel()) && "recommend".equals(o.getStrategy())).collect(Collectors.toMap(
                        ContentPlatformVideoDataStatAgg::getVideoId, ContentPlatformVideoDataStatAgg::getFissionRate));
        List<VideoContentItemVO> result = new ArrayList<>();
        for (ContentPlatformVideo video : videoList) {
            VideoContentItemVO item = new VideoContentItemVO();
            item.setVideoId(video.getVideoId());
            item.setTitle(video.getTitle());
            item.setCover(video.getCover());
            VideoDetail detail = coverMap.get(video.getVideoId());
            if (Objects.nonNull(detail)) {
                String cover = detail.getCover().substring(0, detail.getCover().indexOf("/watermark"));
                item.setCover(cover);
            }
            item.setVideo(video.getVideo());
            item.setScore(video.getScore());
            item.setIndustryFissionRate(typeVideoScoreMap.get(video.getVideoId()));
            item.setChannelFissionRate(channelVideoScoreMap.get(video.getVideoId()));
            item.setRecommendScore(recommendChannelVideoScoreMap.get(video.getVideoId()));
            if (Objects.isNull(item.getRecommendScore())) {
                item.setRecommendScore(recommendTypeVideoScoreMap.get(video.getVideoId()));
            }
            item.setCategory(defaultString(video.getCategory()));
            item.setExperimentId("hot");
            result.add(item);
        }
        return result;
    }

    @Override
    public List<ContentPlatformVideoDataStatAgg> getTypeChannelVideoDataStatAggList(String datastatDt,
                                                                                    String type,
                                                                                    List<String> channels,
                                                                                    List<Long> videoIds) {
        ContentPlatformVideoDataStatAggExample example = new ContentPlatformVideoDataStatAggExample();
        example.createCriteria().andVideoIdIn(videoIds).andDtEqualTo(datastatDt).andTypeEqualTo(type)
                .andChannelIn(channels);
        return videoDataStatAggMapper.selectByExample(example);
    }

    @Override
    public Page<VideoContentItemVO> getUploadVideoContentList(VideoContentListParam param) {
        ContentPlatformAccount user = LoginUserContext.getUser();
        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int offset = (param.getPageNum() - 1) * param.getPageSize();
        String datastatDt = planMapperExt.getVideoDatastatMaxDt();
        int count = planMapperExt.getUploadVideoCount(param, user.getId());
        result.setTotalSize(count);
        if (count == 0) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        String sort = getVideoContentListSort(param.getSort());
        String type = param.getIndustryType().getDescription();
        String channel = getVideoContentListChannel(param.getSort(), user.getChannel());
        List<ContentPlatformVideo> videoList = planMapperExt.getUploadVideoList(param, user.getId(), datastatDt,
                type, channel, "normal", offset, param.getPageSize(), sort);
        List<VideoContentItemVO> list = buildVideoContentItemVOList(videoList, type, channel, user.getChannel(), datastatDt);
        result.setObjs(list);
        return result;
    }

    @Override
    public List<String> getVideoContentCategoryList(String category) {
        String dt = planMapperExt.getVideoMaxDt();
        return planMapperExt.getVideoCategoryList(dt, category);
    }

    @Override
    public List<String> getVideoContentCoverFrameList(VideoContentCoverFrameParam param) {
        return videoApiService.getCoverImagePaths(param.getVideoId());
    }

    @Override
    public Page<QwPlanItemVO> qwPlanList(QwPlanListParam param) {
        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        Page<QwPlanItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int offset = (param.getPageNum() - 1) * param.getPageSize();
        int count = planMapperExt.getQwPlanCount(param, loginAccount.getId());
        result.setTotalSize(count);
        if (count == 0) {
            return result;
        }
        List<ContentPlatformQwPlan> planList = planMapperExt.getQwPlanList(param,
                loginAccount.getId(), offset, param.getPageSize());
        List<QwPlanItemVO> list = buildQwPlanItemVOList(planList);
        result.setObjs(list);
        return result;
    }

    /**
     * 企微计划列表导出：
     * 复用 /qw/list 的过滤条件（type/scene/subChannel/title）与起止日期过滤，
     * 不传起止日期时默认只导出当天（1天）数据，最多导出 2000 条。
     */
    @Override
    public String qwPlanExport(QwPlanListParam param) {
        // 未传日期时，默认仅导出当天数据
        if (!StringUtils.hasText(param.getStartDate()) && !StringUtils.hasText(param.getEndDate())) {
            String today = DateUtil.getThatDayDateString();
            param.setStartDate(today);
            param.setEndDate(today);
        } else if (!StringUtils.hasText(param.getStartDate())) {
            param.setStartDate(param.getEndDate());
        } else if (!StringUtils.hasText(param.getEndDate())) {
            param.setEndDate(param.getStartDate());
        }
        // 强制分页参数：最多导出 2000 条
        param.setPageNum(1);
        param.setExportPageSize(2000);

        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        List<ContentPlatformQwPlan> planList = planMapperExt.getQwPlanList(param,
                loginAccount.getId(), 0, param.getPageSize());
        List<QwPlanItemVO> list = buildQwPlanItemVOList(planList);
        return generateQwPlanExcelFile(list);
    }

    private String generateQwPlanExcelFile(List<QwPlanItemVO> dataList) {
        List<QwPlanItemExportVO> list = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(dataList)) {
            for (QwPlanItemVO data : dataList) {
                QwPlanItemExportVO vo = new QwPlanItemExportVO();
                vo.setType(convertQwPlanTypeText(data.getType()));
                vo.setScene(convertQwPlanSceneText(data.getScene()));
                vo.setSubChannel(data.getSubChannel());
                vo.setTitle(data.getTitle());
                vo.setCover(data.getShareCover());
                vo.setPageUrl(data.getPageUrl());
                if (Objects.nonNull(data.getCreateTimestamp())) {
                    vo.setCreateTime(DateUtil.getDateString(data.getCreateTimestamp(), "yyyy-MM-dd HH:mm:ss"));
                }
                list.add(vo);
            }
        } else {
            list.add(new QwPlanItemExportVO());
        }
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        EasyExcel.write(out, QwPlanItemExportVO.class).sheet("企微计划").doWrite(list);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(out.toByteArray());
        String fileName = "企微计划_" + System.currentTimeMillis() + ".xls";
        AliOssFileTool.saveInPublicReturnHost(byteArrayInputStream, EnumPublicBuckets.PUBBUCKET, fileName, EnumFileType.TEMP_PICTURE);
        return CdnUtil.DOWNLOAD_CDN_URL_HOST_PICTURE + fileName;
    }

    private String convertQwPlanTypeText(Integer type) {
        if (Objects.isNull(type)) {
            return "";
        }
        return QwPlanTypeEnum.from(type).getDescription();
    }

    private String convertQwPlanSceneText(Integer scene) {
        if (Objects.isNull(scene)) {
            return "";
        }
        if (scene == 0) {
            return "群发";
        }
        if (scene == 1) {
            return "单发";
        }
        return String.valueOf(scene);
    }

    private List<QwPlanItemVO> buildQwPlanItemVOList(List<ContentPlatformQwPlan> planList) {
        if (CollectionUtils.isEmpty(planList)) {
            return null;
        }
        List<Long> planIds = planList.stream().map(ContentPlatformQwPlan::getId).collect(Collectors.toList());
        List<ContentPlatformQwPlanVideo> videoList = getQwPlanVideoList(planIds);
        Map<Long, ContentPlatformQwPlanVideo> videoMap = videoList.stream()
                .collect(Collectors.toMap(ContentPlatformQwPlanVideo::getPlanId, Function.identity()));
        List<QwPlanItemVO> result = new ArrayList<>();
        for (ContentPlatformQwPlan qwPlan : planList) {
            QwPlanItemVO planItemVO = new QwPlanItemVO();
            planItemVO.setId(qwPlan.getId());
            planItemVO.setType(qwPlan.getType());
            planItemVO.setSubChannel(qwPlan.getSubChannel());
            planItemVO.setScene(qwPlan.getScene());
            planItemVO.setPageUrl(qwPlan.getPageUrl());
            planItemVO.setCreateTimestamp(qwPlan.getCreateTimestamp());
            ContentPlatformQwPlanVideo videoItem = videoMap.get(qwPlan.getId());
            if (Objects.nonNull(videoItem)) {
                planItemVO.setTitle(videoItem.getTitle());
                planItemVO.setCover(videoItem.getCover());
                planItemVO.setVideo(videoItem.getVideo());
            }
            planItemVO.setShareCover(getShareCover(planItemVO.getType(), planItemVO.getCover()));
            result.add(planItemVO);
        }
        return result;
    }

    private String getShareCover(Integer type, String cover) {
        if (!StringUtils.hasText(cover)) {
            return "";
        }
        if (cover.contains("?")) {
            cover = cover.substring(0, cover.indexOf("?"));
        }
        // 裁剪
        cover = cover + "?x-oss-process=image/resize,m_fill,w_600,h_480,limit_0/format,jpg";
        // 水印 公众号推送不增加播放按钮
        if (cover.contains("yishihui")
                && (Objects.isNull(type) || (type != ContentPlatformGzhPlanTypeEnum.FWH_PUSH.getVal() && type != ContentPlatformGzhPlanTypeEnum.GZH_PUSH.getVal()))) {
            cover = cover + "/watermark,image_eXNoL3BpYy93YXRlcm1hcmtlci9pY29uX3BsYXlfd2hpdGUucG5nP3gtb3NzLXByb2Nlc3M9aW1hZ2UvcmVzaXplLHdfMTQ0,g_center";
        }
        return cover;
    }

    @Override
    public List<ContentPlatformQwPlanVideo> getQwPlanVideoList(List<Long> planIds) {
        ContentPlatformQwPlanVideoExample example = new ContentPlatformQwPlanVideoExample();
        example.createCriteria().andPlanIdIn(planIds);
        return qwPlanVideoMapper.selectByExample(example);
    }

    @Override
    public List<ContentPlatformVideo> getVideoContentListByVideoIds(List<Long> videoIds) {
        ContentPlatformVideoExample example = new ContentPlatformVideoExample();
        example.createCriteria().andVideoIdIn(videoIds);
        example.setOrderByClause("id asc");
        return contentPlatformVideoMapper.selectByExample(example);
    }

    @Override
    public List<ContentPlatformVideoAgg> getVideoContentAggListByVideoIds(List<Long> videoIds) {
        ContentPlatformVideoAggExample example = new ContentPlatformVideoAggExample();
        example.createCriteria().andVideoIdIn(videoIds);
        example.setOrderByClause("id asc");
        return contentPlatformVideoAggMapper.selectByExample(example);
    }

    @Override
    public List<QwPlanItemVO> qwPlanSave(QwPlanSaveParam param) {
        ContentPlatformAccount loginUser = LoginUserContext.getUser();
        if (StringUtils.hasText(param.getSubChannel()) && param.getSubChannel().length() > 20) {
            throw new CommonException(ExceptionEnum.QW_PLAN_SUB_CHANNEL_LENGTH);
        }
        Long now = System.currentTimeMillis();
        List<ContentPlatformQwPlan> list = new ArrayList<>();
        for (QwPlanSaveVideoParam videoParam : param.getVideoList()) {
            ContentPlatformQwPlan qwPlan = new ContentPlatformQwPlan();
            qwPlan.setType(param.getType());
            qwPlan.setSubChannel(param.getSubChannel());
            qwPlan.setScene(videoParam.getScene());
            String carrierId = StringUtils.hasText(param.getSubChannel()) ? param.getSubChannel() : "wxbdd2a2e93d9a6e25";
            String pageUrl = messageAttachmentService.getPage(loginUser.getChannel(), carrierId,
                    "dyyqw", "企微", QwPlanTypeEnum.from(param.getType()).getDescription(),
                    "位置1", videoParam.getVideoId(), videoParam.getExperimentId());
            // 判断是否为特殊首层，追加 isSpecialLayer 参数
            String layerType = redisUtils.getString("cp:qw_hz_layer_type:" + loginUser.getChannel());
            if ("特殊".equals(layerType)) {
                pageUrl = pageUrl + "%26isSpecialLayer%3D1";
            }
            //pageUrl = videoMultiService.setVideoMultiTitleCoverPagePath(videoParam.getVideoId(), pageUrl,
            //        videoParam.getTitle(), videoParam.getCover());
            String rootSourceId = MessageUtil.getRootSourceId(pageUrl);
            qwPlan.setPageUrl(pageUrl);
            qwPlan.setRootSourceId(rootSourceId);
            qwPlan.setCreateAccountId(loginUser.getId());
            qwPlan.setCreateTimestamp(now);
            planMapperExt.insertQwPlanReturnId(qwPlan);
            list.add(qwPlan);
            // 保存视频内容
            saveQwPlanVideo(videoParam, qwPlan.getId(), loginUser.getId());
        }
        return buildQwPlanItemVOList(list);
    }

    @Override
    public void saveQwPlanVideo(QwPlanSaveVideoParam videoParam, Long planId, Long loginAccountId) {
        ContentPlatformQwPlanVideo item = new ContentPlatformQwPlanVideo();
        item.setPlanId(planId);
        item.setVideoId(videoParam.getVideoId());
        item.setTitle(videoParam.getTitle());
        item.setCover(videoParam.getCover());
        item.setVideo(videoParam.getVideo());
        item.setExperimentId(videoParam.getExperimentId());
        item.setCreateAccountId(loginAccountId);
        item.setCreateTimestamp(System.currentTimeMillis());
        qwPlanVideoMapper.insertSelective(item);
    }

    @Override
    public String getSharePic(String pageUrl) {
        return vlogApiService.getSharePic(pageUrl, shareAppType);
    }

    @Override
    public String getShareUrlLink(String pageUrl) {
        return vlogApiService.getShareUrlLink(pageUrl, shareAppType);
    }

    @Override
    public List<ContentPlatformGzhPlanVideo> getGzhPlanVideoListByCooperateAccountId(String ghId) {
        return planMapperExt.getGzhPlanVideoListByCooperateAccountId(ghId);
    }

    @Override
    public List<ContentPlatformQwPlan> getQwPlanListByRootSourceIds(List<String> rootSourceIds) {
        ContentPlatformQwPlanExample example = new ContentPlatformQwPlanExample();
        example.createCriteria().andRootSourceIdIn(rootSourceIds);
        return qwPlanMapper.selectByExample(example);
    }

    @Override
    public List<ContentPlatformQwPlanVideo> getQwPlanVideoListByPlanIds(List<Long> planIds) {
        ContentPlatformQwPlanVideoExample example = new ContentPlatformQwPlanVideoExample();
        example.createCriteria().andPlanIdIn(planIds);
        return qwPlanVideoMapper.selectByExample(example);
    }

    @Override
    public String getGzhChannel(String ghId) {
        ContentPlatformGzhAccount gzhAccount = cooperateAccountService.getGzhAccount(ghId);
        if (Objects.nonNull(gzhAccount)) {
            ContentPlatformAccount account = accountService.getAccountById(gzhAccount.getCreateAccountId());
            ContentPlatformGzhPlanExample example = new ContentPlatformGzhPlanExample();
            example.createCriteria().andCreateAccountIdEqualTo(account.getId()).andAccountIdEqualTo(gzhAccount.getId())
                    .andStatusEqualTo(AccountStatusEnum.NORMAL.getVal()).andTypeEqualTo(ContentPlatformGzhPlanTypeEnum.FWH_PUSH.getVal());
            List<ContentPlatformGzhPlan> list = gzhPlanMapper.selectByExample(example);
            if (CollectionUtils.isNotEmpty(list)) {
                return account.getChannel();
            }
        }
        return null;
    }

    @Override
    public void gzhPlanImport(List<String> channels) {
        List<ContentPlatformAccount> accountList = accountService.getAccountListByChannels(channels);
        for (ContentPlatformAccount account : accountList) {
            Long now = System.currentTimeMillis();
            List<GhDetail> ghDetailList = ghDetailService.getByChannel(account.getChannel());
            ghDetailList = ghDetailList.stream()
                    .filter(o -> Objects.equals(o.getStrategyStatus(), StrategyStatusEnum.STRATEGY.status))
                    .collect(Collectors.toList());
            if (CollectionUtils.isEmpty(ghDetailList)) {
                continue;
            }
            List<String> ghIds = ghDetailList.stream().map(GhDetail::getGhId).collect(Collectors.toList());
            List<ContentPlatformGzhAccount> gzhAccountList = cooperateAccountService.getCooperateAccountListByGhIds(account.getId(), ghIds);
            List<ContentPlatformGzhPlan> existsPlanList = getGzhPlanListByCreateAccountId(account.getId());
            List<Long> existsAccountIds = existsPlanList.stream().map(ContentPlatformGzhPlan::getAccountId).distinct().collect(Collectors.toList());
            Map<String, ContentPlatformGzhAccount> gzhAccountMap = gzhAccountList.stream()
                    .collect(Collectors.toMap(ContentPlatformGzhAccount::getGhId, Function.identity()));
            for (GhDetail ghDetail : ghDetailList) {
                ContentPlatformGzhAccount gzhAccount = gzhAccountMap.get(ghDetail.getGhId());
                if (Objects.isNull(gzhAccount)) {
                    continue;
                }
                ContentPlatformGzhPlan gzhPlan = new ContentPlatformGzhPlan();
                if (existsAccountIds.contains(gzhAccount.getId())) {
                    continue;
                }
                gzhPlan.setAccountId(gzhAccount.getId());
                gzhPlan.setType(ContentPlatformGzhPlanTypeEnum.AUTO_REPLY.getVal());
                gzhPlan.setScene(0);
                gzhPlan.setSelectVideoType(ghDetail.getStrategyStatus());
                gzhPlan.setPublishStage(PublishStageEnum.USER.getVal());
                if (StringUtils.hasText(gzhAccount.getExternalId())) {
                    if (aigcApiService.checkContainMessagePlan(gzhAccount.getExternalId())) {
                        aigcApiService.closeAccountMessagePublishPlan(gzhAccount.getExternalId());
                        gzhPlan.setPublishStage(PublishStageEnum.PLATFORM.getVal());
                        String externalId = aigcApiService.createPublishPlan(gzhAccount.getExternalId(), gzhAccount.getName(), account.getChannel());
                        gzhPlan.setExternalId(externalId);
                        gzhPlan.setStagePublishStatus(1);
                    }
                }
                gzhPlan.setCreateAccountId(account.getId());
                gzhPlan.setCreateTimestamp(now);
                gzhPlan.setUpdateTimestamp(now);
                planMapperExt.insertGzhPlanReturnId(gzhPlan);
                if (!StringUtils.hasText(ghDetail.getVideoIds())) {
                    continue;
                }
                List<Long> videoIds = JSONObject.parseArray(ghDetail.getVideoIds(), Long.class);
                if (CollectionUtils.isEmpty(videoIds)) {
                    continue;
                }
                Map<Long, VideoDetail> videoMap = messageAttachmentService.getVideoDetail(new HashSet<>(videoIds));
                for (Map.Entry<Long, VideoDetail> entry : videoMap.entrySet()) {
                    Long videoId = entry.getKey();
                    VideoDetail videoDetail = entry.getValue();
                    ContentPlatformGzhPlanVideo item = new ContentPlatformGzhPlanVideo();
                    item.setPlanId(gzhPlan.getId());
                    item.setVideoId(videoId);
                    item.setTitle(videoDetail.getTitle());
                    item.setCover(videoDetail.getCover());
                    item.setVideo(videoDetail.getVideoPath());
                    item.setCreateAccountId(account.getId());
                    item.setCreateTimestamp(System.currentTimeMillis());
                    gzhPlanVideoMapper.insertSelective(item);
                }
            }
        }
    }

    @Override
    public void qwPlanImport(List<String> channels) {
        List<AdPutFlowRecordTencent> list = new ArrayList<>();
        for (String channel : channels) {
            Integer count = adApiService.getPutFlowListTencentCount(channel, 1, 1);
            if (count == 0) {
                continue;
            }
            int pageNum = 1000;
            int totalPage = (count / pageNum) + 1;
            for (int i = 1; i <= totalPage; i++) {
                list.addAll(adApiService.getPutFlowListTencent(channel, i, pageNum));
            }
        }
        List<String> filterChannels = Arrays.asList("ml2025", "mj", "nh", "qc", "tczy", "wb", "xj", "wxm", "wxmx1", "wxmx", "xycsd", "xycsd1", "xycsd2", "xycsd3", "xycsd4", "xycsd5", "xycsd6", "xycsd7", "xycsd8", "xycsd9", "xycsd10", "xycsd11", "xycsd12", "xycsd13", "xycsd14", "xycsd15", "xycsd16", "xycsd17", "xycsd18", "shy");
        Map<String, List<AdPutFlowRecordTencent>> channelMap = list.stream()
                .filter(o -> filterChannels.contains(o.getChannel()))
                .collect(Collectors.groupingBy(AdPutFlowRecordTencent::getChannel));
        for (Map.Entry<String, List<AdPutFlowRecordTencent>> entry : channelMap.entrySet()) {
            String channel = entry.getKey();
            List<AdPutFlowRecordTencent> recordList = entry.getValue();
            ContentPlatformAccount account = accountService.getAccountByChannel(channel);
            List<String> rootSourceIds = recordList.stream().map(AdPutFlowRecordTencent::getRootSourceId).distinct().collect(Collectors.toList());
            List<ContentPlatformQwPlan> existsPlanList = getQwPlanListByRootSourceIds(rootSourceIds);
            List<String> existsRootSourceIds = existsPlanList.stream().map(ContentPlatformQwPlan::getRootSourceId).collect(Collectors.toList());
            List<Long> videoIds = recordList.stream().filter(o -> !existsRootSourceIds.contains(o.getRootSourceId()))
                    .map(AdPutFlowRecordTencent::getVideoId).distinct().collect(Collectors.toList());
            Map<Long, VideoDetail> videoMap = new HashMap<>();
            for (List<Long> partition : Lists.partition(videoIds, 20)) {
                Set<Long> ids = new HashSet<>(partition);
                videoMap.putAll(messageAttachmentService.getVideoDetail(ids));
            }
            Long now = System.currentTimeMillis();
            for (AdPutFlowRecordTencent record : recordList) {
                if (existsRootSourceIds.contains(record.getRootSourceId())) {
                    continue;
                }
                ContentPlatformQwPlan qwPlan = new ContentPlatformQwPlan();
                QwPlanTypeEnum typeEnum = QwPlanTypeEnum.from(record.getPutTypeTwo());
                qwPlan.setType(typeEnum.getVal());
                qwPlan.setScene(0);
                qwPlan.setPageUrl(record.getUrl());
                qwPlan.setRootSourceId(record.getRootSourceId());
                qwPlan.setCreateAccountId(account.getId());
                qwPlan.setCreateTimestamp(now);
                // 保存视频内容
                VideoDetail videoDetail = videoMap.get(record.getVideoId());
                if (Objects.isNull(videoDetail)) {
                    continue;
                }
                QwPlanSaveVideoParam videoParam = new QwPlanSaveVideoParam();
                videoParam.setVideoId(record.getVideoId());
                videoParam.setTitle(record.getVideoTitle());
                videoParam.setCover(videoDetail.getCover());
                videoParam.setVideo(videoDetail.getVideoPath());
                planMapperExt.insertQwPlanReturnId(qwPlan);
                saveQwPlanVideo(videoParam, qwPlan.getId(), account.getId());
            }
        }
    }

    @Override
    public Boolean gzhPushCheckExistRootSourceId(String rootSourceId) {
        String redisKey = "check_gzh_push_exist_root_source_id_" + rootSourceId;
        String value = redisUtils.get(redisKey);
        if (org.apache.commons.lang3.StringUtils.isNotEmpty(value)) {
            return Boolean.parseBoolean(value);
        }
        List<ContentPlatformGzhPlanVideo> list = planMapperExt.getGzhPushPlanVideoByRootSourceId(rootSourceId, ContentPlatformGzhPlanTypeEnum.GZH_PUSH.getVal());
        boolean exists = !org.springframework.util.CollectionUtils.isEmpty(list);
        // 缓存1小时
        redisUtils.set(redisKey, String.valueOf(exists), 60 * 60);
        return exists;
    }

    @Override
    public Page<VideoContentItemVO> getDemandVideoContentList(VideoContentListParam param) {
        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        // 仅允许企微type（2-企微-社群、3-企微-自动回复）传入，其他返回空
        if (param.getType() == null || (param.getType() != 2 && param.getType() != 3)) {
            result.setTotalSize(0);
            result.setObjs(new ArrayList<>());
            return result;
        }
        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        String crowdSegment = loginAccount.getChannel();
        int offset = (param.getPageNum() - 1) * param.getPageSize();
        String dt = demandVideoMapperExt.getMaxDt(null);
        if (!StringUtils.hasText(dt)) {
            result.setTotalSize(0);
            result.setObjs(new ArrayList<>());
            return result;
        }
        int count = demandVideoMapperExt.countByCondition(dt, null, null, param.getTitle(), crowdSegment);
        result.setTotalSize(count);
        if (count == 0) {
            result.setObjs(new ArrayList<>());
            return result;
        }
        List<ContentPlatformDemandVideo> videoList = demandVideoMapperExt.selectByCondition(dt,
                null, null, param.getTitle(), crowdSegment, offset, param.getPageSize());
        List<VideoContentItemVO> list = buildDemandVideoContentItemVOList(videoList);
        result.setObjs(list);
        return result;
    }

    @Override
    public Page<VideoContentItemVO> getVectorVideoContentList(VideoContentListParam param) {
        if (StringUtils.hasText(param.getTitle())) {
            return getVideoContentListByTitleV2(param);
        }
        Page<VideoContentItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        result.setTotalSize(0);
        result.setObjs(new ArrayList<>());
        return result;
    }

    /**
     * 搜索:在当前入口的 demand 白名单(rov >= 0.03)内做 title LIKE 关键字命中,按 rov DESC 排序。
     * 门控:
     * - 用户身份 type ∈ {2 自营, 3 代理},否则返回空
     * - 渠道 ∈ {小程序投流-稳定, 公众号投流-稳定},否则返回空
     * - 小程序投流必填 crowdPackage,公众号投流必填 ghName,否则返回空
     * 流程:
     * 1. demand 白名单(channel + crowdSegment/channelLevel3 + demand_strategy IN (人群需求,优质相似) + rov>=0.03)
     * SQL 已 ORDER BY rov DESC, id ASC
     * 2. 同 video_id 去重:putIfAbsent 取 max rov 代表行 —— 与搜索 rov DESC 排序对齐,
     * 与 priorScene 池(取 max sceneSumRov 代表行)有意区别;同 dt 内稳定,跨 dt 重灌可能换代表行。
     * 3. title.trim().toLowerCase().contains(query) 关键字命中
     * 4. 排序:rov DESC,次级 total_rov DESC,score 字段清空避免前端歧义
     * 5. 按 pageNum/pageSize 内存切片返回
     */
    private Page<VideoContentItemVO> searchByTitleInDemandPool(VideoContentListParam param, ContentPlatformAccount user) {
        Page<VideoContentItemVO> empty = new Page<>(param.getPageNum(), param.getPageSize());
        empty.setTotalSize(0);
        empty.setObjs(new ArrayList<>());

        if (user == null || !ContentPlatformAccountTypeEnum.from(user.getType()).allowSearch()) {
            return empty;
        }
        DemandChannelEnum channel = resolveChannelName(param);
        if (channel == null || !channel.allowSearch()) {
            return empty;
        }
        boolean isXcx = DemandChannelEnum.XCX == channel;
        String crowdPackage = StringUtils.hasText(param.getCrowdPackage()) ? param.getCrowdPackage() : null;
        String ghName = StringUtils.hasText(param.getGhName()) ? param.getGhName() : null;
        if (isXcx && crowdPackage == null) return empty;
        if (!isXcx && ghName == null) return empty;

        String kw = param.getTitle() == null ? "" : param.getTitle().trim().toLowerCase();
        if (kw.isEmpty()) return empty;

        String ch = channel.getValue();
        String dt = demandVideoMapperExt.getMaxDt(ch);
        if (!StringUtils.hasText(dt)) return empty;

        List<ContentPlatformDemandVideo> whitelist = demandVideoMapperExt.selectSearchWhitelist(
                dt, ch, isXcx ? crowdPackage : null, isXcx ? null : ghName,
                demandMinRov, priorSceneMinSumRov);
        if (CollectionUtils.isEmpty(whitelist)) return empty;

        // SQL 已 ORDER BY rov DESC,putIfAbsent 即拿到 max rov 代表行(设计意图:与排序键一致)
        LinkedHashMap<Long, ContentPlatformDemandVideo> bestPerVideo = new LinkedHashMap<>();
        for (ContentPlatformDemandVideo r : whitelist) {
            if (r.getVideoId() == null) continue;
            bestPerVideo.putIfAbsent(r.getVideoId(), r);
        }

        List<VideoContentItemVO> hits = new ArrayList<>();
        for (ContentPlatformDemandVideo demand : bestPerVideo.values()) {
            if (demand.getTitle() == null) continue;
            if (!demand.getTitle().toLowerCase().contains(kw)) continue;
            if (!passesSearchDemandRovFilter(demand.getMatchMethod(), demand.getRov(), demand.getSceneSumRov(),
                    demandMinRov, priorSceneMinSumRov)) continue;
            VideoContentItemVO vo = buildDemandVideoContentItemVOList(Collections.singletonList(demand)).get(0);
            vo.setSearchSource("keyword");
            vo.setScore(null);
            hits.add(vo);
        }

        if (hits.isEmpty()) return empty;

        // rov DESC,次级 total_rov DESC 作稳定兜底
        hits.sort((a, b) -> {
            double ra = a.getRov() == null ? 0d : a.getRov();
            double rb = b.getRov() == null ? 0d : b.getRov();
            int c = Double.compare(rb, ra);
            if (c != 0) return c;
            double ta = a.getTotalRov() == null ? 0d : a.getTotalRov();
            double tb = b.getTotalRov() == null ? 0d : b.getTotalRov();
            return Double.compare(tb, ta);
        });

        int pageNum = Math.max(1, param.getPageNum());
        int pageSize = param.getPageSize() > 0 ? param.getPageSize() : 10;
        Page<VideoContentItemVO> page = new Page<>(pageNum, pageSize);
        page.setTotalSize(hits.size());
        int from = Math.min((pageNum - 1) * pageSize, hits.size());
        int to = Math.min(pageNum * pageSize, hits.size());
        page.setObjs(new ArrayList<>(hits.subList(from, to)));
        return page;
    }

    private List<VideoContentItemVO> buildDemandVideoContentItemVOList(List<ContentPlatformDemandVideo> videoList) {
        if (CollectionUtils.isEmpty(videoList)) {
            return new ArrayList<>();
        }
        List<VideoContentItemVO> result = new ArrayList<>();
        for (ContentPlatformDemandVideo video : videoList) {
            VideoContentItemVO item = new VideoContentItemVO();
            item.setVideoId(video.getVideoId());
            item.setTitle(video.getTitle());
            item.setCover(video.getCover());
            item.setVideo(video.getVideo());
            item.setScore(video.getScore());
            item.setCrowdSegment(video.getCrowdSegment());
            item.setDemandId(video.getDemandId());
            item.setCrowdPackage(video.getCrowdPackage());
            item.setConversionTarget(video.getConversionTarget());
            item.setPartner(video.getPartner());
            item.setAccount(video.getAccount());
            item.setSceneValue(video.getSceneValue());
            item.setDemandStrategy(video.getDemandStrategy());
            item.setDriveDimensionTime(video.getDriveDimensionTime());
            item.setDimension(video.getDimension());
            item.setDemandFilterSortStrategy(video.getDemandFilterSortStrategy());
            item.setDemandType(video.getDemandType());
            item.setDemandContentId(video.getDemandContentId());
            item.setDemandContentTitle(video.getDemandContentTitle());
            item.setDemandContentTopic(video.getDemandContentTopic());
            item.setPointType(video.getPointType());
            item.setStandardElement(video.getStandardElement());
            item.setCategory(defaultString(video.getCategory()));
            item.setCategoryName(video.getCategoryName());
            item.setExperimentId(video.getExperimentId());
            item.setSim(video.getSim());
            item.setRov(video.getRov());
            item.setChannelName(video.getChannelName());
            item.setChannelLevel3(video.getChannelLevel3());
            item.setVideoCount(video.getVideoCount());
            item.setVisitUv(video.getVisitUv());
            item.setUvRatio(video.getUvRatio());
            item.setTotalRov(video.getTotalRov());
            item.setSceneSumRov(video.getSceneSumRov());
            item.setMatchMethod(video.getMatchMethod());
            result.add(item);
        }
        return result;
    }

    private static String defaultString(String value) {
        return StringUtils.hasText(value) ? value : "";
    }

    @Override
    public Page<XcxPlanItemVO> xcxPlanList(XcxPlanListParam param) {
        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        Page<XcxPlanItemVO> result = new Page<>(param.getPageNum(), param.getPageSize());
        int offset = (param.getPageNum() - 1) * param.getPageSize();
        int count = planMapperExt.getXcxPlanCount(param, loginAccount.getId());
        result.setTotalSize(count);
        if (count == 0) {
            return result;
        }
        List<ContentPlatformXcxPlan> planList = planMapperExt.getXcxPlanList(param,
                loginAccount.getId(), offset, param.getPageSize());
        List<XcxPlanItemVO> list = buildXcxPlanItemVOList(planList);
        result.setObjs(list);
        return result;
    }

    /**
     * 小程序投流计划导出:复用 xcxPlanList 的 filter(audiencePackage/title/时间范围),
     * 不传时间范围时默认仅导出当天数据,最多导出 2000 条。
     */
    @Override
    public String xcxPlanExport(XcxPlanListParam param) {
        // 未传时间窗口时,默认仅导出当天数据(本地时区 00:00 ~ 次日 00:00)
        if (param.getCreateTimestampStart() == null && param.getCreateTimestampEnd() == null) {
            ZoneId zone = ZoneId.systemDefault();
            long startOfToday = LocalDate.now().atStartOfDay(zone).toInstant().toEpochMilli();
            long startOfTomorrow = LocalDate.now().plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli();
            param.setCreateTimestampStart(startOfToday);
            param.setCreateTimestampEnd(startOfTomorrow);
        }
        // 强制分页参数:最多导出 2000 条
        param.setPageNum(1);
        param.setExportPageSize(2000);

        ContentPlatformAccount loginAccount = LoginUserContext.getUser();
        List<ContentPlatformXcxPlan> planList = planMapperExt.getXcxPlanList(param,
                loginAccount.getId(), 0, param.getPageSize());
        return generateXcxPlanExcelFile(planList);
    }

    private String generateXcxPlanExcelFile(List<ContentPlatformXcxPlan> planList) {
        List<XcxPlanItemExportVO> list = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(planList)) {
            List<Long> planIds = planList.stream().map(ContentPlatformXcxPlan::getId).collect(Collectors.toList());
            ContentPlatformXcxPlanVideoExample videoExample = new ContentPlatformXcxPlanVideoExample();
            videoExample.createCriteria().andPlanIdIn(planIds);
            List<ContentPlatformXcxPlanVideo> videoList = xcxPlanVideoMapper.selectByExample(videoExample);
            Map<Long, ContentPlatformXcxPlanVideo> videoMap = videoList.stream()
                    .collect(Collectors.toMap(ContentPlatformXcxPlanVideo::getPlanId, Function.identity()));
            for (ContentPlatformXcxPlan plan : planList) {
                XcxPlanItemExportVO vo = new XcxPlanItemExportVO();
                vo.setAudiencePackage(plan.getAudiencePackage());
                vo.setRootSourceId(plan.getRootSourceId());
                vo.setPageUrl(plan.getPageUrl());
                vo.setRemark(plan.getRemark());
                if (Objects.nonNull(plan.getCreateTimestamp())) {
                    vo.setCreateTime(DateUtil.getDateString(plan.getCreateTimestamp(), "yyyy-MM-dd HH:mm:ss"));
                }
                ContentPlatformXcxPlanVideo videoItem = videoMap.get(plan.getId());
                if (Objects.nonNull(videoItem)) {
                    vo.setVideoId(videoItem.getVideoId());
                    vo.setTitle(videoItem.getTitle());
                    String shareCover = getShareCover(null, videoItem.getCover());
                    vo.setCover(StringUtils.hasText(shareCover) ? shareCover : videoItem.getCover());
                }
                list.add(vo);
            }
        } else {
            list.add(new XcxPlanItemExportVO());
        }
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        EasyExcel.write(out, XcxPlanItemExportVO.class).sheet("小程序投流计划").doWrite(list);
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(out.toByteArray());
        String fileName = "小程序投流计划_" + System.currentTimeMillis() + ".xls";
        AliOssFileTool.saveInPublicReturnHost(byteArrayInputStream, EnumPublicBuckets.PUBBUCKET, fileName, EnumFileType.TEMP_PICTURE);
        return CdnUtil.DOWNLOAD_CDN_URL_HOST_PICTURE + fileName;
    }

    @Override
    public List<XcxPlanItemVO> xcxPlanSave(XcxPlanSaveParam param) {
        ContentPlatformAccount loginUser = LoginUserContext.getUser();
        if (!Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.INTERNAL.getVal())
                && !Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.AGENT.getVal())) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_INTERNAL_ONLY);
        }
        long baseNow = System.currentTimeMillis();
        long offset = 0L;
        List<ContentPlatformXcxPlan> list = new ArrayList<>();
        for (XcxPlanSaveVideoParam videoParam : param.getVideoList()) {
            int count = (videoParam.getCount() == null || videoParam.getCount() <= 0)
                    ? 1 : videoParam.getCount();
            if (count > 200) {
                throw new CommonException(ExceptionEnum.PARAM_ERROR);
            }
            for (int i = 0; i < count; i++) {
                long ts = baseNow + offset++;
                ContentPlatformXcxPlan xcxPlan = new ContentPlatformXcxPlan();
                xcxPlan.setAudiencePackage(param.getAudiencePackage());
                xcxPlan.setRemark(param.getRemark());

                Staff staff = new Staff();
                staff.setCarrierId("wxf7261ed54f2e450e");
                staff.setRemark(param.getRemark());
                String pageUrl = messageAttachmentService.getPageNoCache("pages/category", "touliu", "tencent", staff,
                        videoParam.getVideoId(), "小程序", null, null, videoParam.getExperimentId());
                String rootSourceId = MessageUtil.getRootSourceId(pageUrl);
                xcxPlan.setPageUrl(pageUrl);
                xcxPlan.setRootSourceId(rootSourceId);
                xcxPlan.setStatus(PlanStatusEnum.NORMAL.getVal());
                xcxPlan.setCreateAccountId(loginUser.getId());
                xcxPlan.setCreateTimestamp(ts);
                xcxPlan.setUpdateTimestamp(ts);
                planMapperExt.insertXcxPlanReturnId(xcxPlan);
                list.add(xcxPlan);
                // 保存视频内容
                saveXcxPlanVideo(videoParam, xcxPlan.getId(), loginUser.getId());
            }
        }
        return buildXcxPlanItemVOList(list);
    }

    @Override
    public void xcxPlanDelete(Long id) {
        ContentPlatformAccount loginUser = LoginUserContext.getUser();
        if (!Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.INTERNAL.getVal())
                && !Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.AGENT.getVal())) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_INTERNAL_ONLY);
        }
        ContentPlatformXcxPlan plan = xcxPlanMapper.selectByPrimaryKey(id);
        if (Objects.isNull(plan) || !Objects.equals(plan.getCreateAccountId(), loginUser.getId())) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_NOT_EXISTS);
        }
        plan.setStatus(PlanStatusEnum.DELETED.getVal());
        plan.setUpdateTimestamp(System.currentTimeMillis());
        xcxPlanMapper.updateByPrimaryKeySelective(plan);
    }

    @Override
    public List<XcxPlanItemVO> xcxPlanMultiLink(XcxPlanMultiLinkParam param) {
        ContentPlatformAccount loginUser = LoginUserContext.getUser();
        if (!Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.INTERNAL.getVal())
                && !Objects.equals(loginUser.getType(), ContentPlatformAccountTypeEnum.AGENT.getVal())) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_INTERNAL_ONLY);
        }
        if (Objects.isNull(param.getPlanId()) || Objects.isNull(param.getCount())
                || param.getCount() < 1 || param.getCount() > 200) {
            throw new CommonException(ExceptionEnum.PARAM_ERROR);
        }
        ContentPlatformXcxPlan src = xcxPlanMapper.selectByPrimaryKey(param.getPlanId());
        if (Objects.isNull(src) || !Objects.equals(src.getCreateAccountId(), loginUser.getId())
                || !Objects.equals(src.getStatus(), PlanStatusEnum.NORMAL.getVal())) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_NOT_EXISTS);
        }
        ContentPlatformXcxPlanVideoExample videoExample = new ContentPlatformXcxPlanVideoExample();
        videoExample.createCriteria().andPlanIdEqualTo(src.getId());
        List<ContentPlatformXcxPlanVideo> srcVideoList = xcxPlanVideoMapper.selectByExample(videoExample);
        if (CollectionUtils.isEmpty(srcVideoList)) {
            throw new CommonException(ExceptionEnum.XCX_PLAN_NOT_EXISTS);
        }
        ContentPlatformXcxPlanVideo srcVideo = srcVideoList.get(0);

        long baseNow = System.currentTimeMillis();
        List<ContentPlatformXcxPlan> created = new ArrayList<>();
        try {
            for (int i = 0; i < param.getCount(); i++) {
                long ts = baseNow + i;
                ContentPlatformXcxPlan xcxPlan = new ContentPlatformXcxPlan();
                xcxPlan.setAudiencePackage(src.getAudiencePackage());
                xcxPlan.setRemark(src.getRemark());
                Staff staff = new Staff();
                staff.setCarrierId("wxf7261ed54f2e450e");
                staff.setRemark(src.getRemark());
                String pageUrl = messageAttachmentService.getPageNoCache("pages/category", "touliu", "tencent", staff,
                        srcVideo.getVideoId(), "小程序", null, null, srcVideo.getExperimentId());
                String rootSourceId = MessageUtil.getRootSourceId(pageUrl);
                xcxPlan.setPageUrl(pageUrl);
                xcxPlan.setRootSourceId(rootSourceId);
                xcxPlan.setStatus(PlanStatusEnum.NORMAL.getVal());
                xcxPlan.setCreateAccountId(loginUser.getId());
                xcxPlan.setCreateTimestamp(ts);
                xcxPlan.setUpdateTimestamp(ts);
                planMapperExt.insertXcxPlanReturnId(xcxPlan);
                created.add(xcxPlan);

                XcxPlanSaveVideoParam vp = new XcxPlanSaveVideoParam();
                vp.setVideoId(srcVideo.getVideoId());
                vp.setTitle(srcVideo.getTitle());
                vp.setCover(srcVideo.getCover());
                vp.setVideo(srcVideo.getVideo());
                vp.setExperimentId(srcVideo.getExperimentId());
                saveXcxPlanVideo(vp, xcxPlan.getId(), loginUser.getId());
            }
        } catch (Exception e) {
            log.error("xcxPlanMultiLink failed at {}/{} for planId={}, rolling back {} created plans",
                    created.size(), param.getCount(), param.getPlanId(), created.size(), e);
            rollbackXcxPlans(created);
            throw new CommonException(ExceptionEnum.SYSTEM_ERROR);
        }
        return buildXcxPlanItemVOList(created);
    }

    private void rollbackXcxPlans(List<ContentPlatformXcxPlan> created) {
        if (CollectionUtils.isEmpty(created)) {
            return;
        }
        Long now = System.currentTimeMillis();
        for (ContentPlatformXcxPlan plan : created) {
            try {
                plan.setStatus(PlanStatusEnum.DELETED.getVal());
                plan.setUpdateTimestamp(now);
                xcxPlanMapper.updateByPrimaryKeySelective(plan);
            } catch (Exception ex) {
                log.error("xcxPlanMultiLink rollback failed for planId={}", plan.getId(), ex);
            }
        }
    }

    @Override
    public List<String> getXcxAudiencePackageList() {
        String dt = demandVideoMapperExt.getMaxDt(DemandChannelEnum.XCX.getValue());
        if (!StringUtils.hasText(dt)) {
            return Collections.emptyList();
        }
        return demandVideoMapperExt.selectDistinctCrowdPackages(dt, DemandChannelEnum.XCX.getValue());
    }

    private List<XcxPlanItemVO> buildXcxPlanItemVOList(List<ContentPlatformXcxPlan> planList) {
        if (CollectionUtils.isEmpty(planList)) {
            return null;
        }
        List<Long> planIds = planList.stream().map(ContentPlatformXcxPlan::getId).collect(Collectors.toList());
        ContentPlatformXcxPlanVideoExample videoExample = new ContentPlatformXcxPlanVideoExample();
        videoExample.createCriteria().andPlanIdIn(planIds);
        List<ContentPlatformXcxPlanVideo> videoList = xcxPlanVideoMapper.selectByExample(videoExample);
        Map<Long, ContentPlatformXcxPlanVideo> videoMap = videoList.stream()
                .collect(Collectors.toMap(ContentPlatformXcxPlanVideo::getPlanId, Function.identity()));
        List<XcxPlanItemVO> result = new ArrayList<>();
        for (ContentPlatformXcxPlan xcxPlan : planList) {
            XcxPlanItemVO planItemVO = new XcxPlanItemVO();
            planItemVO.setId(xcxPlan.getId());
            planItemVO.setAudiencePackage(xcxPlan.getAudiencePackage());
            planItemVO.setRemark(xcxPlan.getRemark());
            planItemVO.setPageUrl(xcxPlan.getPageUrl());
            planItemVO.setCreateTimestamp(xcxPlan.getCreateTimestamp());
            ContentPlatformXcxPlanVideo videoItem = videoMap.get(xcxPlan.getId());
            if (Objects.nonNull(videoItem)) {
                planItemVO.setTitle(videoItem.getTitle());
                planItemVO.setCover(videoItem.getCover());
                planItemVO.setVideo(videoItem.getVideo());
            }
            planItemVO.setShareCover(getShareCover(null, planItemVO.getCover()));
            result.add(planItemVO);
        }
        return result;
    }

    private void saveXcxPlanVideo(XcxPlanSaveVideoParam videoParam, Long planId, Long loginAccountId) {
        ContentPlatformXcxPlanVideo item = new ContentPlatformXcxPlanVideo();
        item.setPlanId(planId);
        item.setVideoId(videoParam.getVideoId());
        item.setTitle(videoParam.getTitle());
        item.setCover(videoParam.getCover());
        item.setVideo(videoParam.getVideo());
        item.setExperimentId(videoParam.getExperimentId());
        item.setCreateAccountId(loginAccountId);
        item.setCreateTimestamp(System.currentTimeMillis());
        xcxPlanVideoMapper.insertSelective(item);
    }

}
