package srv_playlet import ( "context" "math/rand" "regexp" "strings" "time" "videoweb/app/service/reportser" "videoweb/pub/common/log" "videoweb/pub/common/response" "videoweb/pub/common/srvdef" "videoweb/pub/models/playletcategorymod" "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_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 = findPlayletsSortedByCategoryRank(ctx, filter, *req.CategoryId, (pageNum-1)*pageSize, pageSize, playletmod.CategoryRankAdminSort()) } 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 } categoryNameMap, err := buildCategoryNameMap(ctx, list) 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, categoryNameMap, 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, "标题不能为空", "") } categoryBinds, err := resolveAddPlayletCategoryBinds(ctx, req.CategoryBinds, req.CategoryIds) if err != nil { return nil, err } 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 } 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{ CategoryIds: playletmod.CategoryIdsFromBinds(categoryBinds), CategoryBinds: categoryBinds, ThemeIds: themeIds, 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, Preview: 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, // 管理后台新增的短剧都是原创 IsRecommend: req.IsRecommend, ShowOnBanner: req.ShowOnBanner, Banner: banner, 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) } 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 srv_mediaresource.FillPlayletCompressedCover(context.Background(), doc) clearAppPlayletHomeCache() // 上架短剧异步写入 ES(含 SEO),避免阻塞后台接口。 if doc.Status == playletmod.StatusUp { syncId := doc.Id 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图不能为空", "") } } 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.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.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.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 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.Preview != nil { setDoc[playletmod.FN.Playlet.Preview] = *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) } exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) if err != nil { return err } if req.CategoryBinds != nil || req.CategoryIds != nil { categoryBinds, serr := resolvePlayletCategoryBinds(ctx, req.CategoryBinds, req.CategoryIds, exist.CategoryBinds) if serr != nil { return serr } setDoc[playletmod.FN.Playlet.CategoryBinds] = categoryBinds setDoc[playletmod.FN.Playlet.CategoryIds] = playletmod.CategoryIdsFromBinds(categoryBinds) } 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)) } clearAppPlayletHomeCache() // 编辑后按当前上下架状态异步同步 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 } // 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 { // 上架时写入上架时间;未传则用当前时间。下架不覆盖历史上架时间。 setDoc[playletmod.FN.Playlet.OnlineAt] = resolvePlayletOnlineAt(req.OnlineAt, now) } 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 } clearAppPlayletHomeCache() // 上架会同步写入 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 flag := reportser.PlayletStatusFlag(req.Status); flag != 0 { reportPlayletStatusEvents(ctx, ids, flag) } return nil } func reportPlayletStatusEvents(ctx context.Context, ids []int64, flag int64) { if len(ids) == 0 { return } 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.Title, Value: 1}, {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, })) if err != nil { log.Error("短剧状态变更上报查询失败", log.Any("短剧ID", ids), log.E(err)) return } for _, item := range list { reportser.ReportPlayletStatusChange(item, flag) } } // 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() 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(ctx, 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() 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() 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.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, } 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 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 } // 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, }}, ) 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, }}, ) 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.CategoryId != nil { filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{*req.CategoryId}} } if req.ThemeId != nil { filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{*req.ThemeId}} } 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.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)} } 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 } // 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 passed != nil && !passed.IsZero() { return *passed } return now } // 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) 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": []bson.M{ {playletmod.FN.Playlet.OnlineAt: bson.M{"$exists": false}}, {playletmod.FN.Playlet.OnlineAt: nil}, {playletmod.FN.Playlet.OnlineAt: time.Time{}}, }, } } // 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 } description := playletmod.BuildPlayNameDescription(doc.Desc, doc.PlayName, doc.UpdateStatus, doc.TotalEpisode) if description == "" { return } doc.Description = description doc.SeoInfo.Description = description } 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) } if playName == "" { return } desc := exist.Desc if req.Desc != nil { desc = strings.TrimSpace(*req.Desc) } totalEpisode := exist.TotalEpisode if req.TotalEpisode != nil { totalEpisode = *req.TotalEpisode } description := playletmod.BuildPlayNameDescription(desc, playName, exist.UpdateStatus, totalEpisode) if description == "" { return } setDoc[playletmod.FN.Playlet.Description] = description 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) seo.Description = description setDoc["seoInfo"] = seo } func playletToWebResp(item *playletmod.Playlet, categoryNameMap map[int64]string, 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, IsFeatured: item.IsFeatured, IsOriginal: item.IsOriginal, IsRecommend: item.IsRecommend, ShowOnBanner: item.ShowOnBanner, Banner: item.Banner, ReviewAt: item.ReviewAt, OnlineAt: item.OnlineAt, CategoryIds: playletmod.CategoryIdsFromBinds(binds), CategoryNames: categoryNamesFromMap(playletmod.CategoryIdsFromBinds(binds), categoryNameMap), CategoryBinds: toProtoCategoryBinds(binds), CategoryRank: playletmod.CategoryRankOf(binds, filterCategoryId), ThemeIds: item.ThemeIds, ThemeNames: themeNamesFromMap(item.ThemeIds, themeNameMap), Title: item.Title, PlayName: item.PlayName, Description: item.Description, Slug: item.Slug, Desc: item.Desc, Anchor: item.Anchor, Cover: item.Cover, 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 buildCategoryNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { categoryIds := make([]int64, 0) seen := make(map[int64]struct{}) for _, item := range list { if item == nil { continue } for _, categoryId := range playletmod.CategoryIdsFromBinds(playletmod.EffectiveCategoryBinds(item)) { if categoryId <= 0 { continue } if _, ok := seen[categoryId]; ok { continue } seen[categoryId] = struct{}{} categoryIds = append(categoryIds, categoryId) } } result := make(map[int64]string) if len(categoryIds) == 0 { return result, nil } categories, err := playletcategorymod.FindManyPlayletCategory(ctx, bson.M{ playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": categoryIds}, }) if err != nil { return nil, err } for _, category := range categories { if category == nil { continue } result[category.Id] = category.Name } return result, nil } // categoryNamesFromMap 按分类ID顺序返回分类名称。 func categoryNamesFromMap(categoryIds []int64, categoryNameMap map[int64]string) []string { result := make([]string, 0, len(categoryIds)) for _, categoryId := range categoryIds { if name := categoryNameMap[categoryId]; name != "" { result = append(result, name) } } return result } 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 } func resolveAddPlayletCategoryBinds(ctx context.Context, binds []proto.PlayletCategoryBind, ids []int64) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { if len(binds) > 0 { return resolvePlayletCategoryBinds(ctx, &binds, nil, nil) } return resolvePlayletCategoryBinds(ctx, nil, &ids, nil) } func resolvePlayletCategoryBinds(ctx context.Context, binds *[]proto.PlayletCategoryBind, ids *[]int64, exist []playletmod.PlayletCategoryBind) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { if binds != nil { result := playletmod.NormalizeCategoryBinds(toModCategoryBinds(*binds)) if err := validatePlayletCategoryIds(ctx, playletmod.CategoryIdsFromBinds(result)); err != nil { return nil, err } return result, nil } if ids != nil { categoryIds, err := normalizePlayletCategoryIds(*ids) if err != nil { return nil, err } if err := validatePlayletCategoryIds(ctx, categoryIds); err != nil { return nil, err } 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 } func findPlayletsSortedByCategoryRank(ctx context.Context, filter bson.M, categoryId int64, skip, limit int64, sort bson.D) (playletmod.PlayletList, *srvdef.SrvErr) { return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryId, skip, limit, sort, nil) } // normalizePlayletCategoryIds 校验并去重分类ID;空数组表示不绑定任何分类。 func normalizePlayletCategoryIds(categoryIds []int64) ([]int64, *srvdef.SrvErr) { result := make([]int64, 0, len(categoryIds)) seen := make(map[int64]struct{}, len(categoryIds)) for _, categoryId := range categoryIds { if categoryId <= 0 { return nil, srvdef.New(srvdef.ErrParamError, "分类ID无效", "") } if _, ok := seen[categoryId]; ok { continue } seen[categoryId] = struct{}{} result = append(result, categoryId) } return result, nil } // 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, PayType: item.PayType, SingleCoins: item.SingleCoins, WatermarkStatus: item.WatermarkStatus, MediaSourceId: item.MediaSourceId, MediaTaskId: item.MediaTaskId, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } // validatePlayletCategoryIds 校验分类ID是否存在;空数组允许表示未绑定分类。 func validatePlayletCategoryIds(ctx context.Context, categoryIds []int64) *srvdef.SrvErr { if len(categoryIds) == 0 { return nil } count, err := playletcategorymod.CountPlayletCategory(ctx, bson.M{ playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": categoryIds}, }) if err != nil { return err } if count != int64(len(categoryIds)) { return srvdef.New(srvdef.ErrParamError, "分类不存在", "") } return nil } 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 }