Initial commit: web_hghl_server

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:28:14 +08:00
co-authored by Claude Opus 4.7
commit 65fca60622
653 changed files with 135901 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
package service
import (
"videoweb/web/service/srv_ipblack"
"videoweb/web/service/srv_mediaresource"
"videoweb/web/service/srv_search"
"videoweb/web/skd"
)
func Start() {
skd.Start()
srv_ipblack.Init()
srv_mediaresource.Start()
srv_search.Start()
}
func Stop() {
skd.Stop()
srv_mediaresource.Stop()
srv_search.Stop()
}
+60
View File
@@ -0,0 +1,60 @@
package srv_acc
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/mwacc"
"videoweb/pub/models/oplog"
"go.mongodb.org/mongo-driver/bson"
)
func GetOpLogs(ctx context.Context, logSearch *OpLogSearch) (
int64, OpLogInfoList, *srvdef.SrvErr) {
//log.Info("GetOpLogs Test 1")
filter, findOption := constructFilterAndOpts(logSearch)
total, serr := oplog.CountOpLogs(ctx, filter)
if serr != nil {
return 0, nil, serr
}
list := make(OpLogInfoList, 0)
if total == 0 {
return 0, list, nil
}
//log.Info("GetOpLogs Test 2", log.Any("filter", filter), log.Any("findOption", findOption))
opLogs, serr := oplog.FindManyOpLogs(ctx, filter, findOption)
if serr != nil {
return 0, nil, serr
}
//log.Info("GetOpLogs Test 3")
actIds := make([]int64, 0)
for _, v := range opLogs {
actIds = append(actIds, v.ActId)
}
accFilter := bson.M{mwacc.FN.MwAcc.Id: bson.M{"$in": actIds}}
accs, serr := mwacc.FindManyAccByFilter(ctx, accFilter)
if serr != nil {
return 0, nil, serr
}
//log.Info("GetOpLogs Test 4")
for _, opLog := range opLogs {
olInfo := &OpLogInfo{
Path: opLog.Path,
ActId: opLog.ActId,
Action: opLog.Action,
Param: opLog.RawData,
CreatedAt: opLog.CreatedAt,
}
for _, acc := range accs {
if acc.Id == olInfo.ActId {
olInfo.ActName = acc.UserName
break
}
}
list = append(list, olInfo)
}
//log.Info("GetOpLogs Test 5")
return total, list, nil
}
+24
View File
@@ -0,0 +1,24 @@
package srv_acc
import "time"
type OpLogInfo struct {
Path string `json:"path" bson:"path"` // 请求接口
ActId int64 `json:"actId" bson:"actId"` // 操作人Id
Action string `json:"action" bson:"action"` // 操作目的
Param string `json:"param" bson:"rawData"` // 操作内容
CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 操作时间
ActName string `json:"actName" bson:"actName"` // 操作人
}
type OpLogInfoList []*OpLogInfo
type OpLogSearch struct {
PageNum int64 `json:"pageNum"` // 页码
PageSize int64 `json:"pageSize"` //
Path *string `json:"path"` // 请求接口
Action *string `json:"action"` // 操作目的
ActId *int64 `json:"actId"` // 操作人Id
StartTime *time.Time `json:"startTime"` // 开始时间
EndTime *time.Time `json:"endTime"` // 开始时间
}
+34
View File
@@ -0,0 +1,34 @@
package srv_acc
import (
"videoweb/pub/models/oplog"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// constructFilterAndOpts 根据搜索参数构建查询filter和options
func constructFilterAndOpts(param *OpLogSearch) (
bson.M, *options.FindOptions) {
filter := bson.M{}
if param.Path != nil && *param.Path != "" {
filter[oplog.FN.OpLog.Path] = bson.M{"$regex": param.Path}
}
if param.Action != nil && *param.Action != "" {
filter[oplog.FN.OpLog.Action] = param.Action
}
if param.ActId != nil && *param.ActId > 0 {
filter[oplog.FN.OpLog.ActId] = param.ActId
}
if param.StartTime != nil && param.EndTime != nil {
filter[oplog.FN.OpLog.CreatedAt] = bson.M{"$gte": param.StartTime, "$lt": param.EndTime}
}
sort := bson.M{oplog.FN.OpLog.CreatedAt: -1}
opt := &options.FindOptions{}
opt.SetSort(sort)
skip := (param.PageNum - 1) * param.PageSize
opt.SetSkip(skip)
opt.SetLimit(param.PageSize)
return filter, opt
}
+165
View File
@@ -0,0 +1,165 @@
package srv_actress
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/actress"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ActressList(ctx context.Context, req *ActressListReq) (*ActressListResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[actress.FN.Actress.Id] = *req.Id
}
if req.TranslationCategoryId != nil && *req.TranslationCategoryId > 0 {
filter[actress.FN.Actress.TranslationCategoryId] = *req.TranslationCategoryId
}
if req.Region != nil && len(*req.Region) > 0 {
filter[actress.FN.Actress.Region] = bson.M{"$regex": *req.Region}
}
if req.Status != nil {
filter[actress.FN.Actress.Status] = *req.Status
}
if req.Alias != nil && *req.Alias != "" {
filter[actress.FN.Actress.Alias] = bson.M{"$regex": *req.Alias}
}
count, err := actress.CountActresss(ctx, filter)
if err != nil {
log.Warn("ActressList CountActresss", log.Any("filter", filter), log.E(err))
return nil, err
}
resp := &ActressListResp{
Total: 0,
List: make([]*actress.Actress, 0),
}
if count == 0 {
return resp, nil
}
opts := &options.FindOptions{}
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
sort := bson.M{actress.FN.Actress.CreatedAt: -1}
switch req.SortType {
case 1:
sort = bson.M{actress.FN.Actress.Score: -1}
case 2:
sort = bson.M{actress.FN.Actress.Rank: -1}
case 3:
sort = bson.M{actress.FN.Actress.Works: -1}
case 4:
sort = bson.M{actress.FN.Actress.Real.WatchTimes: -1}
case 5:
sort = bson.M{actress.FN.Actress.HotValue: -1, actress.FN.Actress.Id: -1}
}
opts.SetSort(sort)
list, err := actress.FindManyActresss(ctx, filter, opts)
if err != nil {
log.Warn("ActressList FindManyActresss", log.Any("filter", filter), log.E(err))
return nil, err
}
resp.Total = count
resp.List = list
return resp, nil
}
func UpdateActress(ctx context.Context, req *ActressUpdateReq) *srvdef.SrvErr {
current, err := actress.FindOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id})
if err != nil {
log.Warn("UpdateActress", log.Any("id", req.Id), log.E(err))
return err
}
alias := current.Alias
if req.Param.Alias != nil {
alias = *req.Param.Alias
}
keywords := current.SeoInfo.Keyworld
if req.Param.SeoInfo != nil {
keywords = req.Param.SeoInfo.Keyworld
}
exists := func(s string) bool {
doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, req.Id))
return doc != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Param.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, alias), exists)
if serr != nil {
return serr
}
if changed {
req.Param.Slug = &slug
} else {
req.Param.Slug = nil
}
now := time.Now()
req.Param.UpdatedAt = &now
return actress.UpdateOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id}, bson.M{"$set": req.Param})
}
func AddActress(ctx context.Context, req *ActressAddReq) *srvdef.SrvErr {
if len(req.Alias) == 0 {
return srvdef.New(srvdef.ErrParamError, "alias 不能为空", "alias 不能为空")
}
_, err := actress.FindOneActress(ctx, bson.M{
actress.FN.Actress.Alias: req.Alias,
})
if err != nil && err.Code != srvdef.ErrNoData {
log.Warn("AddActress", log.Any("req", req), log.E(err))
return err
}
if err == nil {
log.Warn("AddActress Re", log.Any("req", req))
return srvdef.New(srvdef.RepeatActress)
}
now := time.Now()
info := &actress.Actress{
Id: mongos.Lid.Actress.Get(),
Alias: req.Alias,
Slug: req.Slug,
SeoInfo: req.SeoInfo,
Avatar: req.Avatar,
Cover: req.Cover,
Desc: req.Desc,
TranslationCategoryId: req.TranslationCategoryId,
Meas: req.Meas,
Office: req.Office,
DebutDate: req.DebutDate,
BornDate: req.BornDate,
Region: req.Region,
Score: req.Score,
Rank: req.Rank,
HotValue: req.HotValue,
Status: req.Status,
Works: 0,
Real: pubdef.Flow{},
CreatedAt: now,
UpdatedAt: now,
}
exists := func(s string) bool {
doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, 0))
return doc != nil
}
if serr := slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Alias), exists); serr != nil {
return serr
}
return actress.InsertOneActress(ctx, info)
}
func DeleteActress(ctx context.Context, req *ActressDelReq) *srvdef.SrvErr {
_, err := actress.FindOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id})
if err != nil {
log.Warn("DeleteActress", log.Any("id", req.Id), log.E(err))
return err
}
_, err = actress.DelOneActress(ctx, bson.M{actress.FN.Actress.Id: req.Id})
return err
}
+69
View File
@@ -0,0 +1,69 @@
package srv_actress
import (
"time"
"videoweb/pub/models/actress"
"videoweb/pub/pubdef"
)
type ActressListReq struct {
pubdef.Page
Id *int64 `form:"id" json:"id"` // Id
Alias *string `form:"alias" json:"alias"` // 别名
TranslationCategoryId *int64 `form:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id
Region *string `form:"region" json:"region"` // 地区/国家
Status *bool `form:"status" json:"status" ` // 是否启用
SortType int64 `form:"sortType" json:"sortType"` // 排序方式 0=创建时间 1=评分 2=排序值 3=作品数 4=总观看次数 5=热度值
}
type ActressListResp struct {
Total int64 `json:"total"`
List []*actress.Actress `json:"list"`
}
type ActressUpdateReq struct {
Id int64 `json:"id,omitempty" binding:"required"`
Param ActressUpdateParam `json:"param" binding:"required"`
}
type ActressUpdateParam struct {
Alias *string `bson:"alias,omitempty" json:"alias"` // 别名
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Avatar *string `bson:"avatar,omitempty" json:"avatar"` // 头像
Cover *string `bson:"cover,omitempty" json:"cover"` // 封面大图
Desc *string `bson:"desc,omitempty" json:"desc"` // 详细描述
TranslationCategoryId *int64 `bson:"translationCategoryId,omitempty" json:"translationCategoryId"` // 关联导航分类id
Meas *actress.Meas `bson:"meas,omitempty" json:"meas"` // 三围
Office *string `json:"office" bson:"office,omitempty"` // 事务所
DebutDate *time.Time `bson:"debutDate,omitempty" json:"debutDate" ` // 出道日期
BornDate *time.Time `bson:"bornDate,omitempty" json:"bornDate"` // 出生日期
Region *string `json:"region" bson:"region,omitempty"` // 地区/国家
Score *string `json:"score" bson:"score,omitempty"` // 评分
Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序
HotValue *int64 `json:"hotValue" bson:"hotValue,omitempty"` // 热度值
Status *bool `json:"status" bson:"status,omitempty"` // 是否启用
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息
UpdatedAt *time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` // 更新时间
}
type ActressAddReq struct {
Alias string `bson:"alias" json:"alias"` // 别名
Slug string `json:"slug" bson:"slug"` // slug,不传则按 SEO 关键词或别名自动生成
SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息
Avatar string `bson:"avatar" json:"avatar"` // 头像
Cover string `bson:"cover" json:"cover"` // 封面大图
Desc string `bson:"desc" json:"desc"` // 详细描述
TranslationCategoryId int64 `bson:"translationCategoryId" json:"translationCategoryId"` // 关联导航分类id
Meas actress.Meas `bson:"meas" json:"meas"` // 三围
Office string `json:"office" bson:"office"` // 事务所
DebutDate time.Time `bson:"debutDate" json:"debutDate" ` // 出道日期
BornDate time.Time `bson:"bornDate" json:"bornDate"` // 出生日期
Region string `json:"region" bson:"region"` // 地区/国家
Score string `json:"score" bson:"score"` // 评分
Rank int64 `json:"rank" bson:"rank"` // 排序
HotValue int64 `json:"hotValue" bson:"hotValue"` // 热度值
Status bool `json:"status" bson:"status"` // 是否启用
}
type ActressDelReq struct {
Id int64 `json:"id"`
}
@@ -0,0 +1,111 @@
package srv_announcement
import (
"context"
"time"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/announcementmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetAnnouncementList(ctx context.Context, req *GetAnnouncementListReq) (*GetAnnouncementListResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.Title != nil && *req.Title != "" {
filter[announcementmod.FN.Announcement.Title] = bson.M{"$regex": *req.Title, "$options": "i"}
}
if req.Status != nil {
filter[announcementmod.FN.Announcement.Status] = *req.Status
}
count, serr := announcementmod.CountAnnouncement(ctx, filter)
if serr != nil {
return nil, serr
}
if count == 0 {
return &GetAnnouncementListResp{
Total: 0,
List: make(announcementmod.AnnouncementList, 0),
}, nil
}
opts := options.FindOptions{}
opts.SetSort(bson.D{
{Key: announcementmod.FN.Announcement.Sort, Value: -1},
{Key: announcementmod.FN.Announcement.CreatedAt, Value: -1},
})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, serr := announcementmod.FindManyAnnouncement(ctx, filter, &opts)
if serr != nil {
return nil, serr
}
return &GetAnnouncementListResp{
Total: count,
List: list,
}, nil
}
func AddAnnouncement(ctx context.Context, req *AddAnnouncementReq) *srvdef.SrvErr {
now := time.Now()
status := true
if req.Status != nil {
status = *req.Status
}
doc := &announcementmod.Announcement{
Id: mongos.Lid.Announcement.Get(),
Title: req.Title,
Content: req.Content,
Sort: req.Sort,
Status: status,
CreatedAt: now,
UpdatedAt: now,
}
if err := announcementmod.InsertOneAnnouncement(ctx, doc); err != nil {
return err
}
playletcache.ClearHomeCache()
return nil
}
func UpdateAnnouncement(ctx context.Context, req *UpdateAnnouncementReq) *srvdef.SrvErr {
filter := bson.M{announcementmod.FN.Announcement.Id: req.Id}
updateFields := bson.M{
announcementmod.FN.Announcement.UpdatedAt: time.Now(),
}
if req.Param.Title != nil {
updateFields[announcementmod.FN.Announcement.Title] = *req.Param.Title
}
if req.Param.Content != nil {
updateFields[announcementmod.FN.Announcement.Content] = *req.Param.Content
}
if req.Param.Sort != nil {
updateFields[announcementmod.FN.Announcement.Sort] = *req.Param.Sort
}
if req.Param.Status != nil {
updateFields[announcementmod.FN.Announcement.Status] = *req.Param.Status
}
if err := announcementmod.UpdateOneAnnouncement(ctx, filter, bson.M{"$set": updateFields}); err != nil {
return err
}
playletcache.ClearHomeCache()
return nil
}
func DelAnnouncement(ctx context.Context, req *DelAnnouncementReq) *srvdef.SrvErr {
filter := bson.M{announcementmod.FN.Announcement.Id: req.Id}
count, serr := announcementmod.DelOneAnnouncement(ctx, filter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrAnnouncementNotExsit, "", "")
}
playletcache.ClearHomeCache()
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package srv_announcement
import (
"videoweb/pub/models/announcementmod"
"videoweb/pub/pubdef"
)
type GetAnnouncementListReq struct {
Title *string `json:"title"` // 标题,模糊搜索
Status *bool `json:"status"` // 是否启用
pubdef.Page
}
type GetAnnouncementListResp struct {
Total int64 `json:"total"`
List announcementmod.AnnouncementList `json:"list"`
}
type AddAnnouncementReq struct {
Title string `json:"title" binding:"required"` // 标题
Content string `json:"content"` // 内容
Sort int64 `json:"sort"` // 排序,越大越靠前
Status *bool `json:"status"` // 是否启用
}
type UpdateAnnouncementReq struct {
Id int64 `json:"id" binding:"required"`
Param UpdateAnnouncementParam `json:"param"`
}
type UpdateAnnouncementParam struct {
Title *string `json:"title"`
Content *string `json:"content"`
Sort *int64 `json:"sort"`
Status *bool `json:"status"`
}
type DelAnnouncementReq struct {
Id int64 `json:"id" binding:"required"`
}
+71
View File
@@ -0,0 +1,71 @@
package srv_app
import (
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
)
type AppCategoryInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"` // Id
Name string `json:"name" bson:"name"` // 分类名称
Title string `json:"title" bson:"title"` // APP显示
Desc string `json:"desc" bson:"desc"` // 描述
Rank int64 `json:"rank" bson:"rank"` // 排序
}
type AppCreatorInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Name string `json:"name" bson:"name"` // 帖子标题
Sort int64 `json:"sort" bson:"sort"` // 排序
}
type AppTagInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Title string `json:"title" bson:"title"` // 帖子标题
Sort int64 `json:"sort" bson:"sort"` // 排序
}
// AppVideoInfoSyncReq APP数据同步请求
type AppVideoInfoSyncReq struct {
AppId int64 `json:"appId" bson:"appId"`
LastSyncTime time.Time `json:"lastSyncTime" bson:"lastSyncTime"` // 最后同步时间
PageSize int64 `json:"pageSize" bson:"pageSize"` // 页大小
}
type AppVideoCollectionSyncReq struct {
AppId int64 `json:"appId" bson:"appId"`
CollectionId []int64 `json:"collectionId" bson:"collectionId"`
}
type AppVideoCollectionSyncInfo struct {
CollectionID int64 `json:"collectionId" bson:"collectionId"` // 合集ID
VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID
Rank int64 `json:"rank" bson:"rank"` // 排序
}
type AppVideoSyncInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Title string `json:"title" bson:"title"` // 帖子标题
MediaType videoinformationmod.MediaType `json:"mediaType" bson:"mediaType"` // 资源类型
AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 帖子上架时间
CreatorId int64 `json:"creatorId" bson:"creatorId"` // 作者Id
CreatorName string `json:"creatorName" bson:"creatorName"` // 作者名称
Keyworld []string `bson:"keyworld,omitempty" json:"keyworld"` // 关键字
FirstTag string `bson:"firstTag,omitempty" json:"firstTag"` // 一级标签
SecondTags []string `bson:"secondTags,omitempty" json:"secondTags"` // 二级标签标签
CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面
Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述
Remark string `bson:"remark,omitempty" json:"remark"` // 备注
Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点
Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态
Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频
CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类Id
Tags []int64 `json:"tags" bson:"tags"` // 标签
IsHot bool `json:"isHot" bson:"isHot"` // 是否热门
Sort int64 `json:"sort" bson:"sort"` // 排序
Real pubdef.Flow `json:"real" bson:"real"` // 真实流量
VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间
CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间
}
+124
View File
@@ -0,0 +1,124 @@
package srv_app
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videocollectionmediamod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
)
func AppVideoInfoSync(ctx context.Context, req *AppVideoInfoSyncReq) ([]*videoinformationmod.VideoInformation, *srvdef.SrvErr) {
filter := bson.M{
videoinformationmod.FN.VideoInformation.UpdatedAt: bson.M{
"$gt": req.LastSyncTime},
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: 1})
opts.SetLimit(req.PageSize)
list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if err != nil {
log.Error("FindManyVideoInformations Failed", log.Any("err", err))
return nil, err
}
return list, err
}
func AppVideoCollectionSync(ctx context.Context, req *AppVideoCollectionSyncReq) ([]*AppVideoCollectionSyncInfo, *srvdef.SrvErr) {
filter := bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: bson.M{"$in": req.CollectionId},
}
list, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, filter)
if err != nil {
log.Error("FindManyVideoCollectionMediaModels Failed", log.Any("err", err))
return nil, err
}
rsl := make([]*AppVideoCollectionSyncInfo, 0)
for _, v := range list {
rsl = append(rsl, &AppVideoCollectionSyncInfo{
CollectionID: v.CollectionID,
VideoId: v.VideoId,
Rank: v.Rank,
})
}
return rsl, nil
}
func AppCategoryList(ctx context.Context) ([]AppCategoryInfo, *srvdef.SrvErr) {
filter := bson.M{
videocategorymod.FN.VideoCategory.Status: true,
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1})
list, err := videocategorymod.FindManyVideoCategorys(ctx, filter, opts)
if err != nil {
log.Error("FindManyVideoCategorys Failed", log.Any("err", err))
return nil, err
}
resp := make([]AppCategoryInfo, 0)
for _, v := range list {
resp = append(resp, AppCategoryInfo{
Id: v.Id,
Name: v.Name,
Title: v.Title,
Desc: v.Desc,
Rank: v.Rank,
})
}
return resp, err
}
func AppCreatorList(ctx context.Context) ([]AppCreatorInfo, *srvdef.SrvErr) {
filter := bson.M{
videocreatermod.FN.VideoCreaterInfo.Status: true,
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{
videocreatermod.FN.VideoCreaterInfo.Sort: -1,
})
list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, filter, opts)
if err != nil {
log.Error("FindManyVideoCreaterInfos Failed", log.Any("err", err))
return nil, err
}
resp := make([]AppCreatorInfo, 0)
for _, v := range list {
resp = append(resp, AppCreatorInfo{
Id: v.Id,
Name: v.Name,
Sort: v.Sort,
})
}
return resp, err
}
func AppTagList(ctx context.Context) ([]AppTagInfo, *srvdef.SrvErr) {
filter := bson.M{
contenttagmod.FN.ContentTag.Status: true,
contenttagmod.FN.ContentTag.Types: contenttagmod.ContentTagTypeVideo,
}
opts := &options.FindOptions{}
opts.SetSort(bson.D{
{Key: contenttagmod.FN.ContentTag.Rank, Value: -1},
{Key: contenttagmod.FN.ContentTag.Id, Value: -1},
})
list, err := contenttagmod.FindManyContentTag(ctx, filter, opts)
if err != nil {
log.Error("FindManyContentTag Failed", log.Any("err", err))
return nil, err
}
resp := make([]AppTagInfo, 0)
for _, v := range list {
resp = append(resp, AppTagInfo{
Id: v.Id,
Title: v.Name,
Sort: v.Rank,
})
}
return resp, err
}
+76
View File
@@ -0,0 +1,76 @@
package srv_cfg
import (
"context"
"fmt"
"strings"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/cfg"
"go.mongodb.org/mongo-driver/bson"
)
// GetSystemConfig 得到系统配置
func GetSystemConfig(ctx context.Context) (*cfg.SysCfg, *srvdef.SrvErr) {
filter := bson.M{
cfg.FN.SysCfg.Id: 1, //固定id
}
sysCfg, serr := cfg.FindOneSysCfg(ctx, filter)
if serr != nil {
return nil, serr
}
return sysCfg, nil
}
// emptyWatermark 水印字段为空时也写入空字符串,避免 omitempty 跳过导致旧值残留
func emptyWatermark(v *string) *string {
empty := ""
if v == nil || strings.TrimSpace(*v) == "" {
return &empty
}
s := strings.TrimSpace(*v)
return &s
}
func applyEmptyWatermarkFields(info *SysCfgUpdate) {
if info == nil {
return
}
info.WatermarkImage = emptyWatermark(info.WatermarkImage)
info.WatermarkMedia = emptyWatermark(info.WatermarkMedia)
info.WatermarkMediaTop = emptyWatermark(info.WatermarkMediaTop)
info.WatermarkMediaTopRight = emptyWatermark(info.WatermarkMediaTopRight)
info.WatermarkMediaBottomLeft = emptyWatermark(info.WatermarkMediaBottomLeft)
}
// UpdateSystemConfig 更新系统配置
func UpdateSystemConfig(ctx context.Context, info *SysCfgUpdate) *srvdef.SrvErr {
applyEmptyWatermarkFields(info)
filter := bson.M{
cfg.FN.SysCfg.Id: 1, //固定id
}
fmt.Println("filter", filter)
updateDoc := bson.M{"$set": info}
serr := cfg.UpdateOneSysCfg(ctx, filter, updateDoc)
if serr != nil {
return serr
}
return nil
}
// SetAppKey 单独更新媒资库 appKey,并刷新内存缓存。
func SetAppKey(ctx context.Context, appKey string) *srvdef.SrvErr {
appKey = strings.TrimSpace(appKey)
filter := bson.M{
cfg.FN.SysCfg.Id: 1,
}
updateDoc := bson.M{"$set": bson.M{
cfg.FN.SysCfg.AppKey: appKey,
}}
if serr := cfg.UpdateOneSysCfg(ctx, filter, updateDoc); serr != nil {
return serr
}
cfg.CacheAppKey(appKey)
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package srv_cfg
import (
"go.mongodb.org/mongo-driver/bson/primitive"
)
type CDNNode struct {
Url *string `json:"url" bson:"url,omitempty"`
Ratio *int `json:"ratio" bson:"ratio,omitempty"`
}
type SysCfgUpdate struct {
UnencryptedCdn *string `json:"unencryptedCdn" bson:"unencryptedCdn,omitempty"` // 不加密资源使用的cdn
SitemapDefaultImages *[]string `json:"sitemapDefaultImages" bson:"sitemapDefaultImages,omitempty"` // Sitemap里面默认封面图
PdfAddr *string `json:"pdfAddr" bson:"pdfAddr"` // 回家的路PDF
ImgAppCdn *string `json:"imgAppCdn" bson:"imgAppCdn"` // 图片CDN(app使用)
ImgAppUsCdn *string `json:"imgAppUsCdn" bson:"imgAppUsCdn"` // 图片CDN(海外app使用)
ImgWebCdn *string `json:"imgWebCdn" bson:"imgWebCdn"` // 图片CDN(web使用)
MediaWebCdn *string `json:"mediaWebCdn" bson:"mediaWebCdn"` // 视频CDN(web使用)
MediaAppCdn *string `json:"mediaAppCdn" bson:"mediaAppCdn"` // 视频CDN(app使用)
MediaAppUsCdn *string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn"` // 视频CDN(海外app使用)
WatermarkImage *string `json:"watermarkImage" bson:"watermarkImage"` // 图片水印文件
WatermarkMedia *string `json:"watermarkMedia" bson:"watermarkMedia"` // 视频水印文件(底部右边)
WatermarkMediaTop *string `json:"watermarkMediaTop" bson:"watermarkMediaTop"` // 视频水印文件(顶部左边)
WatermarkMediaTopRight *string `json:"watermarkMediaTopRight" bson:"watermarkMediaTopRight"` // 视频水印文件(顶部右边)
WatermarkMediaBottomLeft *string `json:"watermarkMediaBottomLeft" bson:"watermarkMediaBottomLeft"` // 视频水印文件(底部左边)
Mp4Addr *string `json:"mp4Addr" bson:"mp4Addr"` // mp4地址(web使用)
NewUrlAddr *string `json:"newUrlAddr" bson:"newUrlAddr"` //最新地址
NewUrlAddr1 *string `json:"newUrlAddr1" bson:"newUrlAddr1"` //最新地址(备用地址1)
NewUrlAddr2 *string `json:"newUrlAddr2" bson:"newUrlAddr2"` //最新地址(备用地址2)
ForeverUrlAddr *string `json:"foreverUrlAddr" bson:"foreverUrlAddr"` //永久地址
GmailAddr *string `json:"gmailAddr" bson:"gmailAddr"` //邮箱地址
AppAddr *string `json:"appAddr" bson:"appAddr"` //APP下载地址(落地页)
DownloadVideoUrl *string `json:"downloadVideoUrl" bson:"downloadVideoUrl"` // 下载视频URL
WeiChart *string `json:"weiChart" bson:"weiChart"` //微信群
QQ *string `json:"qq" bson:"qq"` //qq群
TgGroup *string `json:"tgGroup" bson:"tgGroup"` //tg群
PotaoGroup *string `json:"potaoGroup" bson:"potaoGroup"` //土豆群
GitAddr *string `json:"gitAddr" bson:"gitAddr"` //git地址
TwitterAddr *string `json:"twitterAddr" bson:"twitterAddr"` //推特地址
Telegram *string `json:"telegram" bson:"telegram"` //商务tg
TelegramAdd *string `json:"telegramAdd" bson:"telegramAdd"` //加入我们tg
TranslationAddr *string `json:"translationAddr" bson:"translationAddr"` //应用导航
//SEO
WebTitle *string `json:"webTitle" bson:"webTitle"` // 网站SEO标题
WebKeyworld *[]string `json:"webKeyworld" bson:"webKeyworld"` // 网站SEO关键字
WebDescription *string `json:"webDescription" bson:"webDescription"` // 网站SEO描述
WebSiteName *string `json:"webSiteName" bson:"webSiteName"` // 网站SEO网站名称
WebAddr *string `json:"webAddr" bson:"webAddr"` // 网站SEO网站地址
DHNewUrlAddr1 *string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1"` // 导航页-线路一
DHNewUrlAddr2 *string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2"` // 导航页-线路2
DHNewUrlAddr3 *string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3"` // 导航页-线路3
DHTitle *string `json:"dhTitle" bson:"dhTitle"` // 导航SEO标题
DHKeyworld *[]string `json:"dhKeyworld" bson:"dhKeyworld"` // 导航SEO关键字
DHDescription *string `json:"dhDescription" bson:"dhDescription"` // 导航SEO描述
DHSiteName *string `json:"dhSiteName" bson:"dhSiteName"` // 导航SEO网站名称
ChannelCode *string `json:"channelCode" bson:"channelCode"` // 渠道码
EnableH265 *bool `json:"enableH265" bson:"enableH265"` // 是否允许客户端使用 H265 地址播放
M3U8DayRateLimitMax *int64 `json:"m3u8DayRateLimitMax" bson:"m3u8DayRateLimitMax"` // 每 IP 每天最多不同正片数,<=0 使用默认 80
// Acc2UserCDN *string `json:"acc2UserCDN" bson:"acc2UserCDN"` // up主视频播放CDN
// InviteAddr *string `json:"inviteAddr" bson:"inviteAddr"` // 用户邀请地址
// TelegramContact *string `json:"telegramContact" bson:"telegramContact"` // Telegram联系方式
// PotatoContact *string `json:"potatoContact" bson:"potatoContact"` // Potato联系方式
// DriverGroup *string `json:"driverGroup" bson:"driverGroup"` // 开车群
// CoinPrice *int64 `json:"coinPrice" bson:"coinPrice"` // 金币单价,单位分
// OfficialEmail *string `json:"officialEmail" bson:"officialEmail"` // 官方邮箱
// HeaderM3U8 *string `json:"headerM3U8" bson:"headerM3U8"` // 片头m3u8
// AppCenter *string `json:"appCenter" bson:"appCenter"` // 应用中心
// PayGuidPageUrl *string `json:"payGuidPageUrl" bson:"payGuidPageUrl"` // 支付引导页面地址
// ApiLimit *cfg.ApiCallLimit `json:"apiLimit" bson:"apiLimit"` // 访问接口和频率参数限制(每10秒)
// H5ShareUrl *string `json:"h5ShareUrl" bson:"h5ShareUrl"` // H5分享域名
// OnlineNum *int64 `json:"onlineNum" bson:"onlineNum"` // 在线人数
// Mp4Url *string `json:"mp4Url" bson:"mp4Url"` // MP4域名
// HotScoreConf *cfg.HotScoreConf `json:"hotScoreConf" bson:"hotScoreConf"` // 视频热度值配置
// MinimumDiscount *int64 `json:"minimumDiscount" bson:"minimumDiscount"` // 用户最低折扣
// AndroidGuestKey *string `json:"androdiGuestkey" bson:"androdiGuestkey"` // android注册签名
// H5GuestKey *string `json:"h5GuestKey" bson:"h5GuestKey"` // h5注册签名
// CustomerServiceHost *string `json:"customerServiceHost" bson:"customerServiceHost"` // 客服Host
// AdsType *cfg.AdsTypeConfig `json:"adsType" bson:"adsType"` // 广告模式
// FloatingWindow *FloatingWindow `json:"floatingWindow" bson:"floatingWindow"` // 悬浮窗
// IsLoliBlur *bool `json:"isLoliBlur" bson:"isLoliBlur,omitempty"` // 是否开启站群蒙版
// ChargeGiftChannel *string `json:"chargeGiftChannel" bson:"chargeGiftChannel"` // 充值赠送金币的渠道
// ChargeGiftGoldNumber *int64 `json:"chargeGiftGoldNumber" bson:"chargeGiftGoldNumber"` // 充值赠送金币数量
// AdminTG *string `json:"adminTG" bson:"adminTG"` // 管理员tg
// Theme *cfg.Theme `json:"theme" bson:"theme"`
}
type SysCfgMapParam struct {
Key string `json:"key" bson:"key"` //Key
StringVal string `json:"stringVal" bson:"stringVal"` //字符串的值
IntVal int64 `json:"intVal" bson:"intVal"` //int64的值
Desc string `json:"desc" bson:"desc"` //配置描述
}
type UpdateSysCfgMapReq struct {
Id primitive.ObjectID `json:"id" bson:"_id"` //ID
Param *SysCfgMapParam `json:"param" bson:"param"`
}
type DeleteSysCfgMapReq struct {
Id primitive.ObjectID `json:"id" bson:"_id"` //ID
}
type GetSysCfgMapReq struct {
Key *string `json:"key" bson:"key"`
Desc *string `json:"desc" bson:"desc"`
}
type FloatingWindow struct {
Enable bool `json:"enable" bson:"enable"`
Image string `json:"image" bson:"image"` //浮窗图片
Url string `json:"url" bson:"url"` //浮窗URL
ImageClose string `json:"imageClose" bson:"imageClose"` //浮窗图片
}
+417
View File
@@ -0,0 +1,417 @@
package srv_commend
import (
"context"
"strconv"
"time"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/commentlikemod"
"videoweb/pub/models/commentmod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func SearchComandList(ctx context.Context, param *CommendSearch) (*CommendSearchResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
if param.ObjectId != nil {
filter[commentmod.FN.Comment.ObjectId] = *param.ObjectId
}
if param.ObjectType != nil {
filter[commentmod.FN.Comment.ObjectType] = *param.ObjectType
}
if param.Level != nil {
filter[commentmod.FN.Comment.Level] = *param.Level
}
if param.UserName != nil {
filter[commentmod.FN.Comment.UserName] = bson.M{"$regex": *param.UserName}
}
if param.Text != nil {
filter[commentmod.FN.Comment.Text] = bson.M{"$regex": *param.Text}
}
if param.ReviewStatus != nil {
filter[commentmod.FN.Comment.ReviewStatus] = *param.ReviewStatus
}
sort := bson.D{
{commentmod.FN.Comment.TopRank, -1},
{commentmod.FN.Comment.CreatedAt, -1},
}
opt.SetSort(sort)
skip := (param.PageNum - 1) * param.PageSize
opt.SetSkip(skip)
opt.SetLimit(param.PageSize)
total, serr := commentmod.CountComments(ctx, filter)
if serr != nil {
return nil, serr
}
if total == 0 {
return &CommendSearchResp{}, nil
}
commends, serr := commentmod.FindManyComment(ctx, filter, opt)
if serr != nil {
return nil, serr
}
resp := &CommendSearchResp{
Total: total,
List: make([]*PostCommentInfo, 0),
}
for _, commendInfo := range commends {
tmp := &PostCommentInfo{
Id: commendInfo.Id,
ObjectId: commendInfo.ObjectId,
ObjectName: commendInfo.ObjectName,
UserName: commendInfo.UserName,
Level: commendInfo.Level,
ParentsId: commendInfo.ParentsId,
ReplyUserName: commendInfo.ReplyUserName,
Text: commendInfo.Text,
ReviewStatus: commendInfo.ReviewStatus,
UpdatedAt: commendInfo.UpdatedAt,
Ip: commendInfo.Ip,
CreatedAt: commendInfo.CreatedAt,
ObjectType: commendInfo.ObjectType,
Likes: commendInfo.CountLike,
}
resp.List = append(resp.List, tmp)
}
return resp, nil
}
// DelCommend 删除一条评论
func DelCommend(ctx context.Context, commendId int64) *srvdef.SrvErr {
commendInfo, serr := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: commendId})
if serr != nil {
return serr
}
_, serr = commentmod.DelOneComment(ctx, bson.M{commentmod.FN.Comment.Id: commendId})
if serr != nil {
return serr
}
if serr = commentlikemod.DeleteByCommentIds(ctx, []int64{commendId}); serr != nil {
return serr
}
clearCommentListCacheByComments(commentmod.CommentModelList{commendInfo})
if err := refreshParentReplyCounts(ctx, commentmod.CommentModelList{commendInfo}); err != nil {
return err
}
return updateCommentCountByComments(ctx, commentmod.CommentModelList{commendInfo})
}
// DelCommend 删除用户所有评论
func DelUserCommend(ctx context.Context, ip string) *srvdef.SrvErr {
commendList, serr := commentmod.FindManyComment(ctx, bson.M{commentmod.FN.Comment.Ip: ip})
if serr != nil {
return serr
}
_, serr = commentmod.DelManyComment(ctx, bson.M{commentmod.FN.Comment.Ip: ip})
if serr != nil {
return serr
}
if serr = commentlikemod.DeleteByCommentIds(ctx, commentIdsFromModels(commendList)); serr != nil {
return serr
}
clearCommentListCacheByComments(commendList)
if err := refreshParentReplyCounts(ctx, commendList); err != nil {
return err
}
return updateCommentCountByComments(ctx, commendList)
}
func commentIdsFromModels(comments commentmod.CommentModelList) []int64 {
ids := make([]int64, 0, len(comments))
for _, comment := range comments {
if comment != nil {
ids = append(ids, comment.Id)
}
}
return ids
}
// AddPostComment 发布评论
func AddPostComment(ctx context.Context, req *CommendReplay) *srvdef.SrvErr {
now := time.Now()
if req.Score > 10 {
log.Error("AddPostComment ErrParamError", log.Any("Score", req.Score))
return srvdef.New(srvdef.ErrParamError, "", "")
}
// 2. 生成评论相关信息
commentInfo := &commentmod.CommentModel{
Id: mongos.Lid.Comment.Get(),
ObjectId: req.ObjectId,
ObjectType: pubdef.CommentObjectTypeVideo,
ObjectName: pubdef.CommentObjectTypeName(pubdef.CommentObjectTypeVideo),
UserName: req.UserName,
ParentsId: req.ParentsId,
Text: req.Text,
Level: 1,
UpdatedAt: now,
CreatedAt: now,
}
if commentInfo.ParentsId > 0 {
commentInfo.Level = 2
} else {
commentInfo.Level = 1
}
commentInfo.ReviewStatus = 1 // 管理端的默认审核通过
// 3. 插入数据库
serr := commentmod.InsertOneComment(ctx, commentInfo)
if serr != nil {
log.Error("AddPostComment InsertOnePostComment", log.Any("req", req), log.Any("err", serr))
return serr
}
clearCommentListCacheByComments(commentmod.CommentModelList{commentInfo})
go func() {
_ = updateCommentCountByComments(ctx, commentmod.CommentModelList{commentInfo})
// 4. 修改回复条数
if req.ParentsId > 0 {
filter := bson.M{
commentmod.FN.Comment.Id: req.ParentsId,
}
updateDoc := bson.M{
"$inc": bson.M{
commentmod.FN.Comment.CommentCount: 1,
},
}
serr = commentmod.UpdateOneComment(ctx, filter, updateDoc)
if serr != nil {
log.Error("AddPostComment UpdateOnePostComment", log.Any("req", req), log.Any("err", serr))
return
}
}
}()
return nil
}
// 置顶评论
func TopPostComment(ctx context.Context, id int64) *srvdef.SrvErr {
filter := bson.M{
commentmod.FN.Comment.Id: id,
}
comment, serr := commentmod.FindOneComment(ctx, filter)
if serr != nil {
return serr
}
//获取置顶的一条评论
topRankComment, serr := getTopRankComment(ctx, comment.ObjectId)
if serr != nil {
return serr
}
if topRankComment.Id == id && topRankComment.TopRank != 0 {
return nil
}
serr = commentmod.UpdateOneComment(ctx, filter, bson.M{
"$set": bson.M{
commentmod.FN.Comment.TopRank: topRankComment.TopRank + 1,
},
})
if serr != nil {
return serr
}
clearCommentListCacheByComments(commentmod.CommentModelList{comment})
return nil
}
func getTopRankComment(ctx context.Context, objectId int64) (*commentmod.CommentModel, *srvdef.SrvErr) {
filter := bson.M{
commentmod.FN.Comment.ObjectId: objectId,
}
opts := options.FindOne().SetSort(bson.M{
commentmod.FN.Comment.TopRank: -1,
})
comment, serr := commentmod.FindOneComment(ctx, filter, opts)
if serr != nil {
return nil, serr
}
return comment, nil
}
func ReviewComment(ctx context.Context, id int64, reviewStatus int) *srvdef.SrvErr {
comment, err := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: id})
if err != nil {
return err
}
updateDoc := bson.M{
commentmod.FN.Comment.ReviewStatus: reviewStatus,
}
filter := bson.M{
commentmod.FN.Comment.Id: id,
}
serr := commentmod.UpdateOneComment(ctx, filter, bson.M{"$set": updateDoc})
if serr != nil {
log.Error("审核评论失败", log.Any("id", id), log.Any("reviewStatus", reviewStatus), log.Any("err", serr))
return serr
}
clearCommentListCacheByComments(commentmod.CommentModelList{comment})
if comment.ReviewStatus != reviewStatus {
if err := refreshParentReplyCounts(ctx, commentmod.CommentModelList{comment}); err != nil {
return err
}
}
return updateCommentCountByComments(ctx, commentmod.CommentModelList{comment})
}
// ReviewCommendBatch 批量更新
func ReviewCommendBatch(ctx context.Context, commendIds *[]int64, reviewStatus int) *srvdef.SrvErr {
if commendIds == nil || len(*commendIds) == 0 {
return srvdef.New(srvdef.ErrParamError, "", "")
}
commentList, err := commentmod.FindManyComment(ctx, bson.M{
commentmod.FN.Comment.Id: bson.M{"$in": *commendIds},
})
if err != nil {
return err
}
updateDoc := bson.M{
commentmod.FN.Comment.ReviewStatus: reviewStatus,
}
filter := bson.M{commentmod.FN.Comment.Id: bson.M{"$in": commendIds}}
serr := commentmod.UpdateManyComment(ctx, filter, bson.M{"$set": updateDoc})
if serr != nil {
log.Error("批量审核评论失败", log.Any("commendId", *commendIds), log.Any("reviewStatus", reviewStatus), log.Any("err", serr))
return serr
}
clearCommentListCacheByComments(commentList)
if err := refreshParentReplyCounts(ctx, commentList); err != nil {
return err
}
return updateCommentCountByComments(ctx, commentList)
}
// refreshParentReplyCounts 按已审核的直接回复重算父评论回复数
func refreshParentReplyCounts(ctx context.Context, comments commentmod.CommentModelList) *srvdef.SrvErr {
seen := make(map[int64]struct{}, len(comments))
for _, comment := range comments {
if comment == nil || comment.ParentsId <= 0 {
continue
}
if _, ok := seen[comment.ParentsId]; ok {
continue
}
seen[comment.ParentsId] = struct{}{}
count, err := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ParentsId: comment.ParentsId,
commentmod.FN.Comment.ReviewStatus: 1,
})
if err != nil {
return err
}
if err := commentmod.UpdateOneComment(ctx,
bson.M{commentmod.FN.Comment.Id: comment.ParentsId},
bson.M{"$set": bson.M{commentmod.FN.Comment.CommentCount: count}}); err != nil && err.Code != srvdef.ErrNoData {
return err
}
}
return nil
}
// clearCommentListCacheByComments 按对象ID和对象类型精确清理评论列表缓存
func clearCommentListCacheByComments(commentList commentmod.CommentModelList) {
seen := make(map[string]struct{}, len(commentList))
for _, comment := range commentList {
if comment == nil {
continue
}
key := commentCacheUniqueKey(comment.ObjectId, comment.ObjectType)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
cacheKey := redisconst.CommentListCacheKey(comment.ObjectId, comment.ObjectType)
if err := redis.Del(cacheKey); err != nil {
log.Warn("清理评论列表缓存失败", log.Any("cacheKey", cacheKey), log.E(err))
}
}
}
// commentCacheUniqueKey 生成对象维度去重key,避免批量审核时重复清理同一对象缓存
func commentCacheUniqueKey(objectId int64, objectType pubdef.CommentObjectType) string {
return strconv.FormatInt(objectId, 10) + "_" + strconv.FormatInt(int64(objectType), 10)
}
// UpdateCommentCount 评论审核通过/删除后,同步更新对应媒体内容的真实评论数
// objectType: 0=视频 4=短剧 11=短剧剧集
func UpdateCommentCount(ctx context.Context, commendIds []int64) *srvdef.SrvErr {
commentList, _ := commentmod.FindManyComment(ctx, bson.M{
commentmod.FN.Comment.Id: bson.M{"$in": commendIds},
})
return updateCommentCountByComments(ctx, commentList)
}
func updateCommentCountByComments(ctx context.Context, commentList commentmod.CommentModelList) *srvdef.SrvErr {
seen := make(map[string]struct{}, len(commentList))
for _, comment := range commentList {
if comment == nil {
continue
}
key := commentCacheUniqueKey(comment.ObjectId, comment.ObjectType)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
count, countErr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: comment.ObjectId,
commentmod.FN.Comment.ObjectType: comment.ObjectType,
commentmod.FN.Comment.ReviewStatus: 1,
})
if countErr != nil {
return countErr
}
var serr *srvdef.SrvErr
switch comment.ObjectType {
case pubdef.CommentObjectTypeVideo:
serr = videoinformationmod.UpdateOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: comment.ObjectId},
bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: count}},
)
case pubdef.CommentObjectTypePlaylet:
serr = playletmod.UpdateOnePlaylet(ctx,
bson.M{playletmod.FN.Playlet.Id: comment.ObjectId},
bson.M{"$set": bson.M{playletmod.FN.Playlet.CountComment: count}},
)
case pubdef.CommentObjectTypePlayletChapter:
serr = playletchaptermod.UpdateOnePlayletChapter(ctx,
bson.M{playletchaptermod.FN.PlayletChapter.Id: comment.ObjectId},
bson.M{"$set": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: count}},
)
}
if serr != nil {
log.Error("UpdateCommentCount 更新评论数失败",
log.Any("objectId", comment.ObjectId),
log.Any("objectType", comment.ObjectType),
log.Any("err", serr),
)
return serr
}
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package srv_commend
import (
"time"
"videoweb/pub/pubdef"
)
// CommendSearch 查询评论
type CommendSearch struct {
ObjectId *int64 `json:"objectId" bson:"objectId"` // 对象ID
ObjectType *pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集
UserName *string `json:"userName" bson:"userName"` // 用户名称
Level *int64 `json:"level" bson:"level"` // 评论级别
Text *string `json:"text" bson:"text"` // 评论内容
ReviewStatus *int `json:"reviewStatus"`
PageNum int64 `json:"pageNum"`
PageSize int64 `json:"pageSize"`
}
type PostCommentInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID
Ip string `json:"ip" bson:"ip"` // 评论IP
ObjectType pubdef.CommentObjectType `json:"objectType" bson:"objectType"` // 对象类型 0=视频 4=短剧 11=短剧剧集
ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID
ObjectName string `json:"objectName" bson:"objectName"` // 对象类型名称 视频/短剧
UserName string `json:"userName" bson:"userName"` // 用户名称
Level int64 `json:"level" bson:"level"` // 评论级别
ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID
ReplyUserName string `json:"replyUserName" bson:"replyUserName"` // 回复名称
Text string `json:"text" bson:"text"` // 评论内容
ReviewStatus int `json:"reviewStatus" bson:"reviewStatus"` // 审核状态 0 未审核 1审核成功 2 审核失败
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间
CreatedAt time.Time `bson:"createdAt" json:"createdAt"` // 创建时间
TopRank int64 `json:"topRank" bson:"topRank"` // 置顶排序
Likes int64 `json:"likes" bson:"likes"` // 点赞数
Status pubdef.UserStatus `json:"status" bson:"status"` // 评论用户的状态
}
type CommendSearchResp struct {
Total int64 `json:"total"`
List []*PostCommentInfo `json:"list"`
}
type CommendDelete struct {
Id *int64 `json:"id" bson:"_id" binding:"required"` // Id
}
type CommendReplay struct {
ObjectId int64 `json:"objectId"` // 对象ID
UserName string `json:"userName"` // 用户名
ParentsId int64 `json:"parentsId"` // 评论ID
Text string `json:"text"` // 评论内容
Score int64 `json:"score"` // 评分
}
type CommendUpdate struct {
Score *int64 `json:"score" bson:"score,omitempty"` // 评分
Likes *int64 `json:"likes" bson:"likes,omitempty"` // 点赞数量
}
type CommendUpdateReq struct {
Param CommendUpdate `json:"param" bson:"param"` // 参数
Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID
}
type CommendTopReq struct {
Id int64 `json:"id" bson:"_id,omitempty"` // 评论ID
}
type CommendReviewReq struct {
Id int64 `json:"id"`
ReviewStatus int `json:"reviewStatus"`
}
+68
View File
@@ -0,0 +1,68 @@
package srv_contactus
import (
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/contactusmod"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetContactusList(ctx *gin.Context, req *GetContactusListReq) (resp *GetContactusListResp, err *srvdef.SrvErr) {
filter := bson.M{}
if req.CategoryName != nil && len(*req.CategoryName) > 0 {
filter[contactusmod.FN.ContactUsModel.CategoryName] = *req.CategoryName
}
if req.Ip != nil && len(*req.Ip) > 0 {
filter[contactusmod.FN.ContactUsModel.Ip] = *req.Ip
}
if req.Name != nil && len(*req.Name) > 0 {
filter[contactusmod.FN.ContactUsModel.Name] = *req.Name
}
if req.Email != nil && len(*req.Email) > 0 {
filter[contactusmod.FN.ContactUsModel.Email] = *req.Email
}
var total int64
total, err = contactusmod.CountContactUsModels(ctx, filter)
if err != nil {
log.Warn("GetContactusList.CountContactUsModels", log.Any("filter", filter), log.E(err))
return
}
resp = &GetContactusListResp{
Total: 0,
List: make(contactusmod.ContactUsModelList, 0),
}
if total == 0 {
return resp, nil
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{contactusmod.FN.ContactUsModel.CreatedAt: -1})
opts.SetLimit(req.PageSize).SetSkip((req.PageNum - 1) * req.PageSize)
list, err := contactusmod.FindManyContactUsModel(ctx, filter, opts)
if err != nil {
log.Warn("GetContactusList.FindManyContactUsModels", log.Any("filter", filter), log.E(err))
return
}
resp.List = list
return resp, nil
}
func DelContactus(ctx *gin.Context, id primitive.ObjectID) *srvdef.SrvErr {
if id.IsZero() {
return srvdef.New(srvdef.ErrParamError)
}
_, err := contactusmod.DelOneContactUsModel(ctx, bson.M{contactusmod.FN.ContactUsModel.Id: id})
return err
}
func UpdateContactus(ctx *gin.Context, id primitive.ObjectID, status int64) *srvdef.SrvErr {
if id.IsZero() {
return srvdef.New(srvdef.ErrParamError)
}
err := contactusmod.UpdateOneContactUsModel(ctx, bson.M{contactusmod.FN.ContactUsModel.Id: id}, bson.M{
"$set": bson.M{contactusmod.FN.ContactUsModel.Status: status},
})
return err
}
+30
View File
@@ -0,0 +1,30 @@
package srv_contactus
import (
"videoweb/pub/models/contactusmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type GetContactusListReq struct {
pubdef.Page
Ip *string `form:"ip" json:"ip"` // ip
Name *string `form:"name" json:"name"` // 姓名
Email *string `form:"email" json:"email"` // 邮箱
CategoryName *string `form:"categoryName" json:"categoryName"` // 分类
}
type GetContactusListResp struct {
Total int64 `json:"total"`
List contactusmod.ContactUsModelList `json:"list"`
}
type DelOneContactusReq struct {
Id primitive.ObjectID `form:"id" json:"id"`
}
type UpdateContactusReq struct {
Id primitive.ObjectID `form:"id" json:"id"`
Status int64 `form:"status" json:"status"`
}
@@ -0,0 +1,325 @@
package srv_contentexcel
import (
"context"
"fmt"
"io"
"math/rand"
"strconv"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const queryBatchSize = 500
var importAddedTimeStart = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local)
func randomImportAddedTime(r *rand.Rand) time.Time {
now := time.Now()
if !now.After(importAddedTimeStart) {
return now
}
if r == nil {
r = rand.New(rand.NewSource(time.Now().UnixNano()))
}
delta := now.UnixNano() - importAddedTimeStart.UnixNano()
if delta <= 0 {
return now
}
return importAddedTimeStart.Add(time.Duration(r.Int63n(delta + 1)))
}
// Export 按条件导出视频 SEO 字段为 Excel。
func Export(ctx context.Context, req *ExportReq) ([]byte, string, *srvdef.SrvErr) {
contentType, serr := normalizeContentType(req.Type)
if serr != nil {
return nil, "", serr
}
dateFilter, serr := buildTimeRangeFilter(req.StartTime, req.EndTime)
if serr != nil {
return nil, "", serr
}
ids, serr := normalizeExportIDs(req.Ids)
if serr != nil {
return nil, "", serr
}
var rows []ContentRow
var err error
switch contentType {
case ContentTypeVideo:
rows, err = queryVideoRows(ctx, dateFilter, req.Status, ids)
}
if err != nil {
return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error())
}
data, err := buildWorkbook(rows)
if err != nil {
return nil, "", srvdef.New(srvdef.ExcelExportErr, err.Error())
}
filename := fmt.Sprintf("%s_content_%s.xlsx", contentType, time.Now().Format("20060102150405"))
return data, filename, nil
}
// Import 读取 Excel 并按 ID 批量更新标题、描述、关键词。
func Import(ctx context.Context, contentType string, reader io.Reader) (*ImportResult, *srvdef.SrvErr) {
normalizedType, serr := normalizeContentType(contentType)
if serr != nil {
return nil, serr
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "读取上传文件失败")
}
rows, err := parseWorkbook(data)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, err.Error())
}
result := &ImportResult{Total: len(rows)}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for _, row := range rows {
var updateErr *srvdef.SrvErr
switch normalizedType {
case ContentTypeVideo:
updateErr = updateVideoRow(ctx, row, r)
}
if updateErr != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("ID=%d: %s", row.ID, updateErr.Tips))
continue
}
result.Success++
}
return result, nil
}
func normalizeContentType(raw string) (string, *srvdef.SrvErr) {
switch stringsTrimLower(raw) {
case ContentTypeVideo:
return stringsTrimLower(raw), nil
default:
return "", srvdef.New(srvdef.ErrParamError, "type 仅支持 video")
}
}
func stringsTrimLower(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func buildTimeRangeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
start, err := parseOptionalTime(startTime, false)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "startTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05")
}
end, err := parseOptionalTime(endTime, true)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "endTime 格式错误,支持 2006-01-02 或 2006-01-02 15:04:05")
}
if start != nil && end != nil && start.After(*end) {
return nil, srvdef.New(srvdef.ErrParamError, "startTime 不能晚于 endTime")
}
if start != nil {
filter["$gte"] = *start
}
if end != nil {
filter["$lte"] = *end
}
if len(filter) == 0 {
return nil, nil
}
return filter, nil
}
func parseOptionalTime(raw string, endOfDay bool) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
time.RFC3339,
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return &t, nil
}
}
if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil {
if endOfDay {
end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
return &end, nil
}
return &t, nil
}
return nil, fmt.Errorf("invalid time: %s", raw)
}
func applyTimeFilter(base bson.M, field string, rangeFilter bson.M) bson.M {
if len(rangeFilter) == 0 {
return base
}
base[field] = rangeFilter
return base
}
func applyStatusFilter(base bson.M, field string, status *pubdef.InformationStatus) bson.M {
if status == nil {
return base
}
base[field] = *status
return base
}
func applyIDsFilter(base bson.M, field string, ids []int64) bson.M {
if len(ids) == 0 {
return base
}
base[field] = bson.M{"$in": ids}
return base
}
// ParseExportIDs 解析导出接口的 ids 查询参数,支持 ids=1&ids=2 或 ids=1,2,3。
func ParseExportIDs(raw []string) ([]int64, *srvdef.SrvErr) {
if len(raw) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(raw))
seen := make(map[int64]struct{}, len(raw))
for _, item := range raw {
item = strings.TrimSpace(item)
if item == "" {
continue
}
for _, part := range strings.Split(item, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseInt(part, 10, 64)
if err != nil || id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
return ids, nil
}
func normalizeExportIDs(raw []int64) ([]int64, *srvdef.SrvErr) {
if len(raw) == 0 {
return nil, nil
}
ids := make([]int64, 0, len(raw))
seen := make(map[int64]struct{}, len(raw))
for _, id := range raw {
if id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids 格式错误,需为正整数")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func queryVideoRows(ctx context.Context, dateFilter bson.M, status *pubdef.InformationStatus, ids []int64) ([]ContentRow, error) {
filter := applyIDsFilter(applyStatusFilter(applyTimeFilter(bson.M{
videoinformationmod.FN.VideoInformation.MediaType: bson.M{
"$in": []videoinformationmod.MediaType{
videoinformationmod.MediaType_Video,
videoinformationmod.MediaType_AV,
},
},
}, videoinformationmod.FN.VideoInformation.CreatedAt, dateFilter),
videoinformationmod.FN.VideoInformation.Status, status),
videoinformationmod.FN.VideoInformation.Id, ids)
return queryRowsInBatches(ctx, func(skip int64) ([]ContentRow, bool, error) {
opt := options.Find().
SetSkip(skip).
SetLimit(queryBatchSize).
SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}).
SetProjection(bson.M{
videoinformationmod.FN.VideoInformation.Id: 1,
videoinformationmod.FN.VideoInformation.Title: 1,
videoinformationmod.FN.VideoInformation.Desc: 1,
videoinformationmod.FN.VideoInformation.Keyworld: 1,
})
list, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt)
if serr != nil {
return nil, false, fmt.Errorf("%s", serr.Tips)
}
rows := make([]ContentRow, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
rows = append(rows, ContentRow{
ID: item.Id,
Title: item.Title,
Desc: item.Desc,
Keyworld: item.Keyworld,
})
}
return rows, int64(len(list)) == queryBatchSize, nil
})
}
func queryRowsInBatches(ctx context.Context, fetch func(skip int64) ([]ContentRow, bool, error)) ([]ContentRow, error) {
all := make([]ContentRow, 0)
var skip int64
for {
rows, hasMore, err := fetch(skip)
if err != nil {
return nil, err
}
all = append(all, rows...)
if !hasMore {
break
}
skip += queryBatchSize
}
return all, nil
}
func updateVideoRow(ctx context.Context, row ContentRow, r *rand.Rand) *srvdef.SrvErr {
now := time.Now()
addedTime := randomImportAddedTime(r)
update := bson.M{
"$set": bson.M{
videoinformationmod.FN.VideoInformation.Title: row.Title,
videoinformationmod.FN.VideoInformation.Desc: row.Desc,
videoinformationmod.FN.VideoInformation.Keyworld: row.Keyworld,
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
videoinformationmod.FN.VideoInformation.AddedTime: addedTime,
videoinformationmod.FN.VideoInformation.UpdatedAt: now,
},
}
return videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: row.ID,
}, update)
}
+32
View File
@@ -0,0 +1,32 @@
package srv_contentexcel
import "videoweb/pub/pubdef"
const (
ContentTypeVideo = "video"
)
// ExportReq 导出查询参数。
type ExportReq struct {
Type string `form:"type" binding:"required"` // video
StartTime string `form:"startTime"` // 可选,按 createdAt 筛选,格式 2006-01-02 或 2006-01-02 15:04:05
EndTime string `form:"endTime"` // 可选,按 createdAt 筛选
Status *pubdef.InformationStatus `form:"status"` // 可选,0初始化 1上架 2下架,不传查全部
Ids []int64 // 可选,按 ID 筛选,支持 ids=1&ids=2 或 ids=1,2,3
}
// ImportResult 导入结果。
type ImportResult struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
}
// ContentRow Excel 单行数据。
type ContentRow struct {
ID int64
Title string
Desc string
Keyworld []string
}
+160
View File
@@ -0,0 +1,160 @@
package srv_contentexcel
import (
"bytes"
"fmt"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
const (
colID = "ID"
colTitle = "标题"
colDesc = "描述"
colKeyworld = "关键词"
colRedLineRemark = "备注(红线-建议下架)"
sheetName = "Sheet1"
excelHeaders = 4
colRedLineRemarkIdx = 4
)
var headerAliases = map[string]int{
"id": 0, colID: 0,
"title": 1, colTitle: 1,
"desc": 2, "description": 2, colDesc: 2,
"keyworld": 3, "keywords": 3, "keyword": 3, colKeyworld: 3,
colRedLineRemark: colRedLineRemarkIdx,
}
func buildWorkbook(rows []ContentRow) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
if err := f.SetSheetRow(sheetName, "A1", &[]string{colID, colTitle, colDesc, colKeyworld}); err != nil {
return nil, err
}
for i, row := range rows {
cell, err := excelize.CoordinatesToCellName(1, i+2)
if err != nil {
return nil, err
}
if err := f.SetSheetRow(sheetName, cell, &[]any{
row.ID,
row.Title,
row.Desc,
strings.Join(row.Keyworld, ","),
}); err != nil {
return nil, err
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func parseWorkbook(data []byte) ([]ContentRow, error) {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("打开 Excel 失败: %w", err)
}
defer f.Close()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("Excel 无工作表")
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return nil, fmt.Errorf("读取 Excel 失败: %w", err)
}
if len(rows) <= 1 {
return nil, fmt.Errorf("Excel 无数据行")
}
colIndex := mapHeaderColumns(rows[0])
if len(colIndex) < excelHeaders {
return nil, fmt.Errorf("Excel 表头需包含: ID、标题、描述、关键词")
}
result := make([]ContentRow, 0, len(rows)-1)
for lineNo, row := range rows[1:] {
contentRow, ok, err := parseDataRow(row, colIndex, lineNo+2)
if err != nil {
return nil, err
}
if !ok {
continue
}
result = append(result, contentRow)
}
if len(result) == 0 {
return nil, fmt.Errorf("Excel 无有效数据行")
}
return result, nil
}
func mapHeaderColumns(header []string) map[int]int {
index := make(map[int]int, excelHeaders)
for i, name := range header {
key := strings.ToLower(strings.TrimSpace(name))
if col, ok := headerAliases[key]; ok {
index[col] = i
}
}
return index
}
func parseDataRow(row []string, colIndex map[int]int, lineNo int) (ContentRow, bool, error) {
if remarkIdx, ok := colIndex[colRedLineRemarkIdx]; ok {
if cellValue(row, remarkIdx) != "" {
return ContentRow{}, false, nil
}
}
idStr := cellValue(row, colIndex[0])
if idStr == "" {
return ContentRow{}, false, nil
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
return ContentRow{}, false, fmt.Errorf("第 %d 行 ID 无效: %q", lineNo, idStr)
}
return ContentRow{
ID: id,
Title: cellValue(row, colIndex[1]),
Desc: cellValue(row, colIndex[2]),
Keyworld: parseKeywords(cellValue(row, colIndex[3])),
}, true, nil
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func parseKeywords(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
raw = strings.NewReplacer(",", ",", ";", ",", ";", ",").Replace(raw)
parts := strings.Split(raw, ",")
keywords := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
keywords = append(keywords, part)
}
}
return keywords
}
+201
View File
@@ -0,0 +1,201 @@
package srv_contentexcel
import (
"bytes"
"math/rand"
"testing"
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"github.com/xuri/excelize/v2"
)
func TestBuildTimeRangeFilter(t *testing.T) {
t.Parallel()
filter, err := buildTimeRangeFilter("2026-01-01", "2026-01-31")
if err != nil {
t.Fatalf("buildTimeRangeFilter() err = %v", err)
}
if filter["$gte"] == nil || filter["$lte"] == nil {
t.Fatalf("buildTimeRangeFilter() = %#v, want range filter", filter)
}
empty, err := buildTimeRangeFilter("", "")
if err != nil {
t.Fatalf("buildTimeRangeFilter() empty err = %v", err)
}
if len(empty) != 0 {
t.Fatalf("buildTimeRangeFilter() empty = %#v, want nil/empty", empty)
}
}
func TestApplyStatusFilter(t *testing.T) {
t.Parallel()
base := bson.M{"a": 1}
if got := applyStatusFilter(base, "status", nil); len(got) != 1 {
t.Fatalf("nil status should not add filter: %#v", got)
}
status := pubdef.InformationStatusVerifyOK
got := applyStatusFilter(bson.M{}, videoinformationmod.FN.VideoInformation.Status, &status)
if got[videoinformationmod.FN.VideoInformation.Status] != pubdef.InformationStatusVerifyOK {
t.Fatalf("applyStatusFilter() status = %#v", got[videoinformationmod.FN.VideoInformation.Status])
}
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local)
end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.Local)
dateFilter := bson.M{"$gte": start, "$lte": end}
videoFilter := applyIDsFilter(applyStatusFilter(
applyTimeFilter(bson.M{}, videoinformationmod.FN.VideoInformation.CreatedAt, dateFilter),
videoinformationmod.FN.VideoInformation.Status, &status,
), videoinformationmod.FN.VideoInformation.Id, []int64{1001, 1002})
if videoFilter[videoinformationmod.FN.VideoInformation.CreatedAt] == nil {
t.Fatal("expected createdAt filter")
}
if videoFilter[videoinformationmod.FN.VideoInformation.Status] != pubdef.InformationStatusVerifyOK {
t.Fatal("expected status filter")
}
if videoFilter[videoinformationmod.FN.VideoInformation.Id] == nil {
t.Fatal("expected id filter")
}
}
func TestParseExportIDs(t *testing.T) {
t.Parallel()
ids, err := ParseExportIDs([]string{"1001,1002", "1003"})
if err != nil {
t.Fatalf("ParseExportIDs() err = %v", err)
}
if len(ids) != 3 || ids[0] != 1001 || ids[1] != 1002 || ids[2] != 1003 {
t.Fatalf("ParseExportIDs() = %v", ids)
}
dup, err := ParseExportIDs([]string{"1001", "1001,1002"})
if err != nil {
t.Fatalf("ParseExportIDs() dup err = %v", err)
}
if len(dup) != 2 {
t.Fatalf("ParseExportIDs() dup = %v, want 2 unique ids", dup)
}
if _, err := ParseExportIDs([]string{"abc"}); err == nil {
t.Fatal("ParseExportIDs() invalid id should fail")
}
}
func TestParseKeywords(t *testing.T) {
t.Parallel()
got := parseKeywords("a,b, c;d")
want := []string{"a", "b", "c", "d"}
if len(got) != len(want) {
t.Fatalf("parseKeywords() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("parseKeywords()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestMapHeaderColumns(t *testing.T) {
t.Parallel()
index := mapHeaderColumns([]string{"ID", "标题", "描述", "关键词"})
for i := 0; i < excelHeaders; i++ {
if _, ok := index[i]; !ok {
t.Fatalf("missing column index %d", i)
}
}
}
func TestParseWorkbook(t *testing.T) {
t.Parallel()
rows := []ContentRow{{
ID: 1001,
Title: "标题A",
Desc: "描述A",
Keyworld: []string{"k1", "k2"},
}}
data, err := buildWorkbook(rows)
if err != nil {
t.Fatalf("buildWorkbook() err = %v", err)
}
parsed, err := parseWorkbook(data)
if err != nil {
t.Fatalf("parseWorkbook() err = %v", err)
}
if len(parsed) != 1 {
t.Fatalf("parseWorkbook() len = %d, want 1", len(parsed))
}
if parsed[0].ID != 1001 || parsed[0].Title != "标题A" {
t.Fatalf("parseWorkbook() = %+v", parsed[0])
}
}
func TestParseWorkbookSkipRedLineRemark(t *testing.T) {
t.Parallel()
f := excelize.NewFile()
defer f.Close()
if err := f.SetSheetRow(sheetName, "A1", &[]string{colID, colTitle, colDesc, colKeyworld, colRedLineRemark}); err != nil {
t.Fatalf("SetSheetRow header err = %v", err)
}
if err := f.SetSheetRow(sheetName, "A2", &[]any{1001, "标题A", "描述A", "k1", "建议下架"}); err != nil {
t.Fatalf("SetSheetRow row1 err = %v", err)
}
if err := f.SetSheetRow(sheetName, "A3", &[]any{1002, "标题B", "描述B", "k2", ""}); err != nil {
t.Fatalf("SetSheetRow row2 err = %v", err)
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("Write() err = %v", err)
}
parsed, err := parseWorkbook(buf.Bytes())
if err != nil {
t.Fatalf("parseWorkbook() err = %v", err)
}
if len(parsed) != 1 {
t.Fatalf("parseWorkbook() len = %d, want 1", len(parsed))
}
if parsed[0].ID != 1002 {
t.Fatalf("parseWorkbook() ID = %d, want 1002", parsed[0].ID)
}
}
func TestRandomImportAddedTime(t *testing.T) {
t.Parallel()
r := rand.New(rand.NewSource(1))
for i := 0; i < 20; i++ {
got := randomImportAddedTime(r)
if got.Before(importAddedTimeStart) {
t.Fatalf("randomImportAddedTime() = %v, before %v", got, importAddedTimeStart)
}
if got.After(time.Now()) {
t.Fatalf("randomImportAddedTime() = %v, after now", got)
}
}
}
func TestNormalizeContentType(t *testing.T) {
t.Parallel()
if _, err := normalizeContentType("video"); err != nil {
t.Fatalf("video should be valid: %v", err)
}
if _, err := normalizeContentType("post"); err == nil {
t.Fatal("post should be invalid")
}
}
+592
View File
@@ -0,0 +1,592 @@
package srv_contenttag
import (
"context"
"regexp"
"sort"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/mwacc"
"videoweb/pub/pubdef"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
defaultContentTagPageSize int64 = 10
maxContentTagPageSize int64 = 100
maxContentTagOptionSize int64 = 200
)
var contentTagWebProjection = bson.D{
{Key: contenttagmod.FN.ContentTag.Id, Value: 1},
{Key: contenttagmod.FN.ContentTag.Name, Value: 1},
{Key: contenttagmod.FN.ContentTag.Slug, Value: 1},
{Key: contenttagmod.FN.ContentTag.Types, Value: 1},
{Key: contenttagmod.FN.ContentTag.Status, Value: 1},
{Key: contenttagmod.FN.ContentTag.Rank, Value: 1},
{Key: contenttagmod.FN.ContentTag.VideoCount, Value: 1},
{Key: contenttagmod.FN.ContentTag.PlayletCount, Value: 1},
{Key: contenttagmod.FN.ContentTag.SeoInfo.Title, Value: 1},
{Key: contenttagmod.FN.ContentTag.SeoInfo.Description, Value: 1},
{Key: contenttagmod.FN.ContentTag.SeoInfo.Keyworld, Value: 1},
{Key: contenttagmod.FN.ContentTag.CreatedById, Value: 1},
{Key: contenttagmod.FN.ContentTag.CreatedByName, Value: 1},
{Key: contenttagmod.FN.ContentTag.UpdatedByName, Value: 1},
{Key: contenttagmod.FN.ContentTag.CreatedAt, Value: 1},
{Key: contenttagmod.FN.ContentTag.UpdatedAt, Value: 1},
}
// ContentTagBindChange 保存一次内容标签变更前后的标签ID集合
type ContentTagBindChange struct {
OldTagIds []int64
NewTagIds []int64
}
// ListContentTag 分页查询统一标签列表
func ListContentTag(ctx context.Context, req *proto.ContentTagListReq) (*proto.ContentTagListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[contenttagmod.FN.ContentTag.Id] = *req.Id
}
if req.Status != nil {
filter[contenttagmod.FN.ContentTag.Status] = *req.Status
}
if req.Type != nil {
if !isValidContentTagType(*req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
filter[contenttagmod.FN.ContentTag.Types] = *req.Type
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[contenttagmod.FN.ContentTag.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
pageNum, pageSize := normalizeContentTagPage(req.PageNum, req.PageSize)
totalCount, err := contenttagmod.CountContentTag(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.ContentTagListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.ContentTagListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := contenttagmod.FindManyContentTag(ctx, filter, options.Find().
SetProjection(contentTagWebProjection).
SetSort(bson.D{
{Key: contenttagmod.FN.ContentTag.Rank, Value: -1},
{Key: contenttagmod.FN.ContentTag.UpdatedAt, Value: -1},
{Key: contenttagmod.FN.ContentTag.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.ContentTagListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, contentTagListItem(item))
}
resp.List = items
return resp, nil
}
// AddContentTag 新增统一标签
func AddContentTag(ctx context.Context, req *proto.ContentTagAddReq, operatorId int64) (*proto.ContentTagAddResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "")
}
if req.Status == nil {
return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "")
}
types, err := normalizeContentTagTypes(req.Types)
if err != nil {
return nil, err
}
operatorId, operatorName := resolveContentTagOperator(ctx, operatorId)
doc := &contenttagmod.ContentTag{
Name: name,
Slug: req.Slug,
Types: types,
Status: *req.Status,
Rank: req.Rank,
VideoCount: 0,
PlayletCount: 0,
SeoInfo: normalizeContentTagSeoValue(req.SeoInfo),
CreatedById: operatorId,
CreatedByName: operatorName,
UpdatedByName: operatorName,
}
exists := func(s string) bool {
item, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter(
contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, 0))
return item != nil
}
if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, name), exists); serr != nil {
return nil, serr
}
if err := contenttagmod.InsertOneContentTag(ctx, doc); err != nil {
return nil, err
}
return &proto.ContentTagAddResp{Id: doc.Id}, nil
}
// UpdateContentTag 更新统一标签
func UpdateContentTag(ctx context.Context, req *proto.ContentTagUpdateReq, operatorId int64) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "标签ID无效", "")
}
current, err := contenttagmod.FindOneContentTag(ctx, bson.M{
contenttagmod.FN.ContentTag.Id: req.Id,
}, options.FindOne().SetProjection(contentTagWebProjection))
if err != nil {
return err
}
nextTypes := append([]contenttagmod.ContentTagType{}, current.Types...)
nextVideoCount := current.VideoCount
nextPlayletCount := current.PlayletCount
setDoc := bson.M{contenttagmod.FN.ContentTag.UpdatedAt: time.Now()}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "")
}
setDoc[contenttagmod.FN.ContentTag.Name] = name
}
if req.Types != nil {
types, err := normalizeContentTagTypes(*req.Types)
if err != nil {
return err
}
nextTypes = types
setDoc[contenttagmod.FN.ContentTag.Types] = types
}
if req.Status != nil {
setDoc[contenttagmod.FN.ContentTag.Status] = *req.Status
}
if req.Rank != nil {
setDoc[contenttagmod.FN.ContentTag.Rank] = *req.Rank
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeContentTagSeoValue(*req.SeoInfo)
}
name := current.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := current.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeContentTagSeoValue(*req.SeoInfo).Keyworld
}
exists := func(s string) bool {
item, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter(
contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, req.Id))
return item != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists)
if serr != nil {
return serr
}
if changed {
setDoc[contenttagmod.FN.ContentTag.Slug] = slug
}
if err := validateContentTagCounts(nextTypes, nextVideoCount, nextPlayletCount); err != nil {
return err
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
_, operatorName := resolveContentTagOperator(ctx, operatorId)
setDoc[contenttagmod.FN.ContentTag.UpdatedByName] = operatorName
return contenttagmod.UpdateOneContentTag(ctx,
bson.M{contenttagmod.FN.ContentTag.Id: req.Id},
bson.M{"$set": setDoc},
)
}
// DelContentTag 删除统一标签,存在任一类型绑定数量时禁止删除
func DelContentTag(ctx context.Context, req *proto.ContentTagDelReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "标签ID无效", "")
}
tag, err := contenttagmod.FindOneContentTag(ctx, bson.M{
contenttagmod.FN.ContentTag.Id: req.Id,
}, options.FindOne().SetProjection(contentTagWebProjection))
if err != nil {
return err
}
if tag.VideoCount > 0 || tag.PlayletCount > 0 {
return srvdef.New(srvdef.ErrParamError, "标签已被内容绑定,无法删除", "")
}
count, err := contenttagmod.DelOneContentTag(ctx, bson.M{contenttagmod.FN.ContentTag.Id: req.Id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// ListContentTagOption 查询启用的统一标签下拉列表,传type时按支持类型过滤
func ListContentTagOption(ctx context.Context, req *proto.ContentTagOptionReq) (*proto.ContentTagOptionResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{
contenttagmod.FN.ContentTag.Status: true,
}
if req.Type != nil {
if !isValidContentTagType(*req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
filter[contenttagmod.FN.ContentTag.Types] = *req.Type
}
list, err := contenttagmod.FindManyContentTag(ctx, filter, options.Find().
SetProjection(contentTagWebProjection).
SetSort(bson.D{
{Key: contenttagmod.FN.ContentTag.Rank, Value: -1},
{Key: contenttagmod.FN.ContentTag.Id, Value: -1},
}).
SetLimit(maxContentTagOptionSize))
if err != nil {
return nil, err
}
resp := &proto.ContentTagOptionResp{List: make([]*proto.ContentTagOptionItem, 0, len(list))}
for _, item := range list {
if item == nil {
continue
}
resp.List = append(resp.List, &proto.ContentTagOptionItem{
Id: item.Id,
Name: item.Name,
Types: append([]contenttagmod.ContentTagType{}, item.Types...),
TypeNames: contentTagTypeNames(item.Types),
Rank: item.Rank,
BindCount: contentTagOptionBindCount(item, req.Type),
})
}
return resp, nil
}
// NormalizeContentTagIds 校验标签ID并去重,保留原有顺序便于内容详情按后台提交顺序展示
func NormalizeContentTagIds(tagIds []int64) ([]int64, *srvdef.SrvErr) {
result := normalizeContentTagIdsForDiff(tagIds)
if len(result) != len(tagIds) {
for _, tagId := range tagIds {
if tagId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "标签ID无效", "")
}
}
}
return result, nil
}
// ValidateContentTags 校验统一标签存在、启用,并且支持当前内容类型
func ValidateContentTags(ctx context.Context, tagIds []int64, tagType contenttagmod.ContentTagType) *srvdef.SrvErr {
ids, err := NormalizeContentTagIds(tagIds)
if err != nil {
return err
}
if len(ids) == 0 {
return nil
}
if !isValidContentTagType(tagType) {
return srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
count, err := contenttagmod.CountContentTag(ctx, bson.M{
contenttagmod.FN.ContentTag.Id: bson.M{"$in": ids},
contenttagmod.FN.ContentTag.Status: true,
contenttagmod.FN.ContentTag.Types: tagType,
})
if err != nil {
return err
}
if count != int64(len(ids)) {
return srvdef.New(srvdef.ErrParamError, "标签不存在、未启用或不支持当前内容类型", "")
}
return nil
}
// SyncContentTagBindCount 按绑定差异增减统一标签对应类型的绑定数量
func SyncContentTagBindCount(ctx context.Context, oldTagIds, newTagIds []int64, tagType contenttagmod.ContentTagType) *srvdef.SrvErr {
return SyncContentTagBindCountBatch(ctx, []ContentTagBindChange{
{OldTagIds: oldTagIds, NewTagIds: newTagIds},
}, tagType)
}
// SyncContentTagBindCountBatch 聚合多条内容标签变更后批量增减统一标签对应类型的绑定数量
func SyncContentTagBindCountBatch(ctx context.Context, changes []ContentTagBindChange, tagType contenttagmod.ContentTagType) *srvdef.SrvErr {
if !isValidContentTagType(tagType) {
return srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
field, err := contentTagCountField(tagType)
if err != nil {
return err
}
deltaMap := make(map[int64]int64)
for _, change := range changes {
addIds, removeIds := diffContentTagIds(
normalizeContentTagIdsForDiff(change.OldTagIds),
normalizeContentTagIdsForDiff(change.NewTagIds),
)
for _, tagId := range addIds {
deltaMap[tagId]++
}
for _, tagId := range removeIds {
deltaMap[tagId]--
}
}
if len(deltaMap) == 0 {
return nil
}
now := time.Now()
docs := make([]mongo.WriteModel, 0, len(deltaMap))
for tagId, delta := range deltaMap {
if delta == 0 {
continue
}
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{contenttagmod.FN.ContentTag.Id: tagId}).
SetUpdate(bson.A{
bson.M{"$set": bson.M{
field: bson.M{"$max": bson.A{
0,
bson.M{"$add": bson.A{
bson.M{"$ifNull": bson.A{"$" + field, 0}},
delta,
}},
}},
contenttagmod.FN.ContentTag.UpdatedAt: now,
}},
}))
}
if len(docs) == 0 {
return nil
}
_, err = contenttagmod.BulkWriteContentTag(ctx, docs, options.BulkWrite().SetOrdered(false))
if err != nil {
return err
}
return nil
}
// normalizeContentTagPage 规范分页参数,限制单页数量避免大分页拖慢查询
func normalizeContentTagPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultContentTagPageSize
}
if pageSize > maxContentTagPageSize {
pageSize = maxContentTagPageSize
}
return pageNum, pageSize
}
func normalizeContentTagIdsForDiff(tagIds []int64) []int64 {
result := make([]int64, 0, len(tagIds))
seen := make(map[int64]struct{}, len(tagIds))
for _, tagId := range tagIds {
if tagId <= 0 {
continue
}
if _, ok := seen[tagId]; ok {
continue
}
seen[tagId] = struct{}{}
result = append(result, tagId)
}
return result
}
func diffContentTagIds(oldTagIds, newTagIds []int64) ([]int64, []int64) {
oldMap := make(map[int64]struct{}, len(oldTagIds))
newMap := make(map[int64]struct{}, len(newTagIds))
for _, tagId := range oldTagIds {
oldMap[tagId] = struct{}{}
}
for _, tagId := range newTagIds {
newMap[tagId] = struct{}{}
}
addIds := make([]int64, 0)
removeIds := make([]int64, 0)
for _, tagId := range newTagIds {
if _, ok := oldMap[tagId]; !ok {
addIds = append(addIds, tagId)
}
}
for _, tagId := range oldTagIds {
if _, ok := newMap[tagId]; !ok {
removeIds = append(removeIds, tagId)
}
}
return addIds, removeIds
}
func contentTagCountField(tagType contenttagmod.ContentTagType) (string, *srvdef.SrvErr) {
switch tagType {
case contenttagmod.ContentTagTypeVideo:
return contenttagmod.FN.ContentTag.VideoCount, nil
case contenttagmod.ContentTagTypePlaylet:
return contenttagmod.FN.ContentTag.PlayletCount, nil
default:
return "", srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
}
func contentTagBindCount(item *contenttagmod.ContentTag, tagType contenttagmod.ContentTagType) int64 {
switch tagType {
case contenttagmod.ContentTagTypeVideo:
return item.VideoCount
case contenttagmod.ContentTagTypePlaylet:
return item.PlayletCount
default:
return 0
}
}
func contentTagOptionBindCount(item *contenttagmod.ContentTag, tagType *contenttagmod.ContentTagType) int64 {
if tagType == nil {
return 0
}
return contentTagBindCount(item, *tagType)
}
// normalizeContentTagTypes 去重并校验统一标签支持的内容类型
func normalizeContentTagTypes(types []contenttagmod.ContentTagType) ([]contenttagmod.ContentTagType, *srvdef.SrvErr) {
if len(types) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "标签类型不能为空", "")
}
seen := make(map[contenttagmod.ContentTagType]struct{}, len(types))
result := make([]contenttagmod.ContentTagType, 0, len(types))
for _, item := range types {
if !isValidContentTagType(item) {
return nil, srvdef.New(srvdef.ErrParamError, "标签类型无效", "")
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
sort.Slice(result, func(i, j int) bool {
return result[i] < result[j]
})
return result, nil
}
// isValidContentTagType 判断统一标签类型是否为视频、短剧之一
func isValidContentTagType(tagType contenttagmod.ContentTagType) bool {
return tagType == contenttagmod.ContentTagTypeVideo ||
tagType == contenttagmod.ContentTagTypePlaylet
}
// validateContentTagCounts 校验各类型绑定数量非负且数量字段和支持类型保持一致
func validateContentTagCounts(types []contenttagmod.ContentTagType, videoCount, playletCount int64) *srvdef.SrvErr {
if videoCount < 0 || playletCount < 0 {
return srvdef.New(srvdef.ErrParamError, "绑定数量不能小于0", "")
}
typeMap := make(map[contenttagmod.ContentTagType]struct{}, len(types))
for _, item := range types {
typeMap[item] = struct{}{}
}
if videoCount > 0 && !hasContentTagType(typeMap, contenttagmod.ContentTagTypeVideo) {
return srvdef.New(srvdef.ErrParamError, "存在视频绑定数量时必须支持视频类型", "")
}
if playletCount > 0 && !hasContentTagType(typeMap, contenttagmod.ContentTagTypePlaylet) {
return srvdef.New(srvdef.ErrParamError, "存在短剧绑定数量时必须支持短剧类型", "")
}
return nil
}
func hasContentTagType(typeMap map[contenttagmod.ContentTagType]struct{}, tagType contenttagmod.ContentTagType) bool {
_, ok := typeMap[tagType]
return ok
}
// contentTagListItem 将统一标签模型转换为后台列表响应
func contentTagListItem(item *contenttagmod.ContentTag) *proto.ContentTagListResp {
return &proto.ContentTagListResp{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Types: append([]contenttagmod.ContentTagType{}, item.Types...),
TypeNames: contentTagTypeNames(item.Types),
Status: item.Status,
Rank: item.Rank,
VideoCount: item.VideoCount,
PlayletCount: item.PlayletCount,
SeoInfo: normalizeContentTagSeoValue(item.SeoInfo),
CreatedById: item.CreatedById,
CreatedByName: item.CreatedByName,
UpdatedByName: item.UpdatedByName,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// contentTagTypeNames 将统一标签类型转换为后台展示名称
func contentTagTypeNames(types []contenttagmod.ContentTagType) []string {
names := make([]string, 0, len(types))
for _, item := range types {
switch item {
case contenttagmod.ContentTagTypeVideo:
names = append(names, "视频")
case contenttagmod.ContentTagTypePlaylet:
names = append(names, "短剧")
}
}
return names
}
// normalizeContentTagSeoValue 规整 SEO 字段,避免空切片返回 null
func normalizeContentTagSeoValue(seo pubdef.SeoValue) pubdef.SeoValue {
if seo.Keyworld == nil {
seo.Keyworld = make([]string, 0)
}
return seo
}
// resolveContentTagOperator 从后台账号表解析当前操作人名称
func resolveContentTagOperator(ctx context.Context, operatorId int64) (int64, string) {
if operatorId <= 0 {
return 0, ""
}
acc, err := mwacc.FindOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: operatorId,
}, options.FindOne().SetProjection(bson.D{
{Key: mwacc.FN.MwAcc.Id, Value: 1},
{Key: mwacc.FN.MwAcc.UserName, Value: 1},
}))
if err != nil || acc == nil {
return operatorId, ""
}
return acc.Id, acc.UserName
}
+38
View File
@@ -0,0 +1,38 @@
package srv_detailnav
import (
"videoweb/pub/models/detailnavmod"
"videoweb/pub/pubdef"
)
type GetDetailNavListReq struct {
pubdef.Page
}
type GetDetailNavListResp struct {
Total int64 `json:"total"`
List detailnavmod.DetailNavModelList `json:"list"`
}
type AddDetailNavReq struct {
Name string `json:"name" binding:"required"` // 导航名称
Url string `json:"url" binding:"required"` // 导航链接
Sort int64 `json:"sort"` // 排序
Status *bool `json:"status"` // 是否启用
}
type UpdateDetailNavReq struct {
Id string `json:"id" binding:"required"`
Param UpdateDetailNavParam `json:"param"`
}
type UpdateDetailNavParam struct {
Name *string `json:"name" bson:"name,omitempty"`
Url *string `json:"url" bson:"url,omitempty"`
Sort *int64 `json:"sort" bson:"sort,omitempty"`
Status *bool `json:"status" bson:"status,omitempty"`
}
type DelDetailNavReq struct {
Id string `json:"id" binding:"required"`
}
+110
View File
@@ -0,0 +1,110 @@
package srv_detailnav
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/detailnavmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetDetailNavList 获取详情页导航列表
func GetDetailNavList(ctx context.Context, req *GetDetailNavListReq) (*GetDetailNavListResp, *srvdef.SrvErr) {
filter := bson.M{}
count, serr := detailnavmod.CountDetailNav(ctx, filter)
if serr != nil {
return nil, serr
}
if count == 0 {
return &GetDetailNavListResp{
Total: 0,
List: make(detailnavmod.DetailNavModelList, 0),
}, nil
}
opts := options.FindOptions{}
opts.SetSort(bson.D{
{Key: detailnavmod.FN.DetailNavModel.Sort, Value: 1},
{Key: detailnavmod.FN.DetailNavModel.CreatedAt, Value: -1},
})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, serr := detailnavmod.FindManyDetailNav(ctx, filter, &opts)
if serr != nil {
return nil, serr
}
return &GetDetailNavListResp{
Total: count,
List: list,
}, nil
}
// AddDetailNav 添加详情页导航
func AddDetailNav(ctx context.Context, req *AddDetailNavReq) *srvdef.SrvErr {
now := time.Now()
status := true
if req.Status != nil {
status = *req.Status
}
doc := &detailnavmod.DetailNavModel{
ID: primitive.NewObjectID(),
Name: req.Name,
Url: req.Url,
Sort: req.Sort,
Status: status,
CreatedAt: now,
UpdatedAt: now,
}
return detailnavmod.InsertOneDetailNav(ctx, doc)
}
// UpdateDetailNav 更新详情页导航
func UpdateDetailNav(ctx context.Context, req *UpdateDetailNavReq) *srvdef.SrvErr {
objId, err := primitive.ObjectIDFromHex(req.Id)
if err != nil {
return srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{detailnavmod.FN.DetailNavModel.ID: objId}
updateFields := bson.M{
detailnavmod.FN.DetailNavModel.UpdatedAt: time.Now(),
}
if req.Param.Name != nil {
updateFields[detailnavmod.FN.DetailNavModel.Name] = *req.Param.Name
}
if req.Param.Url != nil {
updateFields[detailnavmod.FN.DetailNavModel.Url] = *req.Param.Url
}
if req.Param.Sort != nil {
updateFields[detailnavmod.FN.DetailNavModel.Sort] = *req.Param.Sort
}
if req.Param.Status != nil {
updateFields[detailnavmod.FN.DetailNavModel.Status] = *req.Param.Status
}
return detailnavmod.UpdateOneDetailNav(ctx, filter, bson.M{"$set": updateFields})
}
// DelDetailNav 删除详情页导航
func DelDetailNav(ctx context.Context, req *DelDetailNavReq) *srvdef.SrvErr {
objId, err := primitive.ObjectIDFromHex(req.Id)
if err != nil {
return srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{detailnavmod.FN.DetailNavModel.ID: objId}
count, serr := detailnavmod.DelOneDetailNav(ctx, filter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package srv_homebanner
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/homebannermod"
"videoweb/pub/models/mwacc"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
defaultHomeBannerPageSize int64 = 10
maxHomeBannerPageSize int64 = 100
)
var homeBannerWebProjection = bson.D{
{Key: homebannermod.FN.HomeBanner.Id, Value: 1},
{Key: homebannermod.FN.HomeBanner.Name, Value: 1},
{Key: homebannermod.FN.HomeBanner.Image, Value: 1},
{Key: homebannermod.FN.HomeBanner.VerticalImage, Value: 1},
{Key: homebannermod.FN.HomeBanner.Url, Value: 1},
{Key: homebannermod.FN.HomeBanner.Rank, Value: 1},
{Key: homebannermod.FN.HomeBanner.Status, Value: 1},
{Key: homebannermod.FN.HomeBanner.CreatedById, Value: 1},
{Key: homebannermod.FN.HomeBanner.CreatedByName, Value: 1},
{Key: homebannermod.FN.HomeBanner.UpdatedByName, Value: 1},
{Key: homebannermod.FN.HomeBanner.CreatedAt, Value: 1},
{Key: homebannermod.FN.HomeBanner.UpdatedAt, Value: 1},
}
// ListHomeBanner 分页查询首页 banner 列表
func ListHomeBanner(ctx context.Context, req *proto.HomeBannerListReq) (*proto.HomeBannerListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[homebannermod.FN.HomeBanner.Id] = *req.Id
}
if req.Status != nil {
filter[homebannermod.FN.HomeBanner.Status] = *req.Status
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[homebannermod.FN.HomeBanner.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
pageNum, pageSize := normalizeHomeBannerPage(req.PageNum, req.PageSize)
totalCount, err := homebannermod.CountHomeBanner(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.HomeBannerListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.HomeBannerListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := homebannermod.FindManyHomeBanner(ctx, filter, options.Find().
SetProjection(homeBannerWebProjection).
SetSort(bson.D{
{Key: homebannermod.FN.HomeBanner.Rank, Value: -1},
{Key: homebannermod.FN.HomeBanner.UpdatedAt, Value: -1},
{Key: homebannermod.FN.HomeBanner.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.HomeBannerListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.HomeBannerListResp{
Id: item.Id,
Name: item.Name,
Image: item.Image,
VerticalImage: item.VerticalImage,
Url: item.Url,
Rank: item.Rank,
Status: item.Status,
CreatedById: item.CreatedById,
CreatedByName: item.CreatedByName,
UpdatedByName: item.UpdatedByName,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
resp.List = items
return resp, nil
}
// AddHomeBanner 新增首页 banner
func AddHomeBanner(ctx context.Context, req *proto.HomeBannerAddReq, operatorId int64) (*proto.HomeBannerAddResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
name := strings.TrimSpace(req.Name)
image := strings.TrimSpace(req.Image)
if name == "" {
return nil, srvdef.New(srvdef.ErrParamError, "名称不能为空", "")
}
if image == "" {
return nil, srvdef.New(srvdef.ErrParamError, "图片不能为空", "")
}
if req.Status == nil {
return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "")
}
operatorId, operatorName := resolveHomeBannerOperator(ctx, operatorId)
doc := &homebannermod.HomeBanner{
Name: name,
Image: image,
VerticalImage: strings.TrimSpace(req.VerticalImage),
Url: strings.TrimSpace(req.Url),
Rank: req.Rank,
Status: *req.Status,
CreatedById: operatorId,
CreatedByName: operatorName,
UpdatedByName: operatorName,
}
if err := homebannermod.InsertOneHomeBanner(ctx, doc); err != nil {
return nil, err
}
return &proto.HomeBannerAddResp{Id: doc.Id}, nil
}
// UpdateHomeBanner 更新首页 banner
func UpdateHomeBanner(ctx context.Context, req *proto.HomeBannerUpdateReq, operatorId int64) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "首页banner ID无效", "")
}
setDoc := bson.M{homebannermod.FN.HomeBanner.UpdatedAt: time.Now()}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "名称不能为空", "")
}
setDoc[homebannermod.FN.HomeBanner.Name] = name
}
if req.Image != nil {
image := strings.TrimSpace(*req.Image)
if image == "" {
return srvdef.New(srvdef.ErrParamError, "图片不能为空", "")
}
setDoc[homebannermod.FN.HomeBanner.Image] = image
}
if req.VerticalImage != nil {
setDoc[homebannermod.FN.HomeBanner.VerticalImage] = strings.TrimSpace(*req.VerticalImage)
}
if req.Url != nil {
setDoc[homebannermod.FN.HomeBanner.Url] = strings.TrimSpace(*req.Url)
}
if req.Rank != nil {
setDoc[homebannermod.FN.HomeBanner.Rank] = *req.Rank
}
if req.Status != nil {
setDoc[homebannermod.FN.HomeBanner.Status] = *req.Status
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
_, operatorName := resolveHomeBannerOperator(ctx, operatorId)
setDoc[homebannermod.FN.HomeBanner.UpdatedByName] = operatorName
return homebannermod.UpdateOneHomeBanner(ctx,
bson.M{homebannermod.FN.HomeBanner.Id: req.Id},
bson.M{"$set": setDoc},
)
}
// DelHomeBanner 删除首页 banner
func DelHomeBanner(ctx context.Context, req *proto.HomeBannerDelReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "首页banner ID无效", "")
}
count, err := homebannermod.DelOneHomeBanner(ctx, bson.M{homebannermod.FN.HomeBanner.Id: req.Id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// normalizeHomeBannerPage 规范分页参数,限制单页数量避免大分页拖慢查询
func normalizeHomeBannerPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultHomeBannerPageSize
}
if pageSize > maxHomeBannerPageSize {
pageSize = maxHomeBannerPageSize
}
return pageNum, pageSize
}
// resolveHomeBannerOperator 从后台账号表解析当前操作人名称
func resolveHomeBannerOperator(ctx context.Context, operatorId int64) (int64, string) {
if operatorId <= 0 {
return 0, ""
}
acc, err := mwacc.FindOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: operatorId,
}, options.FindOne().SetProjection(bson.D{
{Key: mwacc.FN.MwAcc.Id, Value: 1},
{Key: mwacc.FN.MwAcc.UserName, Value: 1},
}))
if err != nil || acc == nil {
return operatorId, ""
}
return acc.Id, acc.UserName
}
+42
View File
@@ -0,0 +1,42 @@
package srv_hotnav
import (
"videoweb/pub/models/hotnavmod"
"videoweb/pub/pubdef"
)
type GetHotNavListReq struct {
pubdef.Page
}
type GetHotNavListResp struct {
Total int64 `json:"total"`
List hotnavmod.HotNavList `json:"list"`
}
type AddHotNavReq struct {
Title string `json:"title" binding:"required"` // 标题
Intro string `json:"intro"` // 介绍
Url string `json:"url" binding:"required"` // 链接
Icon string `json:"icon"` // 图标
Sort int64 `json:"sort"` // 排序
Status *bool `json:"status"` // 是否启用
}
type UpdateHotNavReq struct {
Id int64 `json:"id" binding:"required"`
Param UpdateHotNavParam `json:"param"`
}
type UpdateHotNavParam struct {
Title *string `json:"title"`
Intro *string `json:"intro"`
Url *string `json:"url"`
Icon *string `json:"icon"`
Sort *int64 `json:"sort"`
Status *bool `json:"status"`
}
type DelHotNavReq struct {
Id int64 `json:"id" binding:"required"`
}
+100
View File
@@ -0,0 +1,100 @@
package srv_hotnav
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/hotnavmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetHotNavList(ctx context.Context, req *GetHotNavListReq) (*GetHotNavListResp, *srvdef.SrvErr) {
filter := bson.M{}
count, serr := hotnavmod.CountHotNav(ctx, filter)
if serr != nil {
return nil, serr
}
if count == 0 {
return &GetHotNavListResp{
Total: 0,
List: make(hotnavmod.HotNavList, 0),
}, nil
}
opts := options.FindOptions{}
opts.SetSort(bson.D{
{Key: hotnavmod.FN.HotNav.Sort, Value: -1},
{Key: hotnavmod.FN.HotNav.CreatedAt, Value: -1},
})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, serr := hotnavmod.FindManyHotNav(ctx, filter, &opts)
if serr != nil {
return nil, serr
}
return &GetHotNavListResp{
Total: count,
List: list,
}, nil
}
func AddHotNav(ctx context.Context, req *AddHotNavReq) *srvdef.SrvErr {
now := time.Now()
status := true
if req.Status != nil {
status = *req.Status
}
doc := &hotnavmod.HotNav{
Id: mongos.Lid.HotNav.Get(),
Title: req.Title,
Intro: req.Intro,
Url: req.Url,
Icon: req.Icon,
Sort: req.Sort,
Status: status,
CreatedAt: now,
UpdatedAt: now,
}
return hotnavmod.InsertOneHotNav(ctx, doc)
}
func UpdateHotNav(ctx context.Context, req *UpdateHotNavReq) *srvdef.SrvErr {
filter := bson.M{hotnavmod.FN.HotNav.Id: req.Id}
updateFields := bson.M{
hotnavmod.FN.HotNav.UpdatedAt: time.Now(),
}
if req.Param.Title != nil {
updateFields[hotnavmod.FN.HotNav.Title] = *req.Param.Title
}
if req.Param.Intro != nil {
updateFields[hotnavmod.FN.HotNav.Intro] = *req.Param.Intro
}
if req.Param.Url != nil {
updateFields[hotnavmod.FN.HotNav.Url] = *req.Param.Url
}
if req.Param.Icon != nil {
updateFields[hotnavmod.FN.HotNav.Icon] = *req.Param.Icon
}
if req.Param.Sort != nil {
updateFields[hotnavmod.FN.HotNav.Sort] = *req.Param.Sort
}
if req.Param.Status != nil {
updateFields[hotnavmod.FN.HotNav.Status] = *req.Param.Status
}
return hotnavmod.UpdateOneHotNav(ctx, filter, bson.M{"$set": updateFields})
}
func DelHotNav(ctx context.Context, req *DelHotNavReq) *srvdef.SrvErr {
filter := bson.M{hotnavmod.FN.HotNav.Id: req.Id}
count, serr := hotnavmod.DelOneHotNav(ctx, filter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package srv_ipblack
import (
"videoweb/pub/models/ipblackmod"
"videoweb/pub/pubdef"
)
type GetIPBlackListReq struct {
pubdef.Page
IP *string `json:"ip"`
}
type GetIPBlackListResp struct {
Total int64 `json:"total"`
List []*ipblackmod.IPBlackModel `json:"list"`
}
type AddIPBlackListReq struct {
Ip string `json:"ip"`
Desc string `json:"desc" bson:"desc"` // 备注
}
type DelIpBlackListReq struct {
Ip string `json:"ip"`
}
+102
View File
@@ -0,0 +1,102 @@
package srv_ipblack
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
"sync"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/ipblackmod"
)
var (
ipBlackMap map[string]struct{}
lock sync.RWMutex
)
func Init() {
ctx := context.Background()
list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{})
if err != nil {
log.Error("ipBlackInit FindManyIPBlackModel error", log.E(err))
return
}
tempMaps := make(map[string]struct{})
for _, v := range list {
tempMaps[v.Ip] = struct{}{}
}
lock.Lock()
defer lock.Unlock()
ipBlackMap = tempMaps
}
func CheckIp(ip string) bool {
lock.RLock()
defer lock.RUnlock()
if _, ok := ipBlackMap[ip]; ok {
return true
}
return false
}
func GetIpBlackList(ctx context.Context, req *GetIPBlackListReq) (*GetIPBlackListResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.IP != nil && len(*req.IP) > 0 {
filter[ipblackmod.FN.IPBlackModel.Ip] = req.IP
}
count, err := ipblackmod.CountIPBlackModel(ctx, filter)
if err != nil {
return nil, err
}
if count == 0 {
return &GetIPBlackListResp{
Total: 0,
List: make([]*ipblackmod.IPBlackModel, 0),
}, nil
}
opts := options.FindOptions{}
opts.SetSort(bson.M{ipblackmod.FN.IPBlackModel.CreatedAt: -1})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, err := ipblackmod.FindManyIPBlackModel(ctx, filter, &opts)
if err != nil {
return nil, err
}
resp := &GetIPBlackListResp{
Total: count,
List: list,
}
return resp, nil
}
func AddIpBlack(ctx context.Context, req *AddIPBlackListReq) *srvdef.SrvErr {
err := ipblackmod.InsertOneIPBlackModel(ctx, &ipblackmod.IPBlackModel{
ID: primitive.NewObjectID(),
Ip: req.Ip,
Desc: req.Desc,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err == nil {
lock.Lock()
defer lock.Unlock()
ipBlackMap[req.Ip] = struct{}{}
}
return err
}
func DelIpBlack(ctx context.Context, req *DelIpBlackListReq) *srvdef.SrvErr {
_, err := ipblackmod.DelOneIPBlackModel(ctx, bson.M{
ipblackmod.FN.IPBlackModel.Ip: req.Ip,
})
if err != nil {
lock.Lock()
defer lock.Unlock()
delete(ipBlackMap, req.Ip)
ipBlackMap[req.Ip] = struct{}{}
}
return err
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
package srv_mediaresource
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
goredis "github.com/go-redis/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// PlayletCoverCompressCronSpec 历史空压缩封面扫描频率:每5分钟投递一批
PlayletCoverCompressCronSpec = "0 */5 * * * ?"
playletCoverCompressQueueKeyFmt = "playlet:cover:compress:queue:%d"
playletCoverCompressLockKeyFmt = "playlet:cover:compress:lock:%d:%d"
playletCoverCompressFailKeyFmt = "playlet:cover:compress:fail:v2:%d:%d"
playletCoverCompressLockTTL = 10 * time.Minute
playletCoverCompressFailTTL = 10 * time.Minute
playletCoverCompressPopWait = 5 * time.Second
playletCoverCompressRetryWait = time.Second
playletCoverCompressScanSize = int64(200)
playletCoverCompressBatchSize = 50
)
func playletCoverCompressQueueKey() string {
return fmt.Sprintf(playletCoverCompressQueueKeyFmt, pubdef.AppId)
}
func playletCoverCompressLockKey(playletId int64) string {
return fmt.Sprintf(playletCoverCompressLockKeyFmt, pubdef.AppId, playletId)
}
func playletCoverCompressFailKey(playletId int64) string {
return fmt.Sprintf(playletCoverCompressFailKeyFmt, pubdef.AppId, playletId)
}
type playletCoverCompressJob struct {
PlayletId int64 `json:"playletId"`
CreateTime int64 `json:"createTime"`
}
// EnqueuePlayletCoverCompressJobs 扫描压缩封面为空的短剧并投递后台任务
func EnqueuePlayletCoverCompressJobs() {
ctx := context.Background()
enqueued := 0
scanned := 0
var lastId int64
for enqueued < playletCoverCompressBatchSize {
filter := emptyCompressedCoverFilter()
if lastId > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$gt": lastId}
}
opts := options.Find().
SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
}).
SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}).
SetLimit(playletCoverCompressScanSize)
list, err := playletmod.FindManyPlaylet(ctx, filter, opts)
if err != nil {
log.Error("扫描待压缩短剧封面失败", log.E(err))
return
}
if len(list) == 0 {
break
}
scanned += len(list)
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
lastId = item.Id
if enqueued >= playletCoverCompressBatchSize {
break
}
if has, _ := redis.Exists(playletCoverCompressFailKey(item.Id)); has {
continue
}
if err := pushPlayletCoverCompressJob(ctx, item.Id); err != nil {
log.Error("投递短剧封面压缩任务失败", log.Any("playletId", item.Id), log.E(err))
continue
}
enqueued++
}
if int64(len(list)) < playletCoverCompressScanSize {
break
}
}
log.Info("短剧压缩封面任务扫描完成", log.Any("scanCount", scanned), log.Any("enqueued", enqueued))
}
func emptyCompressedCoverFilter() bson.M {
return bson.M{
playletmod.FN.Playlet.Cover: bson.M{"$nin": []interface{}{"", nil}},
"$or": []bson.M{
{playletmod.FN.Playlet.CompressedCover: bson.M{"$exists": false}},
{playletmod.FN.Playlet.CompressedCover: ""},
{playletmod.FN.Playlet.CompressedCover: nil},
},
}
}
func pushPlayletCoverCompressJob(ctx context.Context, playletId int64) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
payload, err := json.Marshal(playletCoverCompressJob{
PlayletId: playletId,
CreateTime: time.Now().Unix(),
})
if err != nil {
return err
}
return redis.RPush(playletCoverCompressQueueKey(), string(payload))
}
func runPlayletCoverCompressWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
result, err := redis.Client().BLPop(playletCoverCompressPopWait, playletCoverCompressQueueKey()).Result()
if err != nil {
if err == goredis.Nil {
continue
}
log.Error("消费短剧封面压缩任务失败", log.E(err))
time.Sleep(playletCoverCompressRetryWait)
continue
}
if len(result) < 2 {
log.Error("短剧封面压缩任务格式异常", log.Any("result", result))
continue
}
handlePlayletCoverCompressPayload(ctx, result[1])
}
}
func handlePlayletCoverCompressPayload(ctx context.Context, payload string) {
defer func() {
if err := recover(); err != nil {
log.Error("处理短剧封面压缩任务异常", log.Any("err", err), log.Any("payload", payload))
}
}()
var job playletCoverCompressJob
if err := json.Unmarshal([]byte(payload), &job); err != nil {
log.Error("解析短剧封面压缩任务失败", log.Any("payload", payload), log.E(err))
return
}
if job.PlayletId <= 0 {
return
}
locked, err := redis.Setnx_NewOK(playletCoverCompressLockKey(job.PlayletId), true, playletCoverCompressLockTTL)
if err != nil {
log.Error("获取短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err))
return
}
if !locked {
return
}
defer func() {
if err := redis.Del(playletCoverCompressLockKey(job.PlayletId)); err != nil {
log.Error("释放短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err))
}
}()
playlet, serr := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: job.PlayletId}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
}))
if serr != nil || playlet == nil {
if serr != nil {
log.Warn("查询待压缩短剧失败", log.Any("playletId", job.PlayletId), log.E(serr))
}
return
}
if FillPlayletCompressedCover(ctx, playlet) {
log.Info("历史短剧压缩封面完成", log.Any("playletId", playlet.Id))
return
}
if strings.TrimSpace(playlet.CompressedCover) == "" && strings.TrimSpace(playlet.Cover) != "" {
if err := redis.Set(playletCoverCompressFailKey(playlet.Id), "1", playletCoverCompressFailTTL); err != nil {
log.Warn("记录短剧封面压缩失败冷却失败", log.Any("playletId", playlet.Id), log.E(err))
}
}
}
@@ -0,0 +1,177 @@
package srv_mediaresource
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"videoweb/pub/common/imgutil"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/models/cfg"
"videoweb/pub/models/playletmod"
"videoweb/pub/storage/proxy"
"videoweb/web/service/srv_search"
"go.mongodb.org/mongo-driver/bson"
)
// compressPlayletCoverOrEmpty 下载原封面并按 media Resize 规则处理。
// 原图未超过 60KB,或原图是 GIF 时,直接返回原封面地址;其它格式超过阈值则压缩为 JPEG 后上传。
// 失败返回空字符串,不阻断入库。
func compressPlayletCoverOrEmpty(cover string) string {
cover = strings.TrimSpace(cover)
if cover == "" {
return ""
}
pathStr, err := uploadCompressedPlayletCover(cover)
if err != nil {
log.Warn("压缩短剧封面失败", log.Any("cover", cover), log.E(err))
return ""
}
return pathStr
}
// FillPlayletCompressedCover 为指定短剧补写压缩封面,已有值则跳过。
func FillPlayletCompressedCover(ctx context.Context, playlet *playletmod.Playlet) bool {
if playlet == nil || playlet.Id <= 0 {
return false
}
if strings.TrimSpace(playlet.CompressedCover) != "" {
return false
}
cover := strings.TrimSpace(playlet.Cover)
if cover == "" {
return false
}
pathStr := compressPlayletCoverOrEmpty(cover)
if pathStr == "" {
return false
}
now := time.Now()
if err := playletmod.UpdateOnePlaylet(ctx,
bson.M{
playletmod.FN.Playlet.Id: playlet.Id,
"$or": []bson.M{
{playletmod.FN.Playlet.CompressedCover: bson.M{"$exists": false}},
{playletmod.FN.Playlet.CompressedCover: ""},
{playletmod.FN.Playlet.CompressedCover: nil},
},
},
bson.M{"$set": bson.M{
playletmod.FN.Playlet.CompressedCover: pathStr,
playletmod.FN.Playlet.UpdatedAt: now,
}},
); err != nil {
log.Warn("回写短剧压缩封面失败", log.Any("playletId", playlet.Id), log.E(err))
return false
}
playlet.CompressedCover = pathStr
latest, serr := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playlet.Id})
if serr == nil && latest != nil && latest.Status == playletmod.StatusUp {
if syncErr := srv_search.SyncPlayletStatusToES(ctx, []int64{playlet.Id}, latest.Status); syncErr != nil {
log.Warn("压缩封面后同步ES失败", log.Any("playletId", playlet.Id), log.E(syncErr))
}
}
return true
}
func uploadCompressedPlayletCover(cover string) (string, error) {
downloadURL, err := resolvePlayletCoverURL(cover)
if err != nil {
return "", err
}
downloadURL, err = encodeCoverDownloadURL(downloadURL)
if err != nil {
return "", err
}
data, err := downloadPlayletCover(downloadURL)
if err != nil {
return "", err
}
data = imgutil.DecryptImage(data)
if !imgutil.NeedCompress(data) {
return cover, nil
}
fileName := imgutil.SafeCoverFileName(downloadURL)
compressed, outName, err := imgutil.CompressCover(data, fileName)
if err != nil {
return "", err
}
pathStr, err := proxy.UploadImg(outName, compressed, "")
if err != nil {
return "", fmt.Errorf("上传压缩封面失败: %w", err)
}
if strings.TrimSpace(pathStr) == "" {
return "", fmt.Errorf("上传压缩封面返回空路径")
}
return pathStr, nil
}
func resolvePlayletCoverURL(cover string) (string, error) {
if strings.HasPrefix(cover, "http://") || strings.HasPrefix(cover, "https://") {
return cover, nil
}
sysCfg, serr := cfg.FindOneSysCfg(context.Background(), bson.M{cfg.FN.SysCfg.Id: 1})
if serr != nil {
return "", fmt.Errorf("查询图片CDN失败: %s", serr.Error())
}
imageDomain := ""
if sysCfg != nil {
imageDomain = strings.TrimSpace(sysCfg.ImgWebCdn)
}
if imageDomain == "" {
return "", fmt.Errorf("图片CDN未配置,无法下载相对路径封面")
}
return netutil.MergeURL(imageDomain, cover), nil
}
// encodeCoverDownloadURL 把路径里的中文等非 ASCII 段转成百分号编码,避免下载失败或二次拼接异常。
func encodeCoverDownloadURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("解析封面地址失败: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("封面地址无效")
}
escaped := u.EscapedPath()
if escaped != "" {
if dec, err := url.PathUnescape(escaped); err == nil {
u.Path = dec
u.RawPath = ""
}
}
return u.String(), nil
}
func downloadPlayletCover(rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("下载封面失败: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; xjsp-playlet-cover/1.0)")
req.Header.Set("Accept", "image/*")
cli := &http.Client{Timeout: 90 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return nil, fmt.Errorf("下载封面失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载封面失败,状态码 %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return nil, fmt.Errorf("下载封面失败: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("下载封面内容为空")
}
return data, nil
}
@@ -0,0 +1,169 @@
package srv_mediaresource
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
goredis "github.com/go-redis/redis"
)
const (
playletResourceImportQueueKeyFmt = "playlet:resource:import:queue:%d"
playletResourceImportLockKeyFmt = "playlet:resource:import:lock:%d:%d"
playletResourceImportLockTTL = 10 * time.Minute
playletResourceImportPopWait = 5 * time.Second
playletResourceImportRetryWait = time.Second
)
type playletResourceImportJob struct {
Ids []uint `json:"ids"`
OperatorId int64 `json:"operatorId"`
OperatorName string `json:"operatorName"`
CreateTime int64 `json:"createTime"`
}
var playletResourceImportWorker = struct {
mu sync.Mutex
cancel context.CancelFunc
}{}
func playletResourceImportQueueKey() string {
return fmt.Sprintf(playletResourceImportQueueKeyFmt, pubdef.AppId)
}
func playletResourceImportLockKey(mediaId uint) string {
return fmt.Sprintf(playletResourceImportLockKeyFmt, pubdef.AppId, mediaId)
}
// Start 启动短剧媒资导入消费者
func Start() {
playletResourceImportWorker.mu.Lock()
defer playletResourceImportWorker.mu.Unlock()
if playletResourceImportWorker.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
playletResourceImportWorker.cancel = cancel
go runPlayletResourceImportWorker(ctx)
go runPlayletCoverCompressWorker(ctx)
go runPlayletSerialSyncWorker(ctx)
go func() {
time.Sleep(10 * time.Second)
EnqueuePlayletCoverCompressJobs()
}()
log.Info("短剧媒资后台消费者已启动")
}
// Stop 停止短剧媒资导入消费者
func Stop() {
playletResourceImportWorker.mu.Lock()
defer playletResourceImportWorker.mu.Unlock()
if playletResourceImportWorker.cancel == nil {
return
}
playletResourceImportWorker.cancel()
playletResourceImportWorker.cancel = nil
log.Info("短剧媒资后台消费者已停止")
}
// pushPlayletResourceImportJob 投递短剧媒资导入任务
func pushPlayletResourceImportJob(ctx context.Context, ids []uint, operatorId int64, operatorName string) *srvdef.SrvErr {
select {
case <-ctx.Done():
log.Error("投递短剧媒资导入任务取消", log.E(ctx.Err()))
return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败")
default:
}
job := playletResourceImportJob{
Ids: ids,
OperatorId: operatorId,
OperatorName: operatorName,
CreateTime: time.Now().Unix(),
}
payload, err := json.Marshal(job)
if err != nil {
log.Error("序列化短剧媒资导入任务失败", log.Any("ids", ids), log.E(err))
return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败")
}
if err = redis.RPush(playletResourceImportQueueKey(), string(payload)); err != nil {
log.Error("投递短剧媒资导入任务失败", log.Any("ids", ids), log.E(err))
return srvdef.New(srvdef.Failure, "提交同步任务失败", "提交同步任务失败")
}
log.Info("投递短剧媒资导入任务成功", log.Any("ids", ids), log.Any("count", len(ids)))
return nil
}
// runPlayletResourceImportWorker 循环消费短剧媒资导入队列
func runPlayletResourceImportWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
result, err := redis.Client().BLPop(playletResourceImportPopWait, playletResourceImportQueueKey()).Result()
if err != nil {
if err == goredis.Nil {
continue
}
log.Error("消费短剧媒资导入任务失败", log.E(err))
time.Sleep(playletResourceImportRetryWait)
continue
}
if len(result) < 2 {
log.Error("短剧媒资导入任务格式异常", log.Any("result", result))
continue
}
handlePlayletResourceImportPayload(ctx, result[1])
}
}
// handlePlayletResourceImportPayload 处理单个短剧媒资导入任务
func handlePlayletResourceImportPayload(ctx context.Context, payload string) {
defer func() {
if err := recover(); err != nil {
log.Error("处理短剧媒资导入任务异常", log.Any("err", err), log.Any("payload", payload))
}
}()
var job playletResourceImportJob
if err := json.Unmarshal([]byte(payload), &job); err != nil {
log.Error("解析短剧媒资导入任务失败", log.Any("payload", payload), log.E(err))
return
}
ids := dedupePlayletResourceIds(job.Ids)
if len(ids) == 0 {
log.Error("短剧媒资导入任务没有有效媒资ID", log.Any("payload", payload))
return
}
log.Info("开始处理短剧媒资导入任务",
log.Any("ids", ids),
log.Any("count", len(ids)),
log.Any("createTime", job.CreateTime),
log.Any("operatorId", job.OperatorId),
log.Any("operatorName", job.OperatorName))
resp := importPlayletResourceBatch(ctx, ids, job.OperatorId, job.OperatorName)
log.Info("短剧媒资导入任务处理完成",
log.Any("ids", ids),
log.Any("successCount", len(resp.Success)),
log.Any("failedCount", len(resp.Failed)),
log.Any("failed", resp.Failed))
}
// lockPlayletResourceImportMedia 锁定单条媒资导入
func lockPlayletResourceImportMedia(mediaId uint) (bool, error) {
return redis.Setnx_NewOK(playletResourceImportLockKey(mediaId), true, playletResourceImportLockTTL)
}
// unlockPlayletResourceImportMedia 释放单条媒资导入锁
func unlockPlayletResourceImportMedia(mediaId uint) {
if err := redis.Del(playletResourceImportLockKey(mediaId)); err != nil {
log.Error("释放短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err))
}
}
@@ -0,0 +1,214 @@
package srv_mediaresource
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"videoweb/pub/common"
"videoweb/pub/common/httputil"
"videoweb/pub/common/log"
"videoweb/web/api/proto"
)
const (
playletMediaListErrMsg = "短剧媒资列表查询失败"
playletMediaSyncErrMsg = "短剧媒资同步失败"
playletMediaDeletedCode = 7003
)
// playletMediaCenterService 短剧媒资中心客户端
type playletMediaCenterService struct {
url string // 媒资中心接口域名
proId string // 媒资中心商户号
secret string // 媒资中心签名密钥,当前接口按源项目逻辑暂不签名
}
// playletMediaCenterConfig 创建短剧媒资中心客户端所需配置
type playletMediaCenterConfig struct {
Url string // 媒资中心接口域名
ProId string // 媒资中心商户号
Secret string // 媒资中心签名密钥
}
// playletMediaCenterMsg 媒资中心统一响应结构
type playletMediaCenterMsg struct {
Code int `json:"code"` // 媒资中心业务码,0 表示成功
Msg string `json:"msg"` // 媒资中心业务消息
Data json.RawMessage `json:"data"` // 媒资中心业务数据,校验 code 成功后再解析
}
// playletMediaPagination 媒资中心列表分页信息
type playletMediaPagination struct {
IsLastPage bool `json:"is_last_page"` // 是否最后一页
Page uint `json:"page"` // 当前页码
Size uint `json:"size"` // 每页数量
Count uint `json:"count"` // 总数量
}
// playletMediaListReq 媒资中心 /services/media/list 查询参数
type playletMediaListReq struct {
MediaType int `json:"media_type"` // 媒体类型:1-动漫 2-漫画 4-漫画短剧 5-短剧 6-普通短剧
Id *uint `json:"id"` // 媒资中心资源ID
VloggerId *uint `json:"vlogger_id"` // UP主ID
IsUp *int `json:"is_up"` // 是否UP主资源:0-否 1-是
Title *string `json:"title"` // 标题筛选
Tag *string `json:"tag"` // 标签筛选
SyncType int `json:"sync_type"` // 同步类型:1-全量 2-增量
Page uint `json:"page"` // 页码
Size uint `json:"size"` // 每页数量
}
// playletMediaListResp 媒资中心列表响应 data 节点
type playletMediaListResp struct {
Data []*proto.PlayletResourceListItem `json:"data"` // 短剧媒资列表
Pagination playletMediaPagination `json:"pagination"` // 分页信息
}
// playletMediaContent 媒资中心短剧章节内容
type playletMediaContent struct {
ID uint `json:"id"` // 媒资内容ID
SaleCoin uint `json:"sale_coin"` // 单集售价
VloggerId uint `json:"vlogger_id"` // UP主ID
Cover string `json:"cover"` // 封面
MediaId uint `json:"video_id"` // 媒资中心资源ID
MediaType int `json:"media_type"` // 媒体类型
EpisodeNumber int `json:"episode_number"` // 第几集
Title string `json:"title"` // 章节名
Text string `json:"text"` // 文本内容
Urls string `json:"urls"` // 资源地址集
Height uint `json:"height"` // 高
Width uint `json:"width"` // 宽
Ratio float64 `json:"ratio"` // 宽高比
MediaSize uint `json:"media_size"` // 资源大小
PlayTime uint `json:"play_time"` // 播放时长
M3u8Url string `json:"m3u8_url"` // m3u8视频地址
AudioUrl string `json:"audio_url"` // 音频地址
FsResourceId string `json:"fs_resource_id"` // 文件服务资源ID
FsResourceStatus string `json:"fs_resource_status"` // 文件服务资源状态
HashId string `json:"hash_id"` // 资源hash
DataVersion int8 `json:"data_version"` // 数据版本
TerminalCdo string `json:"terminal_cdo"` // 上传者
CreateTime int64 `json:"create_time"` // 创建时间
UpdateTime int64 `json:"update_time"` // 更新时间
}
// playletMediaSyncReq 媒资中心 /services/media/sync 查询参数
type playletMediaSyncReq struct {
Id uint `json:"id"` // 媒资中心资源ID
SyncTime int64 `json:"sync_time"` // 增量同步时间,0 表示拉全量详情
}
// playletMediaSyncResp 媒资中心同步响应 data 节点
type playletMediaSyncResp struct {
IsDelete bool `json:"-"`
Media *proto.PlayletResourceListItem `json:"media"`
MediaContentList []playletMediaContent `json:"media_content_list"`
}
// newPlayletMediaCenterService 创建短剧媒资中心客户端
func newPlayletMediaCenterService(c playletMediaCenterConfig) *playletMediaCenterService {
return &playletMediaCenterService{
url: c.Url,
proId: c.ProId,
secret: c.Secret,
}
}
// mediaListParams 构造媒资中心列表查询参数
func (m *playletMediaCenterService) mediaListParams(p *playletMediaListReq) map[string]string {
params := map[string]string{
"media_type": strconv.Itoa(p.MediaType),
"sync_type": strconv.Itoa(p.SyncType),
"page": strconv.FormatUint(uint64(p.Page), 10),
"size": strconv.FormatUint(uint64(p.Size), 10),
"pro_id": m.proId,
"sign": m.sign(),
}
if p.Id != nil {
params["id"] = strconv.FormatUint(uint64(*p.Id), 10)
}
if p.VloggerId != nil {
params["vlogger_id"] = strconv.FormatUint(uint64(*p.VloggerId), 10)
}
if p.IsUp != nil {
params["is_up"] = strconv.Itoa(*p.IsUp)
}
if p.Title != nil {
params["title"] = *p.Title
}
if p.Tag != nil {
params["tag"] = *p.Tag
}
return params
}
// mediaSyncParams 构造媒资中心同步查询参数
func (m *playletMediaCenterService) mediaSyncParams(p *playletMediaSyncReq) map[string]string {
return map[string]string{
"id": strconv.FormatUint(uint64(p.Id), 10),
"sync_time": strconv.FormatInt(p.SyncTime, 10),
"pro_id": m.proId,
"sign": m.sign(),
}
}
// mediaList 查询短剧媒资列表
func (m *playletMediaCenterService) mediaList(p *playletMediaListReq) (result playletMediaListResp, err error) {
var retMsg playletMediaCenterMsg
params := m.mediaListParams(p)
path := "/services/media/list"
log.Info("查询短剧媒资列表", log.Any("params", params))
code, err := httputil.Client().GetWithJResp(&retMsg, common.BindUrl(m.url, path), nil, params)
if err != nil {
log.Error("查询短剧媒资列表请求失败", log.Any("code", code), log.E(err))
return result, errors.New(playletMediaListErrMsg)
}
if code != http.StatusOK {
log.Error("查询短剧媒资列表状态码异常", log.Any("code", code))
return result, errors.New(playletMediaListErrMsg)
}
if retMsg.Code != 0 {
log.Error("查询短剧媒资列表业务码异常", log.Any("retMsg", retMsg))
return result, errors.New(playletMediaListErrMsg)
}
if err = json.Unmarshal(retMsg.Data, &result); err != nil {
log.Error("解析短剧媒资列表失败", log.E(err))
return result, errors.New(playletMediaListErrMsg)
}
return result, nil
}
// mediaSync 查询短剧媒资详情和章节内容
func (m *playletMediaCenterService) mediaSync(p *playletMediaSyncReq) (result playletMediaSyncResp, err error) {
var retMsg playletMediaCenterMsg
params := m.mediaSyncParams(p)
path := "/services/media/sync"
code, err := httputil.Client().GetWithJResp(&retMsg, common.BindUrl(m.url, path), nil, params)
if err != nil {
log.Error("同步短剧媒资详情请求失败", log.Any("params", params), log.Any("code", code), log.E(err))
return result, errors.New(playletMediaSyncErrMsg)
}
if code != http.StatusOK {
log.Error("同步短剧媒资详情状态码异常", log.Any("params", params), log.Any("code", code))
return result, errors.New(playletMediaSyncErrMsg)
}
if retMsg.Code == playletMediaDeletedCode {
result.IsDelete = true
return result, nil
}
if retMsg.Code != 0 {
log.Error("同步短剧媒资详情业务码异常", log.Any("params", params), log.Any("retMsg", retMsg))
return result, errors.New(playletMediaSyncErrMsg)
}
if err = json.Unmarshal(retMsg.Data, &result); err != nil {
log.Error("解析短剧媒资详情失败", log.Any("params", params), log.E(err))
return result, errors.New(playletMediaSyncErrMsg)
}
return result, nil
}
// sign 生成媒资中心签名
func (m *playletMediaCenterService) sign() string {
return ""
}
@@ -0,0 +1,270 @@
package srv_mediaresource
import (
"context"
"sync"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// defaultOriginalPlayletBackfillPageSize 与媒资库列表默认分页一致。
defaultOriginalPlayletBackfillPageSize = int64(20)
// defaultOriginalPlayletBackfillMaxPages 限制启动回填最多扫描 2000 条媒资。
defaultOriginalPlayletBackfillMaxPages = int64(100)
// maxOriginalPlayletBackfillMaxPages 防止误配置导致启动任务长时间扫全量。
maxOriginalPlayletBackfillMaxPages = int64(1000)
)
// originalPlayletBackfillOnce 保证每个进程只触发一次启动回填。
var originalPlayletBackfillOnce sync.Once
// originalPlayletBackfillOptions 控制 CGDJ 原创短剧回填范围。
type originalPlayletBackfillOptions struct {
DryRun bool // 只预览不写库
PageSize int64 // 媒资库分页大小
MaxPages int64 // 最多扫描页数
}
// originalPlayletBackfillItem 记录单部已导入短剧的回填结果。
type originalPlayletBackfillItem struct {
MediaCenterId int64 // 媒资库主资源 ID
PlayletId int64 // 本地短剧 ID
Title string // 本地短剧标题
MediaTitle string // 媒资库原始标题
Source string // 本次识别出的媒资来源
Before string // 回填前媒资来源
Result string // updated / preview / already_marked / conflict / failed
}
// originalPlayletBackfillResp 汇总一次 CGDJ 回填统计。
type originalPlayletBackfillResp struct {
DryRun bool
PageSize int64
MaxPages int64
Pages int64
SourceTotal int64 // 媒资库返回总数
SourceScanned int64 // 已扫描媒资条数
SourceMatched int64 // 匹配 CGDJ 前缀的媒资条数
Imported int64 // 已导入本地短剧库的条数
Matched int64 // 需要写入媒资来源的条数
Updated int64 // 实际更新成功条数
Skipped int64 // 已有相同来源或并发冲突跳过条数
HasMore bool // 达到 MaxPages 后仍可能存在后续数据
List []*originalPlayletBackfillItem
}
// originalPlayletBackfillStore 抽象外部依赖,方便单测覆盖媒资库和 Mongo。
type originalPlayletBackfillStore struct {
mediaList func(*playletMediaListReq) (playletMediaListResp, error)
findMany func(context.Context, bson.M, *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr)
updateOne func(context.Context, bson.M, bson.M) *srvdef.SrvErr
audit func(context.Context, *originalPlayletBackfillItem)
}
// StartCGDJOriginalBackfillOnce 在 web 启动后异步执行一次 CGDJ 原创短剧标记回填。
func StartCGDJOriginalBackfillOnce(ctx context.Context) {
originalPlayletBackfillOnce.Do(func() {
go runCGDJOriginalBackfillOnce(ctx)
})
}
// runCGDJOriginalBackfillOnce 执行启动回填,成功后顺手触发一次昨日数据回流。
func runCGDJOriginalBackfillOnce(ctx context.Context) {
resp, serr := backfillCGDJOriginalPlaylets(ctx, originalPlayletBackfillOptions{DryRun: false})
if serr != nil {
log.Error("CGDJ 原创短剧标记启动回填失败", log.Any("err", serr), log.Any("resp", resp))
return
}
log.Info("CGDJ 原创短剧标记启动回填完成",
log.Any("pages", resp.Pages),
log.Any("sourceTotal", resp.SourceTotal),
log.Any("sourceScanned", resp.SourceScanned),
log.Any("sourceMatched", resp.SourceMatched),
log.Any("imported", resp.Imported),
log.Any("matched", resp.Matched),
log.Any("updated", resp.Updated),
log.Any("skipped", resp.Skipped),
log.Any("hasMore", resp.HasMore))
ReportAIContentBackflowYesterday()
}
// backfillCGDJOriginalPlaylets 从媒资库 CGDJ 短剧列表回填本地短剧 mediaBusiness。
// 判断依据只使用源媒资标题前缀和 mediaCenterId,不依赖本地短剧标题,避免运营改名后漏判。
func backfillCGDJOriginalPlaylets(ctx context.Context, opts originalPlayletBackfillOptions) (*originalPlayletBackfillResp, *srvdef.SrvErr) {
ms := newPlayletMediaCenterFromConfig()
return backfillCGDJOriginalPlayletsWithStore(ctx, opts, originalPlayletBackfillStore{
mediaList: ms.mediaList,
findMany: func(ctx context.Context, filter bson.M, opts *options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) {
return playletmod.FindManyPlaylet(ctx, filter, opts)
},
updateOne: playletmod.UpdateOnePlaylet,
audit: func(ctx context.Context, item *originalPlayletBackfillItem) {
log.Info("回填短剧媒资业务来源", log.Any("playletId", item.PlayletId), log.Any("mediaCenterId", item.MediaCenterId), log.Any("title", item.Title), log.Any("mediaTitle", item.MediaTitle), log.Any("mediaBusiness", item.Source))
},
})
}
// backfillCGDJOriginalPlayletsWithStore 按媒资库 CGDJ 搜索结果回填本地短剧媒资来源。
func backfillCGDJOriginalPlayletsWithStore(ctx context.Context, opts originalPlayletBackfillOptions, store originalPlayletBackfillStore) (*originalPlayletBackfillResp, *srvdef.SrvErr) {
pageSize, maxPages, serr := normalizeOriginalPlayletBackfillOptions(opts)
if serr != nil {
return nil, serr
}
resp := &originalPlayletBackfillResp{
DryRun: opts.DryRun,
PageSize: pageSize,
MaxPages: maxPages,
List: make([]*originalPlayletBackfillItem, 0),
}
for _, title := range playletMediaBusinessSearchTitles() {
searchTitle := title
for page := int64(1); page <= maxPages; page++ {
data, err := store.mediaList(&playletMediaListReq{
MediaType: playletResourceMediaTypeShort,
Title: &searchTitle,
SyncType: defaultResourceSyncType,
Page: uint(page),
Size: uint(pageSize),
})
if err != nil {
log.Error("查询 CGDJ 短剧媒资失败", log.Any("title", searchTitle), log.Any("page", page), log.E(err))
return resp, srvdef.New(srvdef.Failure, "查询 CGDJ 短剧媒资失败", "查询 CGDJ 短剧媒资失败")
}
resp.Pages++
if page == 1 {
resp.SourceTotal += int64(data.Pagination.Count)
}
if len(data.Data) == 0 {
break
}
if serr = backfillOriginalPlayletPage(ctx, data.Data, resp, store); serr != nil {
return resp, serr
}
if data.Pagination.IsLastPage || int64(len(data.Data)) < pageSize {
break
}
if page == maxPages {
resp.HasMore = true
}
}
}
return resp, nil
}
// normalizeOriginalPlayletBackfillOptions 修正分页参数并限制启动扫描范围。
func normalizeOriginalPlayletBackfillOptions(opts originalPlayletBackfillOptions) (int64, int64, *srvdef.SrvErr) {
pageSize := opts.PageSize
if pageSize <= 0 {
pageSize = defaultOriginalPlayletBackfillPageSize
}
maxPages := opts.MaxPages
if maxPages <= 0 {
maxPages = defaultOriginalPlayletBackfillMaxPages
}
if pageSize > int64(maxResourcePageSize) {
return 0, 0, srvdef.New(srvdef.ErrParamError, "pageSize最多100", "pageSize最多100")
}
if maxPages > maxOriginalPlayletBackfillMaxPages {
return 0, 0, srvdef.New(srvdef.ErrParamError, "maxPages最多1000", "maxPages最多1000")
}
return pageSize, maxPages, nil
}
// backfillOriginalPlayletPage 只更新已导入本地短剧库的 CGDJ 媒资。
func backfillOriginalPlayletPage(ctx context.Context, mediaList []*proto.PlayletResourceListItem, resp *originalPlayletBackfillResp, store originalPlayletBackfillStore) *srvdef.SrvErr {
mediaTitleByID := make(map[uint]string, len(mediaList))
mediaBusinessByID := make(map[uint]string, len(mediaList))
mediaIDs := make([]uint, 0, len(mediaList))
for _, media := range mediaList {
resp.SourceScanned++
business := playletMediaBusiness(media)
if business == "" {
continue
}
if _, exists := mediaTitleByID[media.ID]; exists {
continue
}
resp.SourceMatched++
mediaTitleByID[media.ID] = media.Title
mediaBusinessByID[media.ID] = business
mediaIDs = append(mediaIDs, media.ID)
}
if len(mediaIDs) == 0 {
return nil
}
playlets, serr := store.findMany(ctx, bson.M{
playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIDs},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().SetProjection(bson.M{
playletmod.FN.Playlet.Id: 1,
playletmod.FN.Playlet.MediaCenterId: 1,
playletmod.FN.Playlet.Title: 1,
playletmod.FN.Playlet.MediaBusiness: 1,
}))
if serr != nil {
log.Error("查询已导入 CGDJ 短剧失败", log.Any("mediaIds", mediaIDs), log.Any("err", serr))
return serr
}
for _, playlet := range playlets {
if playlet == nil || playlet.MediaCenterId == 0 {
continue
}
business := mediaBusinessByID[playlet.MediaCenterId]
if business == "" {
continue
}
resp.Imported++
item := &originalPlayletBackfillItem{
MediaCenterId: int64(playlet.MediaCenterId),
PlayletId: playlet.Id,
Title: playlet.Title,
MediaTitle: mediaTitleByID[playlet.MediaCenterId],
Source: business,
Before: playlet.MediaBusiness,
}
if playlet.MediaBusiness == business {
resp.Skipped++
item.Result = "already_marked"
resp.List = append(resp.List, item)
continue
}
resp.Matched++
if resp.DryRun {
item.Result = "preview"
resp.List = append(resp.List, item)
continue
}
serr = store.updateOne(ctx, bson.M{
playletmod.FN.Playlet.Id: playlet.Id,
playletmod.FN.Playlet.MediaCenterId: playlet.MediaCenterId,
playletmod.FN.Playlet.Status: playletmod.StatusUp,
playletmod.FN.Playlet.MediaBusiness: bson.M{"$ne": business},
}, bson.M{"$set": bson.M{
playletmod.FN.Playlet.MediaBusiness: business,
}})
if serr != nil {
if serr.Code == srvdef.ErrNoData {
resp.Skipped++
item.Result = "conflict"
resp.List = append(resp.List, item)
continue
}
item.Result = "failed"
resp.List = append(resp.List, item)
return serr
}
resp.Updated++
item.Result = "updated"
resp.List = append(resp.List, item)
if store.audit != nil {
store.audit(ctx, item)
}
}
return nil
}
+678
View File
@@ -0,0 +1,678 @@
package srv_mediaresource
import (
"context"
"math/rand"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/mwacc"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/web/api/proto"
"videoweb/web/middleware/auth"
"videoweb/web/webg"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
defaultResourceSyncType = 1
defaultResourcePageNum = 1
defaultResourcePageSize = 20
maxResourcePageSize = 100
maxResourceImportIds = 100
playletResourceImportMsg = "同步任务已提交,后台正在处理"
playletHeatMinValue = 1000
playletHeatMaxValue = 5000
playletResourceMediaTypeAnime = 1 // 动漫
playletResourceMediaTypeComic = 4 // 漫画短剧
playletResourceMediaTypeShort = 5 // 短剧
playletResourceMediaTypeNormal = 6 // 普通短剧
)
// ListPlayletResource 分页查询短剧媒资列表
func ListPlayletResource(ctx context.Context, req *proto.PlayletResourceListReq) (*proto.PlayletResourceListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
syncType := defaultResourceSyncType
if req.SyncType != nil {
syncType = *req.SyncType
}
pageNumber := req.PageNumber
if pageNumber <= 0 {
pageNumber = defaultResourcePageNum
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = defaultResourcePageSize
}
if pageSize > maxResourcePageSize {
pageSize = maxResourcePageSize
}
mediaType, err1 := resolvePlayletResourceMediaType(req.MediaType)
if err1 != nil {
return nil, err1
}
ms := newPlayletMediaCenterFromConfig()
data, err := ms.mediaList(&playletMediaListReq{
MediaType: mediaType,
Title: req.Title,
Tag: req.Tag,
SyncType: syncType,
Page: uint(pageNumber),
Size: uint(pageSize),
})
if err != nil {
return nil, srvdef.New(srvdef.Failure, err.Error(), "")
}
if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil {
return nil, err
}
return &proto.PlayletResourceListResp{
List: data.Data,
Total: data.Pagination.Count,
}, nil
}
// ListPlayletResourceChapters 查询短剧媒资剧集信息
func ListPlayletResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) {
if req == nil || req.Id == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "")
}
ms := newPlayletMediaCenterFromConfig()
result, err := ms.mediaSync(&playletMediaSyncReq{
Id: req.Id,
SyncTime: 0,
})
if err != nil {
return nil, srvdef.New(srvdef.Failure, err.Error(), "")
}
if result.IsDelete || result.Media == nil {
return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "")
}
list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList))
for _, item := range result.MediaContentList {
videoUrl := strings.TrimSpace(item.M3u8Url)
if videoUrl == "" {
videoUrl = strings.TrimSpace(item.Urls)
}
list = append(list, &proto.PlayletResourceChapterItem{
Id: item.ID,
EpisodeNumber: item.EpisodeNumber,
Title: strings.TrimSpace(item.Title),
Cover: item.Cover,
VideoUrl: videoUrl,
Height: item.Height,
Width: item.Width,
MediaSize: item.MediaSize,
PlayTime: item.PlayTime,
SaleCoin: item.SaleCoin,
HashId: item.HashId,
CreateTime: item.CreateTime,
UpdateTime: item.UpdateTime,
})
}
return &proto.PlayletResourceChapterListResp{
MediaId: req.Id,
Title: result.Media.Title,
List: list,
Total: len(list),
}, nil
}
// resolvePlayletResourceMediaType 校验后台显式传入的媒资类型
func resolvePlayletResourceMediaType(mediaType *int) (int, *srvdef.SrvErr) {
if mediaType == nil {
return 0, srvdef.New(srvdef.ErrParamError, "媒体类型不能为空", "媒体类型不能为空")
}
if !validPlayletResourceMediaType(*mediaType) {
return 0, srvdef.New(srvdef.ErrParamError, "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧", "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧")
}
return *mediaType, nil
}
// validPlayletResourceMediaType 媒资列表当前允许的媒体类型
func validPlayletResourceMediaType(mediaType int) bool {
switch mediaType {
case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal:
return true
default:
return false
}
}
// validPlayletImportMediaType 允许入库的媒资类型
func validPlayletImportMediaType(mediaType int) bool {
switch mediaType {
case playletResourceMediaTypeAnime, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal:
return true
default:
return false
}
}
// contentTypeFromMediaType 媒资中心 media_type 映射为本项目 contentType
func contentTypeFromMediaType(mediaType int) playletmod.ContentType {
switch mediaType {
case playletResourceMediaTypeAnime:
return playletmod.ContentTypeAnime
case playletResourceMediaTypeComic:
return playletmod.ContentTypeComic
case playletResourceMediaTypeNormal:
return playletmod.ContentTypeAlbum
default:
return playletmod.ContentTypeTheater
}
}
// fillPlayletResourceAddStatus 回填媒资列表是否已加入后台短剧库
func fillPlayletResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceListItem) *srvdef.SrvErr {
mediaIds := make([]uint, 0, len(list))
seen := make(map[uint]struct{}, len(list))
for _, item := range list {
if item == nil || item.ID == 0 {
continue
}
if _, ok := seen[item.ID]; ok {
continue
}
seen[item.ID] = struct{}{}
mediaIds = append(mediaIds, item.ID)
}
if len(mediaIds) == 0 {
return nil
}
playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIds},
})
if err != nil {
log.Error("查询短剧媒资加入状态失败", log.Any("mediaIds", mediaIds), log.Any("err", err))
return err
}
addedMap := make(map[uint]bool, len(playletList))
for _, item := range playletList {
if item == nil || item.MediaCenterId == 0 {
continue
}
addedMap[item.MediaCenterId] = true
}
for _, item := range list {
if item != nil {
item.IsAdd = addedMap[item.ID]
}
}
return nil
}
// ImportPlayletResource 提交短剧媒资入库任务
func ImportPlayletResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) {
ids, err := validatePlayletResourceImportReq(req)
if err != nil {
return "", err
}
operatorId, operatorName := resolveImportOperator(ctx)
if err := pushPlayletResourceImportJob(ctx, ids, operatorId, operatorName); err != nil {
return "", err
}
return playletResourceImportMsg, nil
}
// importPlayletResourceBatch 批量处理短剧媒资入库任务
func importPlayletResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp {
resp := &proto.PlayletResourceUseResp{
Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)),
Failed: make([]*proto.PlayletResourceUseFailed, 0),
}
importedMap, err := findImportedPlayletResourceMap(ctx, ids)
if err != nil {
for _, id := range ids {
resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{
MediaId: id,
Reason: "查询导入状态失败",
})
}
return resp
}
ms := newPlayletMediaCenterFromConfig()
for _, id := range ids {
if importedMap[id] {
resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{
MediaId: id,
Reason: "该媒资已导入",
})
continue
}
success, reason := importSinglePlayletResource(ctx, ms, id, operatorId, operatorName)
if reason != "" {
resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{
MediaId: id,
Reason: reason,
})
continue
}
resp.Success = append(resp.Success, success)
}
return resp
}
// findImportedPlayletResourceMap 批量查询已经导入的媒资ID
func findImportedPlayletResourceMap(ctx context.Context, ids []uint) (map[uint]bool, *srvdef.SrvErr) {
result := make(map[uint]bool, len(ids))
if len(ids) == 0 {
return result, nil
}
playletList, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": ids},
})
if err != nil {
log.Error("批量查询短剧媒资导入状态失败", log.Any("mediaIds", ids), log.Any("err", err))
return nil, err
}
for _, item := range playletList {
if item == nil || item.MediaCenterId == 0 {
continue
}
result[item.MediaCenterId] = true
}
return result, nil
}
func newPlayletMediaCenterFromConfig() *playletMediaCenterService {
return newPlayletMediaCenterService(playletMediaCenterConfig{
Url: webg.Conf.MediaResourceDBCfg.BaseUrl,
ProId: webg.Conf.MediaResourceDBCfg.Merchat,
Secret: webg.Conf.MediaResourceDBCfg.Secret,
})
}
// validatePlayletResourceImportReq 校验短剧媒资入库请求
func validatePlayletResourceImportReq(req *proto.PlayletResourceUseReq) ([]uint, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
ids := dedupePlayletResourceIds(req.Ids)
if len(ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "")
}
if len(ids) > maxResourceImportIds {
return nil, srvdef.New(srvdef.ErrParamError, "单次最多导入100条媒资", "")
}
return ids, nil
}
// dedupePlayletResourceIds 去重并过滤无效媒资ID
func dedupePlayletResourceIds(ids []uint) []uint {
result := make([]uint, 0, len(ids))
seen := make(map[uint]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
// importSinglePlayletResource 导入单条短剧媒资
func importSinglePlayletResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) {
locked, err := lockPlayletResourceImportMedia(mediaId)
if err != nil {
log.Error("获取短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err))
return nil, "获取导入锁失败"
}
if !locked {
return nil, "该媒资正在导入"
}
defer unlockPlayletResourceImportMedia(mediaId)
if reason := checkPlayletResourceImported(ctx, mediaId); reason != "" {
return nil, reason
}
result, err := ms.mediaSync(&playletMediaSyncReq{
Id: mediaId,
SyncTime: 0,
})
if err != nil {
log.Error("同步短剧媒资失败", log.Any("mediaId", mediaId), log.E(err))
return nil, "同步媒资失败"
}
if result.IsDelete || result.Media == nil {
return nil, "媒资不存在或已删除"
}
if !validPlayletImportMediaType(result.Media.MediaType) {
return nil, "该媒资类型不支持导入短剧"
}
if len(result.MediaContentList) == 0 {
return nil, "媒资章节为空"
}
playlet := buildPlayletFromMedia(result.Media, operatorId, operatorName)
playlet.MediaSyncTime = maxPlayletMediaContentUpdateTime(0, result.MediaContentList)
if playlet.Title == "" {
return nil, "媒资标题为空"
}
exists := func(s string) bool {
item, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, 0))
return item != nil
}
if serr := slugx.Ensure(&playlet.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, playlet.Title), exists); serr != nil {
if serr.Tips != "" {
return nil, serr.Tips
}
return nil, "slug生成失败"
}
// 原图继续写入 Cover;压缩成功才写 CompressedCover,失败保持空。
playlet.CompressedCover = compressPlayletCoverOrEmpty(playlet.Cover)
chapters := buildPlayletChaptersFromMedia(result.MediaContentList)
if len(chapters) == 0 {
return nil, "媒资章节为空"
}
if err1 := playletmod.InsertOnePlaylet(ctx, playlet); err1 != nil {
log.Error("短剧媒资主表入库失败", log.Any("mediaId", mediaId), log.Any("err", err1))
return nil, "主资源入库失败"
}
for _, chapter := range chapters {
chapter.PlayletId = playlet.Id
}
if err1 := playletchaptermod.InsertManyPlayletChapter(ctx, chapters); err1 != nil {
log.Error("短剧媒资章节批量入库失败", log.Any("mediaId", mediaId), log.Any("playletId", playlet.Id), log.Any("chapterCount", len(chapters)), log.Any("err", err1))
rollbackImportedPlayletResource(ctx, playlet.Id, collectPlayletChapterIds(chapters))
return nil, "章节入库失败"
}
return &proto.PlayletResourceUseSuccess{
MediaId: mediaId,
PlayletId: playlet.Id,
}, ""
}
// collectPlayletChapterIds 收集章节ID
func collectPlayletChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 {
ids := make([]int64, 0, len(chapters))
for _, chapter := range chapters {
if chapter == nil || chapter.Id == 0 {
continue
}
ids = append(ids, chapter.Id)
}
return ids
}
// checkPlayletResourceImported 检查媒资是否已经导入到本项目短剧表
func checkPlayletResourceImported(ctx context.Context, mediaId uint) string {
_, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.MediaCenterId: mediaId})
if err == nil {
return "该媒资已导入"
}
if err.Code != srvdef.ErrNoData {
log.Error("查询短剧媒资导入状态失败", log.Any("mediaId", mediaId), log.Any("err", err))
return "查询导入状态失败"
}
return ""
}
// buildPlayletFromMedia 将媒资中心主资源转换为本项目 playlet 主表模型
func buildPlayletFromMedia(media *proto.PlayletResourceListItem, operatorId int64, operatorName string) *playletmod.Playlet {
cover := strings.TrimSpace(media.VerticalCover)
if cover == "" {
cover = strings.TrimSpace(media.HorizontalCover)
}
return &playletmod.Playlet{
MediaCenterId: uint(media.ID),
MediaCreatedAt: mediaResourceTime(media.CreateTime),
MediaUpdatedAt: mediaResourceTime(media.UpdateTime),
Title: strings.TrimSpace(media.Title),
Desc: strings.TrimSpace(media.Summary),
Anchor: strings.TrimSpace(media.Anchor),
Cover: cover,
OldTags: oldTagNamesFromPlayletTagsText(media.TagsText),
MediaBusiness: playletMediaBusiness(media),
ContentType: contentTypeFromMediaType(media.MediaType),
TotalEpisode: int64(media.TotalEpisode),
UpdateStatus: normalizePlayletUpdateStatus(media.UpdateStatus),
HeatValue: randomPlayletHeatValue(),
Status: playletmod.StatusDown,
OperatorId: operatorId,
OperatorName: operatorName,
}
}
// playletMediaBusinessCGDJ 赤果短剧、CGDJ 标题前缀对应的媒资业务来源。
const playletMediaBusinessCGDJ = "CGDJ"
// playletMediaBusinessRule 把媒资标题前缀映射成要写入的业务来源。先匹配先生效。
type playletMediaBusinessRule struct {
Prefix string
Business string
}
// playletMediaBusinessRules 多个前缀可以对应同一个业务来源。
// 新增来源时在这里追加;同一来源的不同叫法共用同一个 Business。
var playletMediaBusinessRules = []playletMediaBusinessRule{
//{Prefix: "赤果短剧", Business: playletMediaBusinessCGDJ},
{Prefix: "CGDJ", Business: playletMediaBusinessCGDJ},
}
func playletMediaBusiness(media *proto.PlayletResourceListItem) string {
if media == nil || media.MediaType != playletResourceMediaTypeShort {
return ""
}
title := strings.ToUpper(strings.TrimSpace(media.Title))
for _, rule := range playletMediaBusinessRules {
prefix := strings.ToUpper(strings.TrimSpace(rule.Prefix))
business := strings.TrimSpace(rule.Business)
if prefix == "" || business == "" {
continue
}
if strings.HasPrefix(title, prefix) {
return business
}
}
return ""
}
// playletMediaBusinessSearchTitles 返回回填时用来搜索媒资库的标题前缀。
func playletMediaBusinessSearchTitles() []string {
seen := make(map[string]struct{}, len(playletMediaBusinessRules))
titles := make([]string, 0, len(playletMediaBusinessRules))
for _, rule := range playletMediaBusinessRules {
prefix := strings.TrimSpace(rule.Prefix)
if prefix == "" {
continue
}
key := strings.ToUpper(prefix)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
titles = append(titles, prefix)
}
return titles
}
func oldTagNamesFromPlayletTagsText(tagsText string) []string {
fields := strings.FieldsFunc(strings.TrimSpace(tagsText), func(r rune) bool {
switch r {
case ',', ',', '、', ';', ';', '/', '|':
return true
default:
return false
}
})
names := make([]string, 0, len(fields))
seen := make(map[string]struct{}, len(fields))
for _, field := range fields {
name := strings.TrimSpace(field)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func randomPlayletHeatValue() int64 {
return int64(rand.Intn(playletHeatMaxValue-playletHeatMinValue+1) + playletHeatMinValue)
}
// mediaResourceTime 转换媒资中心时间戳
func mediaResourceTime(ts int64) time.Time {
if ts <= 0 {
return time.Time{}
}
if ts > 1e12 {
return time.UnixMilli(ts)
}
return time.Unix(ts, 0)
}
// buildPlayletChaptersFromMedia 将媒资中心章节资源转换为本项目 playlet_chapter 模型
func buildPlayletChaptersFromMedia(mediaContentList []playletMediaContent) playletchaptermod.PlayletChapterList {
chapters := make(playletchaptermod.PlayletChapterList, 0, len(mediaContentList))
seenMediaContentIds := make(map[uint]struct{}, len(mediaContentList))
seenEpisodes := make(map[int]struct{}, len(mediaContentList))
seenHashIds := make(map[string]struct{}, len(mediaContentList))
seenVideoUrls := make(map[string]struct{}, len(mediaContentList))
for _, item := range mediaContentList {
videoUrl := strings.TrimSpace(item.M3u8Url)
if videoUrl == "" {
videoUrl = strings.TrimSpace(item.Urls)
}
if videoUrl == "" {
continue
}
if isDuplicatePlayletMediaChapter(item, videoUrl, seenMediaContentIds, seenEpisodes, seenHashIds, seenVideoUrls) {
continue
}
episodeNumber := int64(item.EpisodeNumber)
if episodeNumber <= 0 {
episodeNumber = int64(len(chapters) + 1)
}
height := int64(item.Height)
width := int64(item.Width)
ratio := item.Ratio
if ratio <= 0 && height > 0 {
ratio = float64(width) / float64(height)
}
chapters = append(chapters, &playletchaptermod.PlayletChapter{
MediaContentId: uint(item.ID),
CurrentEpisode: episodeNumber,
Title: strings.TrimSpace(item.Title),
Hash: item.HashId,
Height: height,
Width: width,
Ratio: ratio,
VideoUrl: videoUrl,
MediaSize: int64(item.MediaSize),
MediaTime: int64(item.PlayTime),
PayType: playletchaptermod.PayTypeFree,
SingleCoins: int64(item.SaleCoin),
WatermarkStatus: playletchaptermod.WatermarkStatusPending,
})
}
return chapters
}
// isDuplicatePlayletMediaChapter 判断媒资中心章节是否重复
func isDuplicatePlayletMediaChapter(item playletMediaContent, videoUrl string, seenMediaContentIds map[uint]struct{}, seenEpisodes map[int]struct{}, seenHashIds map[string]struct{}, seenVideoUrls map[string]struct{}) bool {
if item.ID > 0 {
if _, ok := seenMediaContentIds[item.ID]; ok {
log.Info("跳过重复短剧媒资章节", log.Any("mediaContentId", item.ID), log.Any("episodeNumber", item.EpisodeNumber))
return true
}
seenMediaContentIds[item.ID] = struct{}{}
return false
}
if item.EpisodeNumber > 0 {
if _, ok := seenEpisodes[item.EpisodeNumber]; ok {
log.Info("跳过重复短剧媒资章节", log.Any("episodeNumber", item.EpisodeNumber))
return true
}
seenEpisodes[item.EpisodeNumber] = struct{}{}
return false
}
hashId := strings.TrimSpace(item.HashId)
if hashId != "" {
if _, ok := seenHashIds[hashId]; ok {
log.Info("跳过重复短剧媒资章节", log.Any("hashId", hashId))
return true
}
seenHashIds[hashId] = struct{}{}
return false
}
if _, ok := seenVideoUrls[videoUrl]; ok {
log.Info("跳过重复短剧媒资章节", log.Any("videoUrl", videoUrl))
return true
}
seenVideoUrls[videoUrl] = struct{}{}
return false
}
// rollbackImportedPlayletResource 回滚单条媒资入库产生的数据
func rollbackImportedPlayletResource(ctx context.Context, playletId int64, chapterIds []int64) {
for _, chapterId := range chapterIds {
if _, err := playletchaptermod.DelOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}); err != nil {
log.Error("回滚短剧媒资章节失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.Any("err", err))
}
}
if _, err := playletmod.DelOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}); err != nil {
log.Error("回滚短剧媒资主表失败", log.Any("playletId", playletId), log.Any("err", err))
}
}
// resolveImportOperator 从后台登录态解析当前操作人
func resolveImportOperator(ctx context.Context) (int64, string) {
c, ok := ctx.(*gin.Context)
if !ok || c == nil {
return 0, ""
}
accId := auth.GetAccount(c)
if accId <= 0 {
return 0, ""
}
acc, err := mwacc.FindOneAccByFilter(c, bson.M{
mwacc.FN.MwAcc.Id: accId,
}, options.FindOne().SetProjection(bson.D{
{Key: mwacc.FN.MwAcc.Id, Value: 1},
{Key: mwacc.FN.MwAcc.UserName, Value: 1},
}))
if err != nil || acc == nil {
return accId, ""
}
return acc.Id, acc.UserName
}
// maxPlayletMediaContentUpdateTime 记录本次同步返回章节中的最大更新时间
func maxPlayletMediaContentUpdateTime(syncTime int64, mediaContentList []playletMediaContent) int64 {
result := syncTime
for _, item := range mediaContentList {
if item.UpdateTime > result {
result = item.UpdateTime
}
}
return result
}
// normalizePlayletUpdateStatus 三方 update_status=0 在本项目按 2-已完结保存
func normalizePlayletUpdateStatus(updateStatus int) int {
if updateStatus == 0 {
return 2
}
return updateStatus
}
@@ -0,0 +1,468 @@
package srv_mediaresource
import (
"context"
"encoding/json"
"fmt"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
"videoweb/web/api/proto"
goredis "github.com/go-redis/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// PlayletSerialSyncCronSpec 连载中短剧同步扫描频率:每1小时扫描一次,扫描阶段只投递 Redis 任务
PlayletSerialSyncCronSpec = "0 0 * * * ?"
// playletSerialSyncQueueKeyFmt 解耦定时扫描和三方同步调用,避免 cron 扫描过程直接阻塞在外部接口上
playletSerialSyncQueueKeyFmt = "playlet:serial:sync:queue:%d"
playletSerialSyncLockKeyFmt = "playlet:serial:sync:lock:%d:%d"
playletSerialSyncLockTTL = 55 * time.Minute
playletSerialSyncPopWait = 5 * time.Second
playletSerialSyncRetryWait = time.Second
playletSerialSyncBatchSize = int64(100)
playletManualSyncMaxCount = 100
)
func playletSerialSyncQueueKey() string {
return fmt.Sprintf(playletSerialSyncQueueKeyFmt, pubdef.AppId)
}
func playletSerialSyncLockKey(mediaId uint) string {
return fmt.Sprintf(playletSerialSyncLockKeyFmt, pubdef.AppId, mediaId)
}
type playletSerialSyncJob struct {
PlayletId int64 `json:"playletId"`
MediaCenterId uint `json:"mediaCenterId"`
MediaSyncTime int64 `json:"mediaSyncTime"`
CreateTime int64 `json:"createTime"`
}
// EnqueueSerialPlayletSyncJobs 扫描连载中短剧并投递 Redis 同步任务
//
// 这里不直接请求三方媒资中心:扫描只负责发现任务,实际同步由后台消费者逐条处理
// 可以降低定时任务执行时间,也方便 Redis 队列在三方接口短暂异常时继续缓冲任务
func EnqueueSerialPlayletSyncJobs() {
ctx := context.Background()
filter := bson.M{
playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0},
playletmod.FN.Playlet.UpdateStatus: 1,
// 原创短剧由后台维护剧集,自动同步只处理媒资导入剧
playletmod.FN.Playlet.IsOriginal: bson.M{"$ne": true},
}
opts := options.Find().
SetSort(bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, {Key: playletmod.FN.Playlet.Id, Value: 1}}).
SetLimit(playletSerialSyncBatchSize)
list, err := playletmod.FindManyPlaylet(ctx, filter, opts)
if err != nil {
log.Error("扫描连载中短剧失败", log.Any("err", err))
return
}
enqueued := 0
for _, item := range list {
if item == nil || item.Id == 0 || item.MediaCenterId == 0 || item.IsOriginal {
continue
}
if err := pushPlayletSerialSyncJob(ctx, item); err != nil {
log.Error("投递连载短剧同步任务失败", log.Any("playletId", item.Id), log.Any("mediaCenterId", item.MediaCenterId), log.E(err))
continue
}
enqueued++
}
log.Info("连载中短剧同步任务扫描完成", log.Any("scanCount", len(list)), log.Any("enqueued", enqueued))
}
// pushPlayletSerialSyncJob 将单部连载短剧投递到 Redis 队列
func pushPlayletSerialSyncJob(ctx context.Context, playlet *playletmod.Playlet) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
job := playletSerialSyncJob{
PlayletId: playlet.Id,
MediaCenterId: playlet.MediaCenterId,
MediaSyncTime: playlet.MediaSyncTime,
CreateTime: time.Now().Unix(),
}
payload, err := json.Marshal(job)
if err != nil {
return err
}
return redis.RPush(playletSerialSyncQueueKey(), string(payload))
}
// runPlayletSerialSyncWorker 常驻消费连载短剧同步队列
func runPlayletSerialSyncWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
result, err := redis.Client().BLPop(playletSerialSyncPopWait, playletSerialSyncQueueKey()).Result()
if err != nil {
if err == goredis.Nil {
continue
}
log.Error("消费连载短剧同步任务失败", log.E(err))
time.Sleep(playletSerialSyncRetryWait)
continue
}
if len(result) < 2 {
log.Error("连载短剧同步任务格式异常", log.Any("result", result))
continue
}
handlePlayletSerialSyncPayload(ctx, result[1])
}
}
// handlePlayletSerialSyncPayload 解析单个 Redis 任务并执行同步,panic 会被捕获避免 worker 退出
func handlePlayletSerialSyncPayload(ctx context.Context, payload string) {
defer func() {
if err := recover(); err != nil {
log.Error("处理连载短剧同步任务异常", log.Any("err", err), log.Any("payload", payload))
}
}()
var job playletSerialSyncJob
if err := json.Unmarshal([]byte(payload), &job); err != nil {
log.Error("解析连载短剧同步任务失败", log.Any("payload", payload), log.E(err))
return
}
if job.PlayletId <= 0 || job.MediaCenterId == 0 {
log.Error("连载短剧同步任务参数异常", log.Any("job", job))
return
}
if _, reason := syncSerialPlaylet(ctx, job, false, false); reason != "" {
log.Error("连载短剧同步任务失败", log.Any("job", job), log.Any("reason", reason))
return
}
log.Info("连载短剧同步任务完成", log.Any("job", job))
}
// SyncPlayletChapters 手动同步指定短剧的媒资剧集,支持单/批量,允许已完结短剧补集或修正状态。
func SyncPlayletChapters(ctx context.Context, req *proto.PlayletChapterSyncReq) (*proto.PlayletChapterSyncResp, *srvdef.SrvErr) {
ids := normalizePlayletChapterSyncIds(req)
if len(ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "")
}
if len(ids) > playletManualSyncMaxCount {
return nil, srvdef.New(srvdef.ErrParamError, "单次最多同步100部短剧", "")
}
resp := &proto.PlayletChapterSyncResp{
Total: len(ids),
List: make([]*proto.PlayletChapterSyncResult, 0, len(ids)),
}
for _, playletId := range ids {
item := syncOnePlayletChapters(ctx, playletId)
if item.Success {
resp.SuccessCount++
} else {
resp.FailedCount++
}
resp.List = append(resp.List, item)
}
return resp, nil
}
// normalizePlayletChapterSyncIds 过滤重复或无效短剧ID。
func normalizePlayletChapterSyncIds(req *proto.PlayletChapterSyncReq) []int64 {
if req == nil {
return nil
}
result := make([]int64, 0, len(req.PlayletIds))
seen := make(map[int64]struct{}, len(req.PlayletIds))
for _, id := range req.PlayletIds {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
// syncOnePlayletChapters 同步单部短剧的媒资剧集,单部失败不影响批量里其他短剧。
func syncOnePlayletChapters(ctx context.Context, playletId int64) *proto.PlayletChapterSyncResult {
result := &proto.PlayletChapterSyncResult{
PlayletId: playletId,
}
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playletId,
}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1},
{Key: playletmod.FN.Playlet.MediaSyncTime, Value: 1},
}))
if err != nil {
result.Reason = playletChapterSyncErrReason(err)
return result
}
if playlet.MediaCenterId == 0 {
result.Reason = "非媒资导入短剧不能同步剧集"
return result
}
newChapterCount, reason := syncSerialPlaylet(ctx, playletSerialSyncJob{
PlayletId: playlet.Id,
MediaCenterId: playlet.MediaCenterId,
MediaSyncTime: playlet.MediaSyncTime,
CreateTime: time.Now().Unix(),
}, true, true)
if reason != "" {
result.Reason = reason
return result
}
result.Success = true
result.NewChapterCount = newChapterCount
return result
}
// playletChapterSyncErrReason 转换单部短剧同步失败原因,避免批量结果里 reason 为空。
func playletChapterSyncErrReason(err *srvdef.SrvErr) string {
if err == nil {
return ""
}
if err.Code == srvdef.ErrNoData {
return "短剧不存在"
}
if reason := err.GetTip(); reason != "" {
return reason
}
if reason := err.GetMsg(); reason != "" {
return reason
}
return err.Error()
}
// syncSerialPlaylet 更新主表集数/状态,只追加新增章节,不覆盖已有章节。
// 手动同步使用 forceFullSync=true 全量拉取,便于恢复本地误删的旧章节;定时任务继续使用增量同步。
func syncSerialPlaylet(ctx context.Context, job playletSerialSyncJob, allowCompleted bool, forceFullSync bool) (int, string) {
locked, err := lockPlayletSerialSync(job.MediaCenterId)
if err != nil {
log.Error("获取连载短剧同步锁失败", log.Any("job", job), log.E(err))
return 0, "获取同步锁失败"
}
if !locked {
return 0, "该媒资正在同步"
}
defer unlockPlayletSerialSync(job.MediaCenterId)
playlet, serr := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: job.PlayletId,
playletmod.FN.Playlet.MediaCenterId: job.MediaCenterId,
})
if serr != nil {
if serr.Code == srvdef.ErrNoData {
return 0, "短剧不存在"
}
log.Error("查询连载短剧失败", log.Any("job", job), log.Any("err", serr))
return 0, "查询短剧失败"
}
if !allowCompleted && playlet.IsOriginal {
return 0, ""
}
if !allowCompleted && playlet.UpdateStatus != 1 {
return 0, ""
}
ms := newPlayletMediaCenterFromConfig()
syncTime := playlet.MediaSyncTime
if forceFullSync {
syncTime = 0
}
result, err := ms.mediaSync(&playletMediaSyncReq{
Id: playlet.MediaCenterId,
SyncTime: syncTime,
})
if err != nil {
log.Error("同步连载短剧媒资失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.E(err))
return 0, "同步媒资失败"
}
if result.IsDelete {
return 0, "媒资不存在或已删除"
}
if result.Media == nil {
return 0, "媒资主资源为空"
}
newSyncTime := maxPlayletMediaContentUpdateTime(playlet.MediaSyncTime, result.MediaContentList)
if reason := updateSerialPlayletInfo(ctx, playlet, result.Media, newSyncTime); reason != "" {
return 0, reason
}
if len(result.MediaContentList) == 0 {
return 0, ""
}
chapters, reason := buildNewSerialPlayletChapters(ctx, playlet.Id, result.MediaContentList)
if reason != "" {
return 0, reason
}
if len(chapters) == 0 {
return 0, ""
}
// 上架且连载中时,新增剧集默认支付方式取剧级设置。
applyPlayletDefaultPayToChapters(playlet, chapters)
if serr = playletchaptermod.InsertManyPlayletChapter(ctx, chapters); serr != nil {
log.Error("连载短剧新增章节入库失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("count", len(chapters)), log.Any("err", serr))
return 0, "新增章节入库失败"
}
log.Info("连载短剧新增章节成功", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("count", len(chapters)))
return len(chapters), ""
}
// updateSerialPlayletInfo 更新三方主资源里会随连载变化的字段
func updateSerialPlayletInfo(ctx context.Context, playlet *playletmod.Playlet, media *proto.PlayletResourceListItem, mediaSyncTime int64) string {
updateStatus := normalizePlayletUpdateStatus(media.UpdateStatus)
setDoc := bson.M{
playletmod.FN.Playlet.TotalEpisode: int64(media.TotalEpisode),
playletmod.FN.Playlet.UpdateStatus: updateStatus,
playletmod.FN.Playlet.MediaUpdatedAt: mediaResourceTime(media.UpdateTime),
playletmod.FN.Playlet.UpdatedAt: time.Now(),
}
if mediaSyncTime > playlet.MediaSyncTime {
setDoc[playletmod.FN.Playlet.MediaSyncTime] = mediaSyncTime
}
if business := playletMediaBusiness(media); business != "" && playlet.MediaBusiness != business {
setDoc[playletmod.FN.Playlet.MediaBusiness] = business
}
if err := playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playlet.Id,
}, bson.M{"$set": setDoc}); err != nil {
log.Error("更新连载短剧主表失败", log.Any("playletId", playlet.Id), log.Any("mediaCenterId", playlet.MediaCenterId), log.Any("err", err))
return "更新主表失败"
}
return ""
}
// buildNewSerialPlayletChapters 根据本地已存在章节做幂等过滤,只返回需要追加的新章节
func buildNewSerialPlayletChapters(ctx context.Context, playletId int64, mediaContentList []playletMediaContent) (playletchaptermod.PlayletChapterList, string) {
exists, err := loadExistingPlayletChapterKeys(ctx, playletId)
if err != nil {
return nil, "查询已有章节失败"
}
chapters := buildPlayletChaptersFromMedia(mediaContentList)
result := make(playletchaptermod.PlayletChapterList, 0, len(chapters))
for _, chapter := range chapters {
if chapter == nil || chapterDuplicateByKeys(chapter, exists) {
continue
}
chapter.PlayletId = playletId
markExistingPlayletChapterKeys(chapter, exists)
result = append(result, chapter)
}
return result, ""
}
type existingPlayletChapterKeys struct {
mediaContentIds map[uint]struct{}
episodes map[int64]struct{}
hashes map[string]struct{}
videoUrls map[string]struct{}
}
// loadExistingPlayletChapterKeys 加载当前短剧已入库章节的去重键,避免连载同步重复插入章节
func loadExistingPlayletChapterKeys(ctx context.Context, playletId int64) (existingPlayletChapterKeys, error) {
keys := existingPlayletChapterKeys{
mediaContentIds: make(map[uint]struct{}),
episodes: make(map[int64]struct{}),
hashes: make(map[string]struct{}),
videoUrls: make(map[string]struct{}),
}
list, serr := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: playletId})
if serr != nil {
return keys, fmt.Errorf("query playlet chapters failed: %+v", serr)
}
for _, chapter := range list {
markExistingPlayletChapterKeys(chapter, keys)
}
return keys, nil
}
// chapterDuplicateByKeys 章节跨批次去重,优先用媒资章节ID,其次用集数、hash、播放地址兜底
func chapterDuplicateByKeys(chapter *playletchaptermod.PlayletChapter, keys existingPlayletChapterKeys) bool {
if chapter.MediaContentId > 0 {
if _, ok := keys.mediaContentIds[chapter.MediaContentId]; ok {
return true
}
}
if chapter.CurrentEpisode > 0 {
if _, ok := keys.episodes[chapter.CurrentEpisode]; ok {
return true
}
}
if chapter.Hash != "" {
if _, ok := keys.hashes[chapter.Hash]; ok {
return true
}
}
if chapter.VideoUrl != "" {
if _, ok := keys.videoUrls[chapter.VideoUrl]; ok {
return true
}
}
return false
}
// markExistingPlayletChapterKeys 把已存在或本批次准备插入的章节登记到去重集合
func markExistingPlayletChapterKeys(chapter *playletchaptermod.PlayletChapter, keys existingPlayletChapterKeys) {
if chapter == nil {
return
}
if chapter.MediaContentId > 0 {
keys.mediaContentIds[chapter.MediaContentId] = struct{}{}
}
if chapter.CurrentEpisode > 0 {
keys.episodes[chapter.CurrentEpisode] = struct{}{}
}
if chapter.Hash != "" {
keys.hashes[chapter.Hash] = struct{}{}
}
if chapter.VideoUrl != "" {
keys.videoUrls[chapter.VideoUrl] = struct{}{}
}
}
// applyPlayletDefaultPayToChapters 上架且连载中时,新增章节默认沿用剧级支付设置。
func applyPlayletDefaultPayToChapters(playlet *playletmod.Playlet, chapters playletchaptermod.PlayletChapterList) {
if playlet == nil || len(chapters) == 0 {
return
}
if playlet.Status != playletmod.StatusUp || playlet.UpdateStatus != 1 {
return
}
payType := playletchaptermod.PayType(playlet.PayType)
singleCoins := playlet.SingleCoins
if payType != playletchaptermod.PayTypeCoin {
singleCoins = 0
}
for _, chapter := range chapters {
if chapter == nil {
continue
}
chapter.PayType = payType
chapter.SingleCoins = singleCoins
}
}
// lockPlayletSerialSync 锁定单个媒资ID,避免同一部连载短剧被多个 worker 同时同步
func lockPlayletSerialSync(mediaId uint) (bool, error) {
return redis.Setnx_NewOK(playletSerialSyncLockKey(mediaId), true, playletSerialSyncLockTTL)
}
// unlockPlayletSerialSync 释放单个媒资ID的同步锁
func unlockPlayletSerialSync(mediaId uint) {
if err := redis.Del(playletSerialSyncLockKey(mediaId)); err != nil {
log.Error("释放连载短剧同步锁失败", log.Any("mediaId", mediaId), log.E(err))
}
}
+74
View File
@@ -0,0 +1,74 @@
package srv_mwacc
import (
"context"
"time"
"videoweb/pub/common/crypt"
"videoweb/pub/common/errors"
"videoweb/pub/models/mwacc"
"go.mongodb.org/mongo-driver/bson"
)
// // 删除角色
// func AccDel(ctx context.Context, roleId int64) (ok bool, err error){
//
// }
// 更改
func AccUp(ctx context.Context, upDoc *AccUpDoc) (ok bool, err error) {
c, err := mwacc.AccCountByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Acc: upDoc.Acc,
mwacc.FN.MwAcc.Id: bson.M{
"$ne": upDoc.Id,
},
})
if c > 0 {
err = errors.MakeWithMsg(errors.DBInsert, "账户名已存在")
return false, err
}
set := bson.M{}
set = bson.M{
mwacc.FN.MwAcc.UserName: upDoc.UserName,
mwacc.FN.MwAcc.Acc: upDoc.Acc,
mwacc.FN.MwAcc.Email: upDoc.Email,
mwacc.FN.MwAcc.RoleId: upDoc.RoleId,
mwacc.FN.MwAcc.IsEnb: upDoc.IsEnb,
mwacc.FN.MwAcc.UpdatedAt: time.Now(),
mwacc.FN.MwAcc.LimitIP: upDoc.LimitIP,
}
err = mwacc.UpOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: upDoc.Id,
}, set)
if err != nil {
err = errors.MakeWithErr(errors.DBUpdate, err)
return false, err
}
return true, err
}
// 更改账号密码
func AccUpPwd(ctx context.Context, upDoc *AccUpPwdDoc) (ok bool, err error) {
set := bson.M{}
set = bson.M{
mwacc.FN.MwAcc.Pwd: crypt.StrToMd5(upDoc.Pwd),
mwacc.FN.MwAcc.UpdatedAt: time.Now(),
}
err = mwacc.UpOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: upDoc.Id,
}, set)
if err != nil {
err = errors.MakeWithErr(errors.DBUpdate, err)
return false, err
}
mwacc.DeleteOneToken(ctx, bson.M{mwacc.FN.MwAccToken.AccId: upDoc.Id})
return true, err
}
+32
View File
@@ -0,0 +1,32 @@
package srv_mwacc
type AccUpDoc struct {
// 账号Id
Id int64 `json:"id,omitempty"`
// 用户名
UserName string `json:"user_name,omitempty"`
// 账号
Acc string `json:"acc,omitempty"`
// // 密码
// Pwd string `json:"pwd,omitempty"`
// 邮箱
Email string `json:"email,omitempty"`
// 角色Id
RoleId int64 `json:"role_id,omitempty"`
// 使能
IsEnb bool `json:"is_enb,omitempty"`
// 允许访问的IP
LimitIP []string `json:"limitIP,omitempty"`
}
type ProductAccountInfo struct {
Id int64 `json:"id"`
Name string `json:"name"`
}
type AccUpPwdDoc struct {
// 账号Id
Id int64 `json:"id,omitempty"`
// 密码
Pwd string `json:"pwd,omitempty"`
}
+179
View File
@@ -0,0 +1,179 @@
package srv_mwacc
import (
"context"
"strings"
"time"
"videoweb/pub/common/crypt"
"videoweb/pub/common/enum/consts"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/google"
"videoweb/pub/models/mwacc"
"videoweb/web/webg"
"github.com/skip2/go-qrcode"
"go.mongodb.org/mongo-driver/bson"
)
func generateToken(userID int64) (string, error) {
return utils.GenerateUserToken(utils.UserClaims{UserID: userID, Expire: consts.AdminJwtTokenExpire}, webg.Conf.JwtKey)
}
// AccSLogin 账号登录
func AccSLogin(ctx context.Context, acc string, pwd string) (*mwacc.MwAcc, *srvdef.SrvErr) {
var data *mwacc.MwAcc
res, dataErr := mwacc.FindOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Acc: acc,
})
if dataErr != nil || res.Id == 0 {
return data, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "")
}
if !res.IsEnb {
return data, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
}
// 通过验证,再次验证密码是否正确
md5pwd := crypt.StrToMd5(pwd)
if res.Pwd != md5pwd {
return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "")
}
// slogin 仅允许角色名含“客服”的账号登录
if !isCustomerServiceRole(ctx, res) {
return data, srvdef.New(srvdef.ErrUserPermission, "仅客服角色可登录", "")
}
getToken, err := generateToken(res.Id)
if err != nil {
return data, srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "")
}
_ = mwacc.UpOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: res.Id,
}, bson.M{
mwacc.FN.MwAcc.LastTime: time.Now(),
})
// 更新最近登录时间
data = res
data.Token = getToken
return data, nil
}
// isCustomerServiceRole 判断账号角色名是否包含“客服”
func isCustomerServiceRole(ctx context.Context, acc *mwacc.MwAcc) bool {
if acc == nil {
return false
}
roleName := acc.RoleName
if roleName == "" && acc.RoleId > 0 {
role, serr := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: acc.RoleId})
if serr == nil && role != nil {
roleName = role.Name
}
}
return strings.Contains(roleName, "客服")
}
// 账号登录
func AccLogin(ctx context.Context, acc string, pwd string, code string) (*mwacc.MwAcc, *srvdef.SrvErr) {
var data *mwacc.MwAcc
res, dataErr := mwacc.FindOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Acc: acc,
})
if dataErr != nil || res.Id == 0 {
return data, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "")
}
if !res.IsEnb {
return data, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
}
// 通过验证,再次验证密码是否正确
md5pwd := crypt.StrToMd5(pwd)
if res.Pwd != md5pwd {
return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "")
}
if !res.IsBase32 {
// 谷歌绑定二维码
googleUrl := google.NewGoogleAuth().GetQrcode(acc, res.Base32)
qrCodePNG, _ := qrcode.Encode(googleUrl, qrcode.Medium, 256)
bq := &mwacc.MwAcc{}
bq.Qurl = qrCodePNG
data = bq
} else {
// hf屏蔽
if webg.Conf.RunMod == "prod" && (res.RoleId != 46761 && res.RoleId != 29601) {
if code == "" {
return data, srvdef.New(srvdef.ErrCaptcha, srvdef.ErrCaptcha.Msg(), "")
}
result, Verr := google.NewGoogleAuth().VerifyCode(res.Base32, code)
if Verr != nil || !result {
return data, srvdef.New(srvdef.ErrCaptchaInvalid, srvdef.ErrCaptchaInvalid.Msg(), "")
}
}
// 通过验证,再次验证密码是否正确
pwd := crypt.StrToMd5(pwd)
if res.Pwd != pwd {
return data, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "")
}
getToken, err := generateToken(res.Id)
if err != nil {
return data, srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "")
}
_ = mwacc.UpOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: res.Id,
}, bson.M{
mwacc.FN.MwAcc.LastTime: time.Now(),
})
// 更新最近登录时间
data = res
data.Token = getToken
}
return data, nil
}
// 账号登录
func ProductAccountList(ctx context.Context, accId int64) ([]ProductAccountInfo, *srvdef.SrvErr) {
resp := make([]ProductAccountInfo, 0)
accInfo, serr := mwacc.FindOneAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.Id: accId,
})
if serr != nil {
log.Error("ProductAccountList FindOneAccByFilter", log.Any("serr", serr))
return nil, srvdef.New(srvdef.ErrDbQueryError, "", "")
}
if accInfo.RoleId == 1000 {
resp = append(resp, ProductAccountInfo{
Id: accInfo.Id,
Name: accInfo.UserName,
})
return resp, nil
}
accList, lerr := mwacc.FindManyAccByFilter(ctx, bson.M{
mwacc.FN.MwAcc.RoleId: 1000,
})
if lerr != nil {
log.Error("ProductAccountList FindManyAccByFilter", log.Any("serr", serr))
return nil, srvdef.New(srvdef.ErrDbQueryError, "", "")
}
for _, v := range accList {
resp = append(resp, ProductAccountInfo{
Id: v.Id,
Name: v.UserName,
})
}
return resp, nil
}
+221
View File
@@ -0,0 +1,221 @@
package srv_mwacc
import (
"context"
"fmt"
"time"
"videoweb/pub/common/errors"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/mwacc"
"go.mongodb.org/mongo-driver/bson"
)
// 删除角色
func RoleDel(ctx context.Context, roleId int64) (bool, *srvdef.SrvErr) {
res, err := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId})
if err != nil || res.Id == 0 {
log.ZapLog.Error(fmt.Sprintf("search error %v", err))
return false, err
}
// 查询记录是否有账户
roles, err1 := mwacc.RoleAggregate(ctx,
[]bson.M{
{"$match": bson.M{mwacc.FN.MwRole.Id: roleId}},
{"$limit": 1},
{"$lookup": bson.M{
"localField": mwacc.FN.MwRole.Id,
"from": mwacc.TN.MwAcc,
"foreignField": mwacc.FN.MwAcc.RoleId,
"as": "onlyRole",
}},
{"$unwind": "$onlyRole"},
{"$project": bson.M{
mwacc.FN.MwRole.Id: 1,
"onlyRole.roleId": 1,
}},
})
if err1 != nil {
log.Error("角色删除失败", log.Any("roleId", roleId))
return false, srvdef.New(srvdef.ErrDbDeleteError, "", "")
}
if len(roles) > 0 {
log.Error("角色删除失败", log.Any("roleId", roleId))
return false, srvdef.New(srvdef.ErrDbDeleteError, "删除失败,此角色有帐户", "")
}
serr := mwacc.DelRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId})
if serr != nil {
return false, serr
}
return true, nil
}
// 获取信息
func RoleGetInfo(ctx context.Context, roleId int64) (*mwacc.MwRole, error) {
rest, err := mwacc.FindOneRoleByFilter(ctx, bson.M{
mwacc.FN.MwRole.Id: roleId,
})
if err != nil {
return nil, err
}
return rest, nil
}
// 角色id
// 角色更新
func RoleUp(ctx context.Context, id int64, name string, desc string, isEnb bool) (ok bool, err error) {
roleCount, err := mwacc.RoleCountByFilter(ctx, bson.M{
mwacc.FN.MwRole.Name: name,
mwacc.FN.MwRole.Id: bson.M{
"$ne": id,
},
})
if roleCount > 0 {
err = errors.MakeWithMsg(errors.DBInsert, "角色名称已存在")
return false, err
}
err = mwacc.UpOneRoleByFilter(ctx, bson.M{
mwacc.FN.MwRole.Id: id,
}, bson.M{
"$set": bson.M{
mwacc.FN.MwRole.Name: name,
mwacc.FN.MwRole.Desc: desc,
mwacc.FN.MwRole.IsEnb: isEnb,
mwacc.FN.MwRole.UpdatedAt: time.Now(),
},
})
if err != nil {
log.ZapLog.Error(fmt.Sprintf("RoleUp error %v", err))
err = errors.MakeWithErr(errors.DBUpdate, err)
return
}
return true, nil
}
// 设置角色状态
func RoleEnable(ctx context.Context, roleId int64, IsEnb bool) (bool, *srvdef.SrvErr) {
rest, err := mwacc.FindOneRoleByFilter(ctx, bson.M{
mwacc.FN.MwRole.Id: roleId,
})
if err != nil || rest.Id == 0 {
return false, err
}
// count, err := mwacc.RoleAggregate(ctx, []bson.M{
// {"$match": bson.M{
// mwacc.FN.MwRole.Id: roleId,
// }},
// {"$limit": 1},
// {"$lookup": bson.M{
// "localField": mwacc.FN.MwRole.Id,
// "from": mwacc.TN.MwAcc,
// "foreignField": mwacc.FN.MwAcc.RoleId,
// "as": "onlyRole",
// }},
// {"$unwind": "$onlyRole"},
// {"$project": bson.M{
// "roleId": 1,
// "onlyRole.roleId": 1,
// }},
// })
data, err1 := mwacc.FindManyAccByFilter(ctx, bson.M{mwacc.FN.MwAcc.RoleId: roleId})
if err1 != nil {
return false, err1
}
if len(data) > 0 && IsEnb == false {
return false, srvdef.New(srvdef.ErrDbUpdateError, "更新失败,此角色下拥有管理员", "")
}
StatusErr := mwacc.UpOneRoleByFilter(ctx, bson.M{
mwacc.FN.MwRole.Id: roleId,
}, bson.M{
"$set": bson.M{
mwacc.FN.MwRole.IsEnb: IsEnb,
},
})
if StatusErr != nil {
return false, StatusErr
}
return true, nil
}
// ////////////////////////////////////////////////////////////////////////////
// 数据库Router转网站Router
// // 角色权限列表
// func RoleAuthList(ctx context.Context, roleId int64) (*mwacc.MwRole, error) {
// opt := &options.FindOneOptions{}
// opt.SetProjection(bson.M{
// mwacc.FN.MwRole.Id: 1,
// mwacc.FN.MwRole.Router: 1,
// })
// res, err := mwacc.FindOneRoleByFilter(ctx, bson.M{mwacc.FN.MwRole.Id: roleId})
// if err != nil {
// return &mwacc.MwRole{}, err
// }
// if res.Id != 0 {
// return res, nil
// }
// return nil, nil
// }
func handleMenu(ctx context.Context, reqRouter []*mwacc.Router, apiMap map[string]int64) map[string]int64 {
if apiMap == nil {
apiMap = make(map[string]int64)
}
for _, v := range reqRouter {
if v.Children != nil && len(v.Children) != 0 {
childs := make([]*mwacc.Router, 0)
for _, v := range v.Children {
childs = append(childs, &v)
}
apiMap = handleMenu(ctx, childs, apiMap)
}
if v.ApiUrl != nil && len(v.ApiUrl) != 0 {
for _, v := range v.ApiUrl {
if v.Status == true {
for _, value := range v.Url {
apiMap[value] = 1
}
}
}
}
}
return apiMap
}
// 更新权限
func UpAuth(ctx context.Context, id int64, dataRouter []*mwacc.Router) (ok bool, err error) {
apiMap := handleMenu(ctx, dataRouter, nil)
keys := make([]string, 0)
for k := range apiMap {
keys = append(keys, k)
}
return mwacc.UpRoleAuth(ctx, id, keys, dataRouter)
}
+219
View File
@@ -0,0 +1,219 @@
package srv_playlet
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletcategorymod"
"videoweb/pub/models/playletmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var playletCategoryWebProjection = bson.D{
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Name, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Slug, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Icon, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Status, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Rank, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Remark, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Title, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Description, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.SeoInfo.Keyworld, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.CreatedAt, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.UpdatedAt, Value: 1},
}
// ListPlayletCategory 分页查询短剧分类列表
func ListPlayletCategory(ctx context.Context, req *proto.PlayletCategoryListReq) (*proto.PlayletCategoryListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[playletcategorymod.FN.PlayletCategory.Id] = *req.Id
}
if req.Status != nil {
filter[playletcategorymod.FN.PlayletCategory.Status] = *req.Status
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[playletcategorymod.FN.PlayletCategory.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
totalCount, err := playletcategorymod.CountPlayletCategory(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletCategoryListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.PlayletCategoryListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := playletcategorymod.FindManyPlayletCategory(ctx, filter, options.Find().
SetProjection(playletCategoryWebProjection).
SetSort(bson.D{
{Key: playletcategorymod.FN.PlayletCategory.Rank, Value: -1},
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.PlayletCategoryListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.PlayletCategoryListResp{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Icon: item.Icon,
Status: item.Status,
Rank: item.Rank,
Remark: item.Remark,
SeoInfo: normalizeSeoValue(item.SeoInfo),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
resp.List = items
return resp, nil
}
// AddPlayletCategory 新增短剧分类
func AddPlayletCategory(ctx context.Context, req *proto.PlayletCategoryAddReq) (*proto.PlayletCategoryAddResp, *srvdef.SrvErr) {
if req == nil || strings.TrimSpace(req.Name) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "分类名称不能为空", "")
}
doc := &playletcategorymod.PlayletCategory{
Name: strings.TrimSpace(req.Name),
Slug: req.Slug,
Icon: strings.TrimSpace(req.Icon),
Status: req.Status,
Rank: req.Rank,
Remark: strings.TrimSpace(req.Remark),
SeoInfo: normalizeSeoValue(req.SeoInfo),
}
exists := func(s string) bool {
item, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter(
playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, 0))
return item != nil
}
if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, doc.Name), exists); serr != nil {
return nil, serr
}
if err := playletcategorymod.InsertOnePlayletCategory(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
return &proto.PlayletCategoryAddResp{Id: doc.Id}, nil
}
// UpdatePlayletCategory 更新短剧分类
func UpdatePlayletCategory(ctx context.Context, req *proto.PlayletCategoryUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "分类ID无效", "")
}
current, err := playletcategorymod.FindOnePlayletCategory(ctx, bson.M{
playletcategorymod.FN.PlayletCategory.Id: req.Id,
})
if err != nil {
return err
}
setDoc := bson.M{playletcategorymod.FN.PlayletCategory.UpdatedAt: time.Now()}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "分类名称不能为空", "")
}
setDoc[playletcategorymod.FN.PlayletCategory.Name] = name
}
if req.Icon != nil {
setDoc[playletcategorymod.FN.PlayletCategory.Icon] = strings.TrimSpace(*req.Icon)
}
if req.Status != nil {
setDoc[playletcategorymod.FN.PlayletCategory.Status] = *req.Status
}
if req.Rank != nil {
setDoc[playletcategorymod.FN.PlayletCategory.Rank] = *req.Rank
}
if req.Remark != nil {
setDoc[playletcategorymod.FN.PlayletCategory.Remark] = strings.TrimSpace(*req.Remark)
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo)
}
name := current.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := current.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeSeoValue(*req.SeoInfo).Keyworld
}
exists := func(s string) bool {
item, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter(
playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, req.Id))
return item != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists)
if serr != nil {
return serr
}
if changed {
setDoc[playletcategorymod.FN.PlayletCategory.Slug] = slug
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
if err := playletcategorymod.UpdateOnePlayletCategory(ctx,
bson.M{playletcategorymod.FN.PlayletCategory.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearAppPlayletHomeCache()
return nil
}
// DelPlayletCategory 删除短剧分类,被短剧引用时禁止删除
func DelPlayletCategory(ctx context.Context, id int64) *srvdef.SrvErr {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "分类ID无效", "")
}
usedCount, err := playletmod.CountPlaylet(ctx, bson.M{
playletmod.FN.Playlet.CategoryIds: bson.M{"$in": []int64{id}},
})
if err != nil {
return err
}
if usedCount > 0 {
return srvdef.New(srvdef.ErrParamError, "分类已被短剧引用,无法删除", "")
}
count, err := playletcategorymod.DelOnePlayletCategory(ctx, bson.M{playletcategorymod.FN.PlayletCategory.Id: id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
clearAppPlayletHomeCache()
return nil
}
+49
View File
@@ -0,0 +1,49 @@
package srv_playlet
import (
"strings"
"videoweb/pub/common/playletcache"
"videoweb/pub/pubdef"
)
const (
defaultPageSize int64 = 10
maxPageSize int64 = 100
)
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 clearAppPlayletHomeCache() {
playletcache.ClearHomeCache()
}
+651
View File
@@ -0,0 +1,651 @@
package srv_playlet
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playlethomeconfigmod"
"videoweb/pub/models/playletthememod"
"videoweb/pub/models/videocategorymod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ListPlayletHomeConfig 分页查询首页配置列表
func ListPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigListReq) (*proto.PlayletHomeConfigListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter, err := buildPlayletHomeConfigFilter(req)
if err != nil {
return nil, err
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
totalCount, err := playlethomeconfigmod.CountPlayletHomeConfig(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletHomeConfigListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.PlayletHomeConfigListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, filter, options.Find().
SetSort(bson.D{
{Key: playlethomeconfigmod.FN.PlayletHomeConfig.Rank, Value: -1},
{Key: playlethomeconfigmod.FN.PlayletHomeConfig.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.PlayletHomeConfigListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, playletHomeConfigToResp(item))
}
resp.List = items
return resp, nil
}
// ListPlayletHomeTopicOptions 分页查询未被其他启用模块占用的题材或视频分类
func ListPlayletHomeTopicOptions(ctx context.Context, req *proto.PlayletHomeTopicOptionsReq) (*proto.PlayletHomeTopicOptionsResp, *srvdef.SrvErr) {
if req == nil || (req.DataType != playlethomeconfigmod.DataTypeAll && req.DataType != playlethomeconfigmod.DataTypeVideo) {
return nil, srvdef.New(srvdef.ErrParamError, "数据类型仅支持1短剧题材、2视频分类", "")
}
if req.ConfigId < 0 {
return nil, srvdef.New(srvdef.ErrParamError, "配置ID无效", "")
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
resp := &proto.PlayletHomeTopicOptionsResp{
List: make([]*proto.PlayletHomeTopicOption, 0),
SelectedList: make([]*proto.PlayletHomeTopicOption, 0),
PageNum: pageNum,
PageSize: pageSize,
}
var selectedIDs []int64
if req.ConfigId > 0 {
current, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.ConfigId})
if err != nil {
return nil, err
}
if current.ModuleType != playlethomeconfigmod.ModuleTypeTopic || current.DataType != req.DataType {
return nil, srvdef.New(srvdef.ErrParamError, "当前配置不是指定数据类型的专题模块", "")
}
selectedIDs = current.TopicIds
}
// 已启用的其他专题模块占用的ID不再出现在可选列表中
configFilter := bson.M{
playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true,
playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType: playlethomeconfigmod.ModuleTypeTopic,
playlethomeconfigmod.FN.PlayletHomeConfig.DataType: req.DataType,
}
if req.ConfigId > 0 {
configFilter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = bson.M{"$ne": req.ConfigId}
}
configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, configFilter,
options.Find().SetProjection(bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds: 1}))
if err != nil {
return nil, err
}
occupied := make(map[int64]struct{})
for _, config := range configs {
if config == nil {
continue
}
for _, id := range config.TopicIds {
occupied[id] = struct{}{}
}
}
skip := (pageNum - 1) * pageSize
if req.DataType == playlethomeconfigmod.DataTypeVideo {
filter := bson.M{videocategorymod.FN.VideoCategory.Status: true}
if len(occupied) > 0 {
ids := make([]int64, 0, len(occupied))
for id := range occupied {
ids = append(ids, id)
}
filter[videocategorymod.FN.VideoCategory.Id] = bson.M{"$nin": ids}
}
resp.TotalCount, err = videocategorymod.CountVideoCategorys(ctx, filter)
if err != nil {
return nil, err
}
list, err := videocategorymod.FindManyVideoCategorys(ctx, filter, options.Find().
SetProjection(bson.M{videocategorymod.FN.VideoCategory.Id: 1, videocategorymod.FN.VideoCategory.Name: 1, videocategorymod.FN.VideoCategory.Status: 1}).
SetSort(bson.D{{Key: videocategorymod.FN.VideoCategory.Rank, Value: -1}, {Key: videocategorymod.FN.VideoCategory.Id, Value: -1}}).
SetSkip(skip).SetLimit(pageSize))
if err != nil {
return nil, err
}
for _, item := range list {
if item != nil {
resp.List = append(resp.List, &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status})
}
}
if len(selectedIDs) > 0 {
selected, err := videocategorymod.FindManyVideoCategorys(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: bson.M{"$in": selectedIDs}},
options.Find().SetProjection(bson.M{videocategorymod.FN.VideoCategory.Id: 1, videocategorymod.FN.VideoCategory.Name: 1, videocategorymod.FN.VideoCategory.Status: 1}))
if err != nil {
return nil, err
}
byID := make(map[int64]*proto.PlayletHomeTopicOption, len(selected))
for _, item := range selected {
if item != nil {
byID[item.Id] = &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}
}
}
for _, id := range selectedIDs {
if item := byID[id]; item != nil {
resp.SelectedList = append(resp.SelectedList, item)
} else {
resp.SelectedList = append(resp.SelectedList, &proto.PlayletHomeTopicOption{Id: id})
}
}
}
return resp, nil
}
filter := bson.M{playletthememod.FN.PlayletTheme.Status: true}
if len(occupied) > 0 {
ids := make([]int64, 0, len(occupied))
for id := range occupied {
ids = append(ids, id)
}
filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$nin": ids}
}
resp.TotalCount, err = playletthememod.CountPlayletTheme(ctx, filter)
if err != nil {
return nil, err
}
list, err := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find().
SetProjection(bson.M{playletthememod.FN.PlayletTheme.Id: 1, playletthememod.FN.PlayletTheme.Name: 1, playletthememod.FN.PlayletTheme.Status: 1}).
SetSort(bson.D{{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}}).
SetSkip(skip).SetLimit(pageSize))
if err != nil {
return nil, err
}
for _, item := range list {
if item != nil {
resp.List = append(resp.List, &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status})
}
}
if len(selectedIDs) > 0 {
selected, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": selectedIDs}},
options.Find().SetProjection(bson.M{playletthememod.FN.PlayletTheme.Id: 1, playletthememod.FN.PlayletTheme.Name: 1, playletthememod.FN.PlayletTheme.Status: 1}))
if err != nil {
return nil, err
}
byID := make(map[int64]*proto.PlayletHomeTopicOption, len(selected))
for _, item := range selected {
if item != nil {
byID[item.Id] = &proto.PlayletHomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}
}
}
for _, id := range selectedIDs {
if item := byID[id]; item != nil {
resp.SelectedList = append(resp.SelectedList, item)
} else {
resp.SelectedList = append(resp.SelectedList, &proto.PlayletHomeTopicOption{Id: id})
}
}
}
return resp, nil
}
// AddPlayletHomeConfig 新增首页配置
func AddPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigAddReq) (*proto.PlayletHomeConfigAddResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
title, style, moduleType, dataType, recommendType, limit, err := normalizePlayletHomeConfig(req.Title, req.Style, req.ModuleType, req.DataType, req.RecommendType, req.Limit)
if err != nil {
return nil, err
}
doc := &playlethomeconfigmod.PlayletHomeConfig{
Title: title,
Slug: req.Slug,
Desc: strings.TrimSpace(req.Desc),
Label: strings.TrimSpace(req.Label),
Style: style,
CardBackgroundImage: strings.TrimSpace(req.CardBackgroundImage),
ModuleType: moduleType,
DataType: dataType,
RecommendType: recommendType,
CategoryId: req.CategoryId,
TopicIds: req.TopicIds,
Rank: req.Rank,
Enabled: req.Enabled,
Limit: limit,
}
if err := validateHomeConfigTopics(ctx, doc); err != nil {
return nil, err
}
exists := func(s string) bool {
item, _ := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, slugx.Filter(
playlethomeconfigmod.FN.PlayletHomeConfig.Slug, playlethomeconfigmod.FN.PlayletHomeConfig.Id, s, 0))
return item != nil
}
if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, title), exists); serr != nil {
return nil, serr
}
if err := playlethomeconfigmod.InsertOnePlayletHomeConfig(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
return &proto.PlayletHomeConfigAddResp{Id: doc.Id}, nil
}
// UpdatePlayletHomeConfig 更新首页配置
func UpdatePlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "id无效", "")
}
setDoc := bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.UpdatedAt: time.Now()}
current, err := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.Id})
if err != nil {
return err
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return srvdef.New(srvdef.ErrParamError, "模块标题不能为空", "")
}
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Title] = title
}
if req.Desc != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Desc] = strings.TrimSpace(*req.Desc)
}
if req.Label != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Label] = strings.TrimSpace(*req.Label)
}
if req.Style != nil {
style, err := normalizePlayletHomeConfigStyle(*req.Style)
if err != nil {
return err
}
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Style] = style
}
if req.CardBackgroundImage != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.CardBackgroundImage] = strings.TrimSpace(*req.CardBackgroundImage)
}
if req.ModuleType != nil {
moduleType, err := normalizePlayletHomeModuleType(*req.ModuleType)
if err != nil {
return err
}
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = moduleType
}
if req.DataType != nil {
dataType, err := normalizePlayletHomeDataType(*req.DataType)
if err != nil {
return err
}
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = dataType
}
if req.RecommendType != nil {
if err := validatePlayletHomeRecommendType(*req.RecommendType); err != nil {
return err
}
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.RecommendType] = *req.RecommendType
}
if req.CategoryId != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.CategoryId] = *req.CategoryId
}
if req.TopicIds != nil {
current.TopicIds = *req.TopicIds
}
if req.ModuleType != nil {
current.ModuleType = *req.ModuleType
}
if req.DataType != nil {
current.DataType = *req.DataType
}
if req.Enabled != nil {
current.Enabled = *req.Enabled
}
if req.TopicIds != nil || req.ModuleType != nil || req.DataType != nil || req.Enabled != nil {
if err := validateHomeConfigTopics(ctx, current); err != nil {
return err
}
if req.TopicIds != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds] = current.TopicIds
}
}
if req.Rank != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Rank] = *req.Rank
}
if req.Enabled != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Enabled] = *req.Enabled
}
if req.Limit != nil {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Limit] = normalizePlayletHomeConfigLimit(*req.Limit)
}
title := current.Title
if req.Title != nil {
title = strings.TrimSpace(*req.Title)
}
exists := func(s string) bool {
item, _ := playlethomeconfigmod.FindOnePlayletHomeConfig(ctx, slugx.Filter(
playlethomeconfigmod.FN.PlayletHomeConfig.Slug, playlethomeconfigmod.FN.PlayletHomeConfig.Id, s, req.Id))
return item != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug,
utils.SlugSourceFromKeywordsOrTitle(nil, title), exists)
if serr != nil {
return serr
}
if changed {
setDoc[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
if err := playlethomeconfigmod.UpdateOnePlayletHomeConfig(ctx,
bson.M{playlethomeconfigmod.FN.PlayletHomeConfig.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearAppPlayletHomeCache()
return nil
}
// DelPlayletHomeConfig 删除首页配置
func DelPlayletHomeConfig(ctx context.Context, req *proto.PlayletHomeConfigDelReq) *srvdef.SrvErr {
if req == nil || len(req.Ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "配置ID不能为空", "")
}
ids := make([]int64, 0, len(req.Ids))
seen := make(map[int64]struct{}, len(req.Ids))
for _, id := range req.Ids {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "配置ID无效", "")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
count, err := playlethomeconfigmod.DelManyPlayletHomeConfig(ctx, bson.M{
playlethomeconfigmod.FN.PlayletHomeConfig.Id: bson.M{"$in": ids},
})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
clearAppPlayletHomeCache()
return nil
}
func buildPlayletHomeConfigFilter(req *proto.PlayletHomeConfigListReq) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Id] = *req.Id
}
if slug := strings.TrimSpace(req.Slug); slug != "" {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Slug] = slug
}
if title := strings.TrimSpace(req.Title); title != "" {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Title] = bson.M{"$regex": regexp.QuoteMeta(title)}
}
if desc := strings.TrimSpace(req.Desc); desc != "" {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Desc] = bson.M{"$regex": regexp.QuoteMeta(desc)}
}
if label := strings.TrimSpace(req.Label); label != "" {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Label] = bson.M{"$regex": regexp.QuoteMeta(label)}
}
if style := strings.TrimSpace(req.Style); style != "" {
normalizedStyle, err := normalizePlayletHomeConfigStyle(style)
if err != nil {
return nil, err
}
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Style] = normalizedStyle
}
if req.ModuleType != nil {
moduleType, err := normalizePlayletHomeModuleType(*req.ModuleType)
if err != nil {
return nil, err
}
if moduleType == playlethomeconfigmod.ModuleTypePlaylet {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = bson.M{"$in": []interface{}{nil, int64(0), playlethomeconfigmod.ModuleTypePlaylet}}
} else {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType] = moduleType
}
}
if req.DataType != nil {
dataType, err := normalizePlayletHomeDataType(*req.DataType)
if err != nil {
return nil, err
}
if dataType == playlethomeconfigmod.DataTypeAll {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = bson.M{"$in": []interface{}{nil, int64(0), playlethomeconfigmod.DataTypeAll}}
} else {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.DataType] = dataType
}
}
if req.RecommendType != nil {
if err := validatePlayletHomeRecommendType(*req.RecommendType); err != nil {
return nil, err
}
filter[playlethomeconfigmod.FN.PlayletHomeConfig.RecommendType] = *req.RecommendType
}
if req.CategoryId != nil && *req.CategoryId > 0 {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.CategoryId] = *req.CategoryId
}
if req.Enabled != nil {
filter[playlethomeconfigmod.FN.PlayletHomeConfig.Enabled] = *req.Enabled
}
return filter, nil
}
func playletHomeConfigToResp(item *playlethomeconfigmod.PlayletHomeConfig) *proto.PlayletHomeConfigListResp {
if item == nil {
return nil
}
return &proto.PlayletHomeConfigListResp{
Id: item.Id,
Slug: item.Slug,
Title: item.Title,
Desc: item.Desc,
Label: item.Label,
Style: item.Style,
CardBackgroundImage: item.CardBackgroundImage,
ModuleType: normalizePlayletHomeModuleTypeForResp(item.ModuleType),
DataType: normalizePlayletHomeDataTypeForResp(item.DataType),
RecommendType: item.RecommendType,
CategoryId: item.CategoryId,
TopicIds: item.TopicIds,
Rank: item.Rank,
Enabled: item.Enabled,
Limit: item.Limit,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// validateHomeConfigTopics 校验专题归属和已启用模块间的专题唯一性
func validateHomeConfigTopics(ctx context.Context, doc *playlethomeconfigmod.PlayletHomeConfig) *srvdef.SrvErr {
if doc.ModuleType != playlethomeconfigmod.ModuleTypeTopic {
if len(doc.TopicIds) > 0 {
return srvdef.New(srvdef.ErrParamError, "列表模块不能配置专题ID", "")
}
return nil
}
seen := make(map[int64]struct{}, len(doc.TopicIds))
ids := make([]int64, 0, len(doc.TopicIds))
for _, id := range doc.TopicIds {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "专题ID必须大于0", "")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
doc.TopicIds = ids
if !doc.Enabled {
return nil
}
if len(ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "启用的专题模块必须配置专题ID", "")
}
if doc.DataType == playlethomeconfigmod.DataTypeVideo {
categories, err := videocategorymod.FindManyVideoCategorys(ctx, bson.M{
videocategorymod.FN.VideoCategory.Id: bson.M{"$in": ids},
videocategorymod.FN.VideoCategory.Status: true,
})
if err != nil {
return err
}
if len(categories) != len(ids) {
return srvdef.New(srvdef.ErrParamError, "视频分类不存在或未上架", "")
}
} else {
themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids},
playletthememod.FN.PlayletTheme.Status: true,
})
if err != nil {
return err
}
if len(themes) != len(ids) {
return srvdef.New(srvdef.ErrParamError, "短剧题材不存在或未上架", "")
}
}
configs, err := playlethomeconfigmod.FindManyPlayletHomeConfig(ctx, bson.M{
playlethomeconfigmod.FN.PlayletHomeConfig.Enabled: true,
playlethomeconfigmod.FN.PlayletHomeConfig.ModuleType: playlethomeconfigmod.ModuleTypeTopic,
playlethomeconfigmod.FN.PlayletHomeConfig.DataType: doc.DataType,
playlethomeconfigmod.FN.PlayletHomeConfig.TopicIds: bson.M{"$in": ids},
})
if err != nil {
return err
}
for _, other := range configs {
if other != nil && other.Id != doc.Id {
return srvdef.New(srvdef.ErrParamError, "专题已分配给其他启用的首页模块", "")
}
}
return nil
}
func normalizePlayletHomeConfig(title string, style string, moduleType int64, dataType int64, recommendType int64, limit int64) (string, string, int64, int64, int64, int64, *srvdef.SrvErr) {
title = strings.TrimSpace(title)
if title == "" {
return "", "", 0, 0, 0, 0, srvdef.New(srvdef.ErrParamError, "模块标题不能为空", "")
}
normalizedStyle, err := normalizePlayletHomeConfigStyle(style)
if err != nil {
return "", "", 0, 0, 0, 0, err
}
normalizedModuleType, err := normalizePlayletHomeModuleType(moduleType)
if err != nil {
return "", "", 0, 0, 0, 0, err
}
normalizedDataType, err := normalizePlayletHomeDataType(dataType)
if err != nil {
return "", "", 0, 0, 0, 0, err
}
if err := validatePlayletHomeRecommendType(recommendType); err != nil {
return "", "", 0, 0, 0, 0, err
}
return title, normalizedStyle, normalizedModuleType, normalizedDataType, recommendType, normalizePlayletHomeConfigLimit(limit), nil
}
func normalizePlayletHomeConfigStyle(style string) (string, *srvdef.SrvErr) {
style = strings.ToUpper(strings.TrimSpace(style))
switch style {
case playlethomeconfigmod.StyleA,
playlethomeconfigmod.StyleB,
playlethomeconfigmod.StyleC,
playlethomeconfigmod.StyleD,
playlethomeconfigmod.StyleE,
playlethomeconfigmod.StyleF,
playlethomeconfigmod.StyleG,
playlethomeconfigmod.StyleH:
return style, nil
default:
return "", srvdef.New(srvdef.ErrParamError, "样式无效,仅支持A-H", "")
}
}
func validatePlayletHomeRecommendType(recommendType int64) *srvdef.SrvErr {
switch recommendType {
case playlethomeconfigmod.RecommendTypeUpdated,
playlethomeconfigmod.RecommendTypeLike,
playlethomeconfigmod.RecommendTypeBrowse,
playlethomeconfigmod.RecommendTypeHeat,
playlethomeconfigmod.RecommendTypeCategory:
return nil
default:
return srvdef.New(srvdef.ErrParamError, "推荐类型无效,仅支持1按更新、2按点赞、3按观看、4按热度、5按分类", "")
}
}
func normalizePlayletHomeModuleType(moduleType int64) (int64, *srvdef.SrvErr) {
if moduleType == 0 {
return playlethomeconfigmod.ModuleTypePlaylet, nil
}
switch moduleType {
case playlethomeconfigmod.ModuleTypePlaylet, playlethomeconfigmod.ModuleTypeTopic:
return moduleType, nil
default:
return 0, srvdef.New(srvdef.ErrParamError, "模块类型无效,仅支持1短剧列表、2专题", "")
}
}
func normalizePlayletHomeModuleTypeForResp(moduleType int64) int64 {
if moduleType == playlethomeconfigmod.ModuleTypeTopic {
return playlethomeconfigmod.ModuleTypeTopic
}
return playlethomeconfigmod.ModuleTypePlaylet
}
func normalizePlayletHomeDataType(dataType int64) (int64, *srvdef.SrvErr) {
if dataType == 0 {
return playlethomeconfigmod.DataTypeAll, nil
}
switch dataType {
case playlethomeconfigmod.DataTypeAll, playlethomeconfigmod.DataTypeVideo:
return dataType, nil
default:
return 0, srvdef.New(srvdef.ErrParamError, "数据类型无效,仅支持1全部上架短剧、2全部上架视频", "")
}
}
func normalizePlayletHomeDataTypeForResp(dataType int64) int64 {
if dataType == playlethomeconfigmod.DataTypeVideo {
return playlethomeconfigmod.DataTypeVideo
}
return playlethomeconfigmod.DataTypeAll
}
func normalizePlayletHomeConfigLimit(limit int64) int64 {
if limit <= 0 {
return playlethomeconfigmod.DefaultLimit
}
if limit > playlethomeconfigmod.MaxLimit {
return playlethomeconfigmod.MaxLimit
}
return limit
}
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
package srv_playlet
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ListPlayletTag 分页查询标签列表
func ListPlayletTag(ctx context.Context, req *proto.PlayletTagListReq) (*proto.PlayletTagListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[playlettagmod.FN.PlayletTag.Id] = *req.Id
}
if req.Status != nil {
filter[playlettagmod.FN.PlayletTag.Status] = *req.Status
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[playlettagmod.FN.PlayletTag.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
totalCount, err := playlettagmod.CountPlayletTag(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletTagListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.PlayletTagListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := playlettagmod.FindManyPlayletTag(ctx, filter, options.Find().
SetSort(bson.D{
{Key: playlettagmod.FN.PlayletTag.Rank, Value: -1},
{Key: playlettagmod.FN.PlayletTag.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.PlayletTagListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.PlayletTagListResp{
Id: item.Id,
Name: item.Name,
Status: item.Status,
Rank: item.Rank,
SeoInfo: normalizeSeoValue(item.SeoInfo),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
resp.List = items
return resp, nil
}
// AddPlayletTag 新增标签
func AddPlayletTag(ctx context.Context, req *proto.PlayletTagAddReq) (*proto.PlayletTagAddResp, *srvdef.SrvErr) {
if req == nil || strings.TrimSpace(req.Name) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "")
}
doc := &playlettagmod.PlayletTag{
Name: strings.TrimSpace(req.Name),
Status: req.Status,
Rank: req.Rank,
SeoInfo: normalizeSeoValue(req.SeoInfo),
}
if err := playlettagmod.InsertOnePlayletTag(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
return &proto.PlayletTagAddResp{Id: doc.Id}, nil
}
// UpdatePlayletTag 更新标签
func UpdatePlayletTag(ctx context.Context, req *proto.PlayletTagUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "id无效", "")
}
setDoc := bson.M{playlettagmod.FN.PlayletTag.UpdatedAt: time.Now()}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "标签名称不能为空", "")
}
setDoc[playlettagmod.FN.PlayletTag.Name] = name
}
if req.Status != nil {
setDoc[playlettagmod.FN.PlayletTag.Status] = *req.Status
}
if req.Rank != nil {
setDoc[playlettagmod.FN.PlayletTag.Rank] = *req.Rank
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo)
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
if err := playlettagmod.UpdateOnePlayletTag(ctx,
bson.M{playlettagmod.FN.PlayletTag.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearAppPlayletHomeCache()
return nil
}
// DelPlayletTag 删除标签(被短剧引用时禁止删除)
func DelPlayletTag(ctx context.Context, id int64) *srvdef.SrvErr {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "id无效", "")
}
usedCount, err := playletmod.CountPlaylet(ctx, bson.M{playletmod.FN.Playlet.TagIds: id})
if err != nil {
return err
}
if usedCount > 0 {
return srvdef.New(srvdef.ErrParamError, "标签已被短剧引用,无法删除", "")
}
count, err := playlettagmod.DelOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Id: id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
clearAppPlayletHomeCache()
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package srv_playlet
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playletthememod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var playletThemeWebProjection = bson.D{
{Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Name, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Slug, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Icon, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Status, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Rank, Value: 1},
{Key: playletthememod.FN.PlayletTheme.SeoInfo.Title, Value: 1},
{Key: playletthememod.FN.PlayletTheme.SeoInfo.Description, Value: 1},
{Key: playletthememod.FN.PlayletTheme.SeoInfo.Keyworld, Value: 1},
{Key: playletthememod.FN.PlayletTheme.CreatedAt, Value: 1},
{Key: playletthememod.FN.PlayletTheme.UpdatedAt, Value: 1},
}
// ListPlayletTheme 分页查询题材列表
func ListPlayletTheme(ctx context.Context, req *proto.PlayletThemeListReq) (*proto.PlayletThemeListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[playletthememod.FN.PlayletTheme.Id] = *req.Id
}
if req.Status != nil {
filter[playletthememod.FN.PlayletTheme.Status] = *req.Status
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[playletthememod.FN.PlayletTheme.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
totalCount, err := playletthememod.CountPlayletTheme(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletThemeListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.PlayletThemeListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find().
SetProjection(playletThemeWebProjection).
SetSort(bson.D{
{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1},
{Key: playletthememod.FN.PlayletTheme.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.PlayletThemeListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.PlayletThemeListResp{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Icon: item.Icon,
Status: item.Status,
Rank: item.Rank,
SeoInfo: normalizeSeoValue(item.SeoInfo),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
resp.List = items
return resp, nil
}
// AddPlayletTheme 新增题材
func AddPlayletTheme(ctx context.Context, req *proto.PlayletThemeAddReq) (*proto.PlayletThemeAddResp, *srvdef.SrvErr) {
if req == nil || strings.TrimSpace(req.Name) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "题材名称不能为空", "")
}
doc := &playletthememod.PlayletTheme{
Name: strings.TrimSpace(req.Name),
Slug: req.Slug,
Icon: strings.TrimSpace(req.Icon),
Status: req.Status,
Rank: req.Rank,
SeoInfo: normalizeSeoValue(req.SeoInfo),
}
exists := func(s string) bool {
item, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter(
playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, 0))
return item != nil
}
if serr := slugx.Ensure(&doc.Slug, utils.SlugSourceFromKeywordsOrTitle(doc.SeoInfo.Keyworld, doc.Name), exists); serr != nil {
return nil, serr
}
if err := playletthememod.InsertOnePlayletTheme(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
return &proto.PlayletThemeAddResp{Id: doc.Id}, nil
}
// UpdatePlayletTheme 更新题材
func UpdatePlayletTheme(ctx context.Context, req *proto.PlayletThemeUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "题材ID无效", "")
}
current, err := playletthememod.FindOnePlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Id: req.Id,
})
if err != nil {
return err
}
setDoc := bson.M{playletthememod.FN.PlayletTheme.UpdatedAt: time.Now()}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "题材名称不能为空", "")
}
setDoc[playletthememod.FN.PlayletTheme.Name] = name
}
if req.Icon != nil {
setDoc[playletthememod.FN.PlayletTheme.Icon] = strings.TrimSpace(*req.Icon)
}
if req.Status != nil {
setDoc[playletthememod.FN.PlayletTheme.Status] = *req.Status
}
if req.Rank != nil {
setDoc[playletthememod.FN.PlayletTheme.Rank] = *req.Rank
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo)
}
name := current.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := current.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeSeoValue(*req.SeoInfo).Keyworld
}
exists := func(s string) bool {
item, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter(
playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, req.Id))
return item != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, req.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists)
if serr != nil {
return serr
}
if changed {
setDoc[playletthememod.FN.PlayletTheme.Slug] = slug
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
if err := playletthememod.UpdateOnePlayletTheme(ctx,
bson.M{playletthememod.FN.PlayletTheme.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearAppPlayletHomeCache()
return nil
}
// DelPlayletTheme 删除题材,被短剧引用时禁止删除
func DelPlayletTheme(ctx context.Context, id int64) *srvdef.SrvErr {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "题材ID无效", "")
}
usedCount, err := playletmod.CountPlaylet(ctx, bson.M{
playletmod.FN.Playlet.ThemeIds: bson.M{"$in": []int64{id}},
})
if err != nil {
return err
}
if usedCount > 0 {
return srvdef.New(srvdef.ErrParamError, "题材已被短剧引用,无法删除", "")
}
count, err := playletthememod.DelOnePlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
clearAppPlayletHomeCache()
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package srv_questionnaire
import (
"videoweb/pub/models/questionnairemod"
"videoweb/pub/pubdef"
)
// ListReq 查询问卷列表请求
type ListReq struct {
pubdef.Page
QuestionType *string `json:"questionType"` // 模糊匹配:问题类型(选填)
Status *string `json:"status"` // 精确匹配:处理状态(选填)
StartTime *int64 `json:"startTime"` // 创建时间范围-开始,Unix 时间戳(秒,选填)
EndTime *int64 `json:"endTime"` // 创建时间范围-结束,Unix 时间戳(秒,选填)
}
// ListResp 查询问卷列表响应
type ListResp struct {
Total int64 `json:"total"` // 满足条件的总条数
List []*questionnairemod.QuestionnaireModel `json:"list"` // 当前页数据
}
// HandleReq 处理问卷请求
type HandleReq struct {
Id string `json:"id" binding:"required"` // 问卷ID(ObjectID 字符串)
Status string `json:"status" binding:"required"` // 目标状态:processed 已处理 / reject 拒绝
Remark string `json:"remark"` // 备注(选填)
}
@@ -0,0 +1,97 @@
package srv_questionnaire
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/questionnairemod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
// List 查询问卷列表(分页 + 条件筛选)
func List(ctx context.Context, req *ListReq) (*ListResp, *srvdef.SrvErr) {
if req.PageNum < 1 {
req.PageNum = 1
}
if req.PageSize < 1 {
req.PageSize = 20
}
filter := bson.M{}
if req.QuestionType != nil && *req.QuestionType != "" {
filter[questionnairemod.FN.Questionnaire.QuestionType] = bson.M{
"$regex": *req.QuestionType,
}
}
if req.Status != nil && *req.Status != "" {
filter[questionnairemod.FN.Questionnaire.Status] = *req.Status
}
if req.StartTime != nil || req.EndTime != nil {
timeRange := bson.M{}
if req.StartTime != nil {
timeRange["$gte"] = time.Unix(*req.StartTime, 0)
}
if req.EndTime != nil {
timeRange["$lte"] = time.Unix(*req.EndTime, 0)
}
filter[questionnairemod.FN.Questionnaire.CreatedAt] = timeRange
}
total, serr := questionnairemod.Count(ctx, filter)
if serr != nil {
return nil, serr
}
if total == 0 {
return &ListResp{Total: 0, List: make([]*questionnairemod.QuestionnaireModel, 0)}, nil
}
opt := options.Find().
SetSort(bson.D{{Key: questionnairemod.FN.Questionnaire.CreatedAt, Value: -1}}).
SetSkip((req.PageNum - 1) * req.PageSize).
SetLimit(req.PageSize)
list, serr := questionnairemod.FindMany(ctx, filter, opt)
if serr != nil {
return nil, serr
}
return &ListResp{Total: total, List: list}, nil
}
// Handle 处理问卷(仅允许"待处理"状态的问卷被更新)
func Handle(ctx context.Context, req *HandleReq) *srvdef.SrvErr {
objId, err := primitive.ObjectIDFromHex(req.Id)
if err != nil {
return srvdef.New(srvdef.ErrParamError, "问卷ID格式错误", "")
}
filter := bson.M{
questionnairemod.FN.Questionnaire.Id: objId,
questionnairemod.FN.Questionnaire.Status: questionnairemod.StatusPending,
}
update := bson.M{
"$set": bson.M{
questionnairemod.FN.Questionnaire.Status: req.Status,
questionnairemod.FN.Questionnaire.Remark: req.Remark,
questionnairemod.FN.Questionnaire.UpdatedAt: time.Now(),
},
}
matched, serr := questionnairemod.UpdateOne(ctx, filter, update)
if serr != nil {
return serr
}
if matched == 0 {
// 区分"不存在"与"状态不是待处理"
_, serr2 := questionnairemod.FindOne(ctx, bson.M{questionnairemod.FN.Questionnaire.Id: objId})
if serr2 != nil {
return srvdef.New(srvdef.ErrNoData, "问卷不存在", "")
}
return srvdef.New(srvdef.Err, "该问卷已处理,不可重复操作", "该问卷已处理,不可重复操作")
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package srv_report
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
)
// ReportVideoStatusChangeEvent 视频状态变更事件上报
func ReportVideoStatusChangeEvent(media *videoinformationmod.VideoInformation, flag, userId int64) {
if media == nil || media.Id == 0 || flag == 0 {
return
}
var lsjId string
var videoTypeID = "default"
var videoTypeName = "默认分类"
if media.CategoryId > 0 {
// 复用 reportser 中的分类解析逻辑
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
category, _ := videocategorymod.FindOneVideoCategory(ctx, bson.M{
videocategorymod.FN.VideoCategory.Id: media.CategoryId,
})
if category != nil {
videoTypeID = fmt.Sprintf("%v", category.Id)
videoTypeName = category.Name
}
}
uaInfo := useragent.UA{SysType: pubdef.ST_PC}
event := dataReport.NewCommonField(dataReport.EventTypeVideoStatusChange, "", uaInfo, strconv.FormatInt(userId, 10), userId)
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoStatusChangeEvent{
MediaId: lsjId,
VideoID: fmt.Sprintf("%v", media.Id),
VideoTitle: media.Title,
VideoTypeID: videoTypeID,
VideoTypeName: videoTypeName,
Flag: dataReport.JsonNumber(flag),
})
b, _ := json.Marshal(event)
if err := eventmod.InsertOneEvent(context.Background(), &eventmod.Event{
EventId: event.EventID,
EventType: event.Event,
Data: string(b),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("ReportVideoStatusChangeEvent fail", log.Any("mediaId", media.Id), log.E(err))
}
}
// VideoStatusFlag 将媒体状态映射为上报flag
func VideoStatusFlag(status pubdef.InformationStatus) int64 {
switch status {
case pubdef.InformationStatusVerifyOK:
return 1 // 上架
case pubdef.InformationStatusVerifyCancel:
return 2 // 未通过
default:
return 0
}
}
+38
View File
@@ -0,0 +1,38 @@
package srv_richtextcontent
import (
"time"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/pubdef"
)
type GetRichTextContentListReq struct {
Title *string `json:"title" bson:"title"` // 标题
Status *bool `json:"status" bson:"status"` // 是否启用
pubdef.Page
}
type GetRichTextContentResp struct {
Total int64 `json:"total" bson:"total"`
List richtextcontentmod.RichTextContentModelList `json:"list" bson:"list"`
}
type DelRichTextContentReq struct {
Id int64 `bson:"_id,omitempty"`
}
type UpdateRichTextContentData struct {
Id int64 `bson:"_id,omitempty" json:"_id,omitempty"`
Title *string `json:"title" bson:"title,omitempty"` // 标题
Content *string `json:"content" bson:"content,omitempty"` // 内容
Location *[]string `json:"location" bson:"location,omitempty"` // 位置
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序值
Status *bool `json:"status" bson:"status,omitempty"` // 是否启用
Desc *string `json:"desc" bson:"desc,omitempty"` // 标题(app)
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
}
type UpdateRichTextContentReq struct {
Id int64 `json:"id" binding:"required"`
Param UpdateRichTextContentData `json:"param" bson:"param"` // appId,唯一id
}
@@ -0,0 +1,71 @@
package srv_richtextcontent
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetRichTextContentList(ctx context.Context, req *GetRichTextContentListReq) (*GetRichTextContentResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.Title != nil {
filter[richtextcontentmod.FN.RichTextContentModel.Title] = bson.M{"$regex": *req.Title, "$options": "i"}
}
if req.Status != nil {
filter[richtextcontentmod.FN.RichTextContentModel.Status] = *req.Status
}
total, err := richtextcontentmod.CountRichTextContentModelModels(ctx, filter)
if err != nil {
return nil, err
}
resp := &GetRichTextContentResp{}
if total == 0 {
return resp, nil
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{richtextcontentmod.FN.RichTextContentModel.UpdatedAt: -1})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, err := richtextcontentmod.FindManyRichTextContentModelModel(ctx, filter, opts)
if err != nil {
return resp, err
}
resp.Total = total
resp.List = list
return resp, nil
}
func AddRichTextContent(ctx context.Context, ac *richtextcontentmod.RichTextContentModel) *srvdef.SrvErr {
ac.Id = mongos.Lid.HtmlText.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
return richtextcontentmod.InsertOneRichTextContentModelModel(ctx, ac)
}
func UpdateRichTextContent(ctx context.Context, id int64, param *UpdateRichTextContentData) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{richtextcontentmod.FN.RichTextContentModel.Id: id}
updateDoc := bson.M{"$set": param}
serr := richtextcontentmod.UpdateOneRichTextContentModelModel(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
func DelRichTextContent(ctx context.Context, req *DelRichTextContentReq) *srvdef.SrvErr {
_, err := richtextcontentmod.DelOneRichTextContentModelModel(ctx, bson.M{
richtextcontentmod.FN.RichTextContentModel.Id: req.Id,
})
return err
}
+13
View File
@@ -0,0 +1,13 @@
package srv_search
import "strings"
func contentTagNamesString(tagIds []int64, tagMap map[int64]string) string {
names := make([]string, 0, len(tagIds))
for _, tagId := range tagIds {
if name := tagMap[tagId]; name != "" {
names = append(names, name)
}
}
return strings.Join(names, ",")
}
+19
View File
@@ -0,0 +1,19 @@
package srv_search
import (
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
)
// Param 引擎参数
type Param struct {
//数据库
Coll *mongos.Collection
//过滤器
CollFilter bson.M
//跟新字段
CollFields []string
//es索引
EsIndex string
}
+187
View File
@@ -0,0 +1,187 @@
package srv_search
import (
"context"
"errors"
"fmt"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
var syncIsRuining = false
func StartES() {
log.Info("srv_search:Start...")
defer log.Info("srv_search:Start!!!")
startSyncEs()
_, _ = skdCron.AddFunc("0 0/17 * * * ?", func() {
startSyncEs()
})
skdCron.Start()
}
func startSyncEs() {
ctx := context.Background()
updateEsData(ctx, esmod.GetVideoIndex(), getVideoEsData)
updateEsData(ctx, esmod.GetPlayletIndex(), getPlayletEsData)
updateEsData(ctx, esmod.GetActressIndex(), getActressEsData)
}
func StopES() {
log.Info("srv_search:Stop...")
defer log.Info("srv_search:Stop!!!")
}
func updateEsData(ctx context.Context, esIndex string, fn func(ctx context.Context, startTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr)) {
log.Info("srv_search:do...", log.Any("ESIndex", esIndex))
defer log.Info("srv_search:do end", log.Any("EsIndex", esIndex))
if fn == nil {
return
}
esRecord, serr := esmod.GetRecordTimeByESIndex(ctx, esIndex)
if serr != nil {
log.Error(fmt.Sprintf("srv_search:startEngine GetRecordByESIndex failed! Index:%v,err:%+v:", esIndex, serr))
return
}
isNew := false
if esRecord == nil {
isNew = true
esRecord = &esmod.ESRecord{
Id: primitive.NewObjectID(),
EsIndex: esIndex,
RecordTime: time.Time{},
Timestamp: esmod.ESTimestamp{},
UpdatedAt: time.Time{},
CreatedAt: time.Now(),
}
}
var now = time.Now()
var esData esmanager.M
// 跨天凌晨5点重置所有数据
if isNew || ((now.Hour() >= 5 && esRecord.UpdatedAt.Local().Hour() < 5) || esRecord.UpdatedAt.Local().Day() < now.Day()) {
log.Info("srv_search:updateEsData 全量同步", log.Any("index", esIndex), log.Any("isNew", isNew))
esData, serr = fn(ctx, nil, nil, esIndex)
if serr != nil {
log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr))
return
}
log.Info("srv_search:updateEsData 全量数据拉取完成", log.Any("index", esIndex), log.Any("count", len(esData)))
if len(esData) == 0 {
if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil {
return
}
return
}
isUpdate, err := commit(esData, esIndex, time.Now())
if err != nil {
log.Error("Elastic sync video data RunSync commit err! ", log.E(err))
return
}
log.Info("srv_search:updateEsData 全量写入ES完成", log.Any("index", esIndex), log.Any("isUpdate", isUpdate))
if isUpdate {
if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil {
return
}
}
} else {
timestamp := esRecord.RecordTime
var recentMinute = utils.RecentMinute(now, esmod.FiveMinuteScale)
log.Info("srv_search:updateEsData 增量同步", log.Any("index", esIndex),
log.Any("from", timestamp.Format("2006-01-02 15:04:05")),
log.Any("to", recentMinute.Format("2006-01-02 15:04:05")))
totalIncr := 0
for recentMinute.After(timestamp) {
var end = timestamp.Add(esmod.FiveMinuteScale * time.Minute)
if end.After(recentMinute) {
end = recentMinute
}
esData, serr = fn(ctx, &timestamp, &end, esIndex)
if serr != nil {
log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr))
return
}
if esData != nil && len(esData) > 0 {
totalIncr += len(esData)
log.Info("srv_search:updateEsData 增量批次",
log.Any("index", esIndex),
log.Any("window_start", timestamp.Format("2006-01-02 15:04:05")),
log.Any("window_end", end.Format("2006-01-02 15:04:05")),
log.Any("count", len(esData)))
isUpdate, err := commit(esData, esIndex, time.Now())
if err != nil {
log.Error("Elastic sync video data RunSync commit err! ", log.E(err))
return
}
if isUpdate {
if err := esmod.SetOneEsRecordTime(nil, esIndex, end); err != nil {
return
}
}
} else {
if err := esmod.SetOneEsRecordTime(nil, esIndex, end); err != nil {
return
}
}
timestamp = end
}
log.Info("srv_search:updateEsData 增量同步完成", log.Any("index", esIndex), log.Any("totalCount", totalIncr))
}
}
func commit(data esmanager.M, esIndex string, syncTime time.Time) (bool, error) {
bulkData := handleBulkCountLimit(data)
isHaveSuccess := false
failedBatches := make([]int, 0)
log.Info("srv_search:commit 开始写入ES", log.Any("index", esIndex),
log.Any("totalDocs", len(data)), log.Any("batches", len(bulkData)))
if bulkData != nil && len(bulkData) > 0 {
for i, doc := range bulkData {
if len(doc) == 0 {
continue
}
if err := esmanager.Instance.Bulk(esIndex, doc); err != nil {
log.Warn(fmt.Sprintf("Elastic sync commit video data SyncDataToES failed!, err:%+v\n", err),
log.Any("index", esIndex), log.Any("batch", i), log.Any("batchSize", len(doc)))
failedBatches = append(failedBatches, i)
} else {
isHaveSuccess = true
log.Info("srv_search:commit 批次写入成功", log.Any("index", esIndex),
log.Any("batch", i), log.Any("batchSize", len(doc)))
}
}
}
if len(failedBatches) > 0 {
return isHaveSuccess, fmt.Errorf("elastic sync commit failed batches: %v", failedBatches)
}
if isHaveSuccess {
if err := esmanager.Instance.Refresh(esIndex); err != nil {
return isHaveSuccess, err
}
return isHaveSuccess, nil
} else {
return isHaveSuccess, errors.New("Elastic sync commit video data UpsertOneTrans failed!")
}
}
func handleBulkCountLimit(data esmanager.M) []esmanager.M {
if data == nil || len(data) == 0 {
return make([]esmanager.M, 0)
}
rsl := make([]esmanager.M, 0)
rsl = append(rsl, make(esmanager.M, batchSize))
index := 0
for k, v := range data {
rsl[index][k] = v
if len(rsl[index]) >= batchSize {
index++
rsl = append(rsl, make(esmanager.M, batchSize))
}
}
return rsl
}
+378
View File
@@ -0,0 +1,378 @@
package srv_search
import (
"context"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/esmanager"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/esmod"
"videoweb/pub/models/playletcategorymod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playletthememod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const playletESPageSize int64 = 1000
var playletESProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
{Key: playletmod.FN.Playlet.IsFeatured, Value: 1},
{Key: playletmod.FN.Playlet.IsRecommend, Value: 1},
{Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1},
{Key: playletmod.FN.Playlet.Banner, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
{Key: playletmod.FN.Playlet.CategoryIds, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.Anchor, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
{Key: playletmod.FN.Playlet.UpdateStatus, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
{Key: playletmod.FN.Playlet.VirtualHeatValue, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: playletmod.FN.Playlet.SeoInfo.Title, Value: 1},
{Key: playletmod.FN.Playlet.SeoInfo.Description, Value: 1},
{Key: playletmod.FN.Playlet.SeoInfo.Keyworld, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1},
}
// getPlayletEsData 查询短剧增量数据并转换为ES批量写入数据
func getPlayletEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) {
list, err1 := getPlayletData(ctx, minTime, endTime)
if err1 != nil {
return nil, err1
}
return playletToEsData(ctx, list)
}
// SyncPlayletsToESByLatestStatus 根据短剧最新上下架状态同步ES搜索数据
func SyncPlayletsToESByLatestStatus(ctx context.Context, ids []int64) *srvdef.SrvErr {
ids = dedupePlayletESIds(ids)
if len(ids) == 0 {
return nil
}
list, err1 := 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 err1 != nil {
return err1
}
foundIds := make(map[int64]struct{}, len(list))
upIds := make([]int64, 0, len(list))
downIds := make([]int64, 0, len(ids))
for _, item := range list {
if item == nil {
continue
}
foundIds[item.Id] = struct{}{}
if item.Status == playletmod.StatusUp {
upIds = append(upIds, item.Id)
} else {
downIds = append(downIds, item.Id)
}
}
for _, id := range ids {
if _, ok := foundIds[id]; !ok {
downIds = append(downIds, id)
}
}
if err1 := SyncPlayletStatusToES(ctx, upIds, playletmod.StatusUp); err1 != nil {
return err1
}
if err1 := SyncPlayletStatusToES(ctx, downIds, playletmod.StatusDown); err1 != nil {
return err1
}
return nil
}
// SyncPlayletStatusToES 根据短剧上下架状态同步ES搜索数据
func SyncPlayletStatusToES(ctx context.Context, ids []int64, status playletmod.Status) *srvdef.SrvErr {
ids = dedupePlayletESIds(ids)
if len(ids) == 0 {
return nil
}
if esmanager.Instance == nil {
log.Error("短剧ES同步失败,ES未初始化", log.Any("短剧ID", ids), log.Any("状态", status))
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
switch status {
case playletmod.StatusUp:
return upsertPlayletsToES(ctx, ids)
case playletmod.StatusDown:
return deletePlayletsFromES(ids)
default:
return nil
}
}
func getPlayletData(ctx context.Context, minTime, endTime *time.Time) (playletmod.PlayletList, *srvdef.SrvErr) {
pageNum := int64(1)
result := make(playletmod.PlayletList, 0)
filter := bson.M{
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}
if minTime != nil && endTime != nil {
filter[playletmod.FN.Playlet.UpdatedAt] = bson.M{
"$gte": minTime,
"$lte": endTime,
}
}
for {
list, err1 := playletmod.FindManyPlaylet(ctx, filter, options.Find().
SetProjection(playletESProjection).
SetSort(bson.D{{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}}).
SetSkip((pageNum-1)*playletESPageSize).
SetLimit(playletESPageSize))
if err1 != nil {
return nil, err1
}
result = append(result, list...)
if int64(len(list)) < playletESPageSize {
break
}
pageNum++
}
return result, nil
}
func playletToEsData(ctx context.Context, list playletmod.PlayletList) (esmanager.M, *srvdef.SrvErr) {
categoryNameMap, err1 := buildPlayletESCategoryNameMap(ctx, list)
if err1 != nil {
return nil, err1
}
themeNameMap, err1 := buildPlayletESThemeNameMap(ctx, list)
if err1 != nil {
return nil, err1
}
tagNameMap, err1 := buildPlayletESTagNameMap(ctx, list)
if err1 != nil {
return nil, err1
}
result := make(esmanager.M, len(list))
for _, item := range list {
if item == nil {
continue
}
result[strconv.FormatInt(item.Id, 10)] = playletToESDoc(item, categoryNameMap, themeNameMap, tagNameMap)
}
return result, nil
}
// upsertPlayletsToES 批量写入已上架短剧ES文档
func upsertPlayletsToES(ctx context.Context, ids []int64) *srvdef.SrvErr {
list, err1 := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().SetProjection(playletESProjection))
if err1 != nil {
return err1
}
if len(list) == 0 {
return nil
}
esData, err1 := playletToEsData(ctx, list)
if err1 != nil {
return err1
}
if len(esData) == 0 {
return nil
}
if err1 = AddMultiDataToES(esmod.GetPlayletIndex(), esData); err1 != nil {
log.Error("批量写入短剧ES失败", log.Any("短剧ID", ids), log.E(err1))
return err1
}
log.Info("批量写入短剧ES成功", log.Any("数量", len(esData)), log.Any("短剧ID", ids))
return nil
}
// deletePlayletsFromES 批量删除短剧ES文档
func deletePlayletsFromES(ids []int64) *srvdef.SrvErr {
strIds := make([]string, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
strIds = append(strIds, strconv.FormatInt(id, 10))
}
if len(strIds) == 0 {
return nil
}
if err1 := batchDelFromES(esmod.GetPlayletIndex(), strIds); err1 != nil {
log.Error("批量删除短剧ES失败", log.Any("短剧ID", ids), log.E(err1))
return err1
}
log.Info("批量删除短剧ES成功", log.Any("数量", len(strIds)), log.Any("短剧ID", ids))
return nil
}
// playletToESDoc 转换短剧Mongo数据为ES文档
func playletToESDoc(item *playletmod.Playlet, categoryNameMap map[int64]string, themeNameMap map[int64]string, tagNameMap map[int64]string) *esmod.ESPlaylet {
return &esmod.ESPlaylet{
Id: item.Id,
Status: item.Status,
IsFeatured: item.IsFeatured,
IsRecommend: item.IsRecommend,
ShowOnBanner: item.ShowOnBanner,
Banner: item.Banner,
OnlineAt: item.OnlineAt,
CategoryIds: item.CategoryIds,
CategoryNames: playletESNamesByIds(item.CategoryIds, categoryNameMap),
ThemeIds: item.ThemeIds,
ThemeNames: playletESNamesByIds(item.ThemeIds, themeNameMap),
Title: item.Title,
Desc: item.Desc,
Anchor: item.Anchor,
Cover: item.DisplayCover(),
TagIds: item.TagIds,
TagNames: playletESNamesByIds(item.TagIds, tagNameMap),
TotalEpisode: item.TotalEpisode,
UpdateStatus: item.UpdateStatus,
CountBrowse: item.DisplayCountBrowse(),
HeatValue: item.DisplayHeatValue(),
CountCollect: item.CountCollect,
CountLike: item.CountLike + item.VirtualCountLike,
SeoInfo: item.SeoInfo,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// buildPlayletESCategoryNameMap 批量查询短剧分类名称
func buildPlayletESCategoryNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) {
ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 {
return item.CategoryIds
})
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result, nil
}
categories, err1 := playletcategorymod.FindManyPlayletCategory(ctx, bson.M{
playletcategorymod.FN.PlayletCategory.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(bson.D{
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1},
{Key: playletcategorymod.FN.PlayletCategory.Name, Value: 1},
}))
if err1 != nil {
return nil, err1
}
for _, category := range categories {
if category != nil {
result[category.Id] = category.Name
}
}
return result, nil
}
// buildPlayletESThemeNameMap 批量查询短剧题材名称
func buildPlayletESThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) {
ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 {
return item.ThemeIds
})
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result, nil
}
themes, err1 := playletthememod.FindManyPlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(bson.D{
{Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Name, Value: 1},
}))
if err1 != nil {
return nil, err1
}
for _, theme := range themes {
if theme != nil {
result[theme.Id] = theme.Name
}
}
return result, nil
}
// buildPlayletESTagNameMap 批量查询短剧标签名称
func buildPlayletESTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) {
ids := collectPlayletESIds(list, func(item *playletmod.Playlet) []int64 {
return item.TagIds
})
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result, nil
}
tags, err1 := contenttagmod.GetMapTags(ctx, ids)
if err1 != nil {
return nil, err1
}
for _, tag := range tags {
if tag != nil {
result[tag.Id] = tag.Name
}
}
return result, nil
}
// collectPlayletESIds 汇总并去重短剧关联ID
func collectPlayletESIds(list playletmod.PlayletList, pick func(item *playletmod.Playlet) []int64) []int64 {
result := make([]int64, 0)
seen := make(map[int64]struct{})
for _, item := range list {
if item == nil {
continue
}
for _, id := range pick(item) {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
}
return result
}
func dedupePlayletESIds(ids []int64) []int64 {
result := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
// playletESNamesByIds 按ID顺序返回ES文档名称集合
func playletESNamesByIds(ids []int64, nameMap map[int64]string) []string {
result := make([]string, 0, len(ids))
for _, id := range ids {
if name := nameMap[id]; name != "" {
result = append(result, name)
}
}
return result
}
+77
View File
@@ -0,0 +1,77 @@
package srv_search
import (
"context"
"strconv"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/esmanager"
"videoweb/pub/models/actress"
"videoweb/pub/models/esmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func getActressEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) {
data, serr := getActressData(ctx, minTime, endTime)
if serr != nil {
return nil, serr
}
return actressToEsData(data), nil
}
func getActressData(ctx context.Context, minTime, endTime *time.Time) ([]*actress.Actress, *srvdef.SrvErr) {
pageNumber := 1
pageSize := 1000
rsl := make([]*actress.Actress, 0)
filter := bson.M{}
if minTime != nil && endTime != nil {
filter[actress.FN.Actress.UpdatedAt] = bson.M{
"$gte": minTime,
"$lte": endTime,
}
}
for {
opts := &options.FindOptions{}
opts.SetSort(bson.M{actress.FN.Actress.UpdatedAt: -1})
opts.SetSkip(int64(pageSize * (pageNumber - 1)))
opts.SetLimit(int64(pageSize))
data, serr := actress.FindManyActresss(ctx, filter, opts)
if serr != nil {
return nil, serr
}
rsl = append(rsl, data...)
if len(data) < pageSize {
break
}
pageNumber++
}
return rsl, nil
}
func actressToEsData(data []*actress.Actress) esmanager.M {
rsl := make(map[string]interface{}, len(data))
for _, v := range data {
esInfo := esmod.ESActress{
Id: v.Id,
Alias: v.Alias,
Avatar: v.Avatar,
Meas: v.Meas,
Desc: v.Desc,
Office: v.Office,
DebutDate: v.DebutDate,
BornDate: v.BornDate,
Region: v.Region,
Score: v.Score,
Works: v.Works,
HotValue: v.HotValue,
Real: v.Real,
Status: v.Status,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
TranslationCategoryId: v.TranslationCategoryId,
Rank: v.Rank,
}
rsl[strconv.FormatInt(v.Id, 10)] = esInfo
}
return rsl
}
+92
View File
@@ -0,0 +1,92 @@
package srv_search
import (
"context"
"strconv"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"videoweb/pub/models/videoinformationmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func getVideoEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) {
data, serr := getVideoData(ctx, minTime, endTime)
if serr != nil {
return nil, serr
}
return videoToEsData(data), nil
}
func getVideoData(ctx context.Context, minTime, endTime *time.Time) ([]*videoinformationmod.VideoInformation, *srvdef.SrvErr) {
pageNumber := 1
pageSize := 1000
rsl := make([]*videoinformationmod.VideoInformation, 0)
filter := bson.M{}
if minTime != nil && endTime != nil {
filter[videoinformationmod.FN.VideoInformation.UpdatedAt] = bson.M{
"$gte": minTime,
"$lte": endTime,
}
}
for {
opts := &options.FindOptions{}
opts.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1})
opts.SetSkip(int64(pageSize * (pageNumber - 1)))
opts.SetLimit(int64(pageSize))
data, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if serr != nil {
return nil, serr
}
rsl = append(rsl, data...)
if len(data) < pageSize {
break
}
pageNumber++
}
return rsl, nil
}
func videoToEsData(data []*videoinformationmod.VideoInformation) esmanager.M {
rsl := make(map[string]interface{}, len(data))
for _, v := range data {
esInfo := esmod.ESVideo{
Id: v.Id,
Title: v.Title,
MediaType: v.MediaType,
AddedTime: v.AddedTime,
CreatorName: v.CreatorName,
Keyworlds: "",
Desc: v.Desc,
Remark: v.Remark,
Status: v.Status,
IsHot: v.IsHot,
Sort: v.Sort,
WatchTimes: v.Real.WatchTimes,
ShowTimes: v.Real.ShowTimes,
Comments: v.Real.Comments,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,
Keyworld: v.Keyworld,
CreatorId: v.CreatorId,
FirstTag: v.FirstTag,
SecondTags: v.SecondTags,
CoverImg: v.CoverImg,
Nodes: v.Nodes,
Video: v.Video,
Tags: v.Tags,
Real: v.Real,
VideoCount: v.VideoCount,
IssuedBrand: v.IssuedBrand,
Bango: v.Bango,
IssuedAt: v.IssuedAt,
}
if v.Keyworld != nil && len(v.Keyworld) > 0 {
esInfo.Keyworlds = strings.Join(v.Keyworld, " ")
}
rsl[strconv.FormatInt(v.Id, 10)] = esInfo
}
return rsl
}
+27
View File
@@ -0,0 +1,27 @@
package srv_search
import (
"github.com/robfig/cron/v3"
"videoweb/pub/common/log"
)
var (
skdCron *cron.Cron
batchSize int = 100
opMaxDiff int32 = 10
)
func Start() {
skdCron = cron.New(cron.WithSeconds())
log.Info("srv_search:Start...")
defer log.Info("srv_search:Start!!!")
go func() {
StartES()
}()
}
func Stop() {
log.Info("srv_search:Stop...")
defer log.Info("srv_search:Stop!!!")
StopES()
}
+229
View File
@@ -0,0 +1,229 @@
package srv_search
import (
"context"
"fmt"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"strconv"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"videoweb/pub/mongos"
)
// resetAllDocsToES 重设所有文档到数据库
func resetAllDocsToES(ctx context.Context, param *Param) *srvdef.SrvErr {
log.Info("srv_search:resetAllDocsToES...", log.Any("ESIndex", param.EsIndex))
defer log.Info("srv_search:startActress!!!")
//删除所有记录
delAllFromES(param.EsIndex)
var pageNum int64 = 1
var pageSize int64 = 200
updSize := 0
for {
findOpt := constructFindOption(pageNum, pageSize)
docs, serr := findManyFromDb(ctx, param, findOpt)
if serr != nil {
return serr
}
if len(docs) == 0 {
break
}
updSize = updSize + len(docs)
updateESData, serr := sliceMapToJsonMap(docs, param)
log.Info("srv_search:resetAllDocsToES", log.Any("updateESData", updateESData))
if serr = batchAddToES(param.EsIndex, updateESData); serr != nil {
return serr
}
pageNum++
log.Info("resetAllToES>>>>>>>>>>>>>>>>>>", log.Any("updateSize:", updSize))
}
if updSize > 0 {
if err := esmanager.Instance.Refresh(param.EsIndex); err != nil {
log.Warn("srv_search:resetAllDocsToES Refresh failed", log.Any("ESIndex", param.EsIndex), log.E(err))
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
}
return nil
}
// constructMatch 构建match
func constructMatch(param *Param) bson.M {
updateFilter := bson.A{}
for _, fieldName := range param.CollFields {
filter := bson.M{"updateDescription.updatedFields." + fieldName: bson.M{"$exists": true}}
updateFilter = append(updateFilter, filter)
}
match := bson.M{"$or": bson.A{
bson.M{"operationType": bson.M{"$in": []string{"delete", "insert", "replace"}}},
bson.M{"$and": bson.A{
bson.M{"operationType": bson.M{"$in": []string{"update"}}},
bson.M{"$or": updateFilter}},
}}}
return match
}
// constructActtressOption 查找选项
func constructFindOption(pageNum int64, pageSize int64) *options.FindOptions {
opt := &options.FindOptions{}
opt.SetSort(bson.M{"createdAt": -1})
skip := (pageNum - 1) * pageSize
opt.SetSkip(skip)
opt.SetLimit(pageSize)
return opt
}
// AddMultiDataToES 添加一批Ex
func AddMultiDataToES(esIndex string, esvs esmanager.M) *srvdef.SrvErr {
if err := esmanager.Instance.Bulk(esIndex, esvs); err != nil {
info := fmt.Sprintf("(srv_search:AddMultiDataToES) ES Bulk failed! err:%+v:", err)
log.Warn(info)
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
return nil
}
// findManyFromDb 查找一批数据
func findManyFromDb(ctx context.Context, param *Param,
opts ...*options.FindOptions) ([]bson.M, *srvdef.SrvErr) {
docs := make([]bson.M, 0)
if err := param.Coll.Find(ctx, &docs, param.CollFilter, opts...); err != nil {
log.Warn("srv_search:findManyFromDb db.Find failed", log.E(err), log.Any("filter", param.CollFilter))
return nil, srvdef.New(srvdef.ErrDbQueryError, "", "")
}
return docs, nil
}
// sliceMapToJsonMap 从map切片构建Map对象
func sliceMapToJsonMap(docs []bson.M, param *Param) (esmanager.M, *srvdef.SrvErr) {
esMap := make(esmanager.M)
for _, doc := range docs {
fieldMap := make(esmanager.M)
for key, value := range doc {
if utils.IncludeString(param.CollFields, key) {
fieldMap[key] = value
} else if key == "_id" {
fieldMap["id"] = value
}
}
if id, ok := doc["_id"].(int64); ok {
strId := strconv.FormatInt(id, 10)
esMap[strId] = fieldMap
} else {
log.Warn("srv_search:sliceMapToJsonMap Key doesn't exist", log.Any("keyId", "_id"))
}
}
return esMap, nil
}
// getInfoFromChangeData 从改变流中获取数据信息
func getInfoFromChangeData(changeData bson.M, param *Param) (
string, *esmod.ESTimestamp, string, esmanager.M, *srvdef.SrvErr) {
operationType, ok := changeData["operationType"].(string)
if ok == false {
log.Error("srv_search:getInfoFromChangeData operationType type error")
return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "")
}
ptimestamp, ok := changeData["clusterTime"].(primitive.Timestamp)
if ok == false {
log.Error("srv_search:getInfoFromChangeData clusterTime type error")
return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "")
}
estimestamp := &esmod.ESTimestamp{T: ptimestamp.T, I: ptimestamp.I}
documentKey, ok := changeData["documentKey"].(primitive.M)
if ok == false {
log.Error("srv_search:getInfoFromChangeData documentKey type error")
return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "")
}
docKey, ok := documentKey["_id"].(int64)
if ok == false {
log.Error("srv_search:getInfoFromChangeData documentKey[_id] type error")
return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "")
}
strId := strconv.FormatInt(docKey, 10)
esDocMap := make(esmanager.M)
if changeData["fullDocument"] != nil {
docMap, ok := changeData["fullDocument"].(primitive.M)
if ok == false {
log.Error("srv_search:getInfoFromChangeData fullDocument type error")
return "", nil, "", nil, srvdef.New(srvdef.ErrData, "", "")
}
for key, value := range docMap {
if utils.IncludeString(param.CollFields, key) {
esDocMap[key] = value
} else if key == "_id" {
esDocMap["id"] = value
}
}
}
return operationType, estimestamp, strId, esDocMap, nil
}
// delAllFromES 删除所有数据
func delAllFromES(esIndex string) *srvdef.SrvErr {
query := esmanager.M{
"query": esmanager.M{
"match_all": esmanager.M{},
},
}
if err := esmanager.Instance.DelMany([]string{esIndex}, query); err != nil {
log.Warn(fmt.Sprintf("(srv_search:DelAllFromEs) Es DelMany failed! err:%+v:", err),
log.Any("query", query))
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
return nil
}
// batchAddToES 添加一批数据到ES
func batchAddToES(esIndex string, esvs esmanager.M) *srvdef.SrvErr {
log.Info("srv_search:resetAllDocsToES", log.Any("esIndex", esIndex), log.Any("esvs", esvs))
if err := esmanager.Instance.Bulk(esIndex, esvs); err != nil {
info := fmt.Sprintf("(srv_search:batchAddToES) ES Bulk failed! err:%+v:", err)
log.Warn(info)
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
return nil
}
// batchDelFromES 删除一批数据从ES
func batchDelFromES(esIndex string, ids []string) *srvdef.SrvErr {
query := esmanager.M{
"query": esmanager.M{
"bool": esmanager.M{
"must": esmanager.A{
{
"ids": esmanager.M{
"values": ids,
},
},
},
},
},
}
if err := esmanager.Instance.DelMany([]string{esIndex}, query); err != nil {
info := fmt.Sprintf(
"(srv_search:batchDelFromES) Es DelMany failed! err:%+v:", err)
log.Warn(info)
return srvdef.New(srvdef.ErrElsOperError, "", "")
}
return nil
}
// watchOpLog 监控表变化
func watchOpLog(ctx context.Context, db *mongos.Collection, pipeline []bson.M,
opt *options.ChangeStreamOptions) (*mongo.ChangeStream, *srvdef.SrvErr) {
changeStream, err := db.WatchOpLog(ctx, pipeline, opt)
if err != nil {
log.Warn("srv_search:watchOpLog db.WatchOpLog failed",
log.E(err), log.Any("pipeline", pipeline))
return nil, srvdef.New(srvdef.ErrDbQueryError, "", "")
}
return changeStream, nil
}
+60
View File
@@ -0,0 +1,60 @@
package srv_seopush
import (
"context"
"time"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/sitemapdirty"
)
// MarkSitemapDirty 通知 app 进程重新生成 sitemap。
func MarkSitemapDirty(ctx context.Context) {
sitemapdirty.Mark(ctx)
}
// NotifyPlayletAfterWrite 短剧写入后:刷新 sitemap 标记。
// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。
func NotifyPlayletAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status, status playletmod.Status, onlineAt time.Time) {
MarkSitemapDirty(ctx)
wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp
if status == playletmod.StatusUp {
if !wasUp {
EnqueueUpdated(ctx, KindPlaylet, playletId, PageVisiblePlaylet(status, onlineAt))
}
return
}
if wasUp {
EnqueueDeleted(ctx, KindPlaylet, playletId)
}
}
// NotifyPlayletAfterStatus 批量上下架后的 SEO 通知
func NotifyPlayletAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) {
MarkSitemapDirty(ctx)
if status == playletmod.StatusUp {
for _, id := range ids {
EnqueueUpdated(ctx, KindPlaylet, id, PageVisiblePlaylet(status, onlineAt))
}
return
}
for _, id := range ids {
EnqueueDeleted(ctx, KindPlaylet, id)
}
}
// NotifyInfoAfterWrite 视频写入后的 SEO 通知。
// 搜索引擎只在新上架或从已上架变为下架时推送,普通改内容不推。
func NotifyInfoAfterWrite(ctx context.Context, kind string, id int64, prevStatus, status pubdef.InformationStatus) {
MarkSitemapDirty(ctx)
wasUp := prevStatus == pubdef.InformationStatusVerifyOK
if PageVisibleInfo(status) {
if !wasUp {
EnqueueUpdated(ctx, kind, id, true)
}
return
}
if wasUp {
EnqueueDeleted(ctx, kind, id)
}
}
+347
View File
@@ -0,0 +1,347 @@
// Package srv_seopush 负责内容新上架/下架时向搜索引擎推送页面地址。
// 业务回调里即时推送一次,失败的任务由 web/skd/job/seopush 兜底重试。
package srv_seopush
import (
"context"
"errors"
"strings"
"time"
"videoweb/pub/common/godef"
"videoweb/pub/common/log"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/seopushmod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/googleindex"
seoindexnow "videoweb/pub/seo/indexnow"
"videoweb/web/service/srv_cfg"
"videoweb/web/webg"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
KindVideo = "video"
KindPlaylet = "playlet"
)
// channelFields 把两个推送通道的字段名抽象出来,共用同一套状态更新逻辑
type channelFields struct {
name string
status string
retry string
errMsg string
at string
}
var googleChannel = channelFields{
name: "google",
status: seopushmod.FN.SeoPushTask.GoogleStatus,
retry: seopushmod.FN.SeoPushTask.GoogleRetry,
errMsg: seopushmod.FN.SeoPushTask.GoogleErr,
at: seopushmod.FN.SeoPushTask.GoogleAt,
}
var indexNowChannel = channelFields{
name: "indexnow",
status: seopushmod.FN.SeoPushTask.IndexNowStatus,
retry: seopushmod.FN.SeoPushTask.IndexNowRetry,
errMsg: seopushmod.FN.SeoPushTask.IndexNowErr,
at: seopushmod.FN.SeoPushTask.IndexNowAt,
}
func kindPrefix(kind string) string {
switch kind {
case KindVideo:
return "/video-detail"
case KindPlaylet:
return "/drama-detail"
default:
return ""
}
}
// DetailURL 拼装详情页完整地址,路径与 sitemap 一致:/{prefix}/{slug},无 slug 回退 ID。
func DetailURL(webAddr, prefix, slug string, id int64) string {
seg := utils.PathSegment(slug, id)
if seg == "" {
return ""
}
return utils.JoinUrlPathKeepSlash(webAddr, strings.TrimRight(prefix, "/")+"/"+seg)
}
// PageVisiblePlaylet 判断短剧详情页当前是否对外可访问。
func PageVisiblePlaylet(status playletmod.Status, onlineAt time.Time) bool {
if status != playletmod.StatusUp {
return false
}
if onlineAt.IsZero() {
return true
}
return !onlineAt.After(time.Now())
}
// PageVisibleInfo 判断视频详情页是否已上架。
func PageVisibleInfo(status pubdef.InformationStatus) bool {
return status == pubdef.InformationStatusVerifyOK
}
// SEOBaseURL 取站点对外地址
func SEOBaseURL(ctx context.Context) string {
sysCfg, serr := srv_cfg.GetSystemConfig(ctx)
if serr != nil || sysCfg == nil {
log.Warn("seopush: get system config failed", log.E(serr))
return ""
}
base := sysCfg.SEOBaseURL()
if base == "" {
log.Warn("seopush: SEO base URL is empty, skip push")
}
return base
}
// Enabled 业务开关是否打开(不含环境判断)。
func Enabled() bool {
return pubdef.Enabled()
}
func canPushExternally() bool {
return pubdef.Enabled() && webg.Conf.RunMod == "prod"
}
// EnqueueUpdated 内容新上架后登记并立即推送,仅在页面已可访问时生效
func EnqueueUpdated(ctx context.Context, kind string, id int64, visible bool) {
if !pubdef.Enabled() || !visible {
return
}
enqueue(ctx, kind, id, seopushmod.ActionUpdated, true)
}
// RegisterUpdated 只登记不立即推送,供批量补推使用
func RegisterUpdated(ctx context.Context, kind string, id int64, visible bool) {
if !pubdef.Enabled() || !visible {
return
}
enqueue(ctx, kind, id, seopushmod.ActionUpdated, false)
}
// EnqueueDeleted 内容下架/删除后登记并立即推送
func EnqueueDeleted(ctx context.Context, kind string, id int64) {
if !pubdef.Enabled() {
return
}
enqueue(ctx, kind, id, seopushmod.ActionDeleted, true)
}
func enqueue(ctx context.Context, kind string, id int64, action string, pushNow bool) {
webAddr := SEOBaseURL(ctx)
if webAddr == "" {
return
}
prefix := kindPrefix(kind)
if prefix == "" {
return
}
pageURL := DetailURL(webAddr, prefix, loadSlug(ctx, kind, id), id)
if pageURL == "" {
return
}
now := time.Now()
task := &seopushmod.SeoPushTask{
Url: pageURL,
VideoId: id,
Action: action,
CreatedAt: now,
UpdatedAt: now,
}
if action == seopushmod.ActionDeleted {
_, _ = seopushmod.DelManySeoPushTask(ctx, bson.M{
seopushmod.FN.SeoPushTask.Url: pageURL,
seopushmod.FN.SeoPushTask.Action: seopushmod.ActionUpdated,
})
}
if serr := seopushmod.UpsertPendingSeoPushTask(ctx, task); serr != nil {
log.Warn("seopush: enqueue task failed", log.Any("url", pageURL), log.E(serr))
return
}
if action == seopushmod.ActionUpdated {
markQueued(ctx, kind, id)
}
if !pushNow {
return
}
godef.Go(func() {
PushTask(context.Background(), webAddr, task)
})
}
func loadSlug(ctx context.Context, kind string, id int64) string {
if id <= 0 {
return ""
}
switch kind {
case KindVideo:
item, err := videoinformationmod.FindOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: id},
options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}}))
if err != nil || item == nil {
return ""
}
return strings.TrimSpace(item.Slug)
case KindPlaylet:
item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: id},
options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}}))
if err != nil || item == nil {
return ""
}
return strings.TrimSpace(item.Slug)
default:
return ""
}
}
func markQueued(ctx context.Context, kind string, id int64) {
var serr error
switch kind {
case KindVideo:
serr = videoinformationmod.UpdateOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: id},
bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}})
case KindPlaylet:
serr = playletmod.UpdateOnePlaylet(ctx,
bson.M{playletmod.FN.Playlet.Id: id},
bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}})
}
if serr != nil {
log.Warn("seopush: mark queued failed", log.Any("kind", kind), log.Any("id", id), log.E(serr))
}
}
// PushTask 推送任务中尚未成功的通道
func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if task == nil || !pubdef.Enabled() {
return
}
if strings.Contains(task.Url, "?") {
log.Warn("seopush: skip query-url task", log.Any("url", task.Url), log.Any("action", task.Action))
return
}
if !canPushExternally() {
dryRun(ctx, task)
return
}
if task.GoogleStatus != seopushmod.PushStatusSuccess {
pushGoogle(ctx, task)
}
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
pushIndexNow(ctx, webAddr, task)
}
}
func dryRun(ctx context.Context, task *seopushmod.SeoPushTask) {
log.Info("seopush: dry-run, external push skipped in non-prod",
log.Any("url", task.Url), log.Any("action", task.Action))
if task.GoogleStatus != seopushmod.PushStatusSuccess {
markSuccess(ctx, task, googleChannel)
}
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
markSuccess(ctx, task, indexNowChannel)
}
}
func pushGoogle(ctx context.Context, task *seopushmod.SeoPushTask) {
notifyType := googleindex.TypeUpdated
if task.Action == seopushmod.ActionDeleted {
notifyType = googleindex.TypeDeleted
}
resp, err := googleindex.Publish(ctx, task.Url, notifyType)
if err == nil {
markSuccess(ctx, task, googleChannel)
log.Info("seopush=google=pushed",
log.Any("url", task.Url),
log.Any("type", notifyType),
log.Any("resp", resp))
return
}
if errors.Is(err, googleindex.ErrQuotaExceeded) {
log.Warn("seopush=google=quota_exceeded",
log.Any("url", task.Url), log.Any("resp", err.Error()))
return
}
if errors.Is(err, googleindex.ErrNotEnabled) {
log.Warn("seopush=google=not_ready", log.Any("url", task.Url))
return
}
markFailed(ctx, task, googleChannel, err)
}
func pushIndexNow(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if strings.TrimSpace(webAddr) == "" {
return
}
config, err := seoindexnow.BuildConfig(webAddr)
if err != nil {
log.Warn("seopush: build indexnow config failed", log.Any("webAddr", webAddr), log.E(err))
return
}
result := seoindexnow.NewBatchIndexNowSubmitter(config).SubmitBatch([]string{task.Url})
if result.SuccessCount > 0 {
markSuccess(ctx, task, indexNowChannel)
log.Info("seopush=indexnow=pushed",
log.Any("url", task.Url),
log.Any("statusCode", result.StatusCodes))
return
}
markFailed(ctx, task, indexNowChannel, errors.New(strings.Join(result.Errors, "; ")))
}
func markSuccess(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields) {
now := time.Now()
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
"$set": bson.M{
ch.status: seopushmod.PushStatusSuccess,
ch.errMsg: "",
ch.at: now,
seopushmod.FN.SeoPushTask.UpdatedAt: now,
},
}); serr != nil {
log.Warn("seopush: mark success failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
}
}
func markFailed(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields, pushErr error) {
now := time.Now()
errText := ""
if pushErr != nil {
errText = pushErr.Error()
}
log.Warn("seopush: push failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.Any("err", errText))
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
"$set": bson.M{
ch.errMsg: errText,
ch.at: now,
seopushmod.FN.SeoPushTask.UpdatedAt: now,
},
"$inc": bson.M{ch.retry: 1},
}); serr != nil {
log.Warn("seopush: mark failed failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
}
}
func taskFilter(task *seopushmod.SeoPushTask) bson.M {
return bson.M{
seopushmod.FN.SeoPushTask.Url: task.Url,
seopushmod.FN.SeoPushTask.Action: task.Action,
}
}
+403
View File
@@ -0,0 +1,403 @@
package srv_slug
import (
"context"
"strings"
"time"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/actress"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/playletcategorymod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playletthememod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
syncSlugDefaultSize = int64(500)
syncSlugMaxSize = int64(2000)
TypeVideo = "video"
TypeVideoCategory = "videoCategory"
TypeActress = "actress"
TypeVideoCreater = "videoCreater"
TypePlaylet = "playlet"
TypePlayletCategory = "playletCategory"
TypePlayletTheme = "playletTheme"
TypeContentTag = "contentTag"
)
type slugJob struct {
kind string
set func(*proto.SlugSyncResp, int64)
run func(context.Context, int64) (int64, bool, *srvdef.SrvErr)
hasMore func(context.Context) bool
}
// SyncEmptySlugs 为视频、分类、女优、作者、短剧、统一标签中空 slug 自动生成。可重复调用,hasMore 为 true 时继续请求。
func SyncEmptySlugs(ctx context.Context, req *proto.SlugSyncReq) (*proto.SlugSyncResp, *srvdef.SrvErr) {
maxSize := syncSlugDefaultSize
if req != nil && req.MaxSize > 0 {
maxSize = req.MaxSize
}
if maxSize > syncSlugMaxSize {
maxSize = syncSlugMaxSize
}
kind := ""
if req != nil {
kind = strings.TrimSpace(req.Type)
}
jobs := allSlugJobs()
if kind != "" {
job, ok := jobByKind(jobs, kind)
if !ok {
return nil, srvdef.New(srvdef.ErrParamError, "不支持的type", "不支持的type")
}
jobs = []slugJob{job}
}
resp := &proto.SlugSyncResp{}
remain := maxSize
for i, job := range jobs {
if remain <= 0 {
resp.HasMore = anyHasEmpty(ctx, jobs[i:])
resp.TotalUpdated = sumUpdated(resp)
return resp, nil
}
updated, more, err := job.run(ctx, remain)
if err != nil {
return nil, err
}
job.set(resp, updated)
remain -= updated
if more {
resp.HasMore = true
resp.TotalUpdated = sumUpdated(resp)
return resp, nil
}
}
resp.TotalUpdated = sumUpdated(resp)
return resp, nil
}
func allSlugJobs() []slugJob {
return []slugJob{
{TypeVideoCategory, func(r *proto.SlugSyncResp, n int64) { r.VideoCategoryUpdated = n }, syncVideoCategoryEmptySlugs, hasVideoCategoryEmpty},
{TypePlayletCategory, func(r *proto.SlugSyncResp, n int64) { r.PlayletCategoryUpdated = n }, syncPlayletCategoryEmptySlugs, hasPlayletCategoryEmpty},
{TypePlayletTheme, func(r *proto.SlugSyncResp, n int64) { r.PlayletThemeUpdated = n }, syncPlayletThemeEmptySlugs, hasPlayletThemeEmpty},
{TypeContentTag, func(r *proto.SlugSyncResp, n int64) { r.ContentTagUpdated = n }, syncContentTagEmptySlugs, hasContentTagEmpty},
{TypeActress, func(r *proto.SlugSyncResp, n int64) { r.ActressUpdated = n }, syncActressEmptySlugs, hasActressEmpty},
{TypeVideoCreater, func(r *proto.SlugSyncResp, n int64) { r.VideoCreaterUpdated = n }, syncVideoCreaterEmptySlugs, hasVideoCreaterEmpty},
{TypePlaylet, func(r *proto.SlugSyncResp, n int64) { r.PlayletUpdated = n }, syncPlayletEmptySlugs, hasPlayletEmpty},
{TypeVideo, func(r *proto.SlugSyncResp, n int64) { r.VideoUpdated = n }, syncVideoEmptySlugs, hasVideoEmpty},
}
}
func jobByKind(jobs []slugJob, kind string) (slugJob, bool) {
for _, job := range jobs {
if job.kind == kind {
return job, true
}
}
return slugJob{}, false
}
func anyHasEmpty(ctx context.Context, jobs []slugJob) bool {
for _, job := range jobs {
if job.hasMore(ctx) {
return true
}
}
return false
}
func sumUpdated(resp *proto.SlugSyncResp) int64 {
return resp.VideoUpdated + resp.VideoCategoryUpdated + resp.ActressUpdated +
resp.VideoCreaterUpdated +
resp.PlayletUpdated + resp.PlayletCategoryUpdated +
resp.PlayletThemeUpdated + resp.ContentTagUpdated
}
func findLimit(maxSize int64) *options.FindOptions {
return options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).SetLimit(maxSize + 1)
}
func fillEmptySlugs[T any](
ctx context.Context,
list []T,
maxSize int64,
idOf func(T) int64,
sourceOf func(T) string,
exists func(slug string, excludeId int64) bool,
update func(context.Context, int64, string) *srvdef.SrvErr,
) (int64, bool, *srvdef.SrvErr) {
hasMore := int64(len(list)) > maxSize
if hasMore {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if any(item) == nil {
continue
}
id := idOf(item)
if id <= 0 {
continue
}
slug := ""
if serr := slugx.Ensure(&slug, sourceOf(item), func(s string) bool {
return exists(s, id)
}); serr != nil {
return updated, hasMore, serr
}
if serr := update(ctx, id, slug); serr != nil {
return updated, hasMore, serr
}
updated++
}
return updated, hasMore, nil
}
func setSlug(ctx context.Context, updateOne func(context.Context, bson.M, bson.M) *srvdef.SrvErr, idField, slugField, updatedAtField string, id int64, slug string) *srvdef.SrvErr {
setDoc := bson.M{slugField: slug}
if updatedAtField != "" {
setDoc[updatedAtField] = time.Now()
}
return updateOne(ctx, bson.M{idField: id}, bson.M{"$set": setDoc})
}
func videoEmptySlugFilter(ctx context.Context) bson.M {
filter := slugx.EmptyFilter()
playletIDs := playletchaptermod.ListLinkedMediaIDs(ctx)
if len(playletIDs) == 0 {
return filter
}
return bson.M{
"$and": []bson.M{
filter,
{
"$nor": []bson.M{
{videoinformationmod.FN.VideoInformation.SyncId: bson.M{"$in": playletIDs}},
{videoinformationmod.FN.VideoInformation.Video.FileId: bson.M{"$in": playletIDs}},
},
},
},
}
}
func hasVideoEmpty(ctx context.Context) bool {
n, _ := videoinformationmod.CountVideoInformations(ctx, videoEmptySlugFilter(ctx))
return n > 0
}
func hasVideoCategoryEmpty(ctx context.Context) bool {
n, _ := videocategorymod.CountVideoCategorys(ctx, slugx.EmptyFilter())
return n > 0
}
func hasActressEmpty(ctx context.Context) bool {
n, _ := actress.CountActresss(ctx, slugx.EmptyFilter())
return n > 0
}
func hasVideoCreaterEmpty(ctx context.Context) bool {
n, _ := videocreatermod.CountVideoCreaterInfos(ctx, slugx.EmptyFilter())
return n > 0
}
func hasPlayletEmpty(ctx context.Context) bool {
n, _ := playletmod.CountPlaylet(ctx, slugx.EmptyFilter())
return n > 0
}
func hasPlayletCategoryEmpty(ctx context.Context) bool {
n, _ := playletcategorymod.CountPlayletCategory(ctx, slugx.EmptyFilter())
return n > 0
}
func hasPlayletThemeEmpty(ctx context.Context) bool {
n, _ := playletthememod.CountPlayletTheme(ctx, slugx.EmptyFilter())
return n > 0
}
func hasContentTagEmpty(ctx context.Context) bool {
n, _ := contenttagmod.CountContentTag(ctx, slugx.EmptyFilter())
return n > 0
}
func syncVideoEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videoinformationmod.FindManyVideoInformations(ctx, videoEmptySlugFilter(ctx), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*videoinformationmod.VideoInformation(list), maxSize,
func(item *videoinformationmod.VideoInformation) int64 { return item.Id },
func(item *videoinformationmod.VideoInformation) string {
return utils.SlugSourceFromKeywordsOrTitle(item.Keyworld, item.Title)
},
func(s string, excludeId int64) bool {
doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter(
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, videoinformationmod.UpdateOneVideoInformation, videoinformationmod.FN.VideoInformation.Id,
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.UpdatedAt, id, slug)
},
)
}
func syncVideoCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videocategorymod.FindManyVideoCategorys(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*videocategorymod.VideoCategory(list), maxSize,
func(item *videocategorymod.VideoCategory) int64 { return item.Id },
func(item *videocategorymod.VideoCategory) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name)
},
func(s string, excludeId int64) bool {
doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter(
videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, videocategorymod.UpdateOneVideoCategory, videocategorymod.FN.VideoCategory.Id,
videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.UpdatedAt, id, slug)
},
)
}
func syncActressEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := actress.FindManyActresss(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*actress.Actress(list), maxSize,
func(item *actress.Actress) int64 { return item.Id },
func(item *actress.Actress) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Alias)
},
func(s string, excludeId int64) bool {
doc, _ := actress.FindOneActress(ctx, slugx.Filter(actress.FN.Actress.Slug, actress.FN.Actress.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, actress.UpdateOneActress, actress.FN.Actress.Id,
actress.FN.Actress.Slug, actress.FN.Actress.UpdatedAt, id, slug)
},
)
}
func syncVideoCreaterEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*videocreatermod.VideoCreaterInfo(list), maxSize,
func(item *videocreatermod.VideoCreaterInfo) int64 { return item.Id },
func(item *videocreatermod.VideoCreaterInfo) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name)
},
func(s string, excludeId int64) bool {
doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter(
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, videocreatermod.UpdateOneVideoCreaterInfo, videocreatermod.FN.VideoCreaterInfo.Id,
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.UpdatedAt, id, slug)
},
)
}
func syncPlayletEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playletmod.FindManyPlaylet(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*playletmod.Playlet(list), maxSize,
func(item *playletmod.Playlet) int64 { return item.Id },
func(item *playletmod.Playlet) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Title)
},
func(s string, excludeId int64) bool {
doc, _ := playletmod.FindOnePlaylet(ctx, slugx.Filter(playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, playletmod.UpdateOnePlaylet, playletmod.FN.Playlet.Id,
playletmod.FN.Playlet.Slug, playletmod.FN.Playlet.UpdatedAt, id, slug)
},
)
}
func syncPlayletCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playletcategorymod.FindManyPlayletCategory(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*playletcategorymod.PlayletCategory(list), maxSize,
func(item *playletcategorymod.PlayletCategory) int64 { return item.Id },
func(item *playletcategorymod.PlayletCategory) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name)
},
func(s string, excludeId int64) bool {
doc, _ := playletcategorymod.FindOnePlayletCategory(ctx, slugx.Filter(
playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, playletcategorymod.UpdateOnePlayletCategory, playletcategorymod.FN.PlayletCategory.Id,
playletcategorymod.FN.PlayletCategory.Slug, playletcategorymod.FN.PlayletCategory.UpdatedAt, id, slug)
},
)
}
func syncPlayletThemeEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playletthememod.FindManyPlayletTheme(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*playletthememod.PlayletTheme(list), maxSize,
func(item *playletthememod.PlayletTheme) int64 { return item.Id },
func(item *playletthememod.PlayletTheme) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name)
},
func(s string, excludeId int64) bool {
doc, _ := playletthememod.FindOnePlayletTheme(ctx, slugx.Filter(
playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, playletthememod.UpdateOnePlayletTheme, playletthememod.FN.PlayletTheme.Id,
playletthememod.FN.PlayletTheme.Slug, playletthememod.FN.PlayletTheme.UpdatedAt, id, slug)
},
)
}
func syncContentTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := contenttagmod.FindManyContentTag(ctx, slugx.EmptyFilter(), findLimit(maxSize))
if err != nil {
return 0, false, err
}
return fillEmptySlugs(ctx, []*contenttagmod.ContentTag(list), maxSize,
func(item *contenttagmod.ContentTag) int64 { return item.Id },
func(item *contenttagmod.ContentTag) string {
return utils.SlugSourceFromKeywordsOrTitle(item.SeoInfo.Keyworld, item.Name)
},
func(s string, excludeId int64) bool {
doc, _ := contenttagmod.FindOneContentTag(ctx, slugx.Filter(
contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.Id, s, excludeId))
return doc != nil
},
func(ctx context.Context, id int64, slug string) *srvdef.SrvErr {
return setSlug(ctx, contenttagmod.UpdateOneContentTag, contenttagmod.FN.ContentTag.Id,
contenttagmod.FN.ContentTag.Slug, contenttagmod.FN.ContentTag.UpdatedAt, id, slug)
},
)
}
+36
View File
@@ -0,0 +1,36 @@
package srv_translation
import (
"time"
"videoweb/pub/models/translationmod"
"videoweb/pub/pubdef"
)
type TranslationInfoReq struct {
pubdef.Page
Id int64 `json:"id" form:"id"` // 导航ID
Name string `json:"name" form:"name"` // 导航名称
TransCategoryId int64 `json:"transCategoryId" form:"transCategoryId"` // 导航分类ID
}
type TranslationInfoResp struct {
Total int64 `json:"total"`
List translationmod.TranslationInfoList `json:"list"`
}
type TranslationInfoUpdate struct {
Name *string `json:"name" bson:"name,omitempty"` // 分类名称
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序
HtmlId *int64 `json:"htmlId" bson:"htmlId,omitempty"` // 富文本ID
Link *string `json:"link" bson:"link,omitempty"` // 外部跳转
IsFriendAds *bool `json:"isFriendAds" bson:"isFriendAds"` // 是否是友链广告位
TransCategoryId *int64 `json:"transCategoryId" bson:"transCategoryId,omitempty"` //
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
}
type TranslationInfoUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param TranslationInfoUpdate `json:"param" bson:"param"` // appId,唯一id
}
type TranslationInfoDelReq struct {
Id int64 `json:"id" binding:"required"`
}
@@ -0,0 +1,93 @@
package srv_translation
import (
"context"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/translationmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetTranslationInfos 按照参数搜索视频标签
func GetTranslationInfos(ctx context.Context, param *TranslationInfoReq) (*TranslationInfoResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &TranslationInfoResp{}
if param.Id > 0 {
filter[translationmod.FN.TranslationInfo.Id] = param.Id
}
if name := strings.TrimSpace(param.Name); name != "" {
filter[translationmod.FN.TranslationInfo.Name] = bson.M{"$regex": name}
}
if param.TransCategoryId > 0 {
filter[translationmod.FN.TranslationInfo.TransCategoryId] = param.TransCategoryId
}
opt.SetSort(bson.M{translationmod.FN.TranslationInfo.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, err1 := translationmod.CountTranslationInfos(ctx, filter)
if err1 != nil {
return resp, err1
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, err1 := translationmod.FindManyTranslationInfos(ctx, filter, opt)
if err1 != nil {
return resp, err1
}
resp.List = ads
return resp, nil
}
// AddTranslationInfo 添加一视频标签
func AddTranslationInfo(ctx context.Context,
ac *translationmod.TranslationInfo) *srvdef.SrvErr {
ac.Id = mongos.Lid.Translation.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
serr := translationmod.InsertOneTranslationInfo(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelTranslationInfo 删除指定视频标签
func DelTranslationInfo(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{translationmod.FN.TranslationInfo.Id: id}
//删除
count, serr := translationmod.DelOneTranslationInfo(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateTranslationInfo 更新指定视频标签
func UpdateTranslationInfo(ctx context.Context, id int64, param TranslationInfoUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{translationmod.FN.TranslationInfo.Id: id}
updateDoc := bson.M{"$set": param}
serr := translationmod.UpdateOneTranslationInfo(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
@@ -0,0 +1,35 @@
package srv_translationcategory
import (
"time"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/pubdef"
)
type TranslationCategoryReq struct {
pubdef.Page
Id int64 `json:"id" form:"id"` // 导航分类ID
Name string `json:"name" form:"name"` // 导航分类名称
}
type TranslationCategoryResp struct {
Total int64 `json:"total"`
List translationcategorymod.TranslationCategoryList `json:"list"`
}
type TranslationCategoryUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Name *string `json:"name" bson:"name,omitempty"` // 名称
Icon *string `json:"icon" bson:"icon,omitempty"` // Icon
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Rank *int64 `json:"rank" bson:"rank,omitempty"` //
CategoryType *int `json:"categoryType" bson:"categoryType"` // 导航类型 1=url跳转 2=二级导航 3=底部导航
UrlValue *string `json:"urlValue" bson:"urlValue"` // 跳转URL
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
}
type TranslationCategoryUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param TranslationCategoryUpdate `json:"param" bson:"param"` // appId,唯一id
}
type TranslationCategoryDelReq struct {
Id int64 `json:"id" binding:"required"`
}
@@ -0,0 +1,90 @@
package srv_translationcategory
import (
"context"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetTranslationCategorys 按照参数搜索视频标签
func GetTranslationCategorys(ctx context.Context, param *TranslationCategoryReq) (*TranslationCategoryResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &TranslationCategoryResp{}
if param.Id > 0 {
filter[translationcategorymod.FN.TranslationCategory.Id] = param.Id
}
if name := strings.TrimSpace(param.Name); name != "" {
filter[translationcategorymod.FN.TranslationCategory.Name] = bson.M{"$regex": name}
}
opt.SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, err1 := translationcategorymod.CountTranslationCategorys(ctx, filter)
if err1 != nil {
return resp, err1
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, err1 := translationcategorymod.FindManyTranslationCategorys(ctx, filter, opt)
if err1 != nil {
return resp, err1
}
resp.List = ads
return resp, nil
}
// AddTranslationCategory 添加一视频标签
func AddTranslationCategory(ctx context.Context,
ac *translationcategorymod.TranslationCategory) *srvdef.SrvErr {
ac.Id = mongos.Lid.TransCategory.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
serr := translationcategorymod.InsertOneTranslationCategory(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelTranslationCategory 删除指定视频标签
func DelTranslationCategory(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id}
//删除
count, serr := translationcategorymod.DelOneTranslationCategory(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateTranslationCategory 更新指定视频标签
func UpdateTranslationCategory(ctx context.Context, id int64, param TranslationCategoryUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id}
updateDoc := bson.M{"$set": param}
serr := translationcategorymod.UpdateOneTranslationCategory(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package srv_user
import (
"videoweb/pub/models/usermod"
"videoweb/pub/pubdef"
)
type UserListReq struct {
pubdef.Page
Id *int64 `json:"id"` // 用户ID
NickName string `json:"nickName"` // 昵称(模糊)
Status *pubdef.UserStatus `json:"status"` // 状态
}
type UserListResp struct {
Total int64 `json:"total"`
List usermod.UserList `json:"list"`
}
type UserUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param usermod.UserUpdate `json:"param"`
}
+56
View File
@@ -0,0 +1,56 @@
package srv_user
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/usermod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetUsers 分页查询用户列表
func GetUsers(ctx context.Context, req *UserListReq) (*UserListResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[usermod.FN.User.Id] = *req.Id
}
if req.NickName != "" {
filter[usermod.FN.User.NickName] = bson.M{"$regex": req.NickName}
}
if req.Status != nil {
filter[usermod.FN.User.Status] = *req.Status
}
opts := options.Find().
SetSort(bson.M{usermod.FN.User.Id: -1}).
SetSkip((req.PageNum - 1) * req.PageSize).
SetLimit(req.PageSize)
resp := &UserListResp{}
total, serr := usermod.CountUsers(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
list, serr := usermod.FindManyUser(ctx, filter, opts)
if serr != nil {
return resp, serr
}
resp.List = list
return resp, nil
}
// UpdateUser 更新指定用户信息
func UpdateUser(ctx context.Context, id int64, param usermod.UserUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = &now
return usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: id},
bson.M{"$set": param},
)
}
+172
View File
@@ -0,0 +1,172 @@
package srv_videocategory
import (
"context"
"time"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/mongos"
"videoweb/web/service/srv_contenttag"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoCategorys 按照参数搜索视频分类
func GetVideoCategorys(ctx context.Context, param *VideoCategoryReq) (*VideoCategoryResp, *srvdef.SrvErr) {
filter := bson.M{}
if param.Id != nil && *param.Id > 0 {
filter[videocategorymod.FN.VideoCategory.Id] = *param.Id
}
if param.Name != nil && *param.Name != "" {
filter[videocategorymod.FN.VideoCategory.Name] = bson.M{"$regex": *param.Name}
}
if param.DisplayPosition != nil {
filter[videocategorymod.FN.VideoCategory.DisplayPosition] = *param.DisplayPosition
}
if param.SortType != nil {
filter[videocategorymod.FN.VideoCategory.SortType] = *param.SortType
}
opt := &options.FindOptions{}
resp := &VideoCategoryResp{}
opt.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videocategorymod.CountVideoCategorys(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videocategorymod.FindManyVideoCategorys(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoCategory 添加一个视频分类
func AddVideoCategory(ctx context.Context,
ac *videocategorymod.VideoCategory) *srvdef.SrvErr {
//if ac.Name != "" {
// count, serr := videocategorymod.CountVideoCategorys(ctx, bson.M{videocategorymod.FN.VideoCategory.Name: ac.Name})
// if serr != nil {
// return serr
// }
// if count > 0 {
// return srvdef.New(srvdef.ErrDbInputExist, "视频分类已存在")
// }
//}
ac.Id = mongos.Lid.VideoCategory.Get()
if ac.Tags == nil {
ac.Tags = make([]int64, 0)
}
tagIds, err := srv_contenttag.NormalizeContentTagIds(ac.Tags)
if err != nil {
return err
}
if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil {
return err
}
ac.Tags = tagIds
exists := func(s string) bool {
doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter(
videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, 0))
return doc != nil
}
if serr := slugx.Ensure(&ac.Slug, utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name), exists); serr != nil {
return serr
}
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
serr := videocategorymod.InsertOneVideoCategory(ctx, ac)
if serr != nil {
return serr
}
playletcache.ClearHomeCache()
return nil
}
// DelVideoCategory 删除指定视频分类
func DelVideoCategory(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id}
//删除
count, serr := videocategorymod.DelOneVideoCategory(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
playletcache.ClearHomeCache()
return nil
}
// UpdateVideoCategory 更新指定视频分类
func UpdateVideoCategory(ctx context.Context, id int64, param VideoCategoryUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id}
current, serr := videocategorymod.FindOneVideoCategory(ctx, adFilter)
if serr != nil {
return serr
}
name := current.Name
if param.Name != nil {
name = *param.Name
}
keywords := current.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
exists := func(s string) bool {
doc, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter(
videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, s, id))
return doc != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, param.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists)
if serr != nil {
return serr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
if param.Tags != nil {
tagIds, err := srv_contenttag.NormalizeContentTagIds(*param.Tags)
if err != nil {
return err
}
if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil {
return err
}
param.Tags = &tagIds
}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videocategorymod.UpdateOneVideoCategory(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
playletcache.ClearHomeCache()
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package srv_videocategory
import (
"time"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/pubdef"
)
type VideoCategoryReq struct {
pubdef.Page
Id *int64 `json:"id"` // 分类 ID
Name *string `json:"name"` // 分类名称(模糊匹配)
DisplayPosition *pubdef.CategoryDisplayPosition `json:"displayPosition" bson:"displayPosition"` // 显示位置 0=仅TAB 1=仅首页 2=两者都显示
SortType *pubdef.CategorySortType `json:"sortType" bson:"sortType"` // 排序方式
}
type VideoCategoryResp struct {
Total int64 `json:"total"`
List videocategorymod.VideoCategoryList `json:"list"`
}
type VideoCategoryUpdate struct {
Name *string `json:"name" bson:"name,omitempty"` // 名称
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Title *string `json:"title" bson:"title,omitempty"` // APP显示
Desc *string `json:"desc" bson:"desc,omitempty"` // 描述
DisplayPosition *pubdef.CategoryDisplayPosition `json:"displayPosition" bson:"displayPosition,omitempty"` // 显示位置 0=仅TAB 1=仅首页 2=两者都显示
SortType *pubdef.CategorySortType `json:"sortType" bson:"sortType,omitempty"` // 排序方式
Tags *[]int64 `json:"tags" bson:"tags,omitempty"` // 关联视频标签
Rank *int64 `json:"rank" bson:"rank,omitempty"` //
TranslationCategoryId *int64 `json:"translationCategoryId" bson:"translationCategoryId,omitempty"` // 导航分类
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
DisplayMode *pubdef.DisplayMode `json:"displayMode" bson:"displayMode,omitempty"` // 展示方式
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow,omitempty"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
}
type VideoCategoryUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoCategoryUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoCategoryDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+42
View File
@@ -0,0 +1,42 @@
package srv_videocollection
import (
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
)
type VideoCollectionOptionMediaReq struct {
CollectionID int64 `json:"collectionID" bson:"collectionID"` // 合集ID
IsAdd bool `json:"isAdd" bson:"isAdd"` // 增加还是移除
VideoIds []int64 `json:"videoIds" bson:"videoIds"` // 视频ID
}
type UpdateCollectionMediaRankReq struct {
CollectionID int64 `json:"collectionID" bson:"collectionID"` //合集ID
VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID
Rank int64 `json:"rank" bson:"rank"` // 排序
}
type GetCollectionMediaListReq struct {
CollectionID int64 `json:"collectionID" bson:"collectionID"`
pubdef.Page
}
type GetCollectionMediaListResp struct {
Total int64 `json:"total" bson:"total"`
List []CollectionMediaInfo `json:"list" bson:"list"`
}
type CollectionMediaInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Title string `json:"title" bson:"title"` // 帖子标题
CoverImg string `bson:"coverImg,omitempty" json:"coverImg"` // 封面
Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述
Remark string `bson:"remark,omitempty" json:"remark"` // 备注
Nodes []videoinformationmod.Node `json:"nodes" bson:"nodes"` // 节点
Status pubdef.InformationStatus `json:"status" bson:"status"` // 帖子上架状态
Video videoinformationmod.VideoInfo `json:"video" bson:"video"` // 视频
Rank int64 `json:"rank" bson:"rank"` // 排序
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间
CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间
}
@@ -0,0 +1,166 @@
package srv_videocollection
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videocollectionmediamod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func VideoCollectionOptionMedia(ctx context.Context, req *VideoCollectionOptionMediaReq) *srvdef.SrvErr {
_, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: req.CollectionID,
videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_Collection,
})
if err != nil {
return err
}
if req.IsAdd {
// 追加视频
videos, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: bson.M{"$in": req.VideoIds},
})
if err != nil {
return err
}
existVideoIds := make(map[int64]struct{}, len(videos))
for _, video := range videos {
existVideoIds[video.VideoId] = struct{}{}
}
addIds := make([]int64, 0, len(req.VideoIds))
for _, id := range req.VideoIds {
if _, ok := existVideoIds[id]; !ok {
addIds = append(addIds, id)
existVideoIds[id] = struct{}{}
}
}
if len(addIds) > 0 {
now := time.Now()
addInfos := make(videocollectionmediamod.VideoCollectionMediaModelList, 0, len(addIds))
for _, id := range addIds {
addInfos = append(addInfos, &videocollectionmediamod.VideoCollectionMediaModel{
ID: primitive.NewObjectID(),
CollectionID: req.CollectionID,
VideoId: id,
Status: pubdef.InformationStatusVerifyOK,
Rank: 0,
CreatedAt: now,
UpdatedAt: now,
})
}
err = videocollectionmediamod.InsertManyVideoCollectionMedia(ctx, addInfos)
if err != nil {
return err
}
}
} else {
// 移除视频
_, err = videocollectionmediamod.DelManyVideoCollectionMediaModel(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: bson.M{"$in": req.VideoIds},
})
if err != nil {
return err
}
}
count, err := videocollectionmediamod.CountVideoCollectionMediaModels(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
})
if err == nil {
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: req.CollectionID,
}, bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.VideoCount: count,
videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(),
}})
}
return nil
}
func UpdateCollectionMediaRank(ctx context.Context, req *UpdateCollectionMediaRankReq) *srvdef.SrvErr {
err := videocollectionmediamod.UpdateOneVideoCollectionMediaModel(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: req.VideoId,
}, bson.M{"$set": bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.Rank: req.Rank,
}})
return err
}
func GetCollectionMediaList(ctx context.Context, req *GetCollectionMediaListReq) (*GetCollectionMediaListResp, *srvdef.SrvErr) {
_, err := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: req.CollectionID,
videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_Collection,
})
if err != nil {
return nil, err
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{videocollectionmediamod.FN.VideoCollectionMediaModel.Rank: 1})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
total, err := videocollectionmediamod.CountVideoCollectionMediaModels(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
})
if err != nil {
return nil, err
}
if total == 0 {
return &GetCollectionMediaListResp{
Total: 0,
List: make([]CollectionMediaInfo, 0),
}, nil
}
videos, err := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.CollectionID: req.CollectionID,
}, opts)
if err != nil {
return nil, err
}
videoIds := make([]int64, 0, len(videos))
for _, video := range videos {
videoIds = append(videoIds, video.VideoId)
}
videoInfos, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds},
})
if err != nil {
return nil, err
}
videoInfoMap := make(map[int64]*videoinformationmod.VideoInformation, len(videoInfos))
for _, info := range videoInfos {
videoInfoMap[info.Id] = info
}
infos := make([]CollectionMediaInfo, 0, len(videos))
for _, video := range videos {
info, ok := videoInfoMap[video.VideoId]
if !ok {
continue
}
infos = append(infos, CollectionMediaInfo{
Id: video.VideoId,
Title: info.Title,
CoverImg: info.CoverImg,
Desc: info.Desc,
Remark: info.Remark,
Nodes: info.Nodes,
Status: info.Status,
Video: info.Video,
Rank: video.Rank,
UpdatedAt: video.UpdatedAt,
CreatedAt: video.CreatedAt,
})
}
resp := &GetCollectionMediaListResp{
Total: total,
List: infos,
}
return resp, nil
}
+126
View File
@@ -0,0 +1,126 @@
package srv_videocreater
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoCreaters 按照参数搜索视频标签
func GetVideoCreaters(ctx context.Context, param *VideoCreaterReq) (*VideoCreaterResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &VideoCreaterResp{}
opt.SetSort(bson.M{videocreatermod.FN.VideoCreaterInfo.Id: 1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videocreatermod.CountVideoCreaterInfos(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videocreatermod.FindManyVideoCreaterInfos(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoCreater 添加一视频标签
func AddVideoCreater(ctx context.Context,
ac *videocreatermod.VideoCreaterInfo) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoCreator.Get()
exists := func(s string) bool {
doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter(
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, 0))
return doc != nil
}
if serr := slugx.Ensure(&ac.Slug, utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name), exists); serr != nil {
return serr
}
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
log.Info("InsertOneVideoCreaterInfo", log.Any("", *ac))
serr := videocreatermod.InsertOneVideoCreaterInfo(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoCreater 删除指定视频标签
func DelVideoCreater(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id}
//删除
count, serr := videocreatermod.DelOneVideoCreaterInfo(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoCreater 更新指定视频标签
func UpdateVideoCreater(ctx context.Context, id int64, param VideoCreaterUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id}
current, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, adFilter)
if serr != nil {
return serr
}
name := current.Name
if param.Name != nil {
name = *param.Name
}
keywords := current.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
exists := func(s string) bool {
doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter(
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, id))
return doc != nil
}
slug, changed, serr := slugx.PrepareOnUpdate(current.Slug, param.Slug,
utils.SlugSourceFromKeywordsOrTitle(keywords, name), exists)
if serr != nil {
return serr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videocreatermod.UpdateOneVideoCreaterInfo(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package srv_videocreater
import (
"time"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/pubdef"
)
type VideoCreaterReq struct {
pubdef.Page
}
type VideoCreaterResp struct {
Total int64 `json:"total"`
List videocreatermod.VideoCreaterInfoList `json:"list"`
}
type VideoCreaterUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Name *string `json:"name" bson:"name,omitempty"` // 名称
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
MediaTypes *[]int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频
IndexNow *int64 `bson:"indexNow,omitempty" json:"-"`
Works *int64 `json:"works" bson:"works,omitempty"` // 作品数量
Image *string `json:"image" bson:"image,omitempty"` // 头像
Background *string `json:"background" bson:"background,omitempty"` // 背景
CreateType *int64 `json:"createType" bson:"createType,omitempty"` // 创作者类型 0=普通 1=官方
Desc *string `json:"desc" bson:"desc,omitempty"` // 简介
}
type VideoCreaterUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoCreaterUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoCreaterDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+166
View File
@@ -0,0 +1,166 @@
package srv_videoinfo
import (
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
)
type VideoInfoReq struct {
Id *int64 `json:"id"` //
ActressIds *[]int64 `json:"actressIds" bson:"actressIds"` // 女优ID
Title *string `json:"title"` // 标题
Bango *string `json:"bango"` // 番号,
CreatorId *int64 `json:"creatorId"` // 作者Id
Status *pubdef.InformationStatus `json:"status"` // 帖子上架状态
Tag *int64 `json:"tag" ` // 标签
IsHot *bool `json:"isHot"` // 是否热门
IsRecommend *bool `json:"isRecommend"` // 是否推荐 true-推荐 false-不推荐
MediaType *videoinformationmod.MediaType `json:"mediaType"` // 资源类型
CreatorName *string `json:"creatorName"` // 作者名称
Keyworld *string `json:"keyworld"` // 关键字
CategoryId *int64 `json:"categoryId"` // 分类ID
VideoTaskId *string `json:"videoTaskId"` // 视频任务id
IsTop *bool `json:"isTop" bson:"isTop"` // 是否置顶
VideoFileId *string `json:"videoFileId"` // 切片id(切片列表的ID)
Sort int64 `json:"sort"` // 排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数。按分类查询且排序方式为排序值时,使用该分类的 categoryRanks 排序
pubdef.Page
StartTime *string `json:"startTime"` // 创建时间-开始
EndTime *string `json:"endTime"` // 创建时间-截止
OptionAccount *string `json:"optionAccount"` // 最后操作人
}
type VideoInfoResp struct {
Total int64 `json:"total"`
List videoinformationmod.VideoInformationList `json:"list"`
}
type AddVideoReq struct {
FileIds []string `json:"fileId"` // 资源id
}
type SyncMediaFileReq struct {
FileIds []string `json:"fileId"` // 媒资库影片 ID
SourceVersion string `json:"sourceVersion,omitempty"` // v1 / v2
FirstTagMap map[string]string `json:"firstTagMap,omitempty"` // 覆盖一级标签
MinVideoSeconds int32 `json:"minVideoSeconds,omitempty"` // 最短时长,0 表示不限制
VideoList []SyncMediaFileItem `json:"videoList,omitempty"` // 媒资库搜索结果,优先使用
}
type SyncMediaFileItem struct {
Id string `json:"id"`
Title string `json:"title"`
FieldNameFs string `json:"fieldNameFs"`
FirstTag string `json:"firstTag"`
SecondTags []string `json:"secondTags"`
VideoTime int32 `json:"videoTime"`
CoverImg string `json:"coverImg"`
Publish string `json:"publish"`
Bango string `json:"bango"`
TaskID string `json:"taskId"`
Keyworld []string `json:"keyworld"`
Width int64 `json:"width"`
Height int64 `json:"height"`
}
type SyncMediaFileResp struct {
Synced int `json:"synced"`
Failed int `json:"failed"`
}
type VideoInfoUpdate struct {
Title *string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题
Slug *string `json:"slug,omitempty" bson:"slug,omitempty"` // slug
ActressIds *[]int64 `json:"actressIds" bson:"actressIds,omitempty"` // 女优ID
AddedTime *time.Time `json:"addedTime,omitempty" bson:"addedTime,omitempty"`
CreatorId *int64 `json:"creatorId,omitempty" bson:"creatorId,omitempty"` // 作者Id
Keyworld *[]string `bson:"keyworld,omitempty" json:"keyworld,omitempty"` //
MediaType *videoinformationmod.MediaType `json:"mediaType,omitempty" bson:"mediaType,omitempty"` // 资源类型
FirstTag *string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签
SecondTags *[]string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签
CoverImg *string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面
UnencryptedImage *string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面
Desc *string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述
Remark *string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注
Nodes *[]videoinformationmod.Node `json:"nodes,omitempty" bson:"nodes,omitempty"` // 节点
Status *pubdef.InformationStatus `json:"status,omitempty" bson:"status,omitempty"` // 帖子上架状态
Sort *int64 `json:"sort,omitempty" bson:"sort,omitempty"` // 排序
CategoryId *int64 `json:"categoryId" bson:"categoryId,omitempty"` // 分类ID
CategoryRanks *[]videoinformationmod.VideoCategoryRank `json:"categoryRanks" bson:"categoryRanks,omitempty"` // 各分类下的排序,传空数组表示清空
Cover *string `json:"cover,omitempty" bson:"cover,omitempty"` // 视频封面
Height *int64 `json:"height,omitempty" bson:"height,omitempty"` // 分辨率高(视频才会存在)
Width *int64 `json:"width,omitempty" bson:"width,omitempty"` // 分辨率宽(视频才会存在)
VideoTime *int32 `bson:"videoTime,omitempty" json:"videoTime,omitempty"` // 影片长度
Tags *[]int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签
IsHot *bool `json:"isHot,omitempty" bson:"isHot,omitempty"` // 是否热门
IsRecommend *bool `json:"isRecommend,omitempty" bson:"isRecommend,omitempty"` // 是否推荐 true-推荐 false-不推荐
UpdatedAt *time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` // 更新时间
CreatorName *string `bson:"creatorName,omitempty"` // 作者名
IsTop *bool `json:"isTop,omitempty" bson:"isTop,omitempty"` // 是否置顶
Video *videoinformationmod.VideoInfo `bson:"video,omitempty"` // 视频
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
IssuedAt *time.Time `json:"issuedAt" bson:"issuedAt"` // 发行时间
IssuedBrand *string `json:"issuedBrand" bson:"issuedBrand"` // 发行商
Bango *string `bson:"bango,omitempty" json:"bango"` // 番号,唯一
SecondUpdatedAt *time.Time `json:"secondUpdatedAt,omitempty" bson:"secondUpdatedAt,omitempty"` // 二次修改时间
}
type VideoInfoUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoInfoUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoInfoDelReq struct {
Id int64 `json:"id" binding:"required"`
}
type AddVideoCollectionReq struct {
Title string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题
Keyworld []string `bson:"keyworld,omitempty" json:"keyworld,omitempty"` // 关键字
FirstTag string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签
SecondTags []string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签
CoverImg string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面
Desc string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述
Remark string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注
Tags []int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签
}
type VideoAddTagsReq struct {
VideoIds []int64 `json:"videoIds" binding:"required"` // 视频ID集合
Tags []int64 `json:"tags" binding:"required"` // 统一标签ID集合
}
type VideoSetTagsReq struct {
Ids []int64 `json:"ids" binding:"required"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置
TagIds *[]int64 `json:"tagIds"` // 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误
}
type SetFakeLikesReq struct {
Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量
LikesMin int64 `json:"likesMin"` // 假点赞数随机最小值,包含该值
LikesMax int64 `json:"likesMax"` // 假点赞数随机最大值,包含该值
}
type SetFakeWatchTimesReq struct {
Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量
WatchTimesMin int64 `json:"watchTimesMin"` // 假观看次数随机最小值,包含该值
WatchTimesMax int64 `json:"watchTimesMax"` // 假观看次数随机最大值,包含该值
}
// SetVideoHeatReq 单/批量设置视频热度
type SetVideoHeatReq struct {
Ids []int64 `json:"ids"` // 视频ID集合
HeatMin int64 `json:"heatMin"` // 热度最小值
HeatMax int64 `json:"heatMax"` // 热度最大值
}
type SetRecommendReq struct {
Ids []int64 `json:"ids"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置
IsRecommend bool `json:"isRecommend"` // 是否推荐 true-推荐 false-不推荐
}
type SetFakeCountResp struct {
MatchedCount int64 `json:"matchedCount"` // 匹配到的视频数量
ModifiedCount int64 `json:"modifiedCount"` // 实际修改的视频数量
}
+152
View File
@@ -0,0 +1,152 @@
package srv_videoinfo
import (
"context"
"fmt"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
type ExternalVideoListReq struct {
pubdef.Page
Date string `form:"date"` // 日期筛选,格式:2006-01-02
Publisher string `form:"publisher"` // 发布人
}
type ExternalVideoListItem struct {
Date string `json:"date"`
Publisher string `json:"publisher"`
SiteName string `json:"siteName"`
Title string `json:"title"`
Keyworld []string `json:"keyworld"`
Tags []string `json:"tags"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Url string `json:"url"`
Content []string `json:"content"`
UploadUserName string `json:"uploadUserName"`
}
type ExternalVideoListResp struct {
Total int64 `json:"total"`
List []ExternalVideoListItem `json:"list"`
}
// SearchExternalVideoList 按日期查询已上架视频,供外部数据接口使用。
func SearchExternalVideoList(ctx context.Context, req *ExternalVideoListReq) (*ExternalVideoListResp, *srvdef.SrvErr) {
cst := time.FixedZone("CST", 8*3600)
var dayStart, dayEnd time.Time
if req.Date != "" {
t, err := time.ParseInLocation("2006-01-02", req.Date, cst)
if err == nil {
dayStart = t
} else {
dayStart = time.Now().In(cst).Truncate(24 * time.Hour).Add(-24 * time.Hour)
}
} else {
now := time.Now().In(cst)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, cst)
dayStart = today.Add(-24 * time.Hour)
}
dayEnd = dayStart.Add(24 * time.Hour)
filter := bson.M{
videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK,
"$or": bson.A{
bson.M{
videoinformationmod.FN.VideoInformation.AddedTime: bson.M{
"$gte": dayStart.UTC(),
"$lt": dayEnd.UTC(),
},
},
bson.M{
videoinformationmod.FN.VideoInformation.SecondUpdatedAt: bson.M{
"$gte": dayStart.UTC(),
"$lt": dayEnd.UTC(),
},
},
},
}
if req.Publisher != "" {
filter[videoinformationmod.FN.VideoInformation.UploadUserName] = req.Publisher
}
total, serr := videoinformationmod.CountVideoInformations(ctx, filter)
if serr != nil {
log.Warn("SearchExternalVideoList CountVideoInformations failed", log.E(serr))
return nil, serr
}
pageSize := req.Page.PageSize
if pageSize <= 0 {
pageSize = 1000
}
pageNum := req.Page.PageNum
if pageNum <= 0 {
pageNum = 1
}
opts := &options.FindOptions{}
opts.SetLimit(pageSize)
opts.SetSkip((pageNum - 1) * pageSize)
opts.SetSort(bson.D{{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}})
list, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if serr != nil {
log.Warn("SearchExternalVideoList FindManyVideoInformations failed", log.E(serr))
return nil, serr
}
tagIdSet := make(map[int64]struct{})
for _, v := range list {
for _, tid := range v.Tags {
tagIdSet[tid] = struct{}{}
}
}
tagTitleMap := make(map[int64]string)
if len(tagIdSet) > 0 {
tagIds := make([]int64, 0, len(tagIdSet))
for tid := range tagIdSet {
tagIds = append(tagIds, tid)
}
tags, _ := contenttagmod.GetMapTags(ctx, tagIds)
for _, t := range tags {
tagTitleMap[t.Id] = t.Name
}
}
items := make([]ExternalVideoListItem, 0, len(list))
for _, v := range list {
tagTitles := make([]string, 0, len(v.Tags))
for _, tid := range v.Tags {
if title, ok := tagTitleMap[tid]; ok {
tagTitles = append(tagTitles, title)
}
}
var content []string
for _, node := range v.Nodes {
content = append(content, node.Text)
}
items = append(items, ExternalVideoListItem{
Date: utils.GetDateString(v.AddedTime),
CreatedAt: utils.GetTimeString(v.CreatedAt),
UpdatedAt: utils.GetTimeString(v.SecondUpdatedAt),
Publisher: v.OptionAccount,
SiteName: pubdef.AppName,
Title: v.Title,
Keyworld: v.Keyworld,
Tags: tagTitles,
Url: fmt.Sprintf("/video/detail?id=%d", v.Id),
Content: content,
UploadUserName: v.UploadUserName,
})
}
return &ExternalVideoListResp{Total: total, List: items}, nil
}
+160
View File
@@ -0,0 +1,160 @@
package srv_videoinfo
import (
"context"
"fmt"
"strings"
"videoweb/pub/common/laosiji"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/videoinformationmod"
"videoweb/web/webg"
"go.mongodb.org/mongo-driver/bson"
)
// TranscodeVideo 手动提交主视频 H265 转码。
func TranscodeVideo(ctx context.Context, videoId int64) *srvdef.SrvErr {
info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
})
if serr != nil {
return serr
}
src := strings.TrimSpace(info.Video.Url)
if src == "" {
return srvdef.New(srvdef.Err, "", "该视频没有可转码的源地址")
}
if laosiji.LaosijiClient == nil || laosiji.LaosijiDomains == nil {
return srvdef.New(srvdef.Err, "", "转码服务未就绪")
}
if info.HevcStatus == videoinformationmod.HevcStatusPending {
return srvdef.New(srvdef.Err, "", "转码请求已提交,正在转码中,请勿重复提交")
}
if err := SubmitTranscodeVideo(ctx, videoId, src, info.Video.FileId); err != nil {
return srvdef.New(srvdef.Err, "", "提交转码失败:"+err.Error())
}
return nil
}
func buildTranscodeSourceURL(videoUrl string) string {
name := strings.TrimSpace(videoUrl)
if name == "" {
return ""
}
if !strings.Contains(name, ".m3u8") {
name += ".m3u8"
}
host := strings.TrimSpace(webg.Conf.WebM3u8Host)
if host == "" {
return ""
}
return utils.JoinUrlPath(host, "/api/web/m3u8/"+name)
}
// SubmitTranscodeVideo 提交主视频转码任务。
func SubmitTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error {
fileURL := buildTranscodeSourceURL(src)
if fileURL == "" {
return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host)
}
sd, err := laosiji.LaosijiDomains.Get(ctx)
if err != nil {
return err
}
result, err := laosiji.LaosijiClient.SubmitTranscoding(ctx, fileURL, fileId, *sd, videoId)
if err != nil {
return err
}
return applyTranscodeResult(ctx, videoId, result)
}
// RefreshTranscodeVideo 查询转码任务状态并回写。
func RefreshTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error {
fileURL := buildTranscodeSourceURL(src)
if fileURL == "" {
return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host)
}
sd, err := laosiji.LaosijiDomains.Get(ctx)
if err != nil {
return err
}
result, found, err := laosiji.LaosijiClient.QueryTranscoding(ctx, fileURL, fileId, *sd)
if err != nil {
return err
}
if !found {
return nil
}
return applyTranscodeResult(ctx, videoId, result)
}
func applyTranscodeResult(ctx context.Context, videoId int64, result laosiji.TranscodeResult) error {
log.Infof("==applyTranscodeResult===> result", log.Any("videoId", videoId), log.Any("result", result))
var update bson.M
switch {
case result.Done:
meta := toVideoMetaInfo(result.HevcMeta)
if meta == nil {
meta = &videoinformationmod.VideoMetaInfo{}
}
h265Url := normalizeH265UrlForSave(result.HevcURL)
meta.M3u8URL = h265Url
set := bson.M{
videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusSuccess,
videoinformationmod.FN.VideoInformation.HevcMetaInfo: meta,
videoinformationmod.FN.VideoInformation.Video.H265Url: h265Url,
}
if result.PreviewVtt != "" {
set[videoinformationmod.FN.VideoInformation.PreviewVtt] = result.PreviewVtt
}
if result.AudioFile != "" {
set[videoinformationmod.FN.VideoInformation.AudioFile] = result.AudioFile
}
if result.SmartCover != "" {
set[videoinformationmod.FN.VideoInformation.SmartCover] = result.SmartCover
}
update = bson.M{"$set": set}
case result.Failed:
update = bson.M{
"$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusFailed},
"$inc": bson.M{videoinformationmod.FN.VideoInformation.HevcFailCount: 1},
}
default:
update = bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusPending}}
}
if serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
}, update); serr != nil {
return fmt.Errorf("update video transcode status: %v", serr)
}
return nil
}
func normalizeH265UrlForSave(url string) string {
url = strings.TrimSpace(url)
return strings.TrimPrefix(url, "/")
}
func toVideoMetaInfo(m *laosiji.MetaInfo) *videoinformationmod.VideoMetaInfo {
if m == nil {
return nil
}
return &videoinformationmod.VideoMetaInfo{
Codec: m.Codec,
Profile: m.Profile,
Width: m.Width,
Height: m.Height,
Fps: m.Fps,
VideoBitrate: m.VideoBitrate,
PixFmt: m.PixFmt,
AudioCodec: m.AudioCodec,
AudioBitrate: m.AudioBitrate,
AudioSampleRate: m.AudioSampleRate,
AudioChannels: m.AudioChannels,
Bitrate: m.Bitrate,
Size: m.Size,
Duration: m.Duration,
}
}
+294
View File
@@ -0,0 +1,294 @@
package srv_videoinfo
import (
"context"
"math/rand"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/storage/proxy"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func videoSyncKey(media proxy.MediaResp) string {
if !media.MediaSourceId.IsZero() {
return media.MediaSourceId.Hex()
}
return media.Id.Hex()
}
func buildVideoSyncFilter(media proxy.MediaResp) bson.M {
keys := []string{media.Id.Hex()}
if !media.MediaSourceId.IsZero() {
msid := media.MediaSourceId.Hex()
if msid != keys[0] {
keys = append(keys, msid)
}
}
or := make([]bson.M, 0, len(keys)*2)
for _, key := range keys {
or = append(or,
bson.M{videoinformationmod.FN.VideoInformation.Video.FileId: key},
bson.M{videoinformationmod.FN.VideoInformation.SyncId: key},
)
}
return bson.M{"$or": or}
}
func IsPlayletMedia(ctx context.Context, media proxy.MediaResp) bool {
return playletchaptermod.IsLinkedMedia(ctx, media.IsPlaylet, media.PlayletKeys()...)
}
func SyncOneVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr {
if IsPlayletMedia(ctx, media) {
log.Info("VideoSync skip playlet media", log.Any("mediaId", media.Id.Hex()), log.Any("mediaSourceId", media.MediaSourceId.Hex()))
return nil
}
// 同步时不再自动写 video_taginfo,标签由后台统一标签绑定维护
syncKey := videoSyncKey(media)
filter := buildVideoSyncFilter(media)
videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, filter)
if serr != nil {
// 新加入的视频
if serr.Code == srvdef.ErrNoData {
videoInfo = media.ToVideoInfo()
if videoInfo == nil {
log.Error("VideoSync FindOneVideoInformation", log.Any("MediaId", media.Id))
return serr
}
if len(media.Publish) > 0 {
creater, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
videocreatermod.FN.VideoCreaterInfo.Name: media.Publish,
})
if serr != nil && serr.Code != srvdef.ErrNoData {
log.Error("VideoSync.FindOneVideoCreaterInfo", log.Any("media", media), log.E(serr))
return serr
}
if creater == nil {
createrId := mongos.Lid.VideoCreator.Get()
creater = &videocreatermod.VideoCreaterInfo{
Id: createrId,
Name: media.Publish,
MediaTypes: []int64{1},
Status: true,
Sort: 0,
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
createrExists := func(s string) bool {
doc, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter(
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, s, 0))
return doc != nil
}
if serr = slugx.Ensure(&creater.Slug, utils.SlugSourceFromKeywordsOrTitle(nil, creater.Name), createrExists); serr != nil {
return serr
}
serr = videocreatermod.InsertOneVideoCreaterInfo(ctx, creater)
if serr != nil {
log.Error("VideoSync.InsertOneVideoCreaterInfo", log.Any("media", media), log.E(serr))
return serr
}
} else {
isFind := false
for _, v := range creater.MediaTypes {
if v == 1 {
isFind = true
}
}
if !isFind {
mediaTypes := append(creater.MediaTypes, 1)
_ = videocreatermod.UpdateOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: creater.Id}, bson.M{
"$set": bson.M{videocreatermod.FN.VideoCreaterInfo.MediaTypes: mediaTypes},
})
}
}
videoInfo.CreatorId = creater.Id
videoInfo.CreatorName = creater.Name
}
videoInfo.IndexNow = 0
applyDefaultFakeFlow(&videoInfo.Fake, rand.New(rand.NewSource(time.Now().UnixNano())))
exists := func(s string) bool {
doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter(
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, 0))
return doc != nil
}
if serr = slugx.Ensure(&videoInfo.Slug, utils.SlugSourceFromKeywordsOrTitle(videoInfo.Keyworld, videoInfo.Title), exists); serr != nil {
return serr
}
serr = videoinformationmod.InsertOneVideoInformation(ctx, videoInfo)
if serr != nil {
log.Error("VideoSync InsertOneVideoInformation", log.Any("videoInfo", videoInfo), log.E(serr))
return serr
}
} else {
log.Error("VideoSync FindOneVideoInformation", log.Any("filter", filter), log.E(serr))
return serr
}
} else {
// 更新资源
nodes := make([]videoinformationmod.Node, 0)
if videoInfo != nil {
mediaNodeMap := make(map[string]videoinformationmod.Node, len(media.Nodes))
for _, node := range media.Nodes {
if node.NodeId != "" {
mediaNodeMap[node.NodeId] = node
}
}
// 更新节点里面图片水印 以本地视频列表的节点为主
for _, node := range videoInfo.Nodes {
if mediaNode, ok := mediaNodeMap[node.NodeId]; ok {
node.Images = mediaNode.Images
}
nodes = append(nodes, node)
}
} else {
nodes = media.Nodes
}
setDoc := bson.M{
videoinformationmod.FN.VideoInformation.Nodes: nodes,
videoinformationmod.FN.VideoInformation.Keyworld: media.Keyworld,
videoinformationmod.FN.VideoInformation.FirstTag: media.FirstTag,
videoinformationmod.FN.VideoInformation.SecondTags: media.SecondTags,
videoinformationmod.FN.VideoInformation.CoverImg: media.CoverImg,
videoinformationmod.FN.VideoInformation.IssuedBrand: media.IssuedBrand,
videoinformationmod.FN.VideoInformation.IssuedAt: media.IssuedAt,
}
for k, v := range defaultFakeFlowSetDoc(rand.New(rand.NewSource(time.Now().UnixNano()))) {
setDoc[k] = v
}
setDoc[videoinformationmod.FN.VideoInformation.SyncId] = syncKey
if len(media.FieldNameFs) > 0 {
video := videoinformationmod.VideoInfo{
TaskId: media.TaskID,
FileId: syncKey,
Url: media.FieldNameFs,
Cover: media.CoverImg,
Height: media.Height,
Width: media.Width,
VideoTime: media.VideoTime,
}
if !strings.HasSuffix(video.Url, ".m3u8") {
video.Url += ".m3u8"
}
setDoc[videoinformationmod.FN.VideoInformation.Video.TaskId] = video.TaskId
setDoc[videoinformationmod.FN.VideoInformation.Video.FileId] = video.FileId
setDoc[videoinformationmod.FN.VideoInformation.Video.Url] = video.Url
setDoc[videoinformationmod.FN.VideoInformation.Video.Cover] = video.Cover
setDoc[videoinformationmod.FN.VideoInformation.Video.Height] = video.Height
setDoc[videoinformationmod.FN.VideoInformation.Video.Width] = video.Width
setDoc[videoinformationmod.FN.VideoInformation.Video.VideoTime] = video.VideoTime
}
if preMp4 := strings.TrimSpace(media.PreMp4FileNameFs); preMp4 != "" {
setDoc[videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs] = preMp4
}
if unencryptedPreMp4 := strings.TrimSpace(media.UnencryptedPreMp4); unencryptedPreMp4 != "" {
setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = unencryptedPreMp4
}
if videoInfo.Slug == "" {
exists := func(s string) bool {
doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter(
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, videoInfo.Id))
return doc != nil
}
slug := ""
if serr = slugx.Ensure(&slug, utils.SlugSourceFromKeywordsOrTitle(media.Keyworld, videoInfo.Title), exists); serr != nil {
return serr
}
setDoc[videoinformationmod.FN.VideoInformation.Slug] = slug
}
serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id}, bson.M{"$set": setDoc})
if serr != nil {
return serr
}
}
playletcache.ClearHomeCache()
_ = syncNodeVideo(ctx, media)
return nil
}
// syncNodeVideo 同步节点里的视频
func syncNodeVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr {
mediaID := videoSyncKey(media)
nodeIDs := []string{mediaID}
if !media.MediaSourceId.IsZero() {
msid := media.MediaSourceId.Hex()
if msid != mediaID {
nodeIDs = append(nodeIDs, msid)
}
}
if media.Id.Hex() != mediaID && media.Id.Hex() != "" {
nodeIDs = append(nodeIDs, media.Id.Hex())
}
filter := bson.M{"nodes.nodeId": bson.M{"$in": nodeIDs}}
opts := &options.FindOptions{}
opts.SetLimit(100)
videoInfo, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if serr != nil {
log.Warn("syncNodeVideo", log.Any("filter", filter), log.E(serr))
return serr
}
if !strings.HasSuffix(media.FieldNameFs, ".m3u8") {
media.FieldNameFs += ".m3u8"
}
if len(videoInfo) > 0 {
writeModels := make([]mongo.WriteModel, 0, len(videoInfo))
now := time.Now()
// 更新视频路径
for _, video := range videoInfo {
if video.Nodes != nil && len(video.Nodes) > 0 {
isFind := false
for i, node := range video.Nodes {
if node.Videos != nil && len(node.Videos) > 0 {
for j, v := range node.Videos {
if v.FileId == mediaID || containsString(nodeIDs, v.FileId) {
node.Videos[j].Url = media.FieldNameFs
isFind = true
break
}
}
}
if isFind {
video.Nodes[i] = node
break
}
}
if isFind {
writeModels = append(writeModels, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: video.Id}).
SetUpdate(bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.UpdatedAt: now,
videoinformationmod.FN.VideoInformation.Nodes: video.Nodes,
}}))
}
}
}
if len(writeModels) > 0 {
_, serr = videoinformationmod.BulkWriteVideoInformation(ctx, writeModels, options.BulkWrite().SetOrdered(false))
if serr != nil {
log.Warn("syncNodeVideo BulkWriteVideoInformation failed", log.Any("filter", filter), log.E(serr))
return serr
}
}
}
return nil
}
func containsString(list []string, target string) bool {
for _, item := range list {
if item == target {
return true
}
}
return false
}
+341
View File
@@ -0,0 +1,341 @@
package srv_videoinfo
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// UnmarshalJSON 兼容 id 使用数字字符串,以及 param 里的表单写法。
func (r *VideoInfoUpdateReq) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if value, ok := raw["id"]; ok {
next, keep, err := normalizeIntRaw(value)
if err != nil {
return fmt.Errorf("id %w", err)
}
if keep && next != nil && !bytes.Equal(bytes.TrimSpace(next), []byte("null")) {
if err := json.Unmarshal(next, &r.Id); err != nil {
return err
}
}
}
if value, ok := raw["param"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
if err := json.Unmarshal(value, &r.Param); err != nil {
return err
}
}
return nil
}
// UnmarshalJSON 兼容后台表单里的空字符串、数字字符串,以及分类排序的多种写法。
func (v *VideoInfoUpdate) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
return nil
}
normalized, err := normalizeVideoUpdateJSON(data)
if err != nil {
return err
}
type alias VideoInfoUpdate
var out alias
if err := json.Unmarshal(normalized, &out); err != nil {
return err
}
*v = VideoInfoUpdate(out)
return nil
}
func normalizeVideoUpdateJSON(data []byte) ([]byte, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
for _, key := range []string{"addedTime", "updatedAt", "issuedAt", "secondUpdatedAt"} {
value, ok := raw[key]
if !ok {
continue
}
next, err := normalizeTimeRaw(value)
if err != nil {
return nil, err
}
if next == nil {
delete(raw, key)
continue
}
raw[key] = next
}
for _, key := range []string{"categoryId", "sort", "creatorId", "height", "width", "videoTime", "indexNow", "mediaType", "status"} {
value, ok := raw[key]
if !ok {
continue
}
next, keep, err := normalizeIntRaw(value)
if err != nil {
return nil, fmt.Errorf("%s %w", key, err)
}
if !keep {
continue
}
if next == nil {
delete(raw, key)
continue
}
raw[key] = next
}
for _, key := range []string{"actressIds", "tags"} {
value, ok := raw[key]
if !ok {
continue
}
next, keep, err := normalizeIntSliceRaw(value)
if err != nil {
return nil, fmt.Errorf("%s %w", key, err)
}
if !keep {
continue
}
if next == nil {
delete(raw, key)
continue
}
raw[key] = next
}
if value, ok := raw["categoryRanks"]; ok {
next, keep, err := normalizeCategoryRanksRaw(value, raw["categoryId"])
if err != nil {
return nil, err
}
if !keep {
delete(raw, "categoryRanks")
} else {
raw["categoryRanks"] = next
}
}
return json.Marshal(raw)
}
func normalizeTimeRaw(raw json.RawMessage) (json.RawMessage, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return raw, nil
}
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return nil, fmt.Errorf("时间格式错误")
}
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
parsed, err := parseFlexTime(text)
if err != nil {
return nil, err
}
return json.Marshal(parsed)
}
func parseFlexTime(text string) (time.Time, error) {
if parsed, err := time.Parse(time.RFC3339Nano, text); err == nil {
return parsed, nil
}
if parsed, err := time.Parse(time.RFC3339, text); err == nil {
return parsed, nil
}
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("时间格式错误: %s", text)
}
// normalizeIntRaw 把数字或数字字符串整理成 JSON 数字。空字符串表示删除该字段。
func normalizeIntRaw(raw json.RawMessage) (json.RawMessage, bool, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil, false, nil
}
if bytes.Equal(raw, []byte("null")) {
return raw, true, nil
}
if raw[0] != '"' {
var number json.Number
if err := json.Unmarshal(raw, &number); err != nil {
return nil, false, fmt.Errorf("数字格式错误")
}
if _, err := number.Int64(); err != nil {
return nil, false, fmt.Errorf("数字格式错误")
}
return raw, true, nil
}
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return nil, false, fmt.Errorf("数字格式错误")
}
text = strings.TrimSpace(text)
if text == "" {
return nil, true, nil
}
value, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return nil, false, fmt.Errorf("数字格式错误: %s", text)
}
encoded, err := json.Marshal(value)
return encoded, true, err
}
func normalizeIntSliceRaw(raw json.RawMessage) (json.RawMessage, bool, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return raw, true, nil
}
if bytes.Equal(raw, []byte(`""`)) {
return nil, true, nil
}
if raw[0] != '[' {
return nil, false, fmt.Errorf("数组格式错误")
}
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, false, err
}
values := make([]int64, 0, len(items))
for _, item := range items {
next, keep, err := normalizeIntRaw(item)
if err != nil {
return nil, false, err
}
if !keep || next == nil || bytes.Equal(bytes.TrimSpace(next), []byte("null")) {
return nil, false, fmt.Errorf("数字格式错误")
}
var value int64
if err := json.Unmarshal(next, &value); err != nil {
return nil, false, err
}
values = append(values, value)
}
encoded, err := json.Marshal(values)
return encoded, true, err
}
func normalizeCategoryRanksRaw(raw, categoryIDRaw json.RawMessage) (json.RawMessage, bool, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte(`""`)) {
return nil, false, nil
}
fallbackID := int64FromRaw(categoryIDRaw)
if raw[0] != '[' {
item, ok, err := normalizeCategoryRankItem(raw, fallbackID)
if err != nil {
return nil, false, err
}
if !ok {
return nil, false, nil
}
encoded, err := json.Marshal([]categoryRankJSON{item})
return encoded, true, err
}
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, false, fmt.Errorf("分类排序格式错误")
}
if len(items) == 0 {
encoded, err := json.Marshal([]categoryRankJSON{})
return encoded, true, err
}
cleaned := make([]categoryRankJSON, 0, len(items))
for _, item := range items {
rank, ok, err := normalizeCategoryRankItem(item, fallbackID)
if err != nil {
return nil, false, err
}
if !ok {
continue
}
cleaned = append(cleaned, rank)
}
if len(cleaned) == 0 {
return nil, false, nil
}
encoded, err := json.Marshal(cleaned)
return encoded, true, err
}
type categoryRankJSON struct {
Id int64 `json:"id"`
Rank int64 `json:"rank"`
}
func normalizeCategoryRankItem(raw json.RawMessage, fallbackID int64) (categoryRankJSON, bool, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return categoryRankJSON{}, false, nil
}
if raw[0] != '{' {
if bytes.Equal(raw, []byte("null")) || bytes.Equal(raw, []byte(`""`)) || rawIsZero(raw) {
return categoryRankJSON{}, false, nil
}
rank := int64FromRaw(raw)
if rank == 0 {
return categoryRankJSON{}, false, fmt.Errorf("分类排序格式错误")
}
if fallbackID <= 0 {
return categoryRankJSON{}, false, fmt.Errorf("分类排序缺少分类ID")
}
return categoryRankJSON{Id: fallbackID, Rank: rank}, true, nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return categoryRankJSON{}, false, fmt.Errorf("分类排序格式错误")
}
id := int64FromRaw(fields["id"])
if id <= 0 {
id = int64FromRaw(fields["categoryId"])
}
rank := int64FromRaw(fields["rank"])
if id <= 0 && !rawHasValue(fields["rank"]) {
return categoryRankJSON{}, false, nil
}
if id <= 0 {
id = fallbackID
}
if id <= 0 {
return categoryRankJSON{}, false, fmt.Errorf("分类排序缺少分类ID")
}
return categoryRankJSON{Id: id, Rank: rank}, true, nil
}
func rawHasValue(raw json.RawMessage) bool {
raw = bytes.TrimSpace(raw)
return len(raw) > 0 && !bytes.Equal(raw, []byte("null")) && !bytes.Equal(raw, []byte(`""`))
}
func int64FromRaw(raw json.RawMessage) int64 {
next, keep, err := normalizeIntRaw(raw)
if err != nil || !keep || next == nil || bytes.Equal(bytes.TrimSpace(next), []byte("null")) {
return 0
}
var value int64
if err := json.Unmarshal(next, &value); err != nil {
return 0
}
return value
}
func rawIsZero(raw json.RawMessage) bool {
next, keep, err := normalizeIntRaw(raw)
return err == nil && keep && bytes.Equal(bytes.TrimSpace(next), []byte("0"))
}
+992
View File
@@ -0,0 +1,992 @@
package srv_videoinfo
import (
"context"
"math/rand"
"net/http"
"time"
"videoweb/pub/common/godef"
"videoweb/pub/common/log"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/actress"
"videoweb/pub/models/contenttagmod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videocollectionmediamod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"videoweb/pub/storage"
"videoweb/pub/storage/proxy"
"videoweb/web/service/srv_contenttag"
"videoweb/web/service/srv_report"
"videoweb/web/service/srv_seopush"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoInfos 按照参数搜索APP
func GetVideoInfos(ctx context.Context, param *VideoInfoReq) (*VideoInfoResp, *srvdef.SrvErr) {
filter := bson.M{}
if param.MediaType != nil {
filter[videoinformationmod.FN.VideoInformation.MediaType] = *param.MediaType
}
if param.Id != nil && *param.Id > 0 {
filter[videoinformationmod.FN.VideoInformation.Id] = *param.Id
}
if param.Title != nil && len(*param.Title) > 0 {
filter[videoinformationmod.FN.VideoInformation.Title] = bson.M{"$regex": *param.Title}
}
if param.Bango != nil && *param.Bango != "" {
filter[videoinformationmod.FN.VideoInformation.Bango] = bson.M{"$regex": *param.Bango}
}
if param.CreatorId != nil && *param.CreatorId > 0 {
filter[videoinformationmod.FN.VideoInformation.CreatorId] = *param.CreatorId
}
if param.Status != nil {
filter[videoinformationmod.FN.VideoInformation.Status] = *param.Status
}
if param.Tag != nil && *param.Tag > 0 {
filter[videoinformationmod.FN.VideoInformation.Tags] = bson.M{"$in": []int64{*param.Tag}}
}
if param.IsHot != nil {
filter[videoinformationmod.FN.VideoInformation.IsHot] = *param.IsHot
}
if param.IsRecommend != nil {
filter[videoinformationmod.FN.VideoInformation.IsRecommend] = *param.IsRecommend
}
if param.CreatorName != nil && len(*param.CreatorName) > 0 {
filter[videoinformationmod.FN.VideoInformation.CreatorName] = bson.M{"$regex": *param.CreatorName}
}
if param.Keyworld != nil && len(*param.Keyworld) > 0 {
filter[videoinformationmod.FN.VideoInformation.Keyworld] = bson.M{"$in": []string{*param.Keyworld}}
}
if param.VideoTaskId != nil && len(*param.VideoTaskId) > 0 {
filter[videoinformationmod.FN.VideoInformation.Video.TaskId] = *param.VideoTaskId
}
if param.VideoFileId != nil && len(*param.VideoFileId) > 0 {
filter[videoinformationmod.FN.VideoInformation.Video.FileId] = *param.VideoFileId
}
if param.CategoryId != nil && *param.CategoryId > 0 {
filter[videoinformationmod.FN.VideoInformation.CategoryId] = *param.CategoryId
}
if param.IsTop != nil {
filter[videoinformationmod.FN.VideoInformation.IsTop] = *param.IsTop
}
if param.ActressIds != nil && len(*param.ActressIds) > 0 {
filter[videoinformationmod.FN.VideoInformation.ActressIds] = bson.M{"$in": *param.ActressIds}
}
if param.StartTime != nil && param.EndTime != nil {
start := utils.GetDayBeginByTime(*param.StartTime)
end := utils.GetDayEndByTime(*param.EndTime)
if end.Unix() > start.Unix() {
filter[videoinformationmod.FN.VideoInformation.CreatedAt] = bson.M{
"$gte": start, "$lte": end,
}
}
}
if param.OptionAccount != nil {
filter[videoinformationmod.FN.VideoInformation.OptionAccount] = *param.OptionAccount
}
opt := &options.FindOptions{}
resp := &VideoInfoResp{
Total: 0,
List: make(videoinformationmod.VideoInformationList, 0),
}
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: -1})
switch param.Sort {
case 0:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.CreatedAt: -1})
case 1:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1})
case 2:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1})
case 3:
opt.SetSort(bson.D{
{videoinformationmod.FN.VideoInformation.Sort, -1},
{videoinformationmod.FN.VideoInformation.AddedTime, -1},
})
case 4:
opt.SetSort(bson.D{
{videoinformationmod.FN.VideoInformation.Real.WatchTimes, -1},
{videoinformationmod.FN.VideoInformation.AddedTime, -1},
})
}
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videoinformationmod.CountVideoInformations(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
var ads videoinformationmod.VideoInformationList
if param.Sort == 3 && param.CategoryId != nil && *param.CategoryId > 0 {
ads, serr = videoinformationmod.FindManyVideoByCategoryRank(ctx, filter, *param.CategoryId, (param.PageNum-1)*param.PageSize, param.PageSize, nil)
} else {
ads, serr = videoinformationmod.FindManyVideoInformations(ctx, filter, opt)
}
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// SetVideoRecommend 单/批量设置视频是否推荐
func SetVideoRecommend(ctx context.Context, req *SetRecommendReq) *srvdef.SrvErr {
if req == nil || len(req.Ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
ids, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return err
}
err = videoinformationmod.UpdateManyVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids},
}, bson.M{
"$set": bson.M{
videoinformationmod.FN.VideoInformation.IsRecommend: req.IsRecommend,
videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(),
},
})
if err != nil {
return err
}
playletcache.ClearHomeCache()
return nil
}
// SetVideoFakeLikes 单/批量随机设置视频假点赞数
func SetVideoFakeLikes(ctx context.Context, req *SetFakeLikesReq) (*SetFakeCountResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.LikesMin < 0 || req.LikesMax < req.LikesMin {
return nil, srvdef.New(srvdef.ErrParamError, "点赞数区间无效")
}
ids, serr := normalizeFakeFlowIds(req.Ids)
if serr != nil {
return nil, serr
}
return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.Fake.Likes, req.LikesMin, req.LikesMax)
}
// SetVideoFakeWatchTimes 单/批量随机设置视频假观看次数
func SetVideoFakeWatchTimes(ctx context.Context, req *SetFakeWatchTimesReq) (*SetFakeCountResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.WatchTimesMin < 0 || req.WatchTimesMax < req.WatchTimesMin {
return nil, srvdef.New(srvdef.ErrParamError, "观看次数区间无效")
}
ids, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return nil, err
}
return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.Fake.WatchTimes, req.WatchTimesMin, req.WatchTimesMax)
}
// SetVideoHeat 单/批量随机设置视频热度
func SetVideoHeat(ctx context.Context, req *SetVideoHeatReq) (*SetFakeCountResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.HeatMin < 0 || req.HeatMax < req.HeatMin {
return nil, srvdef.New(srvdef.ErrParamError, "热度区间无效")
}
ids, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return nil, err
}
return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.HeatValue, req.HeatMin, req.HeatMax)
}
func setVideoFakeField(ctx context.Context, ids []int64, field string, min, max int64) (*SetFakeCountResp, *srvdef.SrvErr) {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
docs := make([]mongo.WriteModel, 0, len(ids))
for _, id := range ids {
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: id}).
SetUpdate(bson.M{"$set": bson.M{field: randomFakeFlowValue(r, min, max)}}))
}
result, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false))
if err != nil {
return nil, err
}
if result.MatchedCount == 0 {
return nil, srvdef.New(srvdef.ErrNoData, "", "")
}
playletcache.ClearHomeCache()
return &SetFakeCountResp{MatchedCount: result.MatchedCount, ModifiedCount: result.ModifiedCount}, nil
}
func normalizeFakeFlowIds(rawIds []int64) ([]int64, *srvdef.SrvErr) {
ids := make([]int64, 0, len(rawIds))
seen := make(map[int64]struct{}, len(rawIds))
for _, id := range rawIds {
if id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids包含无效id")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
return ids, nil
}
const (
defaultFakeFlowMin = int64(1000)
defaultFakeFlowMax = int64(10000)
)
func randomDefaultFakeFlowPair(r *rand.Rand) (watchTimes, likes int64) {
if r == nil {
r = rand.New(rand.NewSource(time.Now().UnixNano()))
}
likes = randomFakeFlowValue(r, defaultFakeFlowMin, defaultFakeFlowMax-1)
watchTimes = randomFakeFlowValue(r, likes+1, defaultFakeFlowMax)
return watchTimes, likes
}
func applyDefaultFakeFlow(fake *pubdef.FakeFlow, r *rand.Rand) {
if fake == nil {
return
}
fake.WatchTimes, fake.Likes = randomDefaultFakeFlowPair(r)
}
func defaultFakeFlowSetDoc(r *rand.Rand) bson.M {
watchTimes, likes := randomDefaultFakeFlowPair(r)
return bson.M{
videoinformationmod.FN.VideoInformation.Fake.WatchTimes: watchTimes,
videoinformationmod.FN.VideoInformation.Fake.Likes: likes,
}
}
const fillZeroFakeFlowBatchSize = 500
func zeroFakeFlowFilter() bson.M {
return bson.M{
"$or": []bson.M{
{
"$expr": bson.M{
"$lt": []interface{}{
bson.M{"$add": []interface{}{"$real.watchTimes", "$fake.watchTimes"}},
defaultFakeFlowMin,
},
},
},
{
"$expr": bson.M{
"$lt": []interface{}{
bson.M{"$add": []interface{}{"$real.likes", "$fake.likes"}},
defaultFakeFlowMin,
},
},
},
},
}
}
// FillZeroVideoFakeFlow 为播放量或收藏总量小于 1000 的视频随机设置 fake 播放量和收藏数(1000-10000,播放量大于收藏量)。
func FillZeroVideoFakeFlow(ctx context.Context) (*SetFakeCountResp, *srvdef.SrvErr) {
filter := zeroFakeFlowFilter()
opt := options.Find().
SetProjection(bson.M{videoinformationmod.FN.VideoInformation.Id: 1}).
SetBatchSize(fillZeroFakeFlowBatchSize).
SetLimit(fillZeroFakeFlowBatchSize)
r := rand.New(rand.NewSource(time.Now().UnixNano()))
resp := &SetFakeCountResp{}
var lastID int64
for {
pageFilter := filter
if lastID > 0 {
pageFilter = bson.M{
"$and": []bson.M{
filter,
{videoinformationmod.FN.VideoInformation.Id: bson.M{"$gt": lastID}},
},
}
}
findOpt := opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: 1})
list, serr := videoinformationmod.FindManyVideoInformations(ctx, pageFilter, findOpt)
if serr != nil {
return nil, serr
}
if len(list) == 0 {
break
}
docs := make([]mongo.WriteModel, 0, len(list))
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
lastID = item.Id
resp.MatchedCount++
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}).
SetUpdate(bson.M{"$set": defaultFakeFlowSetDoc(r)}))
}
if len(docs) == 0 {
break
}
result, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false))
if err != nil {
return nil, err
}
resp.ModifiedCount += result.ModifiedCount
if len(list) < fillZeroFakeFlowBatchSize {
break
}
}
if resp.ModifiedCount > 0 {
playletcache.ClearHomeCache()
}
return resp, nil
}
func randomFakeFlowValue(r *rand.Rand, min, max int64) int64 {
if min == max {
return min
}
return min + r.Int63n(max-min+1)
}
// SyncVideoInfo 同步视频
func SyncVideoInfo(ctx context.Context,
req *AddVideoReq) *srvdef.SrvErr {
ids := make([]string, 0)
for _, id := range req.FileIds {
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
queryMediaReq := &proxy.MediaQueryesReq{
Ids: ids,
}
resp, err := storage.QueryMedias(ctx, queryMediaReq, "")
if err != nil || resp.Code != http.StatusOK {
if resp != nil {
log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err), log.Any("respCode", resp.Code))
} else {
log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err))
}
return srvdef.New(srvdef.ErrMediaNotExsit, err.Error(), err.Error())
}
if len(resp.Data) == 0 {
return nil
}
for _, media := range resp.Data {
se := SyncOneVideo(ctx, media)
if se != nil {
log.Warn("SyncVideoInfo SYncOneVideo failed", log.Any("filter", req.FileIds), log.E(se))
continue
}
}
return nil
}
func mediaFileDomain(sourceVersion string) string {
if sourceVersion == "v1" {
return proxy.BucketVideoAddressV1
}
return proxy.BucketVideoAddressV2
}
func buildMediaRespFromSyncItem(item SyncMediaFileItem) (proxy.MediaResp, error) {
oid, err := primitive.ObjectIDFromHex(item.Id)
if err != nil {
return proxy.MediaResp{}, err
}
return proxy.MediaResp{
Id: oid,
MediaSourceId: oid,
MediaType: 2,
Title: item.Title,
FieldNameFs: item.FieldNameFs,
FirstTag: item.FirstTag,
SecondTags: item.SecondTags,
Keyworld: item.Keyworld,
VideoTime: item.VideoTime,
CoverImg: item.CoverImg,
Publish: item.Publish,
Bango: item.Bango,
TaskID: item.TaskID,
Width: item.Width,
Height: item.Height,
}, nil
}
// SyncVideoFromMediaFile 从媒资库直同步到视频列表(不经过切片任务)。
func SyncVideoFromMediaFile(ctx context.Context, req *SyncMediaFileReq) (*SyncMediaFileResp, *srvdef.SrvErr) {
result := &SyncMediaFileResp{}
if req == nil {
return result, nil
}
minVideoSeconds := req.MinVideoSeconds
syncOne := func(id string, media proxy.MediaResp) {
if media.Id.IsZero() {
result.Failed++
return
}
if minVideoSeconds > 0 && media.VideoTime > 0 && media.VideoTime <= minVideoSeconds {
log.Info("SyncVideoFromMediaFile skip short video",
log.Any("id", id),
log.Any("videoTime", media.VideoTime))
result.Failed++
return
}
if len(media.FieldNameFs) == 0 {
log.Warn("SyncVideoFromMediaFile empty fieldNameFs", log.Any("id", id))
result.Failed++
return
}
media.MediaSourceId = media.Id
media.MediaType = 2
if req.FirstTagMap != nil {
if firstTag, ok := req.FirstTagMap[id]; ok && len(firstTag) > 0 {
media.FirstTag = firstTag
}
}
if se := SyncOneVideo(ctx, media); se != nil {
log.Warn("SyncVideoFromMediaFile SyncOneVideo failed", log.Any("id", id), log.E(se))
result.Failed++
return
}
result.Synced++
}
if len(req.VideoList) > 0 {
for _, item := range req.VideoList {
media, err := buildMediaRespFromSyncItem(item)
if err != nil {
log.Warn("SyncVideoFromMediaFile invalid item", log.Any("id", item.Id), log.E(err))
result.Failed++
continue
}
syncOne(item.Id, media)
}
return result, nil
}
domain := mediaFileDomain(req.SourceVersion)
for _, id := range req.FileIds {
if len(id) == 0 {
continue
}
resp, err := storage.QueryMedia(ctx, &proxy.MediaQueryeReq{Id: id}, domain)
if err != nil || resp == nil || resp.Code != proxy.Success {
if resp != nil {
log.Warn("SyncVideoFromMediaFile QueryMedia failed", log.Any("id", id), log.E(err), log.Any("respCode", resp.Code))
} else {
log.Warn("SyncVideoFromMediaFile QueryMedia failed", log.Any("id", id), log.E(err))
}
result.Failed++
continue
}
media := resp.Data
if media.Id.IsZero() {
oid, parseErr := primitive.ObjectIDFromHex(id)
if parseErr != nil {
log.Warn("SyncVideoFromMediaFile invalid id", log.Any("id", id), log.E(parseErr))
result.Failed++
continue
}
media.Id = oid
}
syncOne(id, media)
}
return result, nil
}
// DelVideoInfo 删除指定APP
func DelVideoInfo(ctx context.Context, id int64) *srvdef.SrvErr {
info, err1 := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: id,
}, options.FindOne().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
}))
if err1 != nil {
return err1
}
adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id}
//删除
count, serr := videoinformationmod.DelOneVideoInformation(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
playletcache.ClearHomeCache()
// 删除后检查一下合集数据
collectionInfos, _ := videocollectionmediamod.FindManyVideoCollectionMediaModels(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: id,
})
if len(collectionInfos) > 0 {
collectionIds := make([]int64, 0)
for _, collectionInfo := range collectionInfos {
collectionIds = append(collectionIds, collectionInfo.CollectionID)
}
if len(collectionIds) > 0 {
_ = videoinformationmod.UpdateManyVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": collectionInfos},
}, bson.M{"$inc": bson.M{
videoinformationmod.FN.VideoInformation.VideoCount: -1,
}})
}
}
_, _ = videocollectionmediamod.DelManyVideoCollectionMediaModel(ctx, bson.M{
videocollectionmediamod.FN.VideoCollectionMediaModel.VideoId: id,
})
if len(info.Tags) > 0 {
oldTagIds := append([]int64{}, info.Tags...)
godef.Go(func() {
_ = srv_contenttag.SyncContentTagBindCount(context.Background(), oldTagIds, nil, contenttagmod.ContentTagTypeVideo)
})
}
return nil
}
// UpdateVideoInfo 更新指定APP
func UpdateVideoInfo(ctx context.Context, id, userId int64, param VideoInfoUpdate) *srvdef.SrvErr {
now := time.Now()
info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: id})
if serr != nil {
return serr
}
param.UpdatedAt = &now
param.SecondUpdatedAt = &now
if param.CreatorId != nil {
name := ""
if *param.CreatorId == 0 {
param.CreatorName = &name
} else {
// 查询作者
c, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
videocreatermod.FN.VideoCreaterInfo.Id: *param.CreatorId,
})
if serr != nil {
return serr
}
name = c.Name
param.CreatorName = &name
}
}
if param.Tags != nil {
tagIds, err1 := srv_contenttag.NormalizeContentTagIds(*param.Tags)
if err1 != nil {
return err1
}
if err1 := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err1 != nil {
return err1
}
param.Tags = &tagIds
}
if param.CategoryRanks != nil {
seen := make(map[int64]bool, len(*param.CategoryRanks))
for _, rank := range *param.CategoryRanks {
if rank.Id <= 0 || seen[rank.Id] {
return srvdef.New(srvdef.ErrParamError, "分类ID无效或重复", "分类ID无效或重复")
}
seen[rank.Id] = true
if _, err := videocategorymod.FindOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: rank.Id}); err != nil {
if err.Code == srvdef.ErrNoData {
return srvdef.New(srvdef.ErrParamError, "视频分类不存在", "视频分类不存在")
}
return err
}
}
}
isChangeActress := checkActressChange(*info, param)
isChangeTag := checkTagChange(*info, param)
if param.ActressIds != nil && len(*param.ActressIds) > 0 {
acts, serr := actress.FindManyActresss(ctx, bson.M{
actress.FN.Actress.Id: bson.M{"$in": *param.ActressIds},
})
if serr != nil {
log.Warn("UpdateVideoInfo FindManyActresss failed", log.Any("filter", param.ActressIds), log.E(serr))
return serr
}
acIds := make([]int64, 0)
for _, a := range acts {
acIds = append(acIds, a.Id)
}
param.ActressIds = &acIds
}
if param.Nodes != nil && len(*param.Nodes) > 0 {
nodes := *param.Nodes
for i, node := range nodes {
if len(node.NodeId) == 0 {
node.NodeId = primitive.NewObjectID().Hex()
nodes[i] = node
}
}
param.Nodes = &nodes
}
if param.Cover != nil && len(*param.Cover) > 0 {
if len(info.Video.Url) > 0 {
info.Video.Cover = *param.Cover
param.Video = &info.Video
}
}
if param.Status != nil && *param.Status != info.Status {
isChangeActress = true
tempActressIds := make([]int64, 0)
if param.ActressIds != nil {
tempActressIds = *param.ActressIds
}
go updateActressWorks(info.ActressIds, tempActressIds)
}
title := info.Title
if param.Title != nil {
title = *param.Title
}
keyworld := info.Keyworld
if param.Keyworld != nil {
keyworld = *param.Keyworld
}
exists := func(s string) bool {
doc, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter(
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, s, id))
return doc != nil
}
slug, changed, slugErr := slugx.PrepareOnUpdate(info.Slug, param.Slug,
utils.SlugSourceFromKeywordsOrTitle(keyworld, title), exists)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id}
param.IndexNow = nil
isPublished := param.Status != nil &&
info.Status != *param.Status &&
*param.Status == pubdef.InformationStatusVerifyOK
if isPublished || (param.IsReIndexNow != nil && *param.IsReIndexNow) {
indexNow := int64(0)
param.IndexNow = &indexNow
}
serr = videoinformationmod.UpdateOneVideoInformation(ctx, adFilter, bson.M{"$set": param})
if serr != nil {
return serr
}
playletcache.ClearHomeCache()
if isChangeActress {
tempActressIds := make([]int64, 0)
if param.ActressIds != nil {
tempActressIds = *param.ActressIds
}
go updateActressWorks(info.ActressIds, tempActressIds)
}
if isChangeTag {
tempIds := make([]int64, 0)
if param.Tags != nil {
tempIds = *param.Tags
}
oldTagIds := append([]int64{}, info.Tags...)
newTagIds := append([]int64{}, tempIds...)
godef.Go(func() {
_ = srv_contenttag.SyncContentTagBindCount(context.Background(), oldTagIds, newTagIds, contenttagmod.ContentTagTypeVideo)
})
}
if param.Status != nil && info.Status != *param.Status {
if flag := srv_report.VideoStatusFlag(*param.Status); flag != 0 {
mediaCopy := *info
mediaCopy.Status = *param.Status
godef.Go(func() {
srv_report.ReportVideoStatusChangeEvent(&mediaCopy, flag, userId)
})
}
}
newStatus := info.Status
if param.Status != nil {
newStatus = *param.Status
}
srv_seopush.NotifyInfoAfterWrite(ctx, srv_seopush.KindVideo, id, info.Status, newStatus)
return nil
}
func checkActressChange(info videoinformationmod.VideoInformation, updateParam VideoInfoUpdate) bool {
if updateParam.ActressIds != nil {
if len(info.ActressIds) != len(*updateParam.ActressIds) {
return true
}
actressIds := *updateParam.ActressIds
for i, v := range info.ActressIds {
if v != actressIds[i] {
return true
}
}
}
return false
}
func checkTagChange(info videoinformationmod.VideoInformation, updateParam VideoInfoUpdate) bool {
if updateParam.Tags != nil {
if len(info.Tags) != len(*updateParam.Tags) {
return true
}
tags := *updateParam.Tags
for i, v := range info.Tags {
if v != tags[i] {
return true
}
}
}
return false
}
func updateActressWorks(oldIds []int64, newIds []int64) {
defer func() {
if err := recover(); err != nil {
log.Error("updateActressWorks failed", log.Any("err", err))
}
}()
mapIds := make(map[int64]struct{})
if oldIds == nil {
oldIds = make([]int64, 0)
}
if newIds == nil {
newIds = make([]int64, 0)
}
for _, id := range newIds {
mapIds[id] = struct{}{}
}
for _, id := range oldIds {
mapIds[id] = struct{}{}
}
ids := make([]int64, 0)
for id := range mapIds {
ids = append(ids, id)
}
ctx := context.Background()
for _, id := range ids {
count, err := videoinformationmod.CountVideoInformations(ctx, bson.M{videoinformationmod.FN.VideoInformation.Status: pubdef.InformationStatusVerifyOK, videoinformationmod.FN.VideoInformation.ActressIds: bson.M{"$in": []int64{id}}})
if err == nil {
_ = actress.UpdateOneActress(ctx, bson.M{
actress.FN.Actress.Id: id,
}, bson.M{
"$set": bson.M{
actress.FN.Actress.Works: count,
},
})
}
}
}
func AddVideoCollection(ctx context.Context, param *AddVideoCollectionReq) *srvdef.SrvErr {
tagIds, err1 := srv_contenttag.NormalizeContentTagIds(param.Tags)
if err1 != nil {
return err1
}
if err1 := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err1 != nil {
return err1
}
info := videoinformationmod.VideoInformation{
Id: mongos.Lid.VideoInfo.Get(),
Title: param.Title,
MediaType: videoinformationmod.MediaType_Collection,
AddedTime: time.Now(),
Keyworld: param.Keyworld,
FirstTag: param.FirstTag,
SecondTags: param.SecondTags,
CoverImg: param.CoverImg,
Desc: param.Desc,
Remark: param.Remark,
Nodes: make([]videoinformationmod.Node, 0),
Status: pubdef.InformationStatusWaiting,
Video: videoinformationmod.VideoInfo{},
Tags: tagIds,
Real: pubdef.Flow{},
VideoCount: 0,
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
if err1 := videoinformationmod.InsertOneVideoInformation(ctx, &info); err1 != nil {
return err1
}
return srv_contenttag.SyncContentTagBindCount(ctx, nil, tagIds, contenttagmod.ContentTagTypeVideo)
}
// AddVideoTags 批量追加视频统一标签
func AddVideoTags(ctx context.Context, req *VideoAddTagsReq) *srvdef.SrvErr {
if req == nil || len(req.VideoIds) == 0 || len(req.Tags) == 0 {
return srvdef.New(srvdef.ErrParamError, "视频ID和标签ID不能为空", "")
}
videoIds, err := normalizeFakeFlowIds(req.VideoIds)
if err != nil {
return err
}
tagIds, err := srv_contenttag.NormalizeContentTagIds(req.Tags)
if err != nil {
return err
}
if len(tagIds) == 0 {
return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "")
}
if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil {
return err
}
list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds},
}, options.Find().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
}))
if err != nil {
return err
}
if len(list) == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
now := time.Now()
docs := make([]mongo.WriteModel, 0, len(list))
changes := make([]srv_contenttag.ContentTagBindChange, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
newTagIds, changed := appendMissingVideoTagIds(item.Tags, tagIds)
if !changed {
continue
}
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}).
SetUpdate(bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.Tags: newTagIds,
videoinformationmod.FN.VideoInformation.UpdatedAt: now,
}}))
changes = append(changes, srv_contenttag.ContentTagBindChange{
OldTagIds: append([]int64{}, item.Tags...),
NewTagIds: newTagIds,
})
}
if len(docs) == 0 {
return nil
}
if _, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)); err != nil {
return err
}
playletcache.ClearHomeCache()
return srv_contenttag.SyncContentTagBindCountBatch(ctx, changes, contenttagmod.ContentTagTypeVideo)
}
// SetVideoTags 单/批量覆盖设置视频统一标签,并同步新标签表视频绑定数量
func SetVideoTags(ctx context.Context, req *VideoSetTagsReq) *srvdef.SrvErr {
if req == nil || len(req.Ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "")
}
if req.TagIds == nil {
return srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "")
}
videoIds, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return err
}
tagIds, err := srv_contenttag.NormalizeContentTagIds(*req.TagIds)
if err != nil {
return err
}
if err := srv_contenttag.ValidateContentTags(ctx, tagIds, contenttagmod.ContentTagTypeVideo); err != nil {
return err
}
list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds},
}, options.Find().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
}))
if err != nil {
return err
}
if len(list) == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
now := time.Now()
docs := make([]mongo.WriteModel, 0, len(list))
changes := make([]srv_contenttag.ContentTagBindChange, 0, len(list))
for _, item := range list {
if item == nil || sameVideoTagIds(item.Tags, tagIds) {
continue
}
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}).
SetUpdate(bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.Tags: tagIds,
videoinformationmod.FN.VideoInformation.UpdatedAt: now,
}}))
changes = append(changes, srv_contenttag.ContentTagBindChange{
OldTagIds: append([]int64{}, item.Tags...),
NewTagIds: append([]int64{}, tagIds...),
})
}
if len(docs) == 0 {
return nil
}
if _, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false)); err != nil {
return err
}
playletcache.ClearHomeCache()
return srv_contenttag.SyncContentTagBindCountBatch(ctx, changes, contenttagmod.ContentTagTypeVideo)
}
func sameVideoTagIds(current, target []int64) bool {
if len(current) != len(target) {
return false
}
for i := range current {
if current[i] != target[i] {
return false
}
}
return true
}
func appendMissingVideoTagIds(current, addIds []int64) ([]int64, bool) {
result := append([]int64{}, current...)
tagMap := make(map[int64]struct{}, len(current)+len(addIds))
for _, tagId := range current {
if tagId > 0 {
tagMap[tagId] = struct{}{}
}
}
changed := false
for _, tagId := range addIds {
if _, ok := tagMap[tagId]; ok {
continue
}
tagMap[tagId] = struct{}{}
result = append(result, tagId)
changed = true
}
return result, changed
}
+38
View File
@@ -0,0 +1,38 @@
package srv_videotag
import (
"time"
"videoweb/pub/models/videotagmod"
"videoweb/pub/pubdef"
)
type VideoTagInfoReq struct {
pubdef.Page
Id *int64 `json:"id"`
Title *string `json:"title"`
}
type VideoTagInfoResp struct {
Total int64 `json:"total"`
List videotagmod.VideoTagInfoList `json:"list"`
}
type VideoTagInfoUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Title *string `json:"title" bson:"title,omitempty"` // 帖子标题
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
Works *int64 `json:"works" bson:"works,omitempty"` // 作品数量
MediaTypes *[]int64 `json:"mediaTypes" bson:"mediaTypes"` // 作者关联的资源类型 1视频
Images *string `json:"images" bson:"images,omitempty"` // 背景
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
}
type VideoTagInfoUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoTagInfoUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoTagInfoDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+103
View File
@@ -0,0 +1,103 @@
package srv_videotag
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/ipdailystatmod"
"videoweb/pub/models/videotagmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// HotTagReq 热门标签请求参数
type HotTagReq struct {
Limit int64 `form:"limit" json:"limit"` // 返回条数,默认 10,最大 1000
}
// HotTagItem 热门标签结果
type HotTagItem struct {
TagId int64 `json:"tagId"`
Title string `json:"title"`
WatchTimes int64 `json:"watchTimes"`
}
// HotTagResp 热门标签返回
type HotTagResp struct {
List []*HotTagItem `json:"list"`
}
// GetHotTagsByPlayData 按预计算的 watchTimes 直接取 TopN(O(logN),依赖 {status:1, watchTimes:-1} 索引)
func GetHotTagsByPlayData(ctx context.Context, req *HotTagReq) (*HotTagResp, *srvdef.SrvErr) {
limit := req.Limit
if limit <= 0 {
limit = 10
}
if limit > 1000 {
limit = 1000
}
resp := &HotTagResp{List: []*HotTagItem{}}
filter := bson.M{
videotagmod.FN.VideoTagInfo.Status: true,
videotagmod.FN.VideoTagInfo.WatchTimes: bson.M{"$gt": 0},
}
opts := options.Find().
SetSort(bson.D{
{Key: videotagmod.FN.VideoTagInfo.WatchTimes, Value: -1},
{Key: videotagmod.FN.VideoTagInfo.Id, Value: 1},
}).
SetLimit(limit)
tags, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opts)
if serr != nil {
return resp, serr
}
for _, t := range tags {
resp.List = append(resp.List, &HotTagItem{
TagId: t.Id,
Title: t.Title,
WatchTimes: t.WatchTimes,
})
}
return resp, nil
}
type IpStatReq struct {
TodayIP int64 `json:"todayIP"`
YesterdayIP int64 `json:"yesterdayIP"`
}
// FetchIpStat 从 MongoDB ip_daily_stat 集合读取 app 端同步过来的 IP 去重数。
// app 端中间件收集 IP 存入本地 Redis,定时任务将 SCard 写入共享 MongoDB,web 在此读取。
// 日期统一按北京时间(Asia/Shanghai)计算。
func FetchIpStat(ctx context.Context) *IpStatReq {
ipStatReq := &IpStatReq{
TodayIP: 0,
YesterdayIP: 0,
}
now := time.Now()
bjNow := now.In(utils.LocBJ)
today := bjNow.Format("20060102")
yesterday := bjNow.AddDate(0, 0, -1).Format("20060102")
if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, today); err == nil {
ipStatReq.TodayIP = cnt
} else {
log.Warn("FetchIpStat: FindCount today failed",
log.Any("appId", pubdef.AppId), log.E(err))
}
if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, yesterday); err == nil {
ipStatReq.YesterdayIP = cnt
} else {
log.Warn("FetchIpStat: FindCount yesterday failed",
log.Any("appId", pubdef.AppId), log.E(err))
}
return ipStatReq
}
+167
View File
@@ -0,0 +1,167 @@
package srv_videotag
import (
"context"
"strings"
"sync"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videotagmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
MediaTypeVideo int64 = 1
MediaTypePost int64 = 2
)
var firstTagMu sync.Mutex
// FindOrCreateByTitle 查询或创建同步数据中的一级标签。
func FindOrCreateByTitle(ctx context.Context, title string, mediaType int64) (int64, *srvdef.SrvErr) {
title = strings.TrimSpace(title)
if title == "" {
return 0, nil
}
firstTagMu.Lock()
defer firstTagMu.Unlock()
filter := bson.M{videotagmod.FN.VideoTagInfo.Title: title}
tag, serr := videotagmod.FindOneVideoTagInfo(ctx, filter)
if serr == nil {
if mediaType > 0 && !containsMediaType(tag.MediaTypes, mediaType) {
if serr = videotagmod.UpdateOneVideoTagInfo(ctx, filter, bson.M{
"$addToSet": bson.M{videotagmod.FN.VideoTagInfo.MediaTypes: mediaType},
"$set": bson.M{videotagmod.FN.VideoTagInfo.UpdatedAt: time.Now()},
}); serr != nil {
return 0, serr
}
}
return tag.Id, nil
}
if serr.Code != srvdef.ErrNoData {
return 0, serr
}
now := time.Now()
tag = &videotagmod.VideoTagInfo{
Id: mongos.Lid.VideoTag.Get(),
Title: title,
Status: true,
MediaTypes: []int64{mediaType},
IndexNow: 0,
CreatedAt: now,
UpdatedAt: now,
}
if serr = videotagmod.InsertOneVideoTagInfo(ctx, tag); serr != nil {
return 0, serr
}
return tag.Id, nil
}
// AppendTagID 向标签列表追加 ID,并避免重复。
func AppendTagID(tags []int64, tagID int64) []int64 {
if tagID <= 0 {
return tags
}
for _, id := range tags {
if id == tagID {
return tags
}
}
return append(tags, tagID)
}
func containsMediaType(mediaTypes []int64, mediaType int64) bool {
for _, value := range mediaTypes {
if value == mediaType {
return true
}
}
return false
}
// GetVideoTagInfos 按照参数搜索视频标签
func GetVideoTagInfos(ctx context.Context, param *VideoTagInfoReq) (*VideoTagInfoResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &VideoTagInfoResp{}
if param.Id != nil {
filter[videotagmod.FN.VideoTagInfo.Id] = *param.Id
}
if param.Title != nil {
filter[videotagmod.FN.VideoTagInfo.Title] = bson.M{"$regex": *param.Title}
}
opt.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Works: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videotagmod.CountVideoTagInfos(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoTagInfo 添加一视频标签
func AddVideoTagInfo(ctx context.Context,
ac *videotagmod.VideoTagInfo) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoTag.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
serr := videotagmod.InsertOneVideoTagInfo(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoTagInfo 删除指定视频标签
func DelVideoTagInfo(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id}
//删除
count, serr := videotagmod.DelOneVideoTagInfo(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoTagInfo 更新指定视频标签
func UpdateVideoTagInfo(ctx context.Context, id int64, param VideoTagInfoUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr := videotagmod.UpdateOneVideoTagInfo(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}