Files
web_hgdjb_server/app/service/playletser/playlet.go
T
rootandClaude Opus 4.7 cd8dea8787 同步 web_hga 的五项改造
- 移除 买点 (user acquisition tracking) 子系统:dataReport SDK、eventmod
  事件模型、reportdata 定时上报 job、reportser/srv_report 包装层,以及业务
  侧的 ~20 处 Report* 调用点;去掉 proto.ConfigInfo 的 ReportSDKConfig
  字段、DataReport yaml 配置块和 pubdef.TrackerAppId。
- 广告获取改走 versionUrl /api/stat/advitise/v2/get(appId=12),移除
  adcenter(Jt) 调用;新增 POST /api/app/ads/click 异步上报;
  proto.AdvitiseData 别名 proxy.AdvitiseData,AList/BList 改名
  AppInfoAList/AppInfoBList。
- web 后台登录取消谷歌验证码(srv_mwacc.AccLogin 去掉 code 参数),密码
  正确即发放 token。
- app 端取消注册/登录/找回密码/游客登录接口及密码、token 相关代码;鉴权
  中间件按 X-User-Agent 中的 devID 识别用户,首次访问自动建号,Redis 缓存
  10 分钟;修改资料不再支持改密码。
- app m3u8 ticket 校验失败不再降级到 fallback 路径,仍按原 m3u8Name 返回。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-11-07 11:22:25 +08:00

4620 lines
163 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package playletser
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/playletcache"
"videoweb/app/service/videoser"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/esmanager"
"videoweb/pub/models/cointransmod"
"videoweb/pub/models/esmod"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/hotsearchmod"
"videoweb/pub/models/playletchaptercollectmod"
"videoweb/pub/models/playletchapterlikemod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletchapterunlockmod"
"videoweb/pub/models/playletcollectmod"
"videoweb/pub/models/playletlikemod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playletwatchhistorymod"
"videoweb/pub/models/playletwatchprogressmod"
"videoweb/pub/models/usermod"
"videoweb/pub/mongos"
"videoweb/pub/payment"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
"videoweb/pub/seo/h5path"
"videoweb/web/service/srv_search"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
homeBannerLimit int64 = 5 // banner图限制
homeConfigPlayletLimit int64 = 12 // 旧首页配置短剧默认数量
homeConfigThemeLimit int64 = 8 // 首页配置题材默认数量
defaultPageSize int64 = 10 // 默认分页条数
guessLikePageSize int64 = 6 // 猜你喜欢默认分页条数
hotListPageSize int64 = 6 // 热门短剧默认分页条数
hotRankPageSize int64 = 20 // 热榜默认分页条数
recommendPageSize int64 = 20 // 今日推荐/热门好剧默认分页条数
maxPageSize int64 = 100 // 最大分页条数
allListSortLatest int64 = 1 // 最新
allListSortHot int64 = 2 // 最热
allListSortCollect int64 = 3 // 收藏最多
topicListSortHot int64 = 2 // 最热
topicListPlayletLimit int64 = 3 // 专题卡片短剧数量限制
topicDetailSortEdit int64 = 1 // 编辑排序
topicDetailSortLatest int64 = 2 // 最新
topicDetailSortHot int64 = 3 // 最热
topicDetailPageSize int64 = 12 // 专题详情默认分页条数
topicDetailCoverLimit int64 = 3 // 专题详情顶部封面数量限制
topicDetailMoreLimit int64 = 3 // 更多专题数量限制
operateListLatest int64 = 1 // 最新更新
operateListHot int64 = 2 // 当前热播
operateListOriginal int64 = 3 // 独家原创
operateListRandom int64 = 4 // 随机推荐
rankHot int64 = 1 // 热播榜
rankNew int64 = 2 // 新剧榜
rankCollect int64 = 3 // 追剧榜
operateStatusYes int64 = 1 // 已操作
operateStatusNo int64 = 2 // 未操作
playletHeatClickInc int64 = 100 // 每次查看详情增加的热度值
playletHeatLikeInc int64 = 1 // 点赞增加的热度值
playletHeatCollectInc int64 = 2 // 收藏增加的热度值
playletHeatBuyInc int64 = 10 // 购买剧集增加的短剧热度值
playletHeatForwardInc int64 = 5 // 转发增加的热度值
homePlayletCacheSchema = "playlet-banner-home-config-modules-v14"
// playletH5DetailPath 短剧Web/H5播放页路径,供SEO canonical使用。
playletH5DetailPath = "/play"
// playletChapterH5DetailPath 短剧Web/H5剧集播放页路径,供SEO canonical使用。
playletChapterH5DetailPath = "/play"
)
var errPlayletChapterAlreadyUnlocked = errors.New("playlet chapter already unlocked")
type playletChapterAccessContext struct {
balance int64
vipValid bool
unlockedChapterIds map[int64]bool
}
type homeConfigThemePlaylets struct {
Theme *playletcache.ThemeItem
Playlets playletmod.PlayletList
TotalCount int64
}
type playletBoughtAggRow struct {
PlayletId int64 `bson:"_id"`
BoughtChapterCount int64 `bson:"boughtChapterCount"`
CostCoins int64 `bson:"costCoins"`
BoughtAt time.Time `bson:"boughtAt"`
LatestUnlockId int64 `bson:"latestUnlockId"`
}
type playletBoughtCountRow struct {
Count int64 `bson:"count"`
}
// playletHomeProjection 查询指定字段
var playletHomeProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
{Key: playletmod.FN.Playlet.IsFeatured, Value: 1},
{Key: playletmod.FN.Playlet.IsRecommend, Value: 1},
{Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1},
{Key: playletmod.FN.Playlet.Banner, Value: 1},
{Key: playletmod.FN.Playlet.MobileBanner, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
{Key: playletmod.FN.Playlet.MediaCreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.CategoryIds, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.PlayName, Value: 1},
{Key: playletmod.FN.Playlet.Description, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.Anchor, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.HorizontalCover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedHorizontalCover, Value: 1},
{Key: playletmod.FN.Playlet.Preview, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
{Key: playletmod.FN.Playlet.UpdateStatus, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
{Key: playletmod.FN.Playlet.ShowOriginal, Value: 1},
{Key: playletmod.FN.Playlet.OperateType, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1},
{Key: "seoInfo", Value: 1},
}
// homeConfigAppProjection 查询首页配置指定字段
var homeConfigAppProjection = bson.D{
{Key: homeconfigmod.FN.HomeConfig.Id, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Name, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Slug, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Desc, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Label, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Style, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CardBackgroundImage, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.TopicIds, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CategoryId, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.RecommendType, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Type, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Limit, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Status, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1},
}
// playletCollectListProjection 我的收藏列表只读取卡片展示和未看集数计算需要的字段。
var playletCollectListProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
{Key: playletmod.FN.Playlet.ShowOriginal, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.PlayName, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
{Key: playletmod.FN.Playlet.UpdateStatus, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: "seoInfo", Value: 1},
}
// playletChapterSEOProjection 查询剧集详情SEO需要的短剧字段,避免拉取整部短剧大字段。
var playletChapterSEOProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
{Key: "seoInfo", Value: 1},
}
// playletChapterInfoProjection 查询详情章节集合需要的字段
var playletChapterInfoProjection = bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1},
}
// playletChapterDetailProjection 查询剧集详情需要的字段
var playletChapterDetailProjection = bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountComment, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.WatermarkStatus, Value: 1},
}
// playletOperateProjection 查询短剧操作接口需要的字段
var playletOperateProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
}
// playletChapterOperateProjection 查询剧集操作接口需要的字段
var playletChapterOperateProjection = bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1},
}
// playletChapterWatchReportProjection 观看上报只需要剧集归属和集数。
var playletChapterWatchReportProjection = bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
}
// playletWatchReportProjection 观看上报只需要短剧总集数计算未看数量。
var playletWatchReportProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
}
// Home 查询app首页短剧数据
func Home(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) {
cacheKey := homePlayletCacheKey()
if resp := getHomePlayletCache(cacheKey); resp != nil {
attachHomeAnnouncements(resp)
attachHomeAdvitise(resp)
return resp, nil
}
resp, err := buildHomePlayletResponse(ctx)
if err != nil {
return nil, err
}
setHomePlayletCache(cacheKey, resp)
attachHomeAnnouncements(resp)
attachHomeAdvitise(resp)
return resp, nil
}
// AllList 分页查询app端全部短剧列表
func AllList(ctx context.Context, req *proto.PlayletAllListReq) (*proto.PlayletAllListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
themeID, err := resolveThemeID(ctx, req.ThemeId, req.ThemeSlug)
if err != nil {
return nil, err
}
req.ThemeId = themeID
filter, err := playletAllListFilter(req)
if err != nil {
return nil, err
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletAllListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
Advitise: configser.GetAdvitise(),
}
if req.ThemeId > 0 {
resp.SeoInfo = buildPlayletThemePageSeoInfo(req.ThemeId)
} else {
resp.SeoInfo = buildPlayletListPageSeoInfo()
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询全部短剧列表题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询全部短剧列表标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// HotList 分页查询热门短剧
func HotList(ctx context.Context, req *proto.PlayletHotListReq) (*proto.PlayletHotListResp, *srvdef.SrvErr) {
if req == nil || req.ExcludePlayletId < 0 {
return nil, srvdef.New(srvdef.ErrParamError, "排除的短剧ID无效", "")
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = hotListPageSize
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, pageSize)
filter := homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.OperateType: bson.M{"$ne": playletmod.OperateTypeComic},
})
if req.ExcludePlayletId > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": req.ExcludePlayletId}
}
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletHotListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletAllListSort(allListSortHot), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询热门短剧题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询热门短剧标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// TopicList 分页查询app端专题剧单
func TopicList(ctx context.Context, req *proto.PlayletTopicPageListReq) (*proto.PlayletTopicPageListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
skip := (pageNum - 1) * pageSize
groups, err := enabledTopicGroups(ctx)
if err != nil {
return nil, err
}
resp := &proto.PlayletTopicPageListResp{
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.PlayletTopicGroupPageItem, 0, len(groups)),
SeoInfo: buildPlayletListPageSeoInfo(),
}
for _, group := range groups {
if group == nil {
continue
}
page, err := topicGroupPageData(ctx, group, skip, pageSize)
if err != nil {
return nil, err
}
resp.List = append(resp.List, page)
}
return resp, nil
}
// TopicDetail 查询app端专题详情
func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) {
if req == nil || (req.Id <= 0 && strings.TrimSpace(req.TopicSlug) == "") || (req.DataType != 0 && req.DataType != 1) {
return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效", "")
}
id, serr := resolveThemeID(ctx, req.Id, req.TopicSlug)
if serr != nil {
return nil, serr
}
req.Id = id
theme := playletcache.GetEnabledTheme(req.Id)
if theme == nil {
return nil, srvdef.New(srvdef.ErrNoData, "专题不存在或已下架", "专题不存在或已下架")
}
pageNum, pageSize := normalizePlayletTopicDetailPage(req.PageNum, req.PageSize)
filter := topicDetailPlayletFilter(req.Id)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletTopicDetailResp{
Id: theme.Id,
DataType: 1,
Advitise: configser.GetAdvitise(),
Name: theme.Name,
Slug: theme.Slug,
Desc: strings.TrimSpace(theme.SeoInfo.Description),
Icon: withImageDomain(ctx, theme.Icon),
Rank: theme.Rank,
PlayletCount: 0,
Covers: make([]string, 0),
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
MoreTopics: make([]*proto.PlayletTopicPageItem, 0),
SeoInfo: buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)),
}
if totalCount == 0 {
return fillPlayletTopicDetailMore(ctx, resp, theme.Id, nil)
}
coverList, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), topicDetailCoverLimit)
if err != nil {
return nil, err
}
skip := (pageNum - 1) * pageSize
pageList := make(playletmod.PlayletList, 0)
if skip < totalCount {
pageList, err = findHomePlaylets(ctx, filter, playletTopicDetailSort(req.SortType), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
}
allPlaylets := mergeHomePlayletLists(coverList, pageList)
resp, err = fillPlayletTopicDetailMore(ctx, resp, theme.Id, allPlaylets)
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询专题详情题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询专题详情标签名称失败", log.E(err))
return nil, err
}
resp.Covers = playletTopicCovers(playletsToHomeCommonData(ctx, coverList, themeNameMap, tagNameMap))
resp.List = commonDataValues(playletsToHomeCommonData(ctx, pageList, themeNameMap, tagNameMap))
resp.PlayletCount = int64(len(resp.List))
return resp, nil
}
// Rank 分页查询app端短剧排行榜
func Rank(ctx context.Context, req *proto.PlayletRankReq) (*proto.PlayletRankResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := homeUpPlayletFilter(nil)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletRankResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
}
resp.SeoInfo = buildPlayletListPageSeoInfo()
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletRankSort(req.SortOrder), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询短剧排行榜题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询短剧排行榜标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// HotRankList 分页查询app端热榜短剧列表
func HotRankList(ctx context.Context, req *proto.PlayletHotRankListReq) (*proto.PlayletHotRankListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
rankType := normalizePlayletHotRankType(req.RankType)
if rankType == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "榜单类型无效", "")
}
pageNum, pageSize := normalizePlayletHotRankPage(req.PageNum, req.PageSize)
filter := homeUpPlayletFilter(nil)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletHotRankListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
RankType: rankType,
List: make([]proto.CommonData, 0),
SeoInfo: buildPlayletListPageSeoInfo(),
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletHotRankSort(rankType), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询热榜题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询热榜标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// TodayUpdate 分页查询app端今日更新短剧列表
func TodayUpdate(ctx context.Context, req *proto.PlayletTodayUpdateReq) (*proto.PlayletTodayUpdateResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := homeUpPlayletFilter(nil)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletTodayUpdateResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
}
resp.SeoInfo = buildPlayletListPageSeoInfo()
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletTodayUpdateSort(), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询今日更新短剧题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询今日更新短剧标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// OperateList 按类型分页查询已上架短剧
func OperateList(ctx context.Context, req *proto.PlayletOperateListReq) (*proto.PlayletOperateListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
typ := req.Type
if typ == 0 {
typ = operateListLatest
}
if typ < operateListLatest || typ > operateListRandom {
return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := operateListFilter(typ)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletOperateListResp{
TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize), Type: typ,
List: make([]proto.CommonData, 0),
}
skip := (pageNum - 1) * pageSize
if totalCount == 0 || skip >= totalCount {
return resp, nil
}
var list playletmod.PlayletList
if typ == operateListRandom {
list, err = findRandomOperatePlaylets(ctx, filter, skip, pageSize, time.Now())
} else {
list, err = findHomePlaylets(ctx, filter, operateListSort(typ), pageSize, options.Find().SetSkip(skip))
}
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询类型短剧列表题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询类型短剧列表标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// TagVideoList 根据标签ID分页查询app端短剧列表
func TagVideoList(ctx context.Context, req *proto.PlayletTagVideoReq) (*proto.PlayletTagVideoResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "标签ID不能为空")
}
tagID, err := resolveTagID(ctx, req.TagId, req.TagSlug)
if err != nil {
return nil, err
}
req.TagId = tagID
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.TagIds: req.TagId,
})
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletTagVideoResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
}
resp.SeoInfo = buildPlayletTagPageSeoInfo(req.TagId)
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询标签短剧题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询标签短剧标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// GuessLikeList 分页查询同题材猜你喜欢短剧
func GuessLikeList(ctx context.Context, req *proto.PlayletGuessLikeListReq) (*proto.PlayletGuessLikeListResp, *srvdef.SrvErr) {
if req == nil || req.PlayletId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
current, err := findGuessLikeCurrentPlaylet(ctx, req.PlayletId)
if err != nil {
return nil, err
}
pageNum, pageSize := normalizePlayletGuessLikePage(req.PageNum, req.PageSize)
resp := &proto.PlayletGuessLikeListResp{
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommonData, 0),
}
if current == nil || len(current.ThemeIds) == 0 {
return resp, nil
}
filter := playletGuessLikeFilter(current)
totalCount, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, err
}
resp.TotalCount = totalCount
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
list, err := findHomePlaylets(ctx, filter, playletGuessLikeSort(), pageSize, options.Find().SetSkip(skip))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
log.Error("查询猜你喜欢题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, list)
if err != nil {
log.Error("查询猜你喜欢标签名称失败", log.E(err))
return nil, err
}
resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap))
return resp, nil
}
// Search 使用ES分页搜索app端短剧
func Search(ctx context.Context, req *proto.PlayletSearchReq) (*proto.PlayletSearchResp, *srvdef.SrvErr) {
// 校验搜索关键词,避免空关键词扫全量ES索引。
if req == nil || strings.TrimSpace(req.Keyword) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空")
}
if esmanager.Instance == nil {
log.Error("短剧搜索ES未初始化")
return nil, srvdef.New(srvdef.ErrESSearchError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
keyword := strings.TrimSpace(req.Keyword)
if pageNum == 1 {
recordHotSearch(keyword)
}
query := playletSearchQuery(keyword, (pageNum-1)*pageSize, pageSize)
var hits esmod.ESPlayletHits
if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil {
if strings.Contains(err.Error(), "index_not_found_exception") {
log.Warn("短剧ES索引不存在,返回空搜索结果", log.Any("关键词", keyword), log.Any("索引", esmod.GetPlayletIndex()))
return emptyPlayletSearchResp(ctx, keyword, pageNum, pageSize), nil
}
log.Error("短剧ES搜索失败", log.Any("关键词", keyword), log.E(err))
return nil, srvdef.New(srvdef.ErrESSearchError, "", "")
}
resp := &proto.PlayletSearchResp{
TotalCount: hits.Total.Value,
PageSize: int(pageSize),
PageNum: int(pageNum),
Keyword: keyword,
List: make([]proto.CommonData, 0, len(hits.Hits)),
EveryoneWatching: make([]*proto.CommonData, 0),
}
if hits.Total.Value == 0 {
resp.EveryoneWatching = guessLikeCommonData(ctx)
return resp, nil
}
labels, err := loadESPlayletLabels(ctx, hits.Hits)
if err != nil {
return nil, err
}
for _, hit := range hits.Hits {
if hit == nil || hit.Source == nil {
continue
}
resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source, labels[hit.Source.Id]))
}
return resp, nil
}
// SearchSuggest 查询短剧搜索框联想数据
func SearchSuggest(ctx context.Context, req *proto.PlayletSearchSuggestReq) (*proto.PlayletSearchSuggestResp, *srvdef.SrvErr) {
if req == nil || strings.TrimSpace(req.Keyword) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空")
}
keyword := strings.TrimSpace(req.Keyword)
resp := &proto.PlayletSearchSuggestResp{
Keyword: keyword,
List: make([]proto.CommonData, 0),
}
if esmanager.Instance == nil {
return resp, nil
}
query := playletSearchQuery(keyword, 0, maxPageSize)
var hits esmod.ESPlayletHits
if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil {
log.Warn("短剧搜索联想ES查询失败", log.Any("关键词", keyword), log.E(err))
return resp, nil
}
labels, err := loadESPlayletLabels(ctx, hits.Hits)
if err != nil {
return nil, err
}
for _, hit := range hits.Hits {
if hit == nil || hit.Source == nil {
continue
}
resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source, labels[hit.Source.Id]))
}
return resp, nil
}
func recordHotSearch(keyword string) {
keyword = hotsearchmod.NormalizeKeyword(keyword)
if keyword == "" {
return
}
go func(word string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := hotsearchmod.IncrByKeyword(ctx, word); err != nil {
log.Warn("记录热搜词失败", log.Any("关键词", word), log.E(err))
}
}(keyword)
}
// HotLike 返回缓存的热搜词和猜你喜欢。
func HotLike(ctx context.Context) *proto.PlayletHotLikeResp {
hotSearch := playletcache.ListHotSearch()
resp := &proto.PlayletHotLikeResp{
HotSearch: make([]proto.PlayletHotSearchItem, 0, len(hotSearch)),
GuessLike: guessLikeCommonData(ctx),
}
for _, item := range hotSearch {
resp.HotSearch = append(resp.HotSearch, proto.PlayletHotSearchItem{
Keyword: item.Keyword,
Count: item.Count,
})
}
return resp
}
// SetLike 设置当前用户短剧点赞状态
func SetLike(ctx context.Context, userId int64, req *proto.PlayletLikeSetReq) (*proto.PlayletLikeSetResp, *srvdef.SrvErr) {
// 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。
if req == nil || req.Id <= 0 || req.IsLike == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误")
}
playlet, changed, err := setPlayletLike(ctx, userId, req.Id, *req.IsLike)
if err != nil {
return nil, err
}
countLike := displayPlayletCountLike(applyLocalCounter(playlet.CountLike, *req.IsLike, changed), playlet.VirtualCountLike)
resp := &proto.PlayletLikeSetResp{
Id: req.Id,
IsLike: operateStatusByFlag(*req.IsLike),
CountLike: countLike,
}
return resp, nil
}
// SetCollect 设置当前用户短剧收藏状态
func SetCollect(ctx context.Context, userId int64, req *proto.PlayletCollectSetReq) (*proto.PlayletCollectSetResp, *srvdef.SrvErr) {
// 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。
if req == nil || req.Id <= 0 || req.IsCollect == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误")
}
playlet, changed, err := setPlayletCollect(ctx, userId, req.Id, *req.IsCollect)
if err != nil {
return nil, err
}
countCollect := applyLocalCounter(playlet.CountCollect, *req.IsCollect, changed)
resp := &proto.PlayletCollectSetResp{
Id: req.Id,
IsCollect: operateStatusByFlag(*req.IsCollect),
CountCollect: countCollect,
}
return resp, nil
}
// ForwardHeat 短剧转发增加热度值
func ForwardHeat(ctx context.Context, userId int64, ip string, req *proto.PlayletForwardHeatReq) (*proto.PlayletForwardHeatResp, *srvdef.SrvErr) {
if req == nil || req.Id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
limited, limitErr := checkPlayletForwardHeatLimit(userId, ip, req.Id)
if limitErr != nil {
log.Error("短剧转发热度频控失败", log.Any("用户ID", userId), log.Any("IP", ip), log.Any("短剧ID", req.Id), log.E(limitErr))
}
if limited {
playlet, srvErr := findPlayletForwardHeatTarget(ctx, req.Id)
if srvErr != nil {
return nil, srvErr
}
return &proto.PlayletForwardHeatResp{
Id: playlet.Id,
HeatValue: playlet.HeatValue,
}, nil
}
playlet, srvErr := incrementPlayletHeatValueAndReturn(ctx, req.Id, playletHeatForwardInc)
if srvErr != nil {
log.Error("短剧转发增加热度失败", log.Any("短剧ID", req.Id), log.E(srvErr))
return nil, srvErr
}
return &proto.PlayletForwardHeatResp{
Id: playlet.Id,
HeatValue: playlet.HeatValue,
}, nil
}
// SetChapterLike 设置当前用户短剧剧集点赞状态
func SetChapterLike(ctx context.Context, userId int64, req *proto.PlayletChapterLikeSetReq) (*proto.PlayletChapterLikeSetResp, *srvdef.SrvErr) {
// 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。
if req == nil || req.ChapterId <= 0 || req.IsLike == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误")
}
chapter, changed, err := setPlayletChapterLike(ctx, userId, req.ChapterId, *req.IsLike)
if err != nil {
return nil, err
}
countLike := applyLocalCounter(chapter.CountLike, *req.IsLike, changed)
resp := &proto.PlayletChapterLikeSetResp{
ChapterId: req.ChapterId,
IsLike: operateStatusByFlag(*req.IsLike),
CountLike: countLike,
}
return resp, nil
}
// SetChapterCollect 设置当前用户短剧剧集收藏状态
func SetChapterCollect(ctx context.Context, userId int64, req *proto.PlayletChapterCollectSetReq) (*proto.PlayletChapterCollectSetResp, *srvdef.SrvErr) {
// 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。
if req == nil || req.ChapterId <= 0 || req.IsCollect == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误")
}
chapter, changed, err := setPlayletChapterCollect(ctx, userId, req.ChapterId, *req.IsCollect)
if err != nil {
return nil, err
}
countCollect := applyLocalCounter(chapter.CountCollect, *req.IsCollect, changed)
resp := &proto.PlayletChapterCollectSetResp{
ChapterId: req.ChapterId,
IsCollect: operateStatusByFlag(*req.IsCollect),
CountCollect: countCollect,
}
return resp, nil
}
// ReportChapterWatch 上报当前用户短剧剧集观看进度
func ReportChapterWatch(ctx context.Context, userId int64, req *proto.PlayletChapterWatchReportReq) (*proto.PlayletChapterWatchReportResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil || req.ChapterId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误")
}
filter := bson.M{
playletchaptermod.FN.PlayletChapter.Id: req.ChapterId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}
if req.PlayletId > 0 {
filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId
}
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterWatchReportProjection))
if err != nil {
log.Error("查询短剧观看上报剧集失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err))
return nil, err
}
if chapter.CurrentEpisode <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "剧集集数无效", "剧集集数无效")
}
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: chapter.PlayletId,
}), options.FindOne().SetProjection(playletWatchReportProjection))
if err != nil {
log.Error("查询短剧观看上报所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err))
return nil, err
}
progress, err := playletwatchprogressmod.SavePlayletWatchProgress(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode, playlet.TotalEpisode, true)
if err != nil {
log.Error("保存短剧观看上报进度失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err))
return nil, err
}
if err = playletwatchhistorymod.SavePlayletWatchHistory(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode); err != nil {
log.Error("保存短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err))
return nil, err
}
watchedCount := collectWatchedCount(progress, playlet.TotalEpisode)
return &proto.PlayletChapterWatchReportResp{
PlayletId: chapter.PlayletId,
ChapterId: chapter.Id,
CurrentEpisode: chapter.CurrentEpisode,
WatchedCount: watchedCount,
UnwatchedCount: collectUnwatchedCount(playlet.TotalEpisode, watchedCount),
LastChapterId: collectLastChapterId(progress),
LastEpisode: collectLastEpisode(progress),
}, nil
}
// ChapterDetail 根据ID查询app端短剧剧集详情
func ChapterDetail(ctx context.Context, userId int64, req *proto.PlayletChapterInfoReq) (*proto.PlayletChapterInfoResp, *srvdef.SrvErr) {
// 播放页切换剧集只查询当前剧集和所属短剧状态,不重复查询整部短剧详情、相关推荐和全部剧集列表。
if req == nil || req.ChapterId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧剧集ID不能为空", "短剧剧集ID不能为空")
}
playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, false)
if err != nil {
return nil, err
}
req.PlayletId = playletID
filter := bson.M{
playletchaptermod.FN.PlayletChapter.Id: req.ChapterId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}
if req.PlayletId > 0 {
filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId
}
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterDetailProjection))
if err != nil {
log.Error("查询短剧剧集详情失败", log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err))
return nil, err
}
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: chapter.PlayletId,
}), options.FindOne().SetProjection(playletChapterSEOProjection))
if err != nil {
log.Error("查询短剧剧集详情所属短剧失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err))
return nil, err
}
if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil {
return nil, err
}
isLike, isCollect, err := findPlayletChapterUserOperateStatus(ctx, userId, chapter.Id)
if err != nil {
return nil, err
}
resp := playletChapterToInfoResp(chapter)
resp.IsLike = isLike
resp.IsCollect = isCollect
resp.SeoInfo = buildPlayletChapterDetailSeoInfo(playlet, chapter)
if err = applyPlayletChapterWatchPermission(ctx, userId, chapter, resp); err != nil {
return nil, err
}
if !resp.CanWatch {
resp.VideoUrl = ""
return resp, nil
}
if err = incrementPlayletChapterBrowse(ctx, chapter.Id); err != nil {
log.Error("增加短剧剧集观看量失败", log.Any("剧集ID", chapter.Id), log.E(err))
return nil, err
}
resp.CountBrowse++
if resp.VideoUrl != "" {
resp.VideoUrl = videoser.GetPlayletM3U8AuthParam(
chapter.Id,
resp.VideoUrl,
videoser.H5M3U8RequestIP(ctx),
strings.TrimSpace(chapter.WatermarkVideoUrl) == "",
userId,
)
}
return resp, nil
}
// ChapterList 根据短剧ID查询app端短剧剧集列表
func ChapterList(ctx context.Context, req *proto.PlayletChapterListReq) (*proto.PlayletChapterListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, true)
if err != nil {
return nil, err
}
req.PlayletId = playletID
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: req.PlayletId,
}), options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
}))
if err != nil {
log.Error("查询短剧剧集列表所属短剧失败", log.Any("短剧ID", req.PlayletId), log.E(err))
return nil, err
}
chapterInfo, err := findPlayletChapters(ctx, req.PlayletId, 0, playlet != nil && playlet.IsOriginal)
if err != nil {
return nil, err
}
return &proto.PlayletChapterListResp{
PlayletId: req.PlayletId,
List: chapterInfo,
}, nil
}
// UnlockChapter 使用金币逐集解锁短剧剧集。
//
// 幂等性约束:
// 1. 事务前先查一次已解锁记录,命中时直接返回成功;
// 2. 事务内先插入 userId+chapterId 唯一解锁记录,再扣金币;
// 3. 并发重复请求只有一个能插入成功,其他请求按已解锁成功处理,不重复扣费。
func UnlockChapter(ctx context.Context, userId int64, req *proto.PlayletChapterUnlockReq) (*proto.PlayletChapterUnlockResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil || req.PlayletId <= 0 || req.ChapterId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID和剧集ID不能为空", "短剧ID和剧集ID不能为空")
}
chapter, playlet, err := findPlayletChapterForUnlock(ctx, req.PlayletId, req.ChapterId)
if err != nil {
return nil, err
}
if chapter.PayType == playletchaptermod.PayTypeFree {
user, err := findPlayletChapterAccessUser(ctx, userId)
if err != nil {
return nil, err
}
return &proto.PlayletChapterUnlockResp{
PlayletId: chapter.PlayletId,
ChapterId: chapter.Id,
Unlocked: true,
CanWatch: true,
Balance: user.Recharge,
CostCoins: 0,
ChangeCoin: 0,
}, nil
}
if chapter.PayType == playletchaptermod.PayTypeVip {
return nil, srvdef.New(srvdef.ErrParamError, "VIP剧集需开通会员后观看", "VIP剧集需开通会员后观看")
}
if chapter.PayType != playletchaptermod.PayTypeCoin {
return nil, srvdef.New(srvdef.ErrParamError, "剧集支付方式无效", "剧集支付方式无效")
}
if chapter.SingleCoins <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "剧集金币价格无效", "剧集金币价格无效")
}
unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id)
if err != nil {
return nil, err
}
user, err := findPlayletChapterAccessUser(ctx, userId)
if err != nil {
return nil, err
}
if unlocked {
return playletChapterUnlockResp(chapter, user.Recharge, 0), nil
}
if user.Recharge < chapter.SingleCoins {
return nil, srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值")
}
orderNo := playletChapterUnlockOrderNo(userId, chapter.Id)
var txSrvErr *srvdef.SrvErr
var balanceAfter int64
txErr := mongos.Shop.Transaction(ctx, func(sctx mongo.SessionContext) error {
inserted, srvErr := playletchapterunlockmod.InsertOnePlayletChapterUnlock(sctx, &playletchapterunlockmod.PlayletChapterUnlock{
UserId: userId,
PlayletId: chapter.PlayletId,
ChapterId: chapter.Id,
Coins: chapter.SingleCoins,
OrderNo: orderNo,
})
if srvErr != nil {
txSrvErr = srvErr
return srvErr
}
if !inserted {
return errPlayletChapterAlreadyUnlocked
}
updatedUser, srvErr := usermod.UserPay(sctx, userId, chapter.SingleCoins)
if srvErr != nil {
txSrvErr = srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值")
return txSrvErr
}
balanceAfter = updatedUser.Recharge
srvErr = payment.WriteCoinTrans(sctx, payment.WriteCoinTransOpt{
UserId: userId,
UserName: updatedUser.NickName,
FlowDir: cointransmod.FlowOut,
TranType: cointransmod.TranTypeVideoUnlock,
Amount: chapter.SingleCoins,
OrderNo: orderNo,
RelatedId: chapter.Id,
Remark: fmt.Sprintf("解锁《%s》", playlet.Title),
}, balanceAfter)
if srvErr != nil {
txSrvErr = srvErr
return srvErr
}
return nil
})
if txErr != nil {
if errors.Is(txErr, errPlayletChapterAlreadyUnlocked) {
user, err = findPlayletChapterAccessUser(ctx, userId)
if err != nil {
return nil, err
}
return playletChapterUnlockResp(chapter, user.Recharge, 0), nil
}
if txSrvErr != nil {
return nil, txSrvErr
}
log.Error("短剧剧集金币解锁事务失败", log.Any("userId", userId), log.Any("chapterId", chapter.Id), log.E(txErr))
return nil, srvdef.New(srvdef.DBTransaction, "解锁失败,请稍后重试", "解锁失败,请稍后重试")
}
updatePlayletChapterUnlockStatsAsync(chapter.PlayletId, chapter.Id)
return playletChapterUnlockResp(chapter, balanceAfter, -chapter.SingleCoins), nil
}
// updatePlayletChapterUnlockStatsAsync 异步更新剧集购买数和短剧热度
func updatePlayletChapterUnlockStatsAsync(playletId int64, chapterId int64) {
if playletId <= 0 || chapterId <= 0 {
return
}
go func() {
if err := playletchaptermod.UpdateOnePlayletChapter(context.Background(), bson.M{
playletchaptermod.FN.PlayletChapter.Id: chapterId,
}, bson.M{
"$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountBuy: 1},
}); err != nil {
log.Error("短剧剧集解锁后更新购买数失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
}
if err := incrementPlayletHeatValue(context.Background(), playletId, playletHeatBuyInc); err != nil {
log.Error("短剧剧集解锁后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
return
}
syncPlayletToESAsync(playletId, "短剧剧集解锁后同步ES失败")
}()
}
// applyPlayletChapterWatchPermission 根据剧集支付方式填充播放权限,并在未授权时清空播放地址。
func applyPlayletChapterWatchPermission(ctx context.Context,
userId int64,
chapter *playletchaptermod.PlayletChapter,
resp *proto.PlayletChapterInfoResp,
) *srvdef.SrvErr {
if chapter == nil || resp == nil {
return srvdef.New(srvdef.ErrNoData, "剧集不存在", "剧集不存在")
}
resp.CostCoins = chapter.SingleCoins
resp.CanWatch = false
resp.NeedUnlock = false
resp.NeedVip = false
resp.VipValid = false
resp.IsUnlocked = false
if chapter.PayType == playletchaptermod.PayTypeFree {
resp.CanWatch = true
return nil
}
var user *usermod.User
if userId > 0 {
var err *srvdef.SrvErr
user, err = findPlayletChapterAccessUser(ctx, userId)
if err != nil {
return err
}
resp.Balance = user.Recharge
resp.VipValid = isUserVipValid(user)
}
switch chapter.PayType {
case playletchaptermod.PayTypeCoin:
if userId <= 0 {
resp.NeedUnlock = true
return nil
}
unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id)
if err != nil {
return err
}
resp.IsUnlocked = unlocked
resp.CanWatch = unlocked
resp.NeedUnlock = !unlocked
case playletchaptermod.PayTypeVip:
resp.CanWatch = resp.VipValid
resp.NeedVip = !resp.VipValid
default:
log.Warn("未知短剧剧集支付方式", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.Any("payType", chapter.PayType))
resp.NeedUnlock = true
}
if !resp.CanWatch {
resp.VideoUrl = ""
}
return nil
}
// findPlayletChapterForUnlock 查询解锁所需的剧集和所属短剧,限制短剧必须已上架。
func findPlayletChapterForUnlock(ctx context.Context, playletId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *playletmod.Playlet, *srvdef.SrvErr) {
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: chapterId,
playletchaptermod.FN.PlayletChapter.PlayletId: playletId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}, options.FindOne().SetProjection(playletChapterDetailProjection))
if err != nil {
log.Error("查询待解锁短剧剧集失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.E(err))
return nil, nil, err
}
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: chapter.PlayletId,
}), options.FindOne().SetProjection(playletChapterSEOProjection))
if err != nil {
log.Error("查询待解锁剧集所属短剧失败", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.E(err))
return nil, nil, err
}
if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil {
return nil, nil, err
}
return chapter, playlet, nil
}
// findPlayletChapterAccessUser 查询播放权限判断需要的用户字段,避免读取完整用户文档。
func findPlayletChapterAccessUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) {
user, err := usermod.FindOneUser(ctx, bson.M{
usermod.FN.User.Id: userId,
}, options.FindOne().SetProjection(bson.D{
{Key: usermod.FN.User.Id, Value: 1},
{Key: usermod.FN.User.NickName, Value: 1},
{Key: usermod.FN.User.Recharge, Value: 1},
{Key: usermod.FN.User.CurrentVipExpire, Value: 1},
}))
if err != nil || user == nil {
return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "")
}
return user, nil
}
func isUserVipValid(user *usermod.User) bool {
return user != nil && user.CurrentVipExpire.After(time.Now())
}
func playletChapterUnlockOrderNo(userId int64, chapterId int64) string {
return fmt.Sprintf("JJS-%d-%d-%d", userId, chapterId, time.Now().UnixNano())
}
func playletChapterUnlockResp(chapter *playletchaptermod.PlayletChapter, balance int64, changeCoin int64) *proto.PlayletChapterUnlockResp {
return &proto.PlayletChapterUnlockResp{
PlayletId: chapter.PlayletId,
ChapterId: chapter.Id,
Unlocked: true,
CanWatch: true,
Balance: balance,
CostCoins: chapter.SingleCoins,
ChangeCoin: changeCoin,
}
}
// setPlayletLike 设置短剧点赞关系
func setPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) {
// 操作接口必须登录,先查询短剧确保目标存在且已上架,再按点赞表真实变更决定是否更新点赞数。
if userId <= 0 {
return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
playlet, err := findPlayletOperateTarget(ctx, userId, playletId)
if err != nil {
return nil, false, err
}
filter := bson.M{
playletlikemod.FN.PlayletLike.UserId: userId,
playletlikemod.FN.PlayletLike.PlayletId: playletId,
}
changed := false
if enabled {
inserted, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{
UserId: userId,
PlayletId: playletId,
})
if err != nil {
log.Error("新增短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return nil, false, err
}
changed = inserted
} else {
deleted, err := playletlikemod.DeleteOnePlayletLike(ctx, filter)
if err != nil {
log.Error("删除短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return nil, false, err
}
changed = deleted > 0
}
if changed {
delta := int64(1)
if !enabled {
delta = -1
}
heatDelta := playletHeatLikeInc
if !enabled {
heatDelta = -playletHeatLikeInc
}
if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountLike, delta, heatDelta); err != nil {
rollbackPlayletLike(ctx, userId, playletId, enabled)
return nil, false, err
}
// 真实点赞数和热度值已变更,异步同步 ES,保证搜索和榜单数据最终一致
syncPlayletToESAsync(playletId, "短剧点赞状态同步ES失败")
}
return playlet, changed, nil
}
// setPlayletCollect 设置短剧收藏关系
func setPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) {
// 操作接口必须登录,先查询短剧确保目标存在且已上架,再按收藏表真实变更决定是否更新收藏数。
if userId <= 0 {
return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
playlet, err := findPlayletOperateTarget(ctx, userId, playletId)
if err != nil {
return nil, false, err
}
filter := bson.M{
playletcollectmod.FN.PlayletCollect.UserId: userId,
playletcollectmod.FN.PlayletCollect.PlayletId: playletId,
}
changed := false
if enabled {
inserted, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{
UserId: userId,
PlayletId: playletId,
})
if err != nil {
log.Error("新增短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return nil, false, err
}
changed = inserted
} else {
deleted, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter)
if err != nil {
log.Error("删除短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return nil, false, err
}
changed = deleted > 0
}
if changed {
delta := int64(1)
if !enabled {
delta = -1
}
heatDelta := playletHeatCollectInc
if !enabled {
heatDelta = -playletHeatCollectInc
}
if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountCollect, delta, heatDelta); err != nil {
rollbackPlayletCollect(ctx, userId, playletId, enabled)
return nil, false, err
}
// 真实收藏数和热度值已变更,异步同步 ES,保证追剧榜和搜索数据最终一致
syncPlayletToESAsync(playletId, "短剧收藏状态同步ES失败")
}
return playlet, changed, nil
}
// setPlayletChapterLike 设置短剧剧集点赞关系
func setPlayletChapterLike(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) {
// 剧集点赞必须登录,先校验剧集存在且所属短剧已上架,再按点赞关系真实变化更新 playlet_chapter.countLike。
if userId <= 0 {
return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId)
if err != nil {
return nil, false, err
}
filter := bson.M{
playletchapterlikemod.FN.PlayletChapterLike.UserId: userId,
playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId,
}
changed := false
if enabled {
inserted, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{
UserId: userId,
PlayletId: chapter.PlayletId,
ChapterId: chapterId,
})
if err != nil {
log.Error("新增短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err))
return nil, false, err
}
changed = inserted
} else {
deleted, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter)
if err != nil {
log.Error("删除短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err))
return nil, false, err
}
changed = deleted > 0
}
if changed {
delta := int64(1)
if !enabled {
delta = -1
}
if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta); err != nil {
rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled)
return nil, false, err
}
heatDelta := playletHeatLikeInc
if !enabled {
heatDelta = -playletHeatLikeInc
}
if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil {
log.Error("更新短剧剧集点赞对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err))
rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled)
rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta)
return nil, false, err
}
}
return chapter, changed, nil
}
// setPlayletChapterCollect 设置短剧剧集收藏关系
func setPlayletChapterCollect(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) {
// 剧集收藏必须登录,先校验剧集存在且所属短剧已上架,再按收藏关系真实变化更新 playlet_chapter.countCollect。
if userId <= 0 {
return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId)
if err != nil {
return nil, false, err
}
filter := bson.M{
playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId,
playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId,
}
changed := false
if enabled {
inserted, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{
UserId: userId,
PlayletId: chapter.PlayletId,
ChapterId: chapterId,
})
if err != nil {
log.Error("新增短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err))
return nil, false, err
}
changed = inserted
} else {
deleted, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter)
if err != nil {
log.Error("删除短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err))
return nil, false, err
}
changed = deleted > 0
}
if changed {
delta := int64(1)
if !enabled {
delta = -1
}
if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta); err != nil {
rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled)
return nil, false, err
}
heatDelta := playletHeatCollectInc
if !enabled {
heatDelta = -playletHeatCollectInc
}
if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil {
log.Error("更新短剧剧集收藏对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err))
rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled)
rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta)
return nil, false, err
}
}
return chapter, changed, nil
}
// findPlayletOperateTarget 查询短剧操作目标
func findPlayletOperateTarget(ctx context.Context, userId int64, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) {
// 操作前只读取计数字段,减少Mongo返回体;状态过滤保证下架短剧不能被点赞或收藏。
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: playletId,
}), options.FindOne().SetProjection(playletOperateProjection))
if err != nil {
log.Error("查询短剧操作目标失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return nil, err
}
return playlet, nil
}
// findPlayletChapterOperateTarget 查询短剧剧集操作目标
func findPlayletChapterOperateTarget(ctx context.Context, userId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *srvdef.SrvErr) {
// 先用章节ID读取最小字段,再校验所属短剧已上架,避免用户操作已下架短剧的剧集。
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: chapterId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}, options.FindOne().SetProjection(playletChapterOperateProjection))
if err != nil {
log.Error("查询短剧剧集操作目标失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err))
return nil, err
}
if _, err = playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: chapter.PlayletId,
}), options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
})); err != nil {
log.Error("查询短剧剧集所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err))
return nil, err
}
return chapter, nil
}
// rollbackPlayletLike 回滚短剧点赞关系
func rollbackPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) {
// 点赞表已经变化但短剧点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。
filter := bson.M{
playletlikemod.FN.PlayletLike.UserId: userId,
playletlikemod.FN.PlayletLike.PlayletId: playletId,
}
if enabled {
if _, err := playletlikemod.DeleteOnePlayletLike(ctx, filter); err != nil {
log.Error("回滚短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
}
return
}
if _, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{
UserId: userId,
PlayletId: playletId,
}); err != nil {
log.Error("恢复短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
}
}
// rollbackPlayletCollect 回滚短剧收藏关系
func rollbackPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) {
// 收藏表已经变化但短剧收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。
filter := bson.M{
playletcollectmod.FN.PlayletCollect.UserId: userId,
playletcollectmod.FN.PlayletCollect.PlayletId: playletId,
}
if enabled {
if _, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter); err != nil {
log.Error("回滚短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
}
return
}
if _, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{
UserId: userId,
PlayletId: playletId,
}); err != nil {
log.Error("恢复短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
}
}
// rollbackPlayletChapterLike 回滚短剧剧集点赞关系
func rollbackPlayletChapterLike(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) {
// 剧集点赞关系已经变化但剧集点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。
filter := bson.M{
playletchapterlikemod.FN.PlayletChapterLike.UserId: userId,
playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId,
}
if enabled {
if _, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter); err != nil {
log.Error("回滚短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
}
return
}
if _, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{
UserId: userId,
PlayletId: playletId,
ChapterId: chapterId,
}); err != nil {
log.Error("恢复短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
}
}
// rollbackPlayletChapterCollect 回滚短剧剧集收藏关系
func rollbackPlayletChapterCollect(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) {
// 剧集收藏关系已经变化但剧集收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。
filter := bson.M{
playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId,
playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId,
}
if enabled {
if _, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter); err != nil {
log.Error("回滚短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
}
return
}
if _, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{
UserId: userId,
PlayletId: playletId,
ChapterId: chapterId,
}); err != nil {
log.Error("恢复短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err))
}
}
// incrementPlayletOperateCounter 增减短剧点赞或收藏计数
func incrementPlayletOperateCounter(ctx context.Context, playletId int64, countField string, delta int64, heatDelta int64) *srvdef.SrvErr {
// 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。
filter := bson.M{
playletmod.FN.Playlet.Id: playletId,
}
if delta < 0 {
filter[countField] = bson.M{"$gt": 0}
}
incDoc := bson.M{
countField: delta,
}
if heatDelta != 0 {
incDoc[playletmod.FN.Playlet.HeatValue] = heatDelta
}
err := playletmod.UpdateOnePlaylet(ctx, filter, bson.M{
"$inc": incDoc,
})
if err != nil {
if delta < 0 && err.Code == srvdef.ErrNoData {
log.Warn("短剧操作计数已为0,跳过扣减", log.Any("短剧ID", playletId), log.Any("计数字段", countField))
return nil
}
log.Error("更新短剧操作计数失败", log.Any("短剧ID", playletId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err))
return err
}
return nil
}
// incrementPlayletChapterOperateCounter 增减短剧剧集点赞或收藏计数
func incrementPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) *srvdef.SrvErr {
// 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。
filter := bson.M{
playletchaptermod.FN.PlayletChapter.Id: chapterId,
}
if delta < 0 {
filter[countField] = bson.M{"$gt": 0}
}
err := playletchaptermod.UpdateOnePlayletChapter(ctx, filter, bson.M{
"$inc": bson.M{
countField: delta,
},
})
if err != nil {
if delta < 0 && err.Code == srvdef.ErrNoData {
log.Warn("短剧剧集操作计数已为0,跳过扣减", log.Any("剧集ID", chapterId), log.Any("计数字段", countField))
return nil
}
log.Error("更新短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err))
return err
}
return nil
}
func rollbackPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) {
rollbackDelta := -delta
if err := incrementPlayletChapterOperateCounter(ctx, chapterId, countField, rollbackDelta); err != nil {
log.Error("回滚短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", rollbackDelta), log.E(err))
}
}
// applyLocalCounter 根据本次关系变更结果计算响应计数
func applyLocalCounter(current int64, enabled bool, changed bool) int64 {
// 响应计数基于操作前短剧计数和本次真实变更计算,避免为返回值再查一次短剧表。
if !changed {
return current
}
if enabled {
return current + 1
}
if current <= 0 {
return 0
}
return current - 1
}
func displayPlayletCountLike(countLike int64, virtualCountLike int64) int64 {
return countLike + virtualCountLike
}
// syncPlayletToESAsync 异步同步短剧到ES
func syncPlayletToESAsync(playletId int64, failMsg string) {
if playletId <= 0 {
return
}
go func() {
// 点赞、收藏入口只允许操作已上架短剧
if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{playletId}, playletmod.StatusUp); err != nil {
log.Error(failMsg, log.Any("短剧ID", playletId), log.E(err))
}
}()
}
// operateStatusByFlag 转换操作状态响应值
func operateStatusByFlag(enabled bool) int64 {
if enabled {
return operateStatusYes
}
return operateStatusNo
}
// Detail 根据ID查询app端短剧详情
func Detail(ctx context.Context, userId int64, req *proto.PlayletInfoReq) (*proto.PlayletInfoResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
playletID, err := resolvePlayletID(ctx, req.Id, req.Slug, true)
if err != nil {
return nil, err
}
req.Id = playletID
item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: req.Id,
}), options.FindOne().SetProjection(playletHomeProjection))
if err != nil {
log.Error("查询短剧详情失败", log.Any("短剧ID", req.Id), log.E(err))
return nil, err
}
if err = incrementPlayletBrowse(ctx, item.Id); err != nil {
log.Error("增加短剧观看量失败", log.Any("短剧ID", item.Id), log.E(err))
return nil, err
}
item.CountBrowse++
item.HeatValue += playletHeatClickInc
themeNameMap, err := buildHomeThemeNameMap(ctx, playletmod.PlayletList{item})
if err != nil {
log.Error("查询短剧详情题材名称失败", log.Any("短剧ID", item.Id), log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, playletmod.PlayletList{item})
if err != nil {
log.Error("查询短剧详情标签名称失败", log.Any("短剧ID", item.Id), log.E(err))
return nil, err
}
commonList := playletsToHomeCommonData(ctx, playletmod.PlayletList{item}, themeNameMap, tagNameMap)
if len(commonList) == 0 || commonList[0] == nil {
return nil, srvdef.New(srvdef.ErrNoData, "", "")
}
chapterInfo, err := findPlayletChapters(ctx, item.Id, userId, item.IsOriginal)
if err != nil {
return nil, err
}
isLike, isCollect, err := findPlayletUserOperateStatus(ctx, userId, item.Id)
if err != nil {
return nil, err
}
resp := &proto.PlayletInfoResp{
CommonData: *commonList[0],
IsLike: isLike,
IsCollect: isCollect,
PlayletChapterInfo: chapterInfo,
SeoInfo: buildPlayletDetailSeoInfo(item, themeNameMap, tagNameMap),
Advitise: configser.GetAdvitise(),
}
if resp.Preview != "" {
resp.Preview = videoser.GetM3U8AuthParam(
item.Id,
resp.Preview,
videoser.H5M3U8RequestIP(ctx),
"",
userId,
)
}
return resp, nil
}
// findPlayletUserOperateStatus 查询当前用户短剧点赞和收藏状态
func findPlayletUserOperateStatus(ctx context.Context, userId int64, playletId int64) (int64, int64, *srvdef.SrvErr) {
// 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证详情接口仍可匿名访问。
if userId <= 0 {
return operateStatusNo, operateStatusNo, nil
}
likeExists, err := playletlikemod.ExistsPlayletLike(ctx, bson.M{
playletlikemod.FN.PlayletLike.UserId: userId,
playletlikemod.FN.PlayletLike.PlayletId: playletId,
})
if err != nil {
log.Error("查询短剧用户点赞状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return operateStatusNo, operateStatusNo, err
}
collectExists, err := playletcollectmod.ExistsPlayletCollect(ctx, bson.M{
playletcollectmod.FN.PlayletCollect.UserId: userId,
playletcollectmod.FN.PlayletCollect.PlayletId: playletId,
})
if err != nil {
log.Error("查询短剧用户收藏状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err))
return operateStatusNo, operateStatusNo, err
}
isLike := operateStatusByFlag(likeExists)
isCollect := operateStatusByFlag(collectExists)
return isLike, isCollect, nil
}
// CollectList 分页查询当前用户的短剧收藏列表
func CollectList(ctx context.Context, userId int64, req *proto.PlayletCollectListReq) (*proto.PlayletCollectListResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := bson.M{
playletcollectmod.FN.PlayletCollect.UserId: userId,
}
if err := applyCollectWatchStatusFilter(ctx, userId, req.WatchStatus, filter); err != nil {
return nil, err
}
totalCount, err := playletcollectmod.CountPlayletCollect(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletCollectListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.PlayletCollectData, 0),
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
collectList, err := playletcollectmod.FindManyPlayletCollect(ctx, filter, options.Find().
SetProjection(bson.D{
{Key: playletcollectmod.FN.PlayletCollect.Id, Value: 1},
{Key: playletcollectmod.FN.PlayletCollect.PlayletId, Value: 1},
{Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: 1},
}).
SetSort(bson.D{
{Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: -1},
{Key: playletcollectmod.FN.PlayletCollect.Id, Value: -1},
}).
SetSkip(skip).
SetLimit(pageSize))
if err != nil {
return nil, err
}
if len(collectList) == 0 {
return resp, nil
}
playletIds := collectPlayletIdsFromCollects(collectList)
playletMap, err := findCollectPlayletMap(ctx, playletIds)
if err != nil {
return nil, err
}
progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds)
if err != nil {
return nil, err
}
playletList := playletListFromMapByCollects(collectList, playletMap)
themeNameMap, err := buildHomeThemeNameMap(ctx, playletList)
if err != nil {
log.Error("查询我的收藏题材名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, playletList)
if err != nil {
log.Error("查询我的收藏标签名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
resp.List = buildPlayletCollectData(ctx, collectList, playletMap, progressMap, themeNameMap, tagNameMap)
return resp, nil
}
func applyCollectWatchStatusFilter(ctx context.Context, userId int64, watchStatus int64, filter bson.M) *srvdef.SrvErr {
if watchStatus == 0 {
return nil
}
if watchStatus != 1 && watchStatus != 2 {
return srvdef.New(srvdef.ErrParamError, "观看状态错误", "观看状态错误")
}
playletIds, err := findCollectWatchStatusPlayletIds(ctx, userId, watchStatus == 1)
if err != nil {
return err
}
filter[playletcollectmod.FN.PlayletCollect.PlayletId] = bson.M{"$in": playletIds}
return nil
}
func findCollectWatchStatusPlayletIds(ctx context.Context, userId int64, isChasing bool) ([]int64, *srvdef.SrvErr) {
list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{
playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId,
playletwatchprogressmod.FN.PlayletWatchProgress.IsChasing: isChasing,
}, options.Find().SetProjection(bson.D{
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1},
}))
if err != nil {
log.Error("查询收藏列表观看状态短剧ID失败", log.Any("用户ID", userId), log.Any("是否追剧中", isChasing), log.E(err))
return nil, err
}
playletIds := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.PlayletId <= 0 {
continue
}
if _, ok := seen[item.PlayletId]; ok {
continue
}
seen[item.PlayletId] = struct{}{}
playletIds = append(playletIds, item.PlayletId)
}
return playletIds, nil
}
// BoughtList 分页查询当前用户已购短剧列表
func BoughtList(ctx context.Context, userId int64, req *proto.PlayletBoughtListReq) (*proto.PlayletBoughtListResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
totalCount, err := countBoughtPlaylets(ctx, userId)
if err != nil {
return nil, err
}
resp := &proto.PlayletBoughtListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.PlayletBoughtData, 0),
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
rows, err := findBoughtPlayletRows(ctx, userId, skip, pageSize)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return resp, nil
}
playletIds := collectPlayletIdsFromBoughtRows(rows)
playletMap, err := findCollectPlayletMap(ctx, playletIds)
if err != nil {
return nil, err
}
progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds)
if err != nil {
return nil, err
}
playletList := playletListFromBoughtRows(rows, playletMap)
themeNameMap, err := buildHomeThemeNameMap(ctx, playletList)
if err != nil {
log.Error("查询我的已购短剧题材名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, playletList)
if err != nil {
log.Error("查询我的已购短剧标签名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
resp.List = buildPlayletBoughtData(ctx, rows, playletMap, progressMap, themeNameMap, tagNameMap)
return resp, nil
}
// countBoughtPlaylets 统计当前用户已购短剧数量
func countBoughtPlaylets(ctx context.Context, userId int64) (int64, *srvdef.SrvErr) {
rows := make([]playletBoughtCountRow, 0, 1)
pipeline := []bson.M{
{
"$match": bson.M{
playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId,
playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0},
},
},
{
"$group": bson.M{
"_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId,
},
},
{
"$count": "count",
},
}
if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
return rows[0].Count, nil
}
// findBoughtPlayletRows 聚合查询当前用户已购短剧分页数据
func findBoughtPlayletRows(ctx context.Context, userId int64, skip int64, limit int64) ([]playletBoughtAggRow, *srvdef.SrvErr) {
rows := make([]playletBoughtAggRow, 0)
pipeline := []bson.M{
{
"$match": bson.M{
playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId,
playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0},
},
},
{
"$group": bson.M{
"_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId,
"boughtChapterCount": bson.M{"$sum": 1},
"costCoins": bson.M{"$sum": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Coins},
"boughtAt": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.CreatedAt},
"latestUnlockId": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Id},
},
},
{
"$sort": bson.D{
{Key: "boughtAt", Value: -1},
{Key: "latestUnlockId", Value: -1},
{Key: "_id", Value: -1},
},
},
{
"$skip": skip,
},
{
"$limit": limit,
},
}
if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil {
return nil, err
}
return rows, nil
}
// collectPlayletIdsFromBoughtRows 从已购短剧聚合结果中提取短剧ID集合
func collectPlayletIdsFromBoughtRows(rows []playletBoughtAggRow) []int64 {
result := make([]int64, 0, len(rows))
seen := make(map[int64]struct{}, len(rows))
for _, row := range rows {
if row.PlayletId <= 0 {
continue
}
if _, ok := seen[row.PlayletId]; ok {
continue
}
seen[row.PlayletId] = struct{}{}
result = append(result, row.PlayletId)
}
return result
}
// playletListFromBoughtRows 按已购短剧聚合顺序返回短剧集合
func playletListFromBoughtRows(rows []playletBoughtAggRow, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList {
result := make(playletmod.PlayletList, 0, len(rows))
for _, row := range rows {
item := playletMap[row.PlayletId]
if item == nil {
continue
}
result = append(result, item)
}
return result
}
// ChapterCollectList 分页查询当前用户收藏的短剧剧集列表
func ChapterCollectList(ctx context.Context, userId int64, req *proto.PlayletChapterCollectListReq) (*proto.PlayletChapterCollectListResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := bson.M{
playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId,
}
totalCount, err := playletchaptercollectmod.CountPlayletChapterCollect(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletChapterCollectListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.PlayletChapterCollectData, 0),
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, filter, options.Find().
SetProjection(bson.D{
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1},
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.PlayletId, Value: 1},
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId, Value: 1},
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: 1},
}).
SetSort(bson.D{
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: -1},
{Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: -1},
}).
SetSkip(skip).
SetLimit(pageSize))
if err != nil {
return nil, err
}
if len(collectList) == 0 {
return resp, nil
}
chapterIds := collectChapterIdsFromChapterCollects(collectList)
chapterMap, err := findChapterCollectChapterMap(ctx, chapterIds)
if err != nil {
return nil, err
}
playletIds := collectPlayletIdsFromChapterCollects(collectList, chapterMap)
playletMap, err := findChapterCollectPlayletMap(ctx, playletIds)
if err != nil {
return nil, err
}
resp.List = buildPlayletChapterCollectData(ctx, collectList, chapterMap, playletMap)
return resp, nil
}
// HistoryList 分页查询当前用户的短剧观看历史
func HistoryList(ctx context.Context, userId int64, req *proto.PlayletHistoryListReq) (*proto.PlayletHistoryListResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletHistoryPage(req.PageNum, req.PageSize)
filter := bson.M{
playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId,
}
totalCount, err := playletwatchhistorymod.CountPlayletWatchHistory(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletHistoryListResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.PlayletHistoryData, 0),
}
if totalCount == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
historyList, err := playletwatchhistorymod.FindManyPlayletWatchHistory(ctx, filter, options.Find().
SetProjection(bson.D{
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: 1},
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId, Value: 1},
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastChapterId, Value: 1},
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastEpisode, Value: 1},
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: 1},
}).
SetSort(bson.D{
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: -1},
{Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: -1},
}).
SetSkip(skip).
SetLimit(pageSize))
if err != nil {
return nil, err
}
if len(historyList) == 0 {
return resp, nil
}
playletIds := collectPlayletIdsFromHistories(historyList)
playletMap, err := findHistoryPlayletMap(ctx, playletIds)
if err != nil {
return nil, err
}
progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds)
if err != nil {
return nil, err
}
playletList := playletListFromMapByHistories(historyList, playletMap)
themeNameMap, err := buildHomeThemeNameMap(ctx, playletList)
if err != nil {
log.Error("查询观看历史题材名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, playletList)
if err != nil {
log.Error("查询观看历史标签名称失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
resp.List = buildPlayletHistoryData(ctx, historyList, playletMap, progressMap, themeNameMap, tagNameMap)
return resp, nil
}
// DeleteHistory 删除当前用户指定短剧观看历史,不影响观看进度和收藏列表
func DeleteHistory(ctx context.Context, userId int64, req *proto.PlayletHistoryDeleteReq) (*proto.PlayletHistoryDeleteResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil || req.PlayletId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{
playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId,
playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId: req.PlayletId,
})
if err != nil {
log.Error("删除单条短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.E(err))
return nil, err
}
return &proto.PlayletHistoryDeleteResp{DeletedCount: deleted}, nil
}
// ClearHistory 清空当前用户的短剧观看历史,不影响观看进度和收藏列表。
func ClearHistory(ctx context.Context, userId int64) (*proto.PlayletHistoryClearResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{
playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId,
})
if err != nil {
return nil, err
}
return &proto.PlayletHistoryClearResp{DeletedCount: deleted}, nil
}
// collectPlayletIdsFromCollects 按收藏列表顺序收集短剧ID并去重,避免批量查询重复ID。
func collectPlayletIdsFromCollects(list playletcollectmod.PlayletCollectList) []int64 {
result := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.PlayletId <= 0 {
continue
}
if _, ok := seen[item.PlayletId]; ok {
continue
}
seen[item.PlayletId] = struct{}{}
result = append(result, item.PlayletId)
}
return result
}
// collectPlayletIdsFromHistories 按观看历史顺序收集短剧ID并去重,避免批量查询重复ID。
func collectPlayletIdsFromHistories(list playletwatchhistorymod.PlayletWatchHistoryList) []int64 {
result := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.PlayletId <= 0 {
continue
}
if _, ok := seen[item.PlayletId]; ok {
continue
}
seen[item.PlayletId] = struct{}{}
result = append(result, item.PlayletId)
}
return result
}
// collectChapterIdsFromChapterCollects 按剧集收藏顺序收集剧集ID并去重。
func collectChapterIdsFromChapterCollects(list playletchaptercollectmod.PlayletChapterCollectList) []int64 {
result := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.ChapterId <= 0 {
continue
}
if _, ok := seen[item.ChapterId]; ok {
continue
}
seen[item.ChapterId] = struct{}{}
result = append(result, item.ChapterId)
}
return result
}
// collectPlayletIdsFromChapterCollects 从收藏关系和剧集数据中收集短剧ID,兼容收藏关系里playletId缺失的历史数据。
func collectPlayletIdsFromChapterCollects(
list playletchaptercollectmod.PlayletChapterCollectList,
chapterMap map[int64]*playletchaptermod.PlayletChapter,
) []int64 {
result := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil {
continue
}
playletId := item.PlayletId
if playletId <= 0 {
if chapter := chapterMap[item.ChapterId]; chapter != nil {
playletId = chapter.PlayletId
}
}
if playletId <= 0 {
continue
}
if _, ok := seen[playletId]; ok {
continue
}
seen[playletId] = struct{}{}
result = append(result, playletId)
}
return result
}
// findCollectPlayletMap 批量查询收藏关联的短剧数据,返回map便于按收藏时间顺序组装。
func findCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
result := make(map[int64]*playletmod.Playlet, len(playletIds))
if len(playletIds) == 0 {
return result, nil
}
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": playletIds},
}, options.Find().SetProjection(playletCollectListProjection))
if err != nil {
log.Error("批量查询我的收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err))
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
result[item.Id] = item
}
return result, nil
}
// findChapterCollectPlayletMap 批量查询剧集收藏关联的短剧数据。
func findChapterCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
result := make(map[int64]*playletmod.Playlet, len(playletIds))
if len(playletIds) == 0 {
return result, nil
}
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": playletIds},
}, options.Find().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
{Key: playletmod.FN.Playlet.UpdateStatus, Value: 1},
}))
if err != nil {
log.Error("批量查询我的剧集收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err))
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
result[item.Id] = item
}
return result, nil
}
// findChapterCollectChapterMap 批量查询收藏关联的剧集数据。
func findChapterCollectChapterMap(ctx context.Context, chapterIds []int64) (map[int64]*playletchaptermod.PlayletChapter, *srvdef.SrvErr) {
result := make(map[int64]*playletchaptermod.PlayletChapter, len(chapterIds))
if len(chapterIds) == 0 {
return result, nil
}
list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": chapterIds},
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}, options.Find().SetProjection(bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1},
}))
if err != nil {
log.Error("批量查询我的剧集收藏剧集失败", log.Any("剧集ID集合", chapterIds), log.E(err))
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
result[item.Id] = item
}
return result, nil
}
// findHistoryPlayletMap 批量查询观看历史关联的短剧数据,返回map便于按观看时间顺序组装。
func findHistoryPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
result := make(map[int64]*playletmod.Playlet, len(playletIds))
if len(playletIds) == 0 {
return result, nil
}
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": playletIds},
}, options.Find().SetProjection(playletHomeProjection))
if err != nil {
log.Error("批量查询观看历史短剧失败", log.Any("短剧ID集合", playletIds), log.E(err))
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
result[item.Id] = item
}
return result, nil
}
// findCollectWatchProgressMap 批量查询当前用户收藏短剧的观看进度,避免收藏列表逐条查询。
func findCollectWatchProgressMap(ctx context.Context, userId int64, playletIds []int64) (map[int64]*playletwatchprogressmod.PlayletWatchProgress, *srvdef.SrvErr) {
result := make(map[int64]*playletwatchprogressmod.PlayletWatchProgress, len(playletIds))
if userId <= 0 || len(playletIds) == 0 {
return result, nil
}
list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{
playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId,
playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId: bson.M{"$in": playletIds},
}, options.Find().SetProjection(bson.D{
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1},
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastChapterId, Value: 1},
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastEpisode, Value: 1},
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedEpisodes, Value: 1},
{Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedCount, Value: 1},
}))
if err != nil {
log.Error("批量查询我的收藏观看进度失败", log.Any("用户ID", userId), log.Any("短剧ID集合", playletIds), log.E(err))
return nil, err
}
for _, item := range list {
if item == nil {
continue
}
result[item.PlayletId] = item
}
return result, nil
}
// playletListFromMapByCollects 按收藏列表顺序构造短剧列表,用于批量查询题材和标签名称。
func playletListFromMapByCollects(collectList playletcollectmod.PlayletCollectList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList {
result := make(playletmod.PlayletList, 0, len(collectList))
for _, collect := range collectList {
if collect == nil {
continue
}
if item := playletMap[collect.PlayletId]; item != nil {
result = append(result, item)
}
}
return result
}
// playletListFromMapByHistories 按观看历史顺序构造短剧列表,用于批量查询题材和标签名称。
func playletListFromMapByHistories(historyList playletwatchhistorymod.PlayletWatchHistoryList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList {
result := make(playletmod.PlayletList, 0, len(historyList))
for _, history := range historyList {
if history == nil {
continue
}
if item := playletMap[history.PlayletId]; item != nil {
result = append(result, item)
}
}
return result
}
// buildPlayletCollectData 组装我的收藏响应,保持playlet_collect的倒序分页顺序。
func buildPlayletCollectData(
ctx context.Context,
collectList playletcollectmod.PlayletCollectList,
playletMap map[int64]*playletmod.Playlet,
progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress,
themeNameMap map[int64]string,
tagNameMap map[int64]string,
) []proto.PlayletCollectData {
result := make([]proto.PlayletCollectData, 0, len(collectList))
for _, collect := range collectList {
if collect == nil {
continue
}
item := playletMap[collect.PlayletId]
if item == nil {
continue
}
progress := progressMap[item.Id]
watchedCount := collectWatchedCount(progress, item.TotalEpisode)
result = append(result, proto.PlayletCollectData{
Id: item.Id,
Title: item.Title,
PlayName: item.PlayName,
Slug: item.Slug,
Cover: withImageDomain(ctx, item.DisplayCover()),
ThemeIds: item.ThemeIds,
ThemeNames: namesByIds(item.ThemeIds, themeNameMap),
ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)),
TagIds: item.TagIds,
TagNames: namesByIds(item.TagIds, tagNameMap),
TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.CountBrowse,
HeatValue: item.HeatValue,
CountCollect: item.CountCollect,
IsCollect: operateStatusYes,
CollectedAt: collect.CreatedAt,
WatchedCount: watchedCount,
UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount),
LastChapterId: collectLastChapterId(progress),
LastEpisode: collectLastEpisode(progress),
SeoInfo: item.SeoInfo,
})
}
return result
}
// buildPlayletBoughtData 组装我的已购短剧响应,保持最近购买时间倒序
func buildPlayletBoughtData(
ctx context.Context,
rows []playletBoughtAggRow,
playletMap map[int64]*playletmod.Playlet,
progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress,
themeNameMap map[int64]string,
tagNameMap map[int64]string,
) []proto.PlayletBoughtData {
result := make([]proto.PlayletBoughtData, 0, len(rows))
for _, row := range rows {
item := playletMap[row.PlayletId]
if item == nil {
continue
}
if len(item.Description) > 0 {
item.SeoInfo.Description = item.Description
}
progress := progressMap[item.Id]
watchedCount := collectWatchedCount(progress, item.TotalEpisode)
result = append(result, proto.PlayletBoughtData{
CommonData: proto.CommonData{
Id: item.Id,
Status: int32(item.Status),
IsFeatured: item.IsFeatured,
IsOriginal: item.IsOriginal || item.MediaCenterId == 0,
ShowOriginal: item.ShowOriginal,
IsRecommend: item.IsRecommend,
IsNew: isOnlineThisWeek(item.OnlineAt),
OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)),
ShowOnBanner: item.ShowOnBanner,
Banner: withImageDomain(ctx, item.Banner),
OnlineAt: item.OnlineAt,
MediaCreatedAt: item.MediaCreatedAt,
MediaUpdatedAt: item.MediaUpdatedAt,
ThemeIds: item.ThemeIds,
ThemeNames: namesByIds(item.ThemeIds, themeNameMap),
ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)),
CategoryIds: item.CategoryIds,
Title: item.Title,
PlayName: item.PlayName,
Description: item.Description,
Slug: item.Slug,
Desc: item.Desc,
Anchor: item.Anchor,
Cover: withImageDomain(ctx, item.DisplayCover()),
HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()),
Preview: item.Preview,
TagIds: item.TagIds,
TagNames: namesByIds(item.TagIds, tagNameMap),
TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.CountBrowse,
HeatValue: item.HeatValue,
CountCollect: item.CountCollect,
CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
SeoInfo: item.SeoInfo,
},
BoughtChapterCount: row.BoughtChapterCount,
CostCoins: row.CostCoins,
BoughtAt: row.BoughtAt,
LastChapterId: collectLastChapterId(progress),
LastEpisode: collectLastEpisode(progress),
WatchedCount: watchedCount,
UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount),
IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount),
})
}
return result
}
// buildPlayletChapterCollectData 组装我的剧集收藏响应,保持playlet_chapter_collect的倒序分页顺序。
func buildPlayletChapterCollectData(
ctx context.Context,
collectList playletchaptercollectmod.PlayletChapterCollectList,
chapterMap map[int64]*playletchaptermod.PlayletChapter,
playletMap map[int64]*playletmod.Playlet,
) []proto.PlayletChapterCollectData {
result := make([]proto.PlayletChapterCollectData, 0, len(collectList))
for _, collect := range collectList {
if collect == nil {
continue
}
chapter := chapterMap[collect.ChapterId]
if chapter == nil {
continue
}
playletId := collect.PlayletId
if playletId <= 0 {
playletId = chapter.PlayletId
}
playlet := playletMap[playletId]
if playlet == nil {
continue
}
result = append(result, proto.PlayletChapterCollectData{
PlayletId: playlet.Id,
PlayletTitle: playlet.Title,
PlayletCover: withImageDomain(ctx, playlet.DisplayCover()),
TotalEpisode: playlet.TotalEpisode,
UpdateStatus: playlet.UpdateStatus,
ChapterId: chapter.Id,
CurrentEpisode: chapter.CurrentEpisode,
ChapterTitle: chapter.Title,
VideoUrl: playletChapterPlayVideoUrl(chapter),
MediaTime: chapter.MediaTime,
PayType: int32(chapter.PayType),
SingleCoins: chapter.SingleCoins,
CountBrowse: chapter.CountBrowse,
CountCollect: chapter.CountCollect,
IsCollect: operateStatusYes,
CollectedAt: collect.CreatedAt,
})
}
return result
}
// buildPlayletHistoryData 组装观看历史响应,保持playlet_watch_history的倒序分页顺序。
func buildPlayletHistoryData(
ctx context.Context,
historyList playletwatchhistorymod.PlayletWatchHistoryList,
playletMap map[int64]*playletmod.Playlet,
progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress,
themeNameMap map[int64]string,
tagNameMap map[int64]string,
) []proto.PlayletHistoryData {
result := make([]proto.PlayletHistoryData, 0, len(historyList))
for _, history := range historyList {
if history == nil {
continue
}
item := playletMap[history.PlayletId]
if item == nil {
continue
}
progress := progressMap[item.Id]
watchedCount := collectWatchedCount(progress, item.TotalEpisode)
result = append(result, proto.PlayletHistoryData{
CommonData: proto.CommonData{
Id: item.Id,
Status: int32(item.Status),
IsFeatured: item.IsFeatured,
IsOriginal: item.IsOriginal || item.MediaCenterId == 0,
ShowOriginal: item.ShowOriginal,
IsRecommend: item.IsRecommend,
IsNew: isOnlineThisWeek(item.OnlineAt),
OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)),
ShowOnBanner: item.ShowOnBanner,
Banner: withImageDomain(ctx, item.Banner),
OnlineAt: item.OnlineAt,
MediaCreatedAt: item.MediaCreatedAt,
MediaUpdatedAt: item.MediaUpdatedAt,
ThemeIds: item.ThemeIds,
ThemeNames: namesByIds(item.ThemeIds, themeNameMap),
ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)),
CategoryIds: item.CategoryIds,
Title: item.Title,
PlayName: item.PlayName,
Description: item.Description,
Slug: item.Slug,
Desc: item.Desc,
Anchor: item.Anchor,
Cover: withImageDomain(ctx, item.DisplayCover()),
TagIds: item.TagIds,
TagNames: namesByIds(item.TagIds, tagNameMap),
TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.CountBrowse,
CountCollect: item.CountCollect,
CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
SeoInfo: item.SeoInfo,
},
LastChapterId: history.LastChapterId,
LastEpisode: history.LastEpisode,
WatchedCount: watchedCount,
UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount),
IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount),
HistoryAt: formatPlayletHistoryTime(history.UpdatedAt),
})
}
return result
}
func formatPlayletHistoryTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.In(utils.LocBJ).Format(pubdef.TimeISO)
}
// collectWatchedCount 读取已看集数,兼容仅保存watchedEpisodes但未写watchedCount的历史数据。
func collectWatchedCount(progress *playletwatchprogressmod.PlayletWatchProgress, totalEpisode int64) int64 {
if progress == nil {
return 0
}
watchedCount := progress.WatchedCount
if watchedCount <= 0 && len(progress.WatchedEpisodes) > 0 {
watchedCount = int64(len(progress.WatchedEpisodes))
}
if watchedCount < 0 {
return 0
}
if totalEpisode > 0 && watchedCount > totalEpisode {
return totalEpisode
}
return watchedCount
}
// collectUnwatchedCount 计算未看集数,避免历史脏数据导致负数。
func collectUnwatchedCount(totalEpisode int64, watchedCount int64) int64 {
if totalEpisode <= watchedCount {
return 0
}
return totalEpisode - watchedCount
}
// isPlayletFinished 判断短剧是否已看完。
func isPlayletFinished(totalEpisode int64, watchedCount int64) bool {
return totalEpisode > 0 && watchedCount >= totalEpisode
}
// collectLastChapterId 获取最近观看剧集ID,无有效进度时返回0。
func collectLastChapterId(progress *playletwatchprogressmod.PlayletWatchProgress) int64 {
if progress == nil || progress.LastChapterId < 0 {
return 0
}
return progress.LastChapterId
}
// collectLastEpisode 获取最近观看集数,无有效进度时返回0。
func collectLastEpisode(progress *playletwatchprogressmod.PlayletWatchProgress) int64 {
if progress == nil || progress.LastEpisode < 0 {
return 0
}
return progress.LastEpisode
}
// findPlayletChapterUserOperateStatus 查询当前用户短剧剧集点赞和收藏状态
func findPlayletChapterUserOperateStatus(ctx context.Context, userId int64, chapterId int64) (int64, int64, *srvdef.SrvErr) {
// 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证剧集详情接口可匿名访问。
if userId <= 0 {
return operateStatusNo, operateStatusNo, nil
}
likeList, err := playletchapterlikemod.FindManyPlayletChapterLike(ctx, bson.M{
playletchapterlikemod.FN.PlayletChapterLike.UserId: userId,
playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId,
}, options.Find().
SetLimit(1).
SetProjection(bson.D{{Key: playletchapterlikemod.FN.PlayletChapterLike.Id, Value: 1}}))
if err != nil {
log.Error("查询短剧剧集用户点赞状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err))
return operateStatusNo, operateStatusNo, err
}
collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, bson.M{
playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId,
playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId,
}, options.Find().
SetLimit(1).
SetProjection(bson.D{{Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1}}))
if err != nil {
log.Error("查询短剧剧集用户收藏状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err))
return operateStatusNo, operateStatusNo, err
}
isLike := operateStatusByFlag(len(likeList) > 0)
isCollect := operateStatusByFlag(len(collectList) > 0)
return isLike, isCollect, nil
}
// incrementPlayletBrowse 原子增加短剧观看量和热度值
func incrementPlayletBrowse(ctx context.Context, id int64) *srvdef.SrvErr {
// 不更新updatedAt,避免详情访问影响今日更新排序。
return playletmod.UpdateOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: id,
}), bson.M{
"$inc": bson.M{
playletmod.FN.Playlet.CountBrowse: 1,
playletmod.FN.Playlet.HeatValue: playletHeatClickInc,
},
})
}
func incrementPlayletHeatValue(ctx context.Context, id int64, delta int64) *srvdef.SrvErr {
if id <= 0 || delta == 0 {
return nil
}
return playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: id,
}, bson.M{
"$inc": bson.M{
playletmod.FN.Playlet.HeatValue: delta,
},
})
}
// checkPlayletForwardHeatLimit 校验同一用户或IP对同一短剧的转发加热频控
func checkPlayletForwardHeatLimit(userId int64, ip string, playletId int64) (bool, error) {
operator := strings.TrimSpace(ip)
if userId > 0 {
operator = "user-" + strconv.FormatInt(userId, 10)
}
if operator == "" {
operator = "unknown"
}
ok, err := redis.Setnx_NewOK(
redisconst.AppPlayletForwardHeatKey(operator, playletId),
1,
redisconst.AppPlayletForwardHeatExpire,
)
if err != nil {
return false, err
}
return !ok, nil
}
// findPlayletForwardHeatTarget 查询转发加热目标短剧,用于频控命中时返回当前热度
func findPlayletForwardHeatTarget(ctx context.Context, id int64) (*playletmod.Playlet, *srvdef.SrvErr) {
playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: id,
}), options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
}))
if err != nil {
log.Error("查询短剧转发热度目标失败", log.Any("短剧ID", id), log.E(err))
return nil, err
}
return playlet, nil
}
// incrementPlayletHeatValueAndReturn 原子增加短剧热度值并返回更新后的热度
func incrementPlayletHeatValueAndReturn(ctx context.Context, id int64, delta int64) (*playletmod.Playlet, *srvdef.SrvErr) {
if id <= 0 || delta == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
return playletmod.FindOneAndUpdatePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: id,
}), bson.M{
"$inc": bson.M{
playletmod.FN.Playlet.HeatValue: delta,
},
}, options.FindOneAndUpdate().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
}))
}
// incrementPlayletChapterBrowse 原子增加短剧剧集观看量
func incrementPlayletChapterBrowse(ctx context.Context, id int64) *srvdef.SrvErr {
// 只递增剧集countBrowse,不更新updatedAt,避免播放行为影响后台维护时间和排序逻辑。
return playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: id,
}, bson.M{
"$inc": bson.M{
playletchaptermod.FN.PlayletChapter.CountBrowse: 1,
},
})
}
// findPlayletChapters 查询短剧全部章节,并批量填充当前用户播放权限。
// 原创短剧只返回已有水印地址的剧集:首次未处理好的不露出,换水印期间仍可播旧地址。
func findPlayletChapters(ctx context.Context, playletId int64, userId int64, isOriginal bool) ([]proto.PlayletChapterData, *srvdef.SrvErr) {
filter := bson.M{
playletchaptermod.FN.PlayletChapter.PlayletId: playletId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}
if isOriginal {
filter[playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl] = bson.M{"$gt": ""}
}
// 详情页播放区需要展示完整选集列表,这里按剧集序号一次性查询全部章节,避免前端再发一次章节请求
list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find().
SetProjection(playletChapterInfoProjection).
SetSort(bson.D{
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
}))
if err != nil {
log.Error("查询短剧章节列表失败", log.Any("短剧ID", playletId), log.E(err))
return nil, err
}
access, err := buildPlayletChapterAccessContext(ctx, userId, playletId, list)
if err != nil {
return nil, err
}
result := make([]proto.PlayletChapterData, 0, len(list))
for _, chapter := range list {
if chapter == nil {
continue
}
result = append(result, playletChapterToData(chapter, access))
}
return result, nil
}
// buildPlayletChapterAccessContext 批量查询详情章节列表所需的用户权限数据。
func buildPlayletChapterAccessContext(ctx context.Context, userId int64, playletId int64, chapters playletchaptermod.PlayletChapterList) (*playletChapterAccessContext, *srvdef.SrvErr) {
access := &playletChapterAccessContext{
unlockedChapterIds: make(map[int64]bool),
}
if userId <= 0 {
return access, nil
}
user, err := findPlayletChapterAccessUser(ctx, userId)
if err != nil {
return nil, err
}
access.balance = user.Recharge
access.vipValid = isUserVipValid(user)
coinChapterIds := collectCoinPayChapterIds(chapters)
if len(coinChapterIds) == 0 {
return access, nil
}
unlocks, err := playletchapterunlockmod.FindManyPlayletChapterUnlock(ctx, bson.M{
playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId,
playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: playletId,
playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId: bson.M{"$in": coinChapterIds},
}, options.Find().SetProjection(bson.D{
{Key: playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId, Value: 1},
}))
if err != nil {
return nil, err
}
for _, item := range unlocks {
if item == nil || item.ChapterId <= 0 {
continue
}
access.unlockedChapterIds[item.ChapterId] = true
}
return access, nil
}
// collectCoinPayChapterIds 收集金币付费剧集ID,减少解锁记录查询范围。
func collectCoinPayChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 {
ids := make([]int64, 0)
for _, chapter := range chapters {
if chapter == nil || chapter.Id <= 0 || chapter.PayType != playletchaptermod.PayTypeCoin {
continue
}
ids = append(ids, chapter.Id)
}
return ids
}
// findGuessLikeCurrentPlaylet 查询猜你喜欢当前短剧
func findGuessLikeCurrentPlaylet(ctx context.Context, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) {
item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: playletId,
}), options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
}))
if err != nil {
log.Error("查询猜你喜欢当前短剧失败", log.Any("短剧ID", playletId), log.E(err))
return nil, err
}
if item == nil {
return nil, srvdef.New(srvdef.ErrNoData, "短剧不存在或已下架", "短剧不存在或已下架")
}
return item, nil
}
// playletGuessLikeFilter 构建猜你喜欢过滤条件
func playletGuessLikeFilter(item *playletmod.Playlet) bson.M {
filter := homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.Id: bson.M{"$ne": item.Id},
})
if item != nil && len(item.ThemeIds) > 0 {
filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": item.ThemeIds}
}
return filter
}
// playletGuessLikeSort 构建猜你喜欢排序
func playletGuessLikeSort() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// playletChapterToData 转换短剧章节响应数据
func playletChapterToData(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext) proto.PlayletChapterData {
if item == nil {
return proto.PlayletChapterData{}
}
videoURL := playletChapterPlayVideoUrl(item)
data := proto.PlayletChapterData{
Id: item.Id,
PlayletId: item.PlayletId,
CurrentEpisode: item.CurrentEpisode,
Title: item.Title,
Md5: item.Md5,
Hash: item.Hash,
Height: item.Height,
Width: item.Width,
Ratio: item.Ratio,
VideoUrl: videoURL,
MediaSize: item.MediaSize,
MediaTime: item.MediaTime,
PayType: int32(item.PayType),
SingleCoins: item.SingleCoins,
CostCoins: item.SingleCoins,
}
applyPlayletChapterDataWatchPermission(item, access, &data)
if !data.CanWatch {
videoURL = ""
}
data.VideoUrl = videoURL
return data
}
// applyPlayletChapterDataWatchPermission 填充详情页章节列表的播放权限状态。
func applyPlayletChapterDataWatchPermission(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext, data *proto.PlayletChapterData) {
if item == nil || data == nil {
return
}
if access != nil {
data.Balance = access.balance
data.VipValid = access.vipValid
}
switch item.PayType {
case playletchaptermod.PayTypeFree:
data.CanWatch = true
case playletchaptermod.PayTypeCoin:
unlocked := access != nil && access.unlockedChapterIds[item.Id]
data.IsUnlocked = unlocked
data.CanWatch = unlocked
data.NeedUnlock = !unlocked
case playletchaptermod.PayTypeVip:
vipValid := access != nil && access.vipValid
data.CanWatch = vipValid
data.NeedVip = !vipValid
default:
data.NeedUnlock = true
}
}
// playletChapterToInfoResp 转换短剧剧集详情响应数据
func playletChapterToInfoResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterInfoResp {
// 剧集详情只返回播放页需要的当前剧集字段;播放地址优先水印地址,为空回退原片。
if item == nil {
return &proto.PlayletChapterInfoResp{}
}
return &proto.PlayletChapterInfoResp{
Id: item.Id,
PlayletId: item.PlayletId,
CurrentEpisode: item.CurrentEpisode,
Title: item.Title,
Md5: item.Md5,
Hash: item.Hash,
Height: item.Height,
Width: item.Width,
Ratio: item.Ratio,
VideoUrl: playletChapterPlayVideoUrl(item),
MediaSize: item.MediaSize,
MediaTime: item.MediaTime,
CountComment: item.CountComment,
CountBrowse: item.CountBrowse,
CountCollect: item.CountCollect,
CountLike: item.CountLike,
IsLike: operateStatusNo,
IsCollect: operateStatusNo,
PayType: int32(item.PayType),
SingleCoins: item.SingleCoins,
}
}
// ensureOriginalChapterWatermarkReady 原创剧集必须已有水印地址才允许播放/解锁。
func ensureOriginalChapterWatermarkReady(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *srvdef.SrvErr {
if playlet == nil || !playlet.IsOriginal || chapter == nil {
return nil
}
if strings.TrimSpace(chapter.WatermarkVideoUrl) != "" {
return nil
}
return srvdef.New(srvdef.ErrNoData, "剧集处理中,请稍后观看", "剧集处理中,请稍后观看")
}
// playletChapterPlayVideoUrl 优先返回水印地址,为空时回退原片地址。
func playletChapterPlayVideoUrl(item *playletchaptermod.PlayletChapter) string {
if item == nil {
return ""
}
if u := strings.TrimSpace(item.WatermarkVideoUrl); u != "" {
return u
}
return item.VideoUrl
}
// buildPlayletDetailSeoInfo 组装短剧详情页SEO信息
func buildPlayletDetailSeoInfo(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if item == nil {
return &seo
}
// 优先使用后台配置的 SeoInfo,缺失字段再回退剧名/简介自动生成。
if title := strings.TrimSpace(item.SeoInfo.Title); title != "" {
seo.Title = title
} else if title := strings.TrimSpace(item.Title); title != "" {
seo.Title = title
}
if desc := strings.TrimSpace(item.Description); desc != "" {
seo.Description = desc
} else if desc := strings.TrimSpace(item.SeoInfo.Description); desc != "" {
seo.Description = desc
} else if desc := truncatePlayletSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" {
seo.Description = desc
}
if len(item.SeoInfo.Keyworld) > 0 {
seo.Keywords = append([]string{}, item.SeoInfo.Keyworld...)
} else {
seo.Keywords = buildPlayletSEOKeywords(item, themeNameMap, tagNameMap, seo.Keywords)
}
seo.CanonicalURL = buildPlayletDetailCanonicalURL(seo.CanonicalURL, item.Slug, item.Id)
return &seo
}
// buildPlayletChapterDetailSeoInfo 组装短剧剧集详情页SEO信息
func buildPlayletChapterDetailSeoInfo(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if playlet == nil && chapter == nil {
return &seo
}
// 优先使用短剧后台配置的 SeoInfo,再回退到剧名/简介自动生成。
if playlet != nil {
if title := strings.TrimSpace(playlet.SeoInfo.Title); title != "" {
seo.Title = title
}
if desc := strings.TrimSpace(playlet.Description); desc != "" {
seo.Description = desc
} else if desc := strings.TrimSpace(playlet.SeoInfo.Description); desc != "" {
seo.Description = desc
}
if len(playlet.SeoInfo.Keyworld) > 0 {
seo.Keywords = append([]string{}, playlet.SeoInfo.Keyworld...)
}
}
if strings.TrimSpace(seo.Title) == "" {
if title := buildPlayletChapterSEOTitle(playlet, chapter); title != "" {
seo.Title = title
}
} else if chapter != nil {
// 已有短剧 SEO 标题时,补充剧集名便于播放页区分。
if episodeTitle := strings.TrimSpace(chapter.Title); episodeTitle != "" {
seo.Title = seo.Title + " " + episodeTitle
} else if chapter.CurrentEpisode > 0 {
seo.Title = seo.Title + " 第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集"
}
}
if strings.TrimSpace(seo.Description) == "" && playlet != nil {
if desc := truncatePlayletSEODescription(strings.TrimSpace(playlet.Desc), 120); desc != "" {
seo.Description = desc
}
}
if len(seo.Keywords) == 0 {
if playlet != nil {
seo.Keywords = buildPlayletSEOKeywords(playlet, nil, nil, seo.Keywords)
} else {
seo.Keywords = buildPlayletChapterSEOKeywords(chapter, seo.Keywords)
}
}
if chapter != nil {
slug := ""
if playlet != nil {
slug = playlet.Slug
}
seo.CanonicalURL = buildPlayletChapterDetailCanonicalURL(seo.CanonicalURL, slug, chapter.PlayletId, chapter.Id)
}
return &seo
}
// buildPlayletListPageSeoInfo 组装短剧总列表页 SEO(无题材/分类筛选)。
func buildPlayletListPageSeoInfo() *proto.SeoInfo {
return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "", "", "/list")
}
// buildPlayletThemePageSeoInfo 组装题材短剧列表页SEO信息(读题材缓存,避免每次查库)
func buildPlayletThemePageSeoInfo(themeId int64) *proto.SeoInfo {
if themeId <= 0 {
return buildPlayletListPageSeoInfo()
}
theme := playletcache.GetEnabledTheme(themeId)
if theme == nil {
return buildPlayletListPageSeoInfo()
}
return buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id))
}
// buildPlayletTagPageSeoInfo 组装标签短剧列表页SEO信息(读标签缓存,避免每次查库)
func buildPlayletTagPageSeoInfo(tagId int64) *proto.SeoInfo {
if tagId <= 0 {
return buildPlayletListPageSeoInfo()
}
tag := playletcache.GetEnabledTag(tagId)
if tag == nil {
return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "短剧标签", "", buildPlayletTagListCanonicalURL("", tagId))
}
return buildPageSeoInfoFromValue(tag.SeoInfo, tag.Name, "", buildPlayletTagListCanonicalURL(tag.Slug, tag.Id))
}
// buildPageSeoInfoFromValue 将后台 SeoValue 转为页面 SeoInfo,缺失字段回退站点默认。
func buildPageSeoInfoFromValue(custom pubdef.SeoValue, fallbackTitle, fallbackDesc, canonicalURL string) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if title := strings.TrimSpace(custom.Title); title != "" {
seo.Title = title
} else if title := strings.TrimSpace(fallbackTitle); title != "" {
seo.Title = title
}
if desc := strings.TrimSpace(custom.Description); desc != "" {
seo.Description = desc
} else if desc := strings.TrimSpace(fallbackDesc); desc != "" {
seo.Description = desc
}
if len(custom.Keyworld) > 0 {
seo.Keywords = append([]string{}, custom.Keyworld...)
}
if canonicalURL != "" {
seo.CanonicalURL = buildPlayletCanonicalURL(seo.CanonicalURL, canonicalURL)
}
return &seo
}
// buildPlayletThemeListCanonicalURL 专题详情页相对路径(对齐 jha-1045-hgdjnet-web /topic-detail/:slug)
func buildPlayletThemeListCanonicalURL(slug string, themeId int64) string {
return h5path.TopicDetail(slug, themeId)
}
// buildPlayletTagListCanonicalURL 标签列表页相对路径(对齐 jha-1045-hgdjnet-web /tags/:slug)
func buildPlayletTagListCanonicalURL(slug string, tagId int64) string {
return h5path.Tag(slug, tagId)
}
func playletSiteEntityPath(prefix, slug string, id int64) string {
return h5path.Entity(prefix, slug, id)
}
// buildPlayletChapterSEOTitle 生成剧集详情页标题,优先展示“短剧名 + 剧集名”。
func buildPlayletChapterSEOTitle(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string {
playletTitle := ""
if playlet != nil {
playletTitle = strings.TrimSpace(playlet.Title)
}
chapterTitle := ""
if chapter != nil {
chapterTitle = strings.TrimSpace(chapter.Title)
if chapterTitle == "" && chapter.CurrentEpisode > 0 {
chapterTitle = "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集"
}
}
if playletTitle == "" {
return chapterTitle
}
if chapterTitle == "" {
return playletTitle
}
return playletTitle + " " + chapterTitle
}
// buildPlayletSEOKeywords 合并短剧标题、题材、标签和全站关键词,并去重。
func buildPlayletSEOKeywords(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string, siteKeywords []string) []string {
capacity := len(siteKeywords) + 1
if item != nil {
capacity += len(item.ThemeIds) + len(item.TagIds)
}
keywords := make([]string, 0, capacity)
seen := make(map[string]struct{}, cap(keywords))
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
keywords = append(keywords, value)
}
if item != nil {
add(item.Title)
for _, name := range namesByIds(item.ThemeIds, themeNameMap) {
add(name)
}
for _, name := range namesByIds(item.TagIds, tagNameMap) {
add(name)
}
}
for _, keyword := range siteKeywords {
add(keyword)
}
return keywords
}
// buildPlayletChapterSEOKeywords 合并剧集标题和全站关键词,并去重。
func buildPlayletChapterSEOKeywords(chapter *playletchaptermod.PlayletChapter, siteKeywords []string) []string {
keywords := make([]string, 0, len(siteKeywords)+1)
seen := make(map[string]struct{}, cap(keywords))
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
keywords = append(keywords, value)
}
if chapter != nil {
add(chapter.Title)
}
for _, keyword := range siteKeywords {
add(keyword)
}
return keywords
}
// truncatePlayletSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述。
func truncatePlayletSEODescription(desc string, maxRunes int) string {
if maxRunes <= 0 || desc == "" {
return ""
}
runes := []rune(desc)
if len(runes) <= maxRunes {
return desc
}
return string(runes[:maxRunes])
}
// buildPlayletDetailCanonicalURL 拼接短剧详情页标准URL。
func buildPlayletDetailCanonicalURL(baseURL, slug string, id int64) string {
return buildPlayletCanonicalURL(baseURL, playletSiteEntityPath(playletH5DetailPath, slug, id))
}
// buildPlayletChapterDetailCanonicalURL 拼接短剧剧集详情页标准URL。
func buildPlayletChapterDetailCanonicalURL(baseURL, slug string, playletId int64, chapterId int64) string {
pagePath := h5path.PlayletChapter(slug, playletId, chapterId)
if pagePath == "" {
pagePath = h5path.Playlet(slug, playletId)
}
return buildPlayletCanonicalURL(baseURL, pagePath)
}
// buildPlayletCanonicalURL 使用全站Web地址拼接页面路径;未配置域名时返回相对路径。
func buildPlayletCanonicalURL(baseURL string, pagePath string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return pagePath
}
return baseURL + pagePath
}
// emptyPlayletSearchResp 构建短剧ES空分页响应
func emptyPlayletSearchResp(ctx context.Context, keyword string, pageNum int64, pageSize int64) *proto.PlayletSearchResp {
// ES索引未创建或暂无数据时返回空列表,保持接口响应结构稳定。
return &proto.PlayletSearchResp{
TotalCount: 0,
PageSize: int(pageSize),
PageNum: int(pageNum),
Keyword: keyword,
List: make([]proto.CommonData, 0),
EveryoneWatching: guessLikeCommonData(ctx),
}
}
// buildHomePlayletResponse 查询并组装app首页短剧数据
func buildHomePlayletResponse(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) {
// 定义返回
resp := &proto.HomePlayletResponse{
HomeBannerData: make([]*proto.CommonData, 0),
Modules: make([]*proto.HomeModuleData, 0),
Announcements: make([]*proto.AnnouncementItem, 0),
}
// Home首页数据响应
bannerList, err := findHomeBanners(ctx)
if err != nil {
log.Error("查询首页banner数据失败", log.E(err))
return nil, err
}
configList, err := findHomeConfigs(ctx)
if err != nil {
log.Error("查询首页配置数据失败", log.E(err))
return nil, err
}
configPlayletMap, configThemeMap, configPlaylets, err := findHomeConfigPlaylets(ctx, configList)
if err != nil {
log.Error("查询首页配置短剧数据失败", log.E(err))
return nil, err
}
allPlaylets := mergeHomePlayletLists(bannerList, configPlaylets)
themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询首页配置题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询首页配置标签名称失败", log.E(err))
return nil, err
}
resp.HomeBannerData = playletsToHomeCommonData(ctx, bannerList, themeNameMap, tagNameMap)
for _, item := range homeConfigsToHomeData(ctx, configList, configPlayletMap, configThemeMap, themeNameMap, tagNameMap) {
resp.Modules = append(resp.Modules, homeConfigToModule(item))
}
seo := configser.GetSiteSeoInfo()
resp.SeoInfo = &seo
return resp, nil
}
func attachHomeAnnouncements(resp *proto.HomePlayletResponse) {
if resp == nil {
return
}
resp.Announcements = configser.GetAnnouncements()
}
func attachHomeAdvitise(resp *proto.HomePlayletResponse) {
if resp == nil {
return
}
resp.Advitise = configser.GetAdvitise()
}
func guessLikeCommonData(ctx context.Context) []*proto.CommonData {
list := playletcache.ListGuessLike()
themeNameMap, _ := buildHomeThemeNameMap(ctx, list)
tagNameMap, _ := buildHomeTagNameMap(ctx, list)
return playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)
}
// findHomeBanners 查询展示在首页 banner 的上架短剧
func findHomeBanners(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) {
return findHomePlaylets(ctx, homeUpPlayletFilter(bson.M{
playletmod.FN.Playlet.ShowOnBanner: true,
}), bson.D{
{Key: playletmod.FN.Playlet.TopRank, Value: -1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}, homeBannerLimit)
}
// findHomeConfigs 查询启用的首页配置
func findHomeConfigs(ctx context.Context) (homeconfigmod.HomeConfigList, *srvdef.SrvErr) {
return homeconfigmod.FindManyHomeConfig(ctx, bson.M{
homeconfigmod.FN.HomeConfig.Status: true,
homeconfigmod.FN.HomeConfig.Type: bson.M{"$in": []int32{homeconfigmod.TypePlaylet, homeconfigmod.TypeTheme}},
}, options.Find().
SetProjection(homeConfigAppProjection).
SetSort(bson.D{
{Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1},
{Key: homeconfigmod.FN.HomeConfig.Id, Value: -1},
}))
}
// findHomeConfigPlaylets 按首页配置查询短剧集合
func findHomeConfigPlaylets(ctx context.Context, configs homeconfigmod.HomeConfigList) (map[int64]playletmod.PlayletList, map[int64][]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
configPlayletMap := make(map[int64]playletmod.PlayletList, len(configs))
configThemeMap := make(map[int64][]homeConfigThemePlaylets, len(configs))
allPlaylets := make(playletmod.PlayletList, 0)
for _, config := range configs {
if config == nil {
continue
}
switch config.Type {
case homeconfigmod.TypePlaylet:
list, err := findHomeConfigPlayletListForConfig(ctx, config, homeConfigDisplayLimit(config))
if err != nil {
return nil, nil, nil, err
}
configPlayletMap[config.Id] = list
allPlaylets = append(allPlaylets, list...)
case homeconfigmod.TypeTheme:
groups, list, err := findHomeConfigThemePlayletsForConfig(ctx, config, 0, homeConfigDisplayLimit(config))
if err != nil {
return nil, nil, nil, err
}
configThemeMap[config.Id] = groups
allPlaylets = append(allPlaylets, list...)
}
}
return configPlayletMap, configThemeMap, allPlaylets, nil
}
func homeConfigDisplayLimit(config *homeconfigmod.HomeConfig) int64 {
if config != nil && config.Limit > 0 {
if config.Limit > maxPageSize {
return maxPageSize
}
return config.Limit
}
if config != nil && config.Type == homeconfigmod.TypeTheme {
return homeConfigThemeLimit
}
return homeConfigPlayletLimit
}
func findHomeConfigPlayletList(ctx context.Context, sortType int32, categoryId, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) {
filter, sort := homeConfigPlayletQuery(sortType, categoryId)
list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, 0, limit)
if err != nil {
return nil, err
}
if sortType != homeconfigmod.SortTypeRecommend || int64(len(list)) >= limit {
return list, nil
}
filter, sort = homeConfigPlayletQuery(homeconfigmod.SortTypeOnlineAt, 0)
if len(list) > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$nin": playletIdsFromList(list)}
}
moreList, err := findHomePlaylets(ctx, filter, sort, limit-int64(len(list)))
if err != nil {
return nil, err
}
return append(list, moreList...), nil
}
func findHomePlayletsByHomeConfigSort(ctx context.Context, filter bson.M, sortType int32, categoryId int64, sort bson.D, skip, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) {
if sortType == homeconfigmod.SortTypeCategory && categoryId > 0 {
return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryId, skip, limit, playletmod.CategoryRankSort(), playletHomeProjection)
}
return findHomePlaylets(ctx, filter, sort, limit, options.Find().SetSkip(skip))
}
func playletIdsFromList(list playletmod.PlayletList) []int64 {
ids := make([]int64, 0, len(list))
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
ids = append(ids, item.Id)
}
return ids
}
func findHomeConfigThemePlaylets(ctx context.Context, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
themes := playletcache.ListEnabledThemesByPlayletCount(0)
result := make([]homeConfigThemePlaylets, 0)
allPlaylets := make(playletmod.PlayletList, 0)
for _, theme := range themes {
if theme == nil {
continue
}
filter := topicDetailPlayletFilter(theme.Id)
count, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, nil, err
}
if count == 0 {
continue
}
list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), homeConfigPlayletLimit)
if err != nil {
return nil, nil, err
}
if len(list) == 0 {
continue
}
result = append(result, homeConfigThemePlaylets{
Theme: theme,
Playlets: list,
TotalCount: count,
})
allPlaylets = append(allPlaylets, list...)
if int64(len(result)) >= limit {
break
}
}
return result, allPlaylets, nil
}
// findHomeConfigThemePlayletsPage 分页查询有短剧数据的首页配置题材
func findHomeConfigThemePlayletsPage(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) {
themes := playletcache.ListEnabledThemesByPlayletCount(0)
result := make([]homeConfigThemePlaylets, 0)
allPlaylets := make(playletmod.PlayletList, 0)
totalCount := int64(0)
for _, theme := range themes {
if theme == nil {
continue
}
filter := topicDetailPlayletFilter(theme.Id)
count, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, 0, nil, err
}
if count == 0 {
continue
}
if totalCount >= skip && int64(len(result)) < limit {
list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), homeConfigPlayletLimit)
if err != nil {
return nil, 0, nil, err
}
result = append(result, homeConfigThemePlaylets{
Theme: theme,
Playlets: list,
TotalCount: count,
})
allPlaylets = append(allPlaylets, list...)
}
totalCount++
}
return result, totalCount, allPlaylets, nil
}
// fillPlayletTopicDetailMore 填充专题详情更多专题
func fillPlayletTopicDetailMore(ctx context.Context, resp *proto.PlayletTopicDetailResp, currentThemeID int64, seedPlaylets playletmod.PlayletList) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) {
if resp == nil {
return resp, nil
}
groups, morePlaylets, err := findPlayletTopicMoreGroups(ctx, currentThemeID, topicDetailMoreLimit)
if err != nil {
return nil, err
}
allPlaylets := mergeHomePlayletLists(seedPlaylets, morePlaylets)
themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询专题详情更多专题题材名称失败", log.E(err))
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets)
if err != nil {
log.Error("查询专题详情更多专题标签名称失败", log.E(err))
return nil, err
}
resp.MoreTopics = playletTopicsToPageData(ctx, groups, themeNameMap, tagNameMap)
return resp, nil
}
// findPlayletTopicMoreGroups 查询更多专题
func findPlayletTopicMoreGroups(ctx context.Context, currentThemeID int64, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
themes := playletcache.ListEnabledThemesByPlayletCount(0)
selected := make([]*playletcache.ThemeItem, 0, limit)
for _, theme := range themes {
if theme == nil || theme.Id == currentThemeID {
continue
}
selected = append(selected, theme)
if int64(len(selected)) >= limit {
break
}
}
groups, _, playlets, err := buildPlayletTopicGroups(ctx, selected, int64(len(selected)), topicListSortHot, topicListPlayletLimit)
if err != nil {
return nil, nil, err
}
return groups, playlets, nil
}
// buildPlayletTopicGroups 查询专题卡片需要的短剧数据
func buildPlayletTopicGroups(ctx context.Context, themes []*playletcache.ThemeItem, totalCount int64, sortType, playletLimit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) {
result := make([]homeConfigThemePlaylets, 0, len(themes))
allPlaylets := make(playletmod.PlayletList, 0)
for _, theme := range themes {
if theme == nil {
continue
}
filter, sort := playletTopicPagePlayletQuery(sortType, theme.Id)
list, err := findHomePlaylets(ctx, filter, sort, playletLimit)
if err != nil {
return nil, 0, nil, err
}
if len(list) == 0 {
continue
}
result = append(result, homeConfigThemePlaylets{
Theme: theme,
Playlets: list,
})
allPlaylets = append(allPlaylets, list...)
}
return result, totalCount, allPlaylets, nil
}
// playletTopicPagePlayletQuery 构建专题卡片短剧查询条件
func playletTopicPagePlayletQuery(sortType int64, themeID int64) (bson.M, bson.D) {
filter := homeUpPlayletFilter(nil)
filter[playletmod.FN.Playlet.ThemeIds] = themeID
if sortType == topicListSortHot {
return filter, bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
return filter, bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// topicDetailPlayletFilter 构建专题详情和首页题材共用的短剧过滤条件
func topicDetailPlayletFilter(themeID int64) bson.M {
return homeUpPlayletFilter(bson.M{playletmod.FN.Playlet.ThemeIds: themeID})
}
func homeConfigCategoryID(sortType int32, configId int64) int64 {
if sortType == homeconfigmod.SortTypeCategory && configId > 0 {
return configId
}
return 0
}
func homeConfigPlayletQuery(sortType int32, categoryId int64) (bson.M, bson.D) {
filter := homeUpPlayletFilter(nil)
switch sortType {
case homeconfigmod.SortTypeRecommend:
filter[playletmod.FN.Playlet.IsRecommend] = true
return filter, playletTodayRecommendSort()
case homeconfigmod.SortTypeHot:
return filter, playletHitSeriesSort()
case homeconfigmod.SortTypeLatest:
return filter, playletRecentlyUpdatedSort()
case homeconfigmod.SortTypeOnlineAt:
return filter, playletTodayUpdateSort()
case homeconfigmod.SortTypeCategory:
if categoryId > 0 {
filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{categoryId}}
}
return filter, playletTodayUpdateSort()
default:
return filter, playletTodayUpdateSort()
}
}
// homeUpPlayletFilter 构建首页短剧上架过滤条件
func homeUpPlayletFilter(extra bson.M) bson.M {
filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}
for key, value := range extra {
filter[key] = value
}
return filter
}
// homePlayletCacheKey 获取app首页短剧缓存key
func homePlayletCacheKey() string {
return redisconst.AppPlayletHomeCacheDataKey(homePlayletCacheVersion() + ":" + homePlayletCacheSchema)
}
// homePlayletCacheVersion 获取app首页短剧缓存版本
func homePlayletCacheVersion() string {
if redis.Client() == nil {
return "0"
}
value, err := redis.Get(redisconst.AppPlayletHomeCacheVersionKey())
if err != nil {
log.Warn("读取app首页短剧缓存版本失败", log.E(err))
return "0"
}
if value == nil || strings.TrimSpace(*value) == "" {
return "0"
}
return strings.TrimSpace(*value)
}
// getHomePlayletCache 读取app首页短剧缓存
func getHomePlayletCache(cacheKey string) *proto.HomePlayletResponse {
if redis.Client() == nil || cacheKey == "" {
return nil
}
value, err := redis.Get(cacheKey)
if err != nil {
log.Warn("读取app首页短剧缓存失败", log.E(err))
return nil
}
if value == nil || *value == "" {
return nil
}
var resp proto.HomePlayletResponse
if err = json.Unmarshal([]byte(*value), &resp); err != nil {
log.Warn("解析app首页短剧缓存失败", log.E(err))
return nil
}
return &resp
}
// setHomePlayletCache 写入app首页短剧缓存
func setHomePlayletCache(cacheKey string, resp *proto.HomePlayletResponse) {
if redis.Client() == nil || cacheKey == "" || resp == nil {
return
}
data, err := json.Marshal(resp)
if err != nil {
log.Warn("序列化app首页短剧缓存失败", log.E(err))
return
}
if err = redis.Set(cacheKey, data, redisconst.AppPlayletHomeCacheExpire); err != nil {
log.Warn("写入app首页短剧缓存失败", log.E(err))
}
}
// findHomePlaylets 查询首页分区或列表短剧数据
func findHomePlaylets(ctx context.Context, filter bson.M, sort bson.D, limit int64, extraOpts ...*options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) {
opt := options.Find().
SetProjection(playletHomeProjection).
SetSort(sort).
SetLimit(limit)
for _, extra := range extraOpts {
if extra == nil {
continue
}
if extra.Skip != nil {
opt.SetSkip(*extra.Skip)
}
}
return playletmod.FindManyPlaylet(ctx, filter, opt)
}
// mergeHomePlayletLists 合并首页各分区短剧,便于后续批量查询题材和标签名称
func mergeHomePlayletLists(lists ...playletmod.PlayletList) playletmod.PlayletList {
total := 0
for _, list := range lists {
total += len(list)
}
result := make(playletmod.PlayletList, 0, total)
for _, list := range lists {
result = append(result, list...)
}
return result
}
// homeConfigsToHomeData 转换首页配置响应数据
func homeConfigsToHomeData(ctx context.Context, list homeconfigmod.HomeConfigList, configPlayletMap map[int64]playletmod.PlayletList, configThemeMap map[int64][]homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigData {
result := make([]*proto.HomeConfigData, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
if item.SortType == homeconfigmod.SortTypeCategory {
if item.Type == homeconfigmod.TypePlaylet && len(configPlayletMap[item.Id]) == 0 {
continue
}
}
data := &proto.HomeConfigData{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Desc: item.Desc,
Keyword: homeconfigmod.NormalizeKeywords(item.Keyword),
Label: item.Label,
Style: item.Style,
CardBackgroundImage: withImageDomain(ctx, item.CardBackgroundImage),
TopicIds: item.TopicIds,
CategoryId: item.CategoryId,
RecommendType: item.RecommendType,
Type: item.Type,
Rank: item.Rank,
Limit: homeConfigDisplayLimit(item),
SortType: item.SortType,
Status: item.Status,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
List: playletsToHomeCommonData(ctx, configPlayletMap[item.Id], themeNameMap, tagNameMap),
ThemeList: make([]*proto.HomeConfigThemeData, 0),
SeoInfo: item.SeoInfo,
}
switch item.Type {
case homeconfigmod.TypeTheme:
data.List = make([]*proto.CommonData, 0)
data.ThemeList = homeConfigThemesToHomeData(ctx, configThemeMap[item.Id], themeNameMap, tagNameMap)
}
result = append(result, data)
}
return result
}
// homeConfigThemesToHomeData 转换首页配置题材响应数据
func homeConfigThemesToHomeData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigThemeData {
result := make([]*proto.HomeConfigThemeData, 0, len(groups))
for _, group := range groups {
if group.Theme == nil || len(group.Playlets) == 0 {
continue
}
list := playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap)
result = append(result, &proto.HomeConfigThemeData{
Id: group.Theme.Id,
Name: group.Theme.Name,
Slug: group.Theme.Slug,
Icon: withImageDomain(ctx, group.Theme.Icon),
Rank: group.Theme.Rank,
PlayletCount: group.TotalCount,
List: list,
SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)),
})
}
return result
}
// playletTopicsToPageData 转换专题剧单分页响应
func playletTopicsToPageData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.PlayletTopicPageItem {
result := make([]*proto.PlayletTopicPageItem, 0, len(groups))
for _, group := range groups {
if group.Theme == nil || len(group.Playlets) == 0 {
continue
}
list := playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap)
result = append(result, &proto.PlayletTopicPageItem{
Id: group.Theme.Id,
Name: group.Theme.Name,
Slug: group.Theme.Slug,
Desc: strings.TrimSpace(group.Theme.SeoInfo.Description),
Icon: withImageDomain(ctx, group.Theme.Icon),
Rank: group.Theme.Rank,
PlayletCount: int64(len(list)),
Covers: playletTopicCovers(list),
List: list,
SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)),
})
}
return result
}
// playletTopicCovers 提取专题卡片封面
func playletTopicCovers(list []*proto.CommonData) []string {
result := make([]string, 0, len(list))
for _, item := range list {
if item == nil || strings.TrimSpace(item.Cover) == "" {
continue
}
result = append(result, item.Cover)
}
return result
}
// buildHomeThemeNameMap 从缓存批量取首页短剧题材名称
func buildHomeThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) {
themeIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 {
return item.ThemeIds
})
return playletcache.ThemeNameMap(themeIds), nil
}
// buildHomeTagNameMap 从缓存批量取首页短剧标签名称
func buildHomeTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) {
tagIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 {
return item.TagIds
})
return playletcache.TagNameMap(tagIds), nil
}
// collectHomeIds 汇总并去重首页短剧的题材或标签ID
func collectHomeIds(list playletmod.PlayletList, pick func(item *playletmod.Playlet) []int64) []int64 {
result := make([]int64, 0)
seen := make(map[int64]struct{})
for _, item := range list {
if item == nil {
continue
}
for _, id := range pick(item) {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
}
return result
}
// playletsToHomeCommonData 转换首页通用短剧响应数据
func playletsToHomeCommonData(ctx context.Context, list playletmod.PlayletList, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.CommonData {
result := make([]*proto.CommonData, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
if len(item.Description) > 0 {
item.SeoInfo.Description = item.Description
}
result = append(result, &proto.CommonData{
Id: item.Id,
Status: int32(item.Status),
IsFeatured: item.IsFeatured,
IsOriginal: item.IsOriginal || item.MediaCenterId == 0,
ShowOriginal: item.ShowOriginal,
IsRecommend: item.IsRecommend,
IsNew: isOnlineThisWeek(item.OnlineAt),
OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)),
ShowOnBanner: item.ShowOnBanner,
Banner: withImageDomain(ctx, item.Banner),
MobileBanner: withImageDomain(ctx, item.MobileBanner),
OnlineAt: item.OnlineAt,
MediaCreatedAt: item.MediaCreatedAt,
MediaUpdatedAt: item.MediaUpdatedAt,
ThemeIds: item.ThemeIds,
ThemeNames: namesByIds(item.ThemeIds, themeNameMap),
ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)),
CategoryIds: item.CategoryIds,
Title: item.Title,
PlayName: item.PlayName,
Description: item.Description,
Slug: item.Slug,
Desc: item.Desc,
Anchor: item.Anchor,
Cover: withImageDomain(ctx, item.DisplayCover()),
HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()),
Preview: item.Preview,
TagIds: item.TagIds,
TagNames: namesByIds(item.TagIds, tagNameMap),
TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.CountBrowse,
HeatValue: item.HeatValue,
CountCollect: item.CountCollect,
CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
SeoInfo: item.SeoInfo,
})
}
return result
}
// slugsByIds 按ID顺序返回 slug
func slugsByIds(ids []int64, slugMap map[int64]string) []string {
result := make([]string, 0, len(ids))
for _, id := range ids {
if slug := slugMap[id]; slug != "" {
result = append(result, slug)
}
}
return result
}
// namesByIds 按ID顺序返回名称
func namesByIds(ids []int64, nameMap map[int64]string) []string {
result := make([]string, 0, len(ids))
for _, id := range ids {
if name := nameMap[id]; name != "" {
result = append(result, name)
}
}
return result
}
// isOnlineThisWeek 判断短剧是否为本周内上架,用于app端展示“上新”标识。
func isOnlineThisWeek(onlineAt time.Time) bool {
if onlineAt.IsZero() {
return false
}
now := time.Now()
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
weekStart := time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
return !onlineAt.Before(weekStart) && !onlineAt.After(now)
}
// commonDataValues 将短剧指针列表转换为响应值列表
func commonDataValues(list []*proto.CommonData) []proto.CommonData {
result := make([]proto.CommonData, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
result = append(result, *item)
}
return result
}
// playletAllListFilter 构建全部短剧分页过滤条件
func playletAllListFilter(req *proto.PlayletAllListReq) (bson.M, *srvdef.SrvErr) {
filter := homeUpPlayletFilter(nil)
if req.ThemeId > 0 {
filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{req.ThemeId}}
}
switch req.UpdateStatus {
case 0:
case playletmod.UpdateStatusSerial, playletmod.UpdateStatusDone:
filter[playletmod.FN.Playlet.UpdateStatus] = req.UpdateStatus
default:
return nil, srvdef.New(srvdef.ErrParamError, "更新状态无效", "更新状态无效")
}
return filter, nil
}
// playletAllListSort 构建全部短剧分页排序
func playletAllListSort(sortType int64) bson.D {
switch sortType {
case allListSortHot:
return bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case allListSortCollect:
return bson.D{
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case allListSortLatest:
fallthrough
default:
return bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
}
// playletTopicDetailSort 构建专题详情短剧排序
func playletTopicDetailSort(sortType int64) bson.D {
switch sortType {
case topicDetailSortLatest:
return bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case topicDetailSortHot:
return bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case topicDetailSortEdit:
fallthrough
default:
return bson.D{
{Key: playletmod.FN.Playlet.TopRank, Value: -1},
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
}
// playletSearchQuery 构建短剧ES搜索查询条件
func playletSearchQuery(keyword string, from int64, size int64) esmanager.M {
return esmanager.M{
"from": from,
"size": size,
"query": esmanager.M{
"bool": esmanager.M{
"must": esmanager.A{
{
"bool": esmanager.M{
"should": esmanager.A{
{
"multi_match": esmanager.M{
"query": keyword,
"fields": playletSearchFields(),
"type": "phrase",
"boost": 3,
},
},
{
// 与剧名入库的 ik_max_word 一致。查询默认的 ik_smart 会把长标题收成索引里没有的整词。
// 1~2 个词仍需全部命中;更长的标题至少命中 75%,避免括号和数字切出的多余词把结果清掉。
"multi_match": esmanager.M{
"query": keyword,
"fields": playletSearchFields(),
"type": "best_fields",
"analyzer": esmanager.AnalyzerIkMaxWord,
"minimum_should_match": "2<75%",
},
},
},
"minimum_should_match": 1,
},
},
},
"filter": esmanager.A{
{
"term": esmanager.M{
esmod.FN.ESPlaylet.Status: int32(playletmod.StatusUp),
},
},
},
},
},
"sort": esmanager.A{
{
"_score": esmanager.M{"order": "desc"},
},
{
esmod.FN.ESPlaylet.OnlineAt: esmanager.M{"order": "desc"},
},
{
esmod.FN.ESPlaylet.Id: esmanager.M{"order": "desc"},
},
},
}
}
// playletSearchFields 返回短剧ES搜索字段和权重
func playletSearchFields() []string {
// 剧名权重最高,题材名和标签名次之,简介兜底命中。
return []string{
esmod.FN.ESPlaylet.Title + "^3",
esmod.FN.ESPlaylet.PlayName + "^3",
esmod.FN.ESPlaylet.ThemeNames + "^2",
esmod.FN.ESPlaylet.TagNames + "^2",
esmod.FN.ESPlaylet.Desc,
}
}
// loadESPlayletLabels 从Mongo批量读取标识,兼容尚未重建的ES文档
func loadESPlayletLabels(ctx context.Context, hits []*esmod.ESPlayletSource) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
labels := make(map[int64]*playletmod.Playlet, len(hits))
ids := make([]int64, 0, len(hits))
for _, hit := range hits {
if hit != nil && hit.Source != nil {
ids = append(ids, hit.Source.Id)
}
}
if len(ids) == 0 {
return labels, nil
}
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
{Key: playletmod.FN.Playlet.ShowOriginal, Value: 1},
}))
if err != nil {
return nil, err
}
for _, item := range list {
if item != nil {
labels[item.Id] = item
}
}
return labels, nil
}
// esPlayletToCommonData 转换ES短剧文档为app通用短剧响应
func esPlayletToCommonData(ctx context.Context, item *esmod.ESPlaylet, label *playletmod.Playlet) proto.CommonData {
isOriginal, showOriginal := false, false
if label != nil {
isOriginal = label.IsOriginal || label.MediaCenterId == 0
showOriginal = label.ShowOriginal
}
return proto.CommonData{
Id: item.Id,
Status: int32(item.Status),
IsFeatured: item.IsFeatured,
IsOriginal: isOriginal,
ShowOriginal: showOriginal,
IsRecommend: item.IsRecommend,
IsNew: isOnlineThisWeek(item.OnlineAt),
OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)),
ShowOnBanner: item.ShowOnBanner,
Banner: withImageDomain(ctx, item.Banner),
OnlineAt: item.OnlineAt,
ThemeIds: item.ThemeIds,
ThemeNames: item.ThemeNames,
ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)),
CategoryIds: item.CategoryIds,
Title: item.Title,
PlayName: item.PlayName,
Description: item.Description,
Slug: item.Slug,
Desc: item.Desc,
Anchor: item.Anchor,
Cover: withImageDomain(ctx, item.Cover),
TagIds: item.TagIds,
TagNames: item.TagNames,
TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.CountBrowse,
HeatValue: item.HeatValue,
CountCollect: item.CountCollect,
CountLike: item.CountLike,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
SeoInfo: item.SeoInfo,
}
}
// playletRankSort 构建app短剧排行榜排序
func playletRankSort(sortOrder int64) bson.D {
switch sortOrder {
case rankHot:
return bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case rankNew:
return bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
case rankCollect:
return bson.D{
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
default:
return bson.D{
{Key: playletmod.FN.Playlet.HeatValue, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
}
// normalizePlayletHotRankType 规范化热榜类型
func normalizePlayletHotRankType(rankType int64) int64 {
if rankType == 0 {
return rankHot
}
switch rankType {
case rankHot, rankNew, rankCollect:
return rankType
default:
return 0
}
}
// playletHotRankSort 构建热榜排序
func playletHotRankSort(rankType int64) bson.D {
return playletRankSort(rankType)
}
// playletTodayRecommendSort 构建今日推荐短剧排序
func playletTodayRecommendSort() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// playletHitSeriesSort 构建热门好剧短剧排序
func playletHitSeriesSort() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.CountBrowse, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// playletRecentlyUpdatedSort 按短剧记录更新时间排序。
func playletRecentlyUpdatedSort() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// playletTodayUpdateSort 构建今日更新短剧排序
func playletTodayUpdateSort() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
}
// normalizePlayletPage 规范化短剧分页参数
func normalizePlayletPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// normalizePlayletGuessLikePage 规范化猜你喜欢分页参数
func normalizePlayletGuessLikePage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = guessLikePageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// normalizePlayletHotRankPage 规范化热榜分页参数
func normalizePlayletHotRankPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = hotRankPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// normalizePlayletTopicDetailPage 规范化专题详情分页参数
func normalizePlayletTopicDetailPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = topicDetailPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// normalizePlayletHistoryPage 规范化观看历史分页参数
func normalizePlayletHistoryPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = recommendPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// withImageDomain 给图片相对路径拼接图片域名,海外 IP 使用 imgAppUsCdn。
func withImageDomain(ctx context.Context, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
imageDomain := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
if imageDomain == "" {
return path
}
return netutil.MergeURL(imageDomain, path)
}