package srv_playlet import ( "context" "math/rand" "regexp" "strings" "time" "videoweb/pub/common/log" "videoweb/pub/common/response" "videoweb/pub/common/srvdef" "videoweb/pub/models/playletchaptercollectmod" "videoweb/pub/models/playletchapterlikemod" "videoweb/pub/models/playletchaptermod" "videoweb/pub/models/playletchapterunlockmod" "videoweb/pub/models/playletmod" "videoweb/pub/models/playlettagmod" "videoweb/pub/models/playletthememod" "videoweb/pub/pubdef" "videoweb/web/api/proto" "videoweb/web/service/srv_aicomment" "videoweb/web/service/srv_mediaresource" "videoweb/web/service/srv_search" "videoweb/web/skd/playletwatermark" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) const ( defaultPageSize int64 = 10 maxPageSize int64 = 100 ) // ListPlaylet 分页查询短剧列表 func ListPlaylet(ctx context.Context, req *proto.PlayletListReq) (*proto.PlayletListPageResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } filter := buildPlayletListFilter(req) pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) totalCount, err := playletmod.CountPlaylet(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletListPageResp{ BasePageResp: response.BasePageResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]*proto.PlayletListResp, 0), }, } if totalCount == 0 { return resp, nil } var list playletmod.PlayletList if req.CategoryId != nil && *req.CategoryId > 0 && req.SortType == proto.PlayletListSortRecommendRank { list, err = playletmod.FindManyPlayletByCategoryRank(ctx, filter, *req.CategoryId, (pageNum-1)*pageSize, pageSize, playletmod.CategoryRankAdminSort(), nil) } else { list, err = playletmod.FindManyPlaylet(ctx, filter, options.Find(). SetSort(buildPlayletListSort(req.SortType)). SetSkip((pageNum-1)*pageSize). SetLimit(pageSize)) } if err != nil { return nil, err } themeNameMap, err := buildThemeNameMap(ctx, list) if err != nil { return nil, err } tagNameMap, err := buildTagNameMap(ctx, list) if err != nil { return nil, err } items := make([]*proto.PlayletListResp, 0, len(list)) for _, item := range list { if item == nil { continue } items = append(items, playletToWebResp(item, themeNameMap, tagNameMap, req.CategoryId)) } resp.List = items return resp, nil } // AddPlaylet 新增短剧 func AddPlaylet(ctx context.Context, req *proto.PlayletAddReq) (*proto.PlayletAddResp, *srvdef.SrvErr) { if req == nil || strings.TrimSpace(req.Title) == "" { return nil, srvdef.New(srvdef.ErrParamError, "标题不能为空", "") } themeIds := dedupePositiveInt64Ids(req.ThemeIds) if len(themeIds) == 0 { return nil, srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") } if err := validatePlayletThemeIds(ctx, themeIds); err != nil { return nil, err } banner := strings.TrimSpace(req.Banner) if req.ShowOnBanner && banner == "" { return nil, srvdef.New(srvdef.ErrParamError, "显示在banner时,banner图不能为空", "") } if err := validatePlayletTagIds(ctx, req.TagIds); err != nil { return nil, err } categoryBinds, err := resolveAddPlayletCategoryBinds(req.CategoryBinds, req.CategoryIds) if err != nil { return nil, err } payType, singleCoins, srvErr := normalizePlayletPaySetting(req.PayType, &req.SingleCoins, true) if srvErr != nil { return nil, srvErr } operateType, srvErr := normalizePlayletOperateType(req.OperateType, true) if srvErr != nil { return nil, srvErr } doc := &playletmod.Playlet{ ThemeIds: themeIds, CategoryIds: playletmod.CategoryIdsFromBinds(categoryBinds), CategoryBinds: categoryBinds, Title: strings.TrimSpace(req.Title), PlayName: strings.TrimSpace(req.PlayName), Slug: strings.TrimSpace(req.Slug), Desc: strings.TrimSpace(req.Desc), Anchor: strings.TrimSpace(req.Anchor), Cover: req.Cover, HorizontalCover: strings.TrimSpace(req.HorizontalCover), Preview: strings.TrimSpace(req.Preview), TagIds: req.TagIds, ContentType: req.ContentType, OperateType: operateType, PayType: payType, SingleCoins: singleCoins, TotalEpisode: req.TotalEpisode, TopRank: req.TopRank, RecommendRank: req.RecommendRank, Status: req.Status, IsFeatured: req.IsFeatured, IsOriginal: true, // 管理后台新增的短剧都是原创 ShowOriginal: req.ShowOriginal, IsRecommend: req.IsRecommend, ShowOnBanner: req.ShowOnBanner, Banner: banner, MobileBanner: strings.TrimSpace(req.MobileBanner), SeoInfo: normalizeSeoValue(req.SeoInfo), } applyPlayletDescription(doc) if doc.Status == playletmod.StatusUp { doc.OnlineAt = resolvePlayletOnlineAt(req.OnlineAt, time.Now()) } if doc.TagIds == nil { doc.TagIds = make([]int64, 0) } if doc.CategoryBinds == nil { doc.CategoryBinds = make([]playletmod.PlayletCategoryBind, 0) } doc.CategoryIds = playletmod.CategoryIdsFromBinds(doc.CategoryBinds) applyPlayletOperatorToDoc(ctx, doc) if err := ensurePlayletSlug(ctx, doc, 0); err != nil { return nil, err } if err := playletmod.InsertOnePlaylet(ctx, doc); err != nil { return nil, err } go func() { srv_mediaresource.FillPlayletCompressedCover(context.Background(), doc) srv_mediaresource.FillPlayletCompressedHorizontalCover(context.Background(), doc) }() clearAppPlayletHomeCache() notifyPlayletSeoAfterWrite(ctx, doc.Id, nil) // 上架短剧异步写入 ES(含 SEO),避免阻塞后台接口。 if doc.Status == playletmod.StatusUp { syncId := doc.Id submitPlayletAiComments(syncId) go func() { if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{syncId}, playletmod.StatusUp); err != nil { log.Error("短剧新增同步ES失败", log.Any("短剧ID", syncId), log.E(err)) } }() } return &proto.PlayletAddResp{Id: doc.Id}, nil } // UpdatePlaylet 更新短剧 func UpdatePlaylet(ctx context.Context, req *proto.PlayletUpdateReq) *srvdef.SrvErr { if req == nil || req.Id <= 0 { return srvdef.New(srvdef.ErrParamError, "id无效", "") } if req.ShowOnBanner != nil || req.Banner != nil { exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } showOnBanner := exist.ShowOnBanner banner := exist.Banner if req.ShowOnBanner != nil { showOnBanner = *req.ShowOnBanner } if req.Banner != nil { banner = strings.TrimSpace(*req.Banner) } if showOnBanner && banner == "" { return srvdef.New(srvdef.ErrParamError, "显示在banner时,banner图不能为空", "") } } exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } setDoc := bson.M{playletmod.FN.Playlet.UpdatedAt: time.Now()} if req.Status != nil { setDoc[playletmod.FN.Playlet.Status] = *req.Status setDoc[playletmod.FN.Playlet.ReviewAt] = time.Now() } if req.IsFeatured != nil { setDoc[playletmod.FN.Playlet.IsFeatured] = *req.IsFeatured } if req.ShowOriginal != nil { setDoc[playletmod.FN.Playlet.ShowOriginal] = *req.ShowOriginal } if req.IsRecommend != nil { setDoc[playletmod.FN.Playlet.IsRecommend] = *req.IsRecommend } if req.ShowOnBanner != nil { setDoc[playletmod.FN.Playlet.ShowOnBanner] = *req.ShowOnBanner } if req.Banner != nil { setDoc[playletmod.FN.Playlet.Banner] = strings.TrimSpace(*req.Banner) } if req.MobileBanner != nil { setDoc[playletmod.FN.Playlet.MobileBanner] = strings.TrimSpace(*req.MobileBanner) } if req.ThemeIds != nil { themeIds := dedupePositiveInt64Ids(*req.ThemeIds) if len(themeIds) == 0 { return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") } if err := validatePlayletThemeIds(ctx, themeIds); err != nil { return err } setDoc[playletmod.FN.Playlet.ThemeIds] = themeIds } if req.CategoryBinds != nil || req.CategoryIds != nil { categoryBinds, serr := resolvePlayletCategoryBinds(req.CategoryBinds, req.CategoryIds, playletmod.EffectiveCategoryBinds(exist)) if serr != nil { return serr } setDoc[playletmod.FN.Playlet.CategoryBinds] = categoryBinds setDoc[playletmod.FN.Playlet.CategoryIds] = playletmod.CategoryIdsFromBinds(categoryBinds) } if req.Title != nil { setDoc[playletmod.FN.Playlet.Title] = strings.TrimSpace(*req.Title) } if req.PlayName != nil { setDoc[playletmod.FN.Playlet.PlayName] = strings.TrimSpace(*req.PlayName) } if req.Desc != nil { setDoc[playletmod.FN.Playlet.Desc] = strings.TrimSpace(*req.Desc) } if req.Anchor != nil { setDoc[playletmod.FN.Playlet.Anchor] = strings.TrimSpace(*req.Anchor) } coverChanged := false horizontalCoverChanged := false if req.Cover != nil { newCover := strings.TrimSpace(*req.Cover) setDoc[playletmod.FN.Playlet.Cover] = newCover exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } if exist == nil || strings.TrimSpace(exist.Cover) != newCover { // 封面变更后清空压缩封面,由后台任务按新原图重新压缩。 setDoc[playletmod.FN.Playlet.CompressedCover] = "" coverChanged = true } } if req.HorizontalCover != nil { newHorizontalCover := strings.TrimSpace(*req.HorizontalCover) setDoc[playletmod.FN.Playlet.HorizontalCover] = newHorizontalCover exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } if exist == nil || strings.TrimSpace(exist.HorizontalCover) != newHorizontalCover { setDoc[playletmod.FN.Playlet.CompressedHorizontalCover] = "" horizontalCoverChanged = true } else if newHorizontalCover != "" && strings.TrimSpace(exist.CompressedHorizontalCover) == "" { horizontalCoverChanged = true } } if req.Preview != nil { setDoc[playletmod.FN.Playlet.Preview] = strings.TrimSpace(*req.Preview) } if req.TagIds != nil { if err := validatePlayletTagIds(ctx, *req.TagIds); err != nil { return err } setDoc[playletmod.FN.Playlet.TagIds] = *req.TagIds } if req.ContentType != nil { setDoc[playletmod.FN.Playlet.ContentType] = *req.ContentType } if req.OperateType != nil { operateType, srvErr := normalizePlayletOperateType(*req.OperateType, false) if srvErr != nil { return srvErr } setDoc[playletmod.FN.Playlet.OperateType] = operateType } if req.PayType != nil || req.SingleCoins != nil { existPayType := playletmod.PayTypeFree existCoins := int64(0) if req.PayType == nil || req.SingleCoins == nil { exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } existPayType = exist.PayType existCoins = exist.SingleCoins } payType := existPayType if req.PayType != nil { payType = *req.PayType } coins := &existCoins if req.SingleCoins != nil { coins = req.SingleCoins } normalizedPayType, normalizedCoins, srvErr := normalizePlayletPaySetting(payType, coins, true) if srvErr != nil { return srvErr } setDoc[playletmod.FN.Playlet.PayType] = normalizedPayType setDoc[playletmod.FN.Playlet.SingleCoins] = normalizedCoins } if req.TotalEpisode != nil { setDoc[playletmod.FN.Playlet.TotalEpisode] = *req.TotalEpisode } if req.TopRank != nil { setDoc[playletmod.FN.Playlet.TopRank] = *req.TopRank } if req.RecommendRank != nil { setDoc[playletmod.FN.Playlet.RecommendRank] = *req.RecommendRank } if req.SeoInfo != nil { setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) } var prevStatus *playletmod.Status if exist != nil { st := exist.Status prevStatus = &st } applyPlayletOnlineAtOnUpdate(exist, req, setDoc, time.Now()) if err := preparePlayletSlugOnUpdate(ctx, req.Id, exist, req, setDoc); err != nil { return err } applyPlayletDescriptionOnUpdate(exist, req, setDoc) if len(setDoc) <= 1 { return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}, bson.M{"$set": setDoc}, ); err != nil { return err } if coverChanged { go func(playletId int64, cover string) { srv_mediaresource.FillPlayletCompressedCover(context.Background(), &playletmod.Playlet{ Id: playletId, Cover: cover, }) }(req.Id, strings.TrimSpace(*req.Cover)) } if horizontalCoverChanged { go func(playletId int64, cover string) { srv_mediaresource.FillPlayletCompressedHorizontalCover(context.Background(), &playletmod.Playlet{ Id: playletId, HorizontalCover: cover, }) }(req.Id, strings.TrimSpace(*req.HorizontalCover)) } clearAppPlayletHomeCache() notifyPlayletSeoAfterWrite(ctx, req.Id, prevStatus) if req.Status != nil && *req.Status == playletmod.StatusUp { submitPlayletAiComments(req.Id) } // 编辑后按当前上下架状态异步同步 ES(含 SEO、题材名等搜索字段)。 syncId := req.Id go func() { exist, err := playletmod.FindOnePlaylet(context.Background(), bson.M{ playletmod.FN.Playlet.Id: syncId, }, options.FindOne().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.Status, Value: 1}, })) if err != nil || exist == nil { if err != nil { log.Error("短剧更新后查询状态失败,跳过ES同步", log.Any("短剧ID", syncId), log.E(err)) } return } if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{syncId}, exist.Status); err != nil { log.Error("短剧更新同步ES失败", log.Any("短剧ID", syncId), log.Any("状态", exist.Status), log.E(err)) } }() return nil } // DelPlaylet 单/批量物理删除短剧及其章节 func DelPlaylet(ctx context.Context, req *proto.PlayletDelReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } 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.Status, Value: 1}, })) if err != nil { return err } if len(list) == 0 { return srvdef.New(srvdef.ErrNoData, "", "") } deleteIds := make([]int64, 0, len(list)) onlineIds := make([]int64, 0, len(list)) for _, item := range list { if item == nil || item.Id <= 0 { continue } deleteIds = append(deleteIds, item.Id) if item.Status == playletmod.StatusUp { onlineIds = append(onlineIds, item.Id) } } if len(deleteIds) == 0 { return srvdef.New(srvdef.ErrNoData, "", "") } deletedPlayletCount, err := playletmod.DelManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": deleteIds}, }) if err != nil { return err } if deletedPlayletCount == 0 { return srvdef.New(srvdef.ErrNoData, "", "") } if _, err := playletchaptermod.DelManyPlayletChapter(ctx, bson.M{ playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": deleteIds}, }); err != nil { return err } clearAppPlayletHomeCache() notifyPlayletSeoAfterDelete(ctx, onlineIds) syncIds := append([]int64(nil), deleteIds...) go func() { if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusDown); err != nil { log.Error("短剧物理删除同步ES失败", log.Any("短剧ID", syncIds), log.E(err)) } }() return nil } // SetPlayletStatus 单/批量设置短剧上下架 func SetPlayletStatus(ctx context.Context, req *proto.PlayletStatusSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } if !isValidPlayletStatus(req.Status) { return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") } now := time.Now() setDoc := bson.M{ playletmod.FN.Playlet.Status: req.Status, playletmod.FN.Playlet.ReviewAt: now, playletmod.FN.Playlet.UpdatedAt: now, } if req.Status == playletmod.StatusUp { // 传入上架时间则整批使用该时间。未传时不在这里覆盖,避免改掉已有上架时间。 if onlineAt, ok := explicitPlayletOnlineAt(req.OnlineAt); ok { setDoc[playletmod.FN.Playlet.OnlineAt] = onlineAt } setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) } applyPlayletOperatorToSetDoc(ctx, setDoc) // 单条和批量都走 Mongo 批量更新,避免批量操作时逐条访问数据库。 if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } if req.Status == playletmod.StatusUp { if _, ok := explicitPlayletOnlineAt(req.OnlineAt); !ok { if err := fillUnsetPlayletOnlineAt(ctx, ids, now); err != nil { return err } } } clearAppPlayletHomeCache() notifyPlayletSeoAfterStatus(ctx, ids, req.Status, now) // 上架会同步写入 ES,ES Bulk 带 refresh 且无超时,同步等待会把接口卡住。 // Mongo 已更新成功后异步同步 ES,避免 status=1 时接口无响应。 syncIds := append([]int64(nil), ids...) syncStatus := req.Status go func() { if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, syncStatus); err != nil { log.Error("短剧状态同步ES失败", log.Any("短剧ID", syncIds), log.Any("状态", syncStatus), log.E(err)) } }() if req.Status == playletmod.StatusUp { submitPlayletAiComments(ids...) } return nil } func submitPlayletAiComments(ids ...int64) { playletIDs := dedupePositiveInt64Ids(ids) if len(playletIDs) == 0 { return } go func() { if err := srv_aicomment.AutoProcessUnsubmittedPlaylets(context.Background(), playletIDs); err != nil { log.Warn("提交短剧自动评论任务失败", log.E(err), log.Any("playletIDs", playletIDs)) } }() } // SetPlayletRecommend 单/批量设置短剧是否推荐 func SetPlayletRecommend(ctx context.Context, req *proto.PlayletRecommendSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } // isRecommend 是明确的 bool 开关,false 表示取消推荐。 setDoc := bson.M{ playletmod.FN.Playlet.IsRecommend: req.IsRecommend, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() return nil } // SetPlayletFeatured 单/批量设置短剧是否爆剧 func SetPlayletFeatured(ctx context.Context, req *proto.PlayletFeaturedSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } // isFeatured 是爆剧开关,直接批量写入请求值,false 表示取消爆剧。 setDoc := bson.M{ playletmod.FN.Playlet.IsFeatured: req.IsFeatured, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() return nil } // SetPlayletOperateType 单/批量设置短剧运营类型 func SetPlayletOperateType(ctx context.Context, req *proto.PlayletOperateTypeSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } operateType, srvErr := normalizePlayletOperateType(req.OperateType, false) if srvErr != nil { return srvErr } setDoc := bson.M{ playletmod.FN.Playlet.OperateType: operateType, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() return nil } // SetPlayletBanner 单/批量设置短剧是否显示在首页banner func SetPlayletBanner(ctx context.Context, req *proto.PlayletBannerSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } // showOnBanner 是首页banner展示开关,只负责批量设置字段值。 setDoc := bson.M{ playletmod.FN.Playlet.ShowOnBanner: req.ShowOnBanner, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() return nil } // SetPlayletTheme 单/批量设置短剧题材 func SetPlayletTheme(ctx context.Context, req *proto.PlayletThemeSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } themeIds := dedupePositiveInt64Ids(req.ThemeIds) if len(themeIds) == 0 { return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") } if err := validatePlayletThemeIds(ctx, themeIds); err != nil { return err } // themeIds 是短剧多题材字段,按请求覆盖原题材集合。 setDoc := bson.M{ playletmod.FN.Playlet.ThemeIds: themeIds, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() markSitemapDirty(ctx) return nil } // SetPlayletTag 单/批量设置短剧标签 func SetPlayletTag(ctx context.Context, req *proto.PlayletTagSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } if req.TagIds == nil { return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "") } tagIds, err := normalizePlayletTagIds(*req.TagIds) if err != nil { return err } if err := validatePlayletTagIds(ctx, tagIds); err != nil { return err } // tagIds 是短剧多标签字段,按请求覆盖原标签集合;空数组表示清空标签。 setDoc := bson.M{ playletmod.FN.Playlet.TagIds: tagIds, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() markSitemapDirty(ctx) return nil } // SetPlayletCategory 单/批量设置短剧分类 func SetPlayletCategory(ctx context.Context, req *proto.PlayletCategorySetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } if req.CategoryBinds == nil && req.CategoryIds == nil { return srvdef.New(srvdef.ErrParamError, "分类不能为空", "") } categoryBinds, err := resolvePlayletCategoryBinds(req.CategoryBinds, req.CategoryIds, nil) if err != nil { return err } setDoc := bson.M{ playletmod.FN.Playlet.CategoryBinds: categoryBinds, playletmod.FN.Playlet.CategoryIds: playletmod.CategoryIdsFromBinds(categoryBinds), playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) if err := playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ); err != nil { return err } clearAppPlayletHomeCache() markSitemapDirty(ctx) return nil } // SetPlayletVirtualLike 单/批量设置短剧虚拟点赞数 func SetPlayletVirtualLike(ctx context.Context, req *proto.PlayletVirtualLikeSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } // 去重并过滤无效ID,避免同一短剧在一次批量请求中被重复赋值。 ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } if req.MinVirtualCountLike < 0 || req.MaxVirtualCountLike < req.MinVirtualCountLike { return srvdef.New(srvdef.ErrParamError, "虚拟点赞数范围无效", "") } // 随机范围内可用数字数量必须覆盖短剧数量,才能保证每条短剧入库值不同。 if req.MaxVirtualCountLike-req.MinVirtualCountLike < int64(len(ids))-1 { return srvdef.New(srvdef.ErrParamError, "虚拟点赞数范围不足,无法保证每条短剧数值不同", "") } values := randomUniqueVirtualLikeValues(req.MinVirtualCountLike, req.MaxVirtualCountLike, len(ids)) now := time.Now() docs := make([]mongo.WriteModel, 0, len(ids)) for index, id := range ids { // 每个短剧使用一个独立随机值,展示点赞数由真实点赞数+虚拟点赞数组成。 setDoc := bson.M{ playletmod.FN.Playlet.VirtualCountLike: values[index], playletmod.FN.Playlet.UpdatedAt: now, } applyPlayletOperatorToSetDoc(ctx, setDoc) docs = append(docs, mongo.NewUpdateOneModel(). SetFilter(bson.M{playletmod.FN.Playlet.Id: id}). SetUpdate(bson.M{"$set": setDoc})) } if err := playletmod.BulkWritePlaylet(ctx, docs); err != nil { return err } clearAppPlayletHomeCache() syncIds := append([]int64(nil), ids...) go func() { // 虚拟点赞数会写入 ES 的展示点赞数字段,后台保存成功后异步刷新 ES。 if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusUp); err != nil { log.Error("短剧虚拟点赞数同步ES失败", log.Any("短剧ID", syncIds), log.E(err)) } }() return nil } // randomUniqueVirtualLikeValues 从[min,max]范围内随机取count个不重复的虚拟点赞数 func randomUniqueVirtualLikeValues(min int64, max int64, count int) []int64 { if count <= 0 { return nil } rng := rand.New(rand.NewSource(time.Now().UnixNano())) result := make([]int64, 0, count) selected := make(map[int64]struct{}, count) start := max - int64(count) + 1 for offset := int64(0); offset < int64(count); offset++ { current := start + offset picked := min + rng.Int63n(current-min+1) // 使用Floyd采样算法,避免在大范围随机时为了去重反复重试。 if _, ok := selected[picked]; ok { selected[current] = struct{}{} result = append(result, current) continue } selected[picked] = struct{}{} result = append(result, picked) } rng.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) return result } // ListPlayletChapter 分页查询章节列表 func ListPlayletChapter(ctx context.Context, req *proto.PlayletChapterListReq) (*proto.PlayletChapterListPageResp, *srvdef.SrvErr) { if req == nil { return nil, srvdef.New(srvdef.ErrParamError, "", "") } filter := bson.M{} if req.Id != nil && *req.Id > 0 { filter[playletchaptermod.FN.PlayletChapter.Id] = *req.Id } if req.PlayletId != nil && *req.PlayletId > 0 { filter[playletchaptermod.FN.PlayletChapter.PlayletId] = *req.PlayletId } if req.Status != nil { filter[playletchaptermod.FN.PlayletChapter.Status] = *req.Status } if req.WatermarkStatus != nil { filter[playletchaptermod.FN.PlayletChapter.WatermarkStatus] = *req.WatermarkStatus } pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) totalCount, err := playletchaptermod.CountPlayletChapter(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletChapterListPageResp{ BasePageResp: response.BasePageResp{ TotalCount: totalCount, PageSize: int(pageSize), PageNum: int(pageNum), List: make([]*proto.PlayletChapterListResp, 0), }, } if totalCount == 0 { return resp, nil } list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). SetSort(bson.D{ {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, }). SetSkip((pageNum-1)*pageSize). SetLimit(pageSize)) if err != nil { return nil, err } items := make([]*proto.PlayletChapterListResp, 0, len(list)) for _, item := range list { if item == nil { continue } items = append(items, chapterToWebResp(item)) } resp.List = items return resp, nil } // AddPlayletChapter 新增章节 func AddPlayletChapter(ctx context.Context, req *proto.PlayletChapterAddReq) (*proto.PlayletChapterAddResp, *srvdef.SrvErr) { if req == nil || req.PlayletId <= 0 || req.CurrentEpisode <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "playletId/currentEpisode无效", "") } playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.PlayletId}) if err != nil { return nil, err } if playlet == nil || !playlet.IsOriginal { return nil, srvdef.New(srvdef.ErrParamError, "只有原创短剧支持新增剧集", "") } doc := &playletchaptermod.PlayletChapter{ PlayletId: req.PlayletId, CurrentEpisode: req.CurrentEpisode, Title: strings.TrimSpace(req.Title), Md5: req.Md5, Hash: req.Hash, Height: req.Height, Width: req.Width, Ratio: req.Ratio, VideoUrl: strings.TrimSpace(req.VideoUrl), MediaSize: req.MediaSize, MediaTime: req.MediaTime, PayType: req.PayType, SingleCoins: req.SingleCoins, Status: playletchaptermod.DefaultStatusByMediaSource(playlet.MediaSource), } if req.Status != nil { if !isValidPlayletChapterStatus(*req.Status) { return nil, srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") } doc.Status = *req.Status } mediaSourceId := strings.TrimSpace(req.MediaSourceId) if playlet.IsOriginal && (doc.VideoUrl != "" || mediaSourceId != "") { doc.WatermarkStatus = playletchaptermod.WatermarkStatusRegistered if mediaSourceId != "" { // 后台上传正片已在 media 创建原片,直接沿用该 ID,避免再 SyncMediaFile 重复处理。 doc.MediaSourceId = mediaSourceId } else { sourceID, regErr := playletwatermark.RegisterChapterSource(doc) if regErr != nil { log.Error("AddPlayletChapter RegisterChapterSource 失败", log.Any("playletId", req.PlayletId), log.Any("videoUrl", doc.VideoUrl), log.E(regErr)) } else { doc.MediaSourceId = sourceID } } } if err := playletchaptermod.InsertOnePlayletChapter(ctx, doc); err != nil { return nil, err } return &proto.PlayletChapterAddResp{Id: doc.Id}, nil } // UpdatePlayletChapter 更新章节 func UpdatePlayletChapter(ctx context.Context, req *proto.PlayletChapterUpdateReq) *srvdef.SrvErr { if req == nil || req.Id <= 0 { return srvdef.New(srvdef.ErrParamError, "id无效", "") } setDoc := bson.M{playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now()} if req.CurrentEpisode != nil { setDoc[playletchaptermod.FN.PlayletChapter.CurrentEpisode] = *req.CurrentEpisode } if req.Title != nil { setDoc[playletchaptermod.FN.PlayletChapter.Title] = strings.TrimSpace(*req.Title) } if req.Md5 != nil { setDoc[playletchaptermod.FN.PlayletChapter.Md5] = *req.Md5 } if req.Hash != nil { setDoc[playletchaptermod.FN.PlayletChapter.Hash] = *req.Hash } if req.Height != nil { setDoc[playletchaptermod.FN.PlayletChapter.Height] = *req.Height } if req.Width != nil { setDoc[playletchaptermod.FN.PlayletChapter.Width] = *req.Width } if req.Ratio != nil { setDoc[playletchaptermod.FN.PlayletChapter.Ratio] = *req.Ratio } if req.VideoUrl != nil { setDoc[playletchaptermod.FN.PlayletChapter.VideoUrl] = *req.VideoUrl } if req.MediaSize != nil { setDoc[playletchaptermod.FN.PlayletChapter.MediaSize] = *req.MediaSize } if req.MediaTime != nil { setDoc[playletchaptermod.FN.PlayletChapter.MediaTime] = *req.MediaTime } if req.PayType != nil { setDoc[playletchaptermod.FN.PlayletChapter.PayType] = *req.PayType } if req.SingleCoins != nil { setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = *req.SingleCoins } if req.Status != nil { if !isValidPlayletChapterStatus(*req.Status) { return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") } setDoc[playletchaptermod.FN.PlayletChapter.Status] = *req.Status } if len(setDoc) <= 1 { return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") } return playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: req.Id}, bson.M{"$set": setDoc}, ) } // DelPlayletChapter 单/批量删除章节,并清理该剧集的点赞、收藏、解锁记录。 func DelPlayletChapter(ctx context.Context, req *proto.PlayletChapterDelReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "章节ID不能为空", "") } filter := bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}} chapters, findErr := playletchaptermod.FindManyPlayletChapter(ctx, filter) if findErr != nil { return findErr } if len(chapters) == 0 { return srvdef.New(srvdef.ErrNoData, "", "") } playletIds := make([]int64, 0, len(chapters)) seenPlaylet := make(map[int64]struct{}, len(chapters)) for _, item := range chapters { if item == nil || item.PlayletId <= 0 { continue } if _, ok := seenPlaylet[item.PlayletId]; ok { continue } seenPlaylet[item.PlayletId] = struct{}{} playletIds = append(playletIds, item.PlayletId) } if len(playletIds) > 0 { playlets, playletErr := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, }, options.Find().SetProjection(bson.M{ playletmod.FN.Playlet.Id: 1, playletmod.FN.Playlet.IsOriginal: 1, })) if playletErr != nil { return playletErr } for _, item := range playlets { if item == nil || !item.IsOriginal { return srvdef.New(srvdef.ErrParamError, "只有原创短剧支持删除剧集", "") } } } count, err := playletchaptermod.DelManyPlayletChapter(ctx, filter) if err != nil { return err } if count == 0 { return srvdef.New(srvdef.ErrNoData, "", "") } if _, serr := playletchapterlikemod.DeleteManyPlayletChapterLike(ctx, bson.M{ playletchapterlikemod.FN.PlayletChapterLike.ChapterId: bson.M{"$in": ids}, }); serr != nil { log.Warn("删除剧集后清理点赞关系失败", log.Any("chapterIds", ids), log.E(serr)) } if _, serr := playletchaptercollectmod.DeleteManyPlayletChapterCollect(ctx, bson.M{ playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: bson.M{"$in": ids}, }); serr != nil { log.Warn("删除剧集后清理收藏关系失败", log.Any("chapterIds", ids), log.E(serr)) } if _, serr := playletchapterunlockmod.DeleteManyPlayletChapterUnlock(ctx, bson.M{ playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId: bson.M{"$in": ids}, }); serr != nil { log.Warn("删除剧集后清理解锁记录失败", log.Any("chapterIds", ids), log.E(serr)) } return nil } // FillPlayletChapterStatusUp 将未写入上架状态的历史剧集设为上架 func FillPlayletChapterStatusUp(ctx context.Context) (*proto.PlayletChapterStatusFillResp, *srvdef.SrvErr) { filter := bson.M{ "$or": []bson.M{ {playletchaptermod.FN.PlayletChapter.Status: bson.M{"$exists": false}}, {playletchaptermod.FN.PlayletChapter.Status: nil}, }, } count, err := playletchaptermod.CountPlayletChapter(ctx, filter) if err != nil { return nil, err } resp := &proto.PlayletChapterStatusFillResp{} if count == 0 { return resp, nil } if err := playletchaptermod.UpdateManyPlayletChapter(ctx, filter, bson.M{ "$set": bson.M{playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp}, }); err != nil { return nil, err } resp.UpdatedCount = count return resp, nil } // SetPlayletChapterStatus 单/批量设置章节上架状态 func SetPlayletChapterStatus(ctx context.Context, req *proto.PlayletChapterStatusSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "章节ID不能为空", "") } if !isValidPlayletChapterStatus(req.Status) { return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") } return playletchaptermod.UpdateManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}}, bson.M{"$set": bson.M{ playletchaptermod.FN.PlayletChapter.Status: req.Status, playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), }}, ) } // SetPlayletChapterPayType 单/批量设置章节支付方式 func SetPlayletChapterPayType(ctx context.Context, req *proto.PlayletChapterPayTypeSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "章节ID不能为空", "") } if !isValidPlayletChapterPayType(req.PayType) { return srvdef.New(srvdef.ErrParamError, "支付方式无效", "") } setDoc := bson.M{ playletchaptermod.FN.PlayletChapter.PayType: req.PayType, playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), } if req.PayType == playletchaptermod.PayTypeCoin { if req.SingleCoins == nil || *req.SingleCoins <= 0 { return srvdef.New(srvdef.ErrParamError, "金币支付时单集金币必须大于0", "") } setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = *req.SingleCoins } else { setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = int64(0) } return playletchaptermod.UpdateManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ) } // SetPlayletPayType 单/批量设置短剧默认支付方式(上架连载中新增剧集沿用) func SetPlayletPayType(ctx context.Context, req *proto.PlayletPayTypeSetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } payType, singleCoins, srvErr := normalizePlayletPaySetting(req.PayType, req.SingleCoins, true) if srvErr != nil { return srvErr } setDoc := bson.M{ playletmod.FN.Playlet.PayType: payType, playletmod.FN.Playlet.SingleCoins: singleCoins, playletmod.FN.Playlet.UpdatedAt: time.Now(), } applyPlayletOperatorToSetDoc(ctx, setDoc) return playletmod.UpdateManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, bson.M{"$set": setDoc}, ) } // ResetPlayletWatermark 按短剧ID批量重置其下全部剧集水印,等待定时任务重新加水印。 // 已有水印地址不清理,新水印完成回写时直接覆盖。 // 原创短剧保留 mediaSourceId / videoUrl / mediaTaskId,直接进入已登记后按原片重跑水印。 // 非原创清空 media 关联并回到未处理,由 SyncMediaFile 重新登记。 func ResetPlayletWatermark(ctx context.Context, req *proto.PlayletWatermarkResetReq) *srvdef.SrvErr { if req == nil { return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") } ids := dedupePositiveInt64Ids(req.Ids) if len(ids) == 0 { return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") } originalIds, otherIds, err := splitPlayletIdsByOriginal(ctx, ids) if err != nil { return err } now := time.Now() if resetErr := resetOriginalChaptersWatermark(ctx, originalIds, now); resetErr != nil { return resetErr } return resetChaptersWatermark(ctx, otherIds, playletchaptermod.WatermarkStatusPending, now) } func splitPlayletIdsByOriginal(ctx context.Context, ids []int64) (originalIds, otherIds []int64, err *srvdef.SrvErr) { originalList, err := playletmod.FindManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": ids}, playletmod.FN.Playlet.IsOriginal: true, }, options.Find().SetProjection(bson.M{playletmod.FN.Playlet.Id: 1})) if err != nil { return nil, nil, err } originalSet := make(map[int64]struct{}, len(originalList)) for _, item := range originalList { if item == nil || item.Id <= 0 { continue } originalSet[item.Id] = struct{}{} originalIds = append(originalIds, item.Id) } for _, id := range ids { if _, ok := originalSet[id]; !ok { otherIds = append(otherIds, id) } } return originalIds, otherIds, nil } func resetOriginalChaptersWatermark(ctx context.Context, playletIds []int64, now time.Time) *srvdef.SrvErr { if len(playletIds) == 0 { return nil } // 原创剧集保留 mediaSourceId / videoUrl / mediaTaskId,以及已有水印地址。 // 新水印完成回写时会直接覆盖 WatermarkVideoUrl。 err := playletchaptermod.UpdateManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": playletIds}}, bson.M{ "$set": bson.M{ playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, playletchaptermod.FN.PlayletChapter.UpdatedAt: now, }, "$unset": bson.M{ playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: "", }, }, ) if err != nil && err.Code == srvdef.ErrNoData { return nil } return err } func resetChaptersWatermark(ctx context.Context, playletIds []int64, status playletchaptermod.WatermarkStatus, now time.Time) *srvdef.SrvErr { if len(playletIds) == 0 { return nil } err := playletchaptermod.UpdateManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": playletIds}}, bson.M{ "$set": bson.M{ playletchaptermod.FN.PlayletChapter.WatermarkStatus: status, playletchaptermod.FN.PlayletChapter.MediaSourceId: "", playletchaptermod.FN.PlayletChapter.MediaTaskId: "", playletchaptermod.FN.PlayletChapter.UpdatedAt: now, }, "$unset": bson.M{ playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: "", }, }, ) if err != nil && err.Code == srvdef.ErrNoData { return nil } return err } func buildPlayletListFilter(req *proto.PlayletListReq) bson.M { filter := bson.M{} if req.Id != nil && *req.Id > 0 { filter[playletmod.FN.Playlet.Id] = *req.Id } if req.ThemeId != nil { filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{*req.ThemeId}} } if req.CategoryId != nil { filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{*req.CategoryId}} } if req.TagId != nil { filter[playletmod.FN.Playlet.TagIds] = bson.M{"$in": []int64{*req.TagId}} } if req.ContentType != nil { filter[playletmod.FN.Playlet.ContentType] = *req.ContentType } if req.OperateType != nil { if *req.OperateType == playletmod.OperateTypeComic { filter[playletmod.FN.Playlet.OperateType] = playletmod.OperateTypeComic } else { // 历史数据可能没有该字段,按短剧处理 filter[playletmod.FN.Playlet.OperateType] = bson.M{"$ne": playletmod.OperateTypeComic} } } if req.PayType != nil { filter[playletmod.FN.Playlet.PayType] = *req.PayType } if req.Status != nil { filter[playletmod.FN.Playlet.Status] = *req.Status } if req.IsFeatured != nil { filter[playletmod.FN.Playlet.IsFeatured] = *req.IsFeatured } if req.IsOriginal != nil { if *req.IsOriginal { filter[playletmod.FN.Playlet.IsOriginal] = true } else { // 历史入库数据可能没有该字段,按非原创处理 filter[playletmod.FN.Playlet.IsOriginal] = bson.M{"$ne": true} } } if req.ShowOriginal != nil { if *req.ShowOriginal { filter[playletmod.FN.Playlet.ShowOriginal] = true } else { filter[playletmod.FN.Playlet.ShowOriginal] = bson.M{"$ne": true} } } if req.IsRecommend != nil { filter[playletmod.FN.Playlet.IsRecommend] = *req.IsRecommend } if req.ShowOnBanner != nil { filter[playletmod.FN.Playlet.ShowOnBanner] = *req.ShowOnBanner } if title := strings.TrimSpace(req.Title); title != "" { filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} } if slug := strings.TrimSpace(req.Slug); slug != "" { filter[playletmod.FN.Playlet.Slug] = slug } if operatorName := strings.TrimSpace(req.OperatorName); operatorName != "" { filter[playletmod.FN.Playlet.OperatorName] = bson.M{"$regex": regexp.QuoteMeta(operatorName)} } if req.MediaSource != nil { mediaSource := strings.TrimSpace(*req.MediaSource) if mediaSource == playletmod.MediaSourceHG { filter[playletmod.FN.Playlet.MediaSource] = playletmod.MediaSourceHG } else if mediaSource == playletmod.MediaSourceDefault { filter["$or"] = []bson.M{ {playletmod.FN.Playlet.MediaSource: playletmod.MediaSourceDefault}, {playletmod.FN.Playlet.MediaSource: bson.M{"$exists": false}}, } } else { filter[playletmod.FN.Playlet.MediaSource] = mediaSource } } return filter } // dedupePositiveInt64Ids 过滤无效ID并去重 func dedupePositiveInt64Ids(ids []int64) []int64 { result := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) for _, id := range ids { // 只有正数ID才是有效的 Mongo 业务自增ID。 if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} result = append(result, id) } return result } func resolveAddPlayletCategoryBinds(binds []proto.PlayletCategoryBind, ids []int64) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { if len(binds) > 0 { return resolvePlayletCategoryBinds(&binds, nil, nil) } return resolvePlayletCategoryBinds(nil, &ids, nil) } func resolvePlayletCategoryBinds(binds *[]proto.PlayletCategoryBind, ids *[]int64, exist []playletmod.PlayletCategoryBind) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { if binds != nil { return playletmod.NormalizeCategoryBinds(toModCategoryBinds(*binds)), nil } if ids != nil { categoryIds := dedupePositiveInt64Ids(*ids) if len(exist) > 0 { return playletmod.MergeCategoryBindsByIds(exist, categoryIds), nil } return playletmod.CategoryBindsFromIds(categoryIds), nil } return make([]playletmod.PlayletCategoryBind, 0), nil } func toModCategoryBinds(binds []proto.PlayletCategoryBind) []playletmod.PlayletCategoryBind { result := make([]playletmod.PlayletCategoryBind, 0, len(binds)) for _, bind := range binds { result = append(result, playletmod.PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) } return result } func toProtoCategoryBinds(binds []playletmod.PlayletCategoryBind) []proto.PlayletCategoryBind { result := make([]proto.PlayletCategoryBind, 0, len(binds)) for _, bind := range binds { result = append(result, proto.PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) } return result } // isValidPlayletStatus 校验短剧上下架状态 func isValidPlayletStatus(status playletmod.Status) bool { return status == playletmod.StatusDown || status == playletmod.StatusUp } // resolvePlayletOnlineAt 上架时间未传或为零时使用当前时间 func resolvePlayletOnlineAt(passed *time.Time, now time.Time) time.Time { if onlineAt, ok := explicitPlayletOnlineAt(passed); ok { return onlineAt } return now } // explicitPlayletOnlineAt 返回请求里显式传入的上架时间。未传或零值视为未传。 func explicitPlayletOnlineAt(passed *time.Time) (time.Time, bool) { if passed == nil || passed.IsZero() { return time.Time{}, false } return *passed, true } // fillUnsetPlayletOnlineAt 给从未设置过上架时间的短剧写入当前时间。已有上架时间的记录不改。 func fillUnsetPlayletOnlineAt(ctx context.Context, ids []int64, now time.Time) *srvdef.SrvErr { if len(ids) == 0 { return nil } err := playletmod.UpdateManyPlaylet(ctx, bson.M{ playletmod.FN.Playlet.Id: bson.M{"$in": ids}, "$or": unsetPlayletOnlineAtClauses(), }, bson.M{"$set": bson.M{ playletmod.FN.Playlet.OnlineAt: now, }}) if err != nil && err.Code == srvdef.ErrNoData { return nil } return err } func unsetPlayletOnlineAtClauses() []bson.M { return []bson.M{ {playletmod.FN.Playlet.OnlineAt: bson.M{"$exists": false}}, {playletmod.FN.Playlet.OnlineAt: nil}, {playletmod.FN.Playlet.OnlineAt: time.Time{}}, } } // applyPlayletOnlineAtOnUpdate 编辑短剧时写入上架时间。 // 新上架未传时间则用当前时间;已上架仅在显式传入时更新。 func applyPlayletOnlineAtOnUpdate(exist *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M, now time.Time) { if req == nil || setDoc == nil { return } goingUp := req.Status != nil && *req.Status == playletmod.StatusUp && (exist == nil || exist.Status != playletmod.StatusUp) if goingUp { setDoc[playletmod.FN.Playlet.OnlineAt] = resolvePlayletOnlineAt(req.OnlineAt, now) setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) return } if req.OnlineAt != nil && !req.OnlineAt.IsZero() { setDoc[playletmod.FN.Playlet.OnlineAt] = *req.OnlineAt } } // FillEmptyPlayletOnlineAt 将已上架但上架时间为空的短剧,回填为该条修改时间 func FillEmptyPlayletOnlineAt(ctx context.Context) (*proto.PlayletOnlineAtFillResp, *srvdef.SrvErr) { filter := emptyOnlineAtUpPlayletFilter() list, err := playletmod.FindManyPlaylet(ctx, filter, options.Find().SetProjection(bson.D{ {Key: playletmod.FN.Playlet.Id, Value: 1}, {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, })) if err != nil { return nil, err } resp := &proto.PlayletOnlineAtFillResp{} if len(list) == 0 { return resp, nil } writes := make([]mongo.WriteModel, 0, len(list)) ids := make([]int64, 0, len(list)) for _, item := range list { if item == nil || item.Id <= 0 { continue } onlineAt := item.UpdatedAt if onlineAt.IsZero() { onlineAt = item.CreatedAt } if onlineAt.IsZero() { continue } writes = append(writes, mongo.NewUpdateOneModel(). SetFilter(bson.M{playletmod.FN.Playlet.Id: item.Id}). SetUpdate(bson.M{"$set": bson.M{ playletmod.FN.Playlet.OnlineAt: onlineAt, }})) ids = append(ids, item.Id) } if len(writes) == 0 { return resp, nil } if err := playletmod.BulkWritePlaylet(ctx, writes); err != nil { return nil, err } resp.UpdatedCount = int64(len(ids)) clearAppPlayletHomeCache() syncIds := append([]int64(nil), ids...) go func() { if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusUp); err != nil { log.Error("回填上架时间同步ES失败", log.Any("短剧ID", syncIds), log.E(err)) } }() return resp, nil } func emptyOnlineAtUpPlayletFilter() bson.M { return bson.M{ playletmod.FN.Playlet.Status: playletmod.StatusUp, "$or": unsetPlayletOnlineAtClauses(), } } // isValidPlayletChapterStatus 校验章节上下架状态 func isValidPlayletChapterStatus(status playletchaptermod.Status) bool { return status == playletchaptermod.StatusDown || status == playletchaptermod.StatusUp } // isValidPlayletChapterPayType 校验章节支付方式 func isValidPlayletChapterPayType(payType playletchaptermod.PayType) bool { return payType == playletchaptermod.PayTypeFree || payType == playletchaptermod.PayTypeCoin || payType == playletchaptermod.PayTypeVip } func normalizePlayletOperateType(operateType playletmod.OperateType, allowDefault bool) (playletmod.OperateType, *srvdef.SrvErr) { if operateType == 0 && allowDefault { return playletmod.OperateTypePlaylet, nil } if !playletmod.IsValidOperateType(operateType) { return 0, srvdef.New(srvdef.ErrParamError, "运营类型无效", "") } return operateType, nil } // isValidPlayletPayType 校验短剧默认支付方式 func isValidPlayletPayType(payType playletmod.PayType) bool { return payType == playletmod.PayTypeFree || payType == playletmod.PayTypeCoin || payType == playletmod.PayTypeVip } // normalizePlayletPaySetting 规范化短剧默认支付方式;非金币时单集金币清零。 func normalizePlayletPaySetting(payType playletmod.PayType, singleCoins *int64, requireCoinAmount bool) (playletmod.PayType, int64, *srvdef.SrvErr) { if !isValidPlayletPayType(payType) { return 0, 0, srvdef.New(srvdef.ErrParamError, "支付方式无效", "") } if payType == playletmod.PayTypeCoin { if requireCoinAmount && (singleCoins == nil || *singleCoins <= 0) { return 0, 0, srvdef.New(srvdef.ErrParamError, "金币支付时单集金币必须大于0", "") } if singleCoins == nil { return payType, 0, nil } return payType, *singleCoins, nil } return payType, 0, nil } // buildPlayletListSort 后台列表排序,均为降序;未传或非法值按创建时间。 func buildPlayletListSort(sortType proto.PlayletListSortType) bson.D { sortKey := playletmod.FN.Playlet.CreatedAt switch sortType { case proto.PlayletListSortRecommendRank: sortKey = playletmod.FN.Playlet.RecommendRank case proto.PlayletListSortUpdatedAt: sortKey = playletmod.FN.Playlet.UpdatedAt case proto.PlayletListSortCountLike: sortKey = playletmod.FN.Playlet.CountLike case proto.PlayletListSortCountBrowse: sortKey = playletmod.FN.Playlet.CountBrowse case proto.PlayletListSortCountCollect: sortKey = playletmod.FN.Playlet.CountCollect case proto.PlayletListSortOnlineAt: sortKey = playletmod.FN.Playlet.OnlineAt } return bson.D{ {Key: sortKey, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}, } } func normalizePage(pageNum, pageSize int64) (int64, int64) { if pageNum <= 0 { pageNum = 1 } if pageSize <= 0 { pageSize = defaultPageSize } if pageSize > maxPageSize { pageSize = maxPageSize } return pageNum, pageSize } // normalizeSeoValue 规范化 SEO 字段,避免 keywords 为 null func normalizeSeoValue(seo pubdef.SeoValue) pubdef.SeoValue { seo.Title = strings.TrimSpace(seo.Title) seo.Description = strings.TrimSpace(seo.Description) if seo.Keyworld == nil { seo.Keyworld = make([]string, 0) return seo } words := make([]string, 0, len(seo.Keyworld)) for _, word := range seo.Keyworld { word = strings.TrimSpace(word) if word == "" { continue } words = append(words, word) } seo.Keyworld = words return seo } func applyPlayletDescription(doc *playletmod.Playlet) { if doc == nil { return } autoDesc := playletmod.BuildPlayletDescription(doc.PlayName, doc.Title, doc.TotalEpisode) // Description = 简介(Desc) + 自动模板,Playlet.Description 与 SeoInfo.Description 始终一致。 merged := strings.TrimSpace(doc.Desc) + autoDesc if merged == "" { return } doc.Description = merged doc.SeoInfo.Description = merged // SEO 标题跟随 Playlet.Title 同步。 if title := strings.TrimSpace(doc.Title); title != "" { doc.SeoInfo.Title = title } } func applyPlayletDescriptionOnUpdate(exist *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M) { if exist == nil || req == nil || setDoc == nil { return } playName := strings.TrimSpace(exist.PlayName) if req.PlayName != nil { playName = strings.TrimSpace(*req.PlayName) } title := strings.TrimSpace(exist.Title) if req.Title != nil { title = strings.TrimSpace(*req.Title) } totalEpisode := exist.TotalEpisode if req.TotalEpisode != nil { totalEpisode = *req.TotalEpisode } autoDesc := playletmod.BuildPlayletDescription(playName, title, totalEpisode) // 简介(Desc) 作为自定义描述的唯一源,取请求值或历史值,不读取被存储的 SeoInfo.Description, // 避免多次保存堆叠。 desc := strings.TrimSpace(exist.Desc) if req.Desc != nil { desc = strings.TrimSpace(*req.Desc) } merged := desc + autoDesc if merged == "" { return } seo := exist.SeoInfo if req.SeoInfo != nil { if current, ok := setDoc["seoInfo"].(pubdef.SeoValue); ok { seo = current } else { seo = normalizeSeoValue(*req.SeoInfo) } } seo = normalizeSeoValue(seo) // 每次 description 更新 → SEO 描述、SEO 标题同步。 seo.Description = merged if title != "" { seo.Title = title } setDoc["seoInfo"] = seo setDoc[playletmod.FN.Playlet.Description] = merged } func playletToWebResp(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string, categoryId *int64) *proto.PlayletListResp { binds := playletmod.EffectiveCategoryBinds(item) var filterCategoryId int64 if categoryId != nil { filterCategoryId = *categoryId } return &proto.PlayletListResp{ Id: item.Id, Status: item.Status, MediaSource: item.MediaSource, IsFeatured: item.IsFeatured, IsOriginal: item.IsOriginal, ShowOriginal: item.ShowOriginal, MediaBusiness: item.MediaBusiness, IsRecommend: item.IsRecommend, ShowOnBanner: item.ShowOnBanner, Banner: item.Banner, MobileBanner: item.MobileBanner, ReviewAt: item.ReviewAt, OnlineAt: item.OnlineAt, ThemeIds: item.ThemeIds, ThemeNames: themeNamesFromMap(item.ThemeIds, themeNameMap), CategoryIds: playletmod.CategoryIdsFromBinds(binds), CategoryBinds: toProtoCategoryBinds(binds), CategoryRank: playletmod.CategoryRankOf(binds, filterCategoryId), Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: item.Cover, HorizontalCover: item.HorizontalCover, CompressedHorizontalCover: item.CompressedHorizontalCover, Preview: item.Preview, TagIds: item.TagIds, TagNames: tagNamesFromMap(item.TagIds, tagNameMap), ContentType: item.ContentType, OperateType: playletmod.NormalizeOperateType(item.OperateType), PayType: item.PayType, SingleCoins: item.SingleCoins, TotalEpisode: item.TotalEpisode, UpdateStatus: item.UpdateStatus, TopRank: item.TopRank, RecommendRank: item.RecommendRank, CountComment: item.CountComment, CountBuy: item.CountBuy, CountBrowse: item.CountBrowse, HeatValue: item.HeatValue, CountCollect: item.CountCollect, CountLike: item.CountLike, VirtualCountLike: item.VirtualCountLike, DisplayCountLike: playletDisplayCountLike(item), CountDisLike: item.CountDisLike, SeoInfo: normalizeSeoValue(item.SeoInfo), OperatorId: item.OperatorId, OperatorName: item.OperatorName, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, CompressedCover: item.CompressedCover, } } func playletDisplayCountLike(item *playletmod.Playlet) int64 { if item == nil { return 0 } return item.CountLike + item.VirtualCountLike } func buildThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { themeIds := make([]int64, 0) seen := make(map[int64]struct{}) for _, item := range list { if item == nil { continue } for _, themeId := range item.ThemeIds { if themeId <= 0 { continue } if _, ok := seen[themeId]; ok { continue } seen[themeId] = struct{}{} themeIds = append(themeIds, themeId) } } result := make(map[int64]string) if len(themeIds) == 0 { return result, nil } themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{ playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIds}, }) if err != nil { return nil, err } for _, theme := range themes { if theme == nil { continue } result[theme.Id] = theme.Name } return result, nil } // themeNamesFromMap 按题材ID顺序返回题材名称 func themeNamesFromMap(themeIds []int64, themeNameMap map[int64]string) []string { result := make([]string, 0, len(themeIds)) for _, themeId := range themeIds { if name := themeNameMap[themeId]; name != "" { result = append(result, name) } } return result } func buildTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { tagIds := make([]int64, 0) seen := make(map[int64]struct{}) for _, item := range list { if item == nil { continue } for _, tagId := range item.TagIds { if tagId <= 0 { continue } if _, ok := seen[tagId]; ok { continue } seen[tagId] = struct{}{} tagIds = append(tagIds, tagId) } } result := make(map[int64]string) if len(tagIds) == 0 { return result, nil } tags, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{ playlettagmod.FN.PlayletTag.Id: bson.M{"$in": tagIds}, }) if err != nil { return nil, err } for _, tag := range tags { if tag == nil { continue } result[tag.Id] = tag.Name } return result, nil } // tagNamesFromMap 按标签ID顺序返回标签名称 func tagNamesFromMap(tagIds []int64, tagNameMap map[int64]string) []string { result := make([]string, 0, len(tagIds)) for _, tagId := range tagIds { if name := tagNameMap[tagId]; name != "" { result = append(result, name) } } return result } // normalizePlayletTagIds 校验并去重标签ID func normalizePlayletTagIds(tagIds []int64) ([]int64, *srvdef.SrvErr) { result := make([]int64, 0, len(tagIds)) seen := make(map[int64]struct{}, len(tagIds)) for _, tagId := range tagIds { if tagId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "标签ID无效", "") } if _, ok := seen[tagId]; ok { continue } seen[tagId] = struct{}{} result = append(result, tagId) } return result, nil } func chapterToWebResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterListResp { return &proto.PlayletChapterListResp{ Id: item.Id, PlayletId: item.PlayletId, MediaContentId: item.MediaContentId, CurrentEpisode: item.CurrentEpisode, Title: item.Title, Md5: item.Md5, Hash: item.Hash, Height: item.Height, Width: item.Width, Ratio: item.Ratio, VideoUrl: item.VideoUrl, WatermarkVideoUrl: item.WatermarkVideoUrl, MediaSize: item.MediaSize, MediaTime: item.MediaTime, CountComment: item.CountComment, CountBuy: item.CountBuy, CountBrowse: item.CountBrowse, CountCollect: item.CountCollect, CountLike: item.CountLike, CountDisLike: item.CountDisLike, Status: item.Status, PayType: item.PayType, SingleCoins: item.SingleCoins, WatermarkStatus: item.WatermarkStatus, MediaSourceId: item.MediaSourceId, MediaTaskId: item.MediaTaskId, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } func validatePlayletTagIds(ctx context.Context, tagIds []int64) *srvdef.SrvErr { if len(tagIds) == 0 { return nil } uniqueIds := make([]int64, 0, len(tagIds)) seen := make(map[int64]struct{}, len(tagIds)) for _, tagId := range tagIds { if tagId <= 0 { return srvdef.New(srvdef.ErrParamError, "标签ID无效", "") } if _, ok := seen[tagId]; ok { continue } seen[tagId] = struct{}{} uniqueIds = append(uniqueIds, tagId) } count, err := playlettagmod.CountPlayletTag(ctx, bson.M{ playlettagmod.FN.PlayletTag.Id: bson.M{"$in": uniqueIds}, }) if err != nil { return err } if count != int64(len(uniqueIds)) { return srvdef.New(srvdef.ErrParamError, "标签不存在", "") } return nil } // validatePlayletThemeIds 校验题材ID是否存在 func validatePlayletThemeIds(ctx context.Context, themeIds []int64) *srvdef.SrvErr { if len(themeIds) == 0 { return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "") } count, err := playletthememod.CountPlayletTheme(ctx, bson.M{ playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIds}, }) if err != nil { return err } if count != int64(len(themeIds)) { return srvdef.New(srvdef.ErrParamError, "题材不存在", "") } return nil }