init: web_hgcg_server 初始化提交

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:44:55 +08:00
co-authored by Claude Opus 4.7
commit 189556e702
627 changed files with 126228 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
package service
import (
"videoweb/web/service/srv_ipblack"
"videoweb/web/service/srv_mediaresource"
"videoweb/web/skd"
)
func Start() {
skd.Start()
srv_ipblack.Init()
srv_mediaresource.Start()
}
func Stop() {
srv_mediaresource.Stop()
skd.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
}
@@ -0,0 +1,101 @@
package srv_announcement
import (
"context"
"time"
"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,
}
return announcementmod.InsertOneAnnouncement(ctx, doc)
}
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
}
return announcementmod.UpdateOneAnnouncement(ctx, filter, bson.M{"$set": updateFields})
}
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, "", "")
}
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"`
}
+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
}
+90
View File
@@ -0,0 +1,90 @@
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,omitempty"` // 回家的路PDF
ImgAppCdn *string `json:"imgAppCdn" bson:"imgAppCdn,omitempty"` // 图片CDN(app使用)
ImgAppUsCdn *string `json:"imgAppUsCdn" bson:"imgAppUsCdn,omitempty"` // 图片CDN(海外app使用)
ImgWebCdn *string `json:"imgWebCdn" bson:"imgWebCdn,omitempty"` // 图片CDN(web使用)
MediaWebCdn *string `json:"mediaWebCdn" bson:"mediaWebCdn,omitempty"` // 视频CDN(web使用)
MediaAppCdn *string `json:"mediaAppCdn" bson:"mediaAppCdn,omitempty"` // 视频CDN(app使用)
MediaAppUsCdn *string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn,omitempty"` // 视频CDN(海外app使用)
WatermarkImage *string `json:"watermarkImage" bson:"watermarkImage,omitempty"` // 图片水印文件
WatermarkMedia *string `json:"watermarkMedia" bson:"watermarkMedia,omitempty"` // 视频水印文件(底部右边)
WatermarkMediaTop *string `json:"watermarkMediaTop" bson:"watermarkMediaTop,omitempty"` // 视频水印文件(顶部左边)
WatermarkMediaTopRight *string `json:"watermarkMediaTopRight" bson:"watermarkMediaTopRight,omitempty"` // 视频水印文件(顶部右边)
WatermarkMediaBottomLeft *string `json:"watermarkMediaBottomLeft" bson:"watermarkMediaBottomLeft,omitempty"` // 视频水印文件(底部左边)
Mp4Addr *string `json:"mp4Addr" bson:"mp4Addr,omitempty"` // mp4地址(web使用)
CustomerServiceHost *string `json:"customerServiceHost" bson:"customerServiceHost,omitempty"` // 在线客服
NewUrlAddr *string `json:"newUrlAddr" bson:"newUrlAddr,omitempty"` //最新地址
NewUrlAddr1 *string `json:"newUrlAddr1" bson:"newUrlAddr1,omitempty"` //最新地址(备用地址1)
NewUrlAddr2 *string `json:"newUrlAddr2" bson:"newUrlAddr2,omitempty"` //最新地址(备用地址2)
ForeverUrlAddr *string `json:"foreverUrlAddr" bson:"foreverUrlAddr,omitempty"` //永久地址
GmailAddr *string `json:"gmailAddr" bson:"gmailAddr,omitempty"` //邮箱地址
AppAddr *string `json:"appAddr" bson:"appAddr,omitempty"` //APP下载地址(落地页)
OfficialAppAddr *string `json:"officialAppAddr" bson:"officialAppAddr,omitempty"` //官网app地址
WeiChart *string `json:"weiChart" bson:"weiChart,omitempty"` //微信群
QQ *string `json:"qq" bson:"qq,omitempty"` //qq群
TgGroup *string `json:"tgGroup" bson:"tgGroup,omitempty"` //tg群
PotaoGroup *string `json:"potaoGroup" bson:"potaoGroup,omitempty"` //土豆群
GitAddr *string `json:"gitAddr" bson:"gitAddr,omitempty"` //git地址
TwitterAddr *string `json:"twitterAddr" bson:"twitterAddr,omitempty"` //推特地址
Telegram *string `json:"telegram" bson:"telegram,omitempty"` //商务tg
TelegramAdd *string `json:"telegramAdd" bson:"telegramAdd,omitempty"` //加入我们tg
TranslationAddr *string `json:"translationAddr" bson:"translationAddr,omitempty"` //应用导航
//SEO
WebTitle *string `json:"webTitle" bson:"webTitle,omitempty"` // 网站SEO标题
WebKeyworld *[]string `json:"webKeyworld" bson:"webKeyworld,omitempty"` // 网站SEO关键字
WebDescription *string `json:"webDescription" bson:"webDescription,omitempty"` // 网站SEO描述
WebSiteName *string `json:"webSiteName" bson:"webSiteName,omitempty"` // 网站SEO网站名称
WebAddr *string `json:"webAddr" bson:"webAddr,omitempty"` // 网站SEO网站地址
DHNewUrlAddr1 *string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1,omitempty"` // 导航页-线路一
DHNewUrlAddr2 *string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2,omitempty"` // 导航页-线路2
DHNewUrlAddr3 *string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3,omitempty"` // 导航页-线路3
FreeVPN *string `json:"freeVPN" bson:"freeVPN,omitempty"` // 免费vpn链接
UseAddress *[]string `json:"useAddress" bson:"useAddress,omitempty"` // 所有使用中的域名
AdvitiesTopicNum *int64 `json:"advitiesTopicNum" bson:"advitiesTopicNum,omitempty"` // 广告间隔
DHTitle *string `json:"dhTitle" bson:"dhTitle,omitempty"` // 导航SEO标题
DHKeyworld *[]string `json:"dhKeyworld" bson:"dhKeyworld,omitempty"` // 导航SEO关键字
DHDescription *string `json:"dhDescription" bson:"dhDescription,omitempty"` // 导航SEO描述
DHSiteName *string `json:"dhSiteName" bson:"dhSiteName,omitempty"` // 导航SEO网站名称
ActressBackGround *string `json:"actressBackGround" bson:"actressBackGround,omitempty"` // 女优通用背景图
ChannelCode *string `json:"channelCode" bson:"channelCode,omitempty"` // 渠道码
EnableH265 *bool `json:"enableH265" bson:"enableH265,omitempty"` // 是否允许客户端使用 H265 地址播放
}
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"` //浮窗图片
}
+26
View File
@@ -0,0 +1,26 @@
package srv_cointrans
import (
"time"
"videoweb/pub/models/cointransmod"
"videoweb/pub/pubdef"
)
type CoinTransReq struct {
pubdef.Page
UserId *int64 `json:"userId"` // 用户ID
FlowDir *cointransmod.FlowDir `json:"flowDir"` // 收支方向:1收入 -1支出
TranType *cointransmod.TranType `json:"tranType"` // 交易类型
StartTime *string `json:"startTime"` // 开始时间 2006-01-02 15:04:05
EndTime *string `json:"endTime"` // 结束时间 2006-01-02 15:04:05
}
type CoinTransResp struct {
Total int64 `json:"total"`
List cointransmod.CoinTransList `json:"list"`
}
func parseTime(s string) (time.Time, bool) {
t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local)
return t, err == nil
}
+62
View File
@@ -0,0 +1,62 @@
package srv_cointrans
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/cointransmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetCoinTrans 分页查询金币交易流水
func GetCoinTrans(ctx context.Context, req *CoinTransReq) (*CoinTransResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.UserId != nil && *req.UserId > 0 {
filter[cointransmod.FN.CoinTrans.UserId] = *req.UserId
}
if req.FlowDir != nil {
filter[cointransmod.FN.CoinTrans.FlowDir] = *req.FlowDir
}
if req.TranType != nil {
filter[cointransmod.FN.CoinTrans.TranType] = *req.TranType
}
timeFilter := bson.M{}
if req.StartTime != nil && *req.StartTime != "" {
if t, ok := parseTime(*req.StartTime); ok {
timeFilter["$gte"] = t
}
}
if req.EndTime != nil && *req.EndTime != "" {
if t, ok := parseTime(*req.EndTime); ok {
timeFilter["$lte"] = t
}
}
if len(timeFilter) > 0 {
filter[cointransmod.FN.CoinTrans.CreatedAt] = timeFilter
}
total, serr := cointransmod.CountCoinTrans(ctx, filter)
if serr != nil {
return nil, serr
}
resp := &CoinTransResp{Total: total}
if total == 0 {
resp.List = cointransmod.CoinTransList{}
return resp, nil
}
opt := options.Find().
SetSort(bson.D{{Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1}}).
SetSkip((req.PageNum - 1) * req.PageSize).
SetLimit(req.PageSize)
list, serr := cointransmod.FindManyCoinTrans(ctx, filter, opt)
if serr != nil {
return nil, serr
}
resp.List = list
return resp, nil
}
+417
View File
@@ -0,0 +1,417 @@
package srv_commend
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/commentmod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
commentObjectTypePlaylet int64 = 10 // 短剧评论
commentObjectTypePlayletChapter int64 = 11 // 短剧剧集评论
commentReviewPass int = 1 // 审核通过
commentLevelFirst int64 = 1 // 一级评论
)
// SearchComandList 查询后台评论列表
func SearchComandList(ctx context.Context, param *CommendSearch) (*CommendSearchResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
// objectId 只表示业务对象ID,必须配合 objectType 才能准确区分短剧评论和剧集评论,避免不同集合ID碰撞。
if param.ObjectId != nil {
filter[commentmod.FN.Comment.ObjectId] = *param.ObjectId
}
if param.ObjectType != nil {
filter[commentmod.FN.Comment.ObjectType] = *param.ObjectType
}
if param.UserId != nil && *param.UserId > 0 {
filter[commentmod.FN.Comment.UserId] = *param.UserId
}
if param.Level != nil {
filter[commentmod.FN.Comment.Level] = *param.Level
}
if param.ObjectName != nil {
filter[commentmod.FN.Comment.ObjectName] = bson.M{"$regex": *param.ObjectName}
}
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),
}
// countLike 是短剧评论点赞数的新字段,likes 保留旧字段兼容后台旧页面读取。
for _, commendInfo := range commends {
tmp := &PostCommentInfo{
Id: commendInfo.Id,
ObjectId: commendInfo.ObjectId,
ObjectType: commendInfo.ObjectType,
ObjectName: commendInfo.ObjectName,
UserId: commendInfo.UserId,
UserName: commendInfo.UserName,
UserAvatar: commendInfo.UserAvatar,
Level: commendInfo.Level,
ParentsId: commendInfo.ParentsId,
ReplyCommentId: commendInfo.ReplyCommentId,
ReplyUserName: commendInfo.ReplyUserName,
Text: commendInfo.Text,
ReviewStatus: commendInfo.ReviewStatus,
UpdatedAt: commendInfo.UpdatedAt,
Ip: commendInfo.Ip,
CreatedAt: commendInfo.CreatedAt,
CountLike: commendInfo.CountLike,
Likes: commendInfo.CountLike,
}
resp.List = append(resp.List, tmp)
}
return resp, nil
}
// DelCommend 删除一条评论
func DelCommend(ctx context.Context, commendId int64) *srvdef.SrvErr {
// 先查出评论旧状态,再删除;后续扣减评论数需要依赖旧的 reviewStatus、level、objectType。
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
}
// 删除后按当前已审核通过评论数重算计数,避免历史增量失败或重复操作导致 countComment 不准。
if serr = refreshCommentCountByComment(ctx, commendInfo); serr != nil {
return serr
}
return nil
}
// 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
}
commendId := make([]int64, 0)
// 删除用户全部评论后逐条重算受影响对象的计数;重复刷新同一对象只会 set 成相同值,不会重复扣减。
for _, commendInfo := range commendList {
if serr = refreshCommentCountByComment(ctx, commendInfo); serr != nil {
return serr
}
commendId = append(commendId, commendInfo.Id)
}
return nil
}
// AddPostComment 发布评论
func AddPostComment(ctx context.Context, req *CommendReplay) *srvdef.SrvErr {
now := time.Now()
if req.Score > 10 {
log.Error("后台回复评论参数错误", log.Any("评分", req.Score))
return srvdef.New(srvdef.ErrParamError, "", "")
}
// 后台回复评论沿用旧的帖子评论逻辑,默认审核通过;短剧/剧集评论由 app 评论入口写入。
replyCommentId := int64(0)
if req.ParentsId > 0 {
replyCommentId = req.ReplyCommentId
if replyCommentId <= 0 {
replyCommentId = req.ParentsId
}
}
commentInfo := &commentmod.CommentModel{
Id: mongos.Lid.Comment.Get(),
ObjectId: req.ObjectId,
UserName: req.UserName,
ParentsId: req.ParentsId,
ReplyCommentId: replyCommentId,
Text: req.Text,
Level: 1,
UpdatedAt: now,
CreatedAt: now,
}
if commentInfo.ParentsId > 0 {
commentInfo.Level = 2
} else {
commentInfo.Level = 1
}
commentInfo.ReviewStatus = 1 // 管理端的默认审核通过
// 插入回复评论成功后,再异步增加父评论回复数,避免回复失败时污染父评论计数。
serr := commentmod.InsertOneComment(ctx, commentInfo)
if serr != nil {
log.Error("后台回复评论入库失败", log.Any("请求参数", req), log.Any("err", serr))
return serr
}
go func() {
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("后台回复评论更新父评论回复数失败", log.Any("请求参数", req), log.Any("err", serr))
return
}
}
}()
return nil
}
// 置顶评论
func TopPostComment(ctx context.Context, id int64) *srvdef.SrvErr {
// 置顶排序采用递增 topRank,新的置顶评论排在当前最高 topRank 前面。
filter := bson.M{
commentmod.FN.Comment.Id: id,
}
comment, serr := commentmod.FindOneComment(ctx, filter)
if serr != nil {
return serr
}
// 获取当前对象下已置顶排序最高的一条评论,用于生成新的 topRank。
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
}
return nil
}
func getTopRankComment(ctx context.Context, objectId int64) (*commentmod.CommentModel, *srvdef.SrvErr) {
// 只按 objectId 查是历史逻辑;短剧/剧集评论如果后续支持置顶,建议再补 objectType 避免ID碰撞。
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, serr := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: id})
if serr != nil {
return serr
}
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), log.Any("err", serr))
return serr
}
comment.ReviewStatus = reviewStatus
// 审核后按当前已通过评论数重算,重复审核同一状态也能修正历史漏加或漏减的 countComment。
if serr = refreshCommentCountByComment(ctx, comment); serr != nil {
return serr
}
return nil
}
// ReviewCommendBatch 批量更新
func ReviewCommendBatch(ctx context.Context, commendIds *[]int64, reviewStatus int) *srvdef.SrvErr {
if commendIds == nil || len(*commendIds) == 0 {
return srvdef.New(srvdef.ErrParamError, "评论ID不能为空", "")
}
// 批量审核也要先查旧数据,统一用每条评论的旧审核状态计算评论数增减。
commentList, serr := commentmod.FindManyComment(ctx, bson.M{
commentmod.FN.Comment.Id: bson.M{"$in": *commendIds},
})
if serr != nil {
return serr
}
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("评论ID集合", *commendIds), log.Any("审核状态", reviewStatus), log.Any("err", serr))
return serr
}
// 审核状态更新成功后再按对象重算计数,避免状态未更新成功时提前改动短剧/剧集评论数。
for _, comment := range commentList {
if comment == nil {
continue
}
if serr = refreshCommentCountByComment(ctx, comment); serr != nil {
return serr
}
}
return nil
}
func refreshCommentCountByComment(ctx context.Context, comment *commentmod.CommentModel) *srvdef.SrvErr {
// 根据评论归属重算对应计数:二级评论刷新父评论回复数,一级短剧/剧集评论刷新业务对象评论数。
if comment == nil {
return nil
}
if comment.Level > commentLevelFirst && comment.ParentsId > 0 {
return refreshParentCommentCount(ctx, comment.ParentsId)
}
if isSupportedCommentType(comment.ObjectType) {
return refreshCommentTargetCount(ctx, comment.ObjectType, comment.ObjectId)
}
return nil
}
func refreshParentCommentCount(ctx context.Context, parentId int64) *srvdef.SrvErr {
// 父评论回复数以当前审核通过的子评论数量为准,直接 set 可以修正历史增量不一致问题。
if parentId <= 0 {
return nil
}
count, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ParentsId: parentId,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
})
if serr != nil {
log.Error("统计父评论回复数失败", log.Any("父评论ID", parentId), log.E(serr))
return serr
}
serr = commentmod.UpdateOneComment(ctx, bson.M{
commentmod.FN.Comment.Id: parentId,
}, bson.M{
"$set": bson.M{commentmod.FN.Comment.CommentCount: count},
})
if serr != nil {
if serr.Code == srvdef.ErrNoData {
log.Warn("父评论不存在,跳过刷新回复数", log.Any("父评论ID", parentId), log.Any("回复数", count))
return nil
}
log.Error("刷新父评论回复数失败", log.Any("父评论ID", parentId), log.Any("回复数", count), log.E(serr))
return serr
}
return nil
}
func refreshCommentTargetCount(ctx context.Context, objectType, objectId int64) *srvdef.SrvErr {
// 评论数以当前审核通过的一级评论数量为准,审核通过、驳回和删除都走同一套重算逻辑。
count, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: objectId,
commentmod.FN.Comment.ObjectType: objectType,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ParentsId: 0,
})
if serr != nil {
log.Error("统计评论数失败", log.Any("对象类型", objectType), log.Any("对象ID", objectId), log.E(serr))
return serr
}
return setCommentTargetCount(ctx, objectType, objectId, count)
}
func setCommentTargetCount(ctx context.Context, objectType, objectId int64, count int64) *srvdef.SrvErr {
// 根据对象类型把重算后的评论数写回业务集合。
switch objectType {
case commentObjectTypePlaylet:
serr := playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: objectId,
}, bson.M{
"$set": bson.M{playletmod.FN.Playlet.CountComment: count},
})
if serr != nil {
log.Error("刷新短剧评论数失败", log.Any("短剧ID", objectId), log.Any("评论数", count), log.E(serr))
return serr
}
case commentObjectTypePlayletChapter:
serr := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: objectId,
}, bson.M{
"$set": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: count},
})
if serr != nil {
log.Error("刷新剧集评论数失败", log.Any("剧集ID", objectId), log.Any("评论数", count), log.E(serr))
return serr
}
}
return nil
}
func isPlayletCommentType(objectType int64) bool {
return objectType == commentObjectTypePlaylet || objectType == commentObjectTypePlayletChapter
}
func isSupportedCommentType(objectType int64) bool {
return isPlayletCommentType(objectType)
}
+80
View File
@@ -0,0 +1,80 @@
package srv_commend
import (
"time"
"videoweb/pub/pubdef"
)
// CommendSearch 查询评论
type CommendSearch struct {
ObjectId *int64 `json:"objectId" bson:"objectId"` // 对象ID
ObjectType *int64 `json:"objectType"` // 对象类型 10-短剧 11-短剧剧集
ObjectName *string `json:"objectName" bson:"objectName"` // 对象名(模糊)
UserId *int64 `json:"userId"` // 评论用户ID
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
ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID
ObjectType int64 `json:"objectType" bson:"objectType"` // 对象类型 10-短剧 11-短剧剧集
ObjectName string `json:"objectName" bson:"objectName"` // 对象名
UserId int64 `json:"userId" bson:"userId"` // 评论用户ID
UserName string `json:"userName" bson:"userName"` // 用户名称
UserAvatar string `json:"userAvatar" bson:"userAvatar"` // 评论用户头像
Level int64 `json:"level" bson:"level"` // 评论级别
ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID
ReplyCommentId int64 `json:"replyCommentId" bson:"replyCommentId"` // 被回复评论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"` // 点赞数
CountLike int64 `json:"countLike" bson:"countLike"` // 评论点赞数
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
ReplyCommentId int64 `json:"replyCommentId"` // 被回复评论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"`
}
+273
View File
@@ -0,0 +1,273 @@
package srv_contactus
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/contactusmod"
"videoweb/pub/models/usermod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// contactUsDefaultPageSize web端联系我们列表默认每页数量
contactUsDefaultPageSize int64 = 10
// contactUsMaxPageSize web端联系我们列表最大每页数量,避免单次请求拉取过多数据
contactUsMaxPageSize int64 = 100
)
var (
// contactUsWebProjection 只读取web联系我们列表需要展示的字段,减少MongoDB传输开销。
contactUsWebProjection = bson.D{
{Key: contactusmod.FN.ContactUs.Id, Value: 1},
{Key: contactusmod.FN.ContactUs.UserId, Value: 1},
{Key: contactusmod.FN.ContactUs.Email, Value: 1},
{Key: contactusmod.FN.ContactUs.QuestionType, Value: 1},
{Key: contactusmod.FN.ContactUs.Title, Value: 1},
{Key: contactusmod.FN.ContactUs.Content, Value: 1},
{Key: contactusmod.FN.ContactUs.Attachments, Value: 1},
{Key: contactusmod.FN.ContactUs.Status, Value: 1},
{Key: contactusmod.FN.ContactUs.Remark, Value: 1},
{Key: contactusmod.FN.ContactUs.CreatedAt, Value: 1},
{Key: contactusmod.FN.ContactUs.UpdatedAt, Value: 1},
}
// contactUsWebSort web端联系我们列表按创建时间倒序返回。
contactUsWebSort = bson.D{
{Key: contactusmod.FN.ContactUs.CreatedAt, Value: -1},
{Key: contactusmod.FN.ContactUs.Id, Value: -1},
}
)
// ListContactUs 分页查询联系我们列表
func ListContactUs(ctx context.Context, req *proto.ContactUsListReq) (*proto.ContactUsListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter, filterErr := buildContactUsListFilter(req)
if filterErr != nil {
return nil, filterErr
}
pageNum, pageSize := normalizeContactUsPage(req.PageNum, req.PageSize)
totalCount, err := contactusmod.CountContactUs(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.ContactUsListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.ContactUsListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := contactusmod.FindManyContactUs(ctx, filter, options.Find().
SetProjection(contactUsWebProjection).
SetSort(contactUsWebSort).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
userNameMap, err := buildContactUsUserNameMap(ctx, list)
if err != nil {
return nil, err
}
resp.List = contactUsListToWebResp(list, userNameMap)
return resp, nil
}
// HandleContactUs 批量更新联系我们处理状态
func HandleContactUs(ctx context.Context, req *proto.ContactUsHandleReq) (*proto.ContactUsHandleResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.Status != 0 && req.Status != 1 {
return nil, srvdef.New(srvdef.ErrParamError, "status无效")
}
ids, err := normalizeContactUsIds(req.Ids)
if err != nil {
return nil, err
}
matchedCount, modifiedCount, err := contactusmod.UpdateManyContactUsWithResult(ctx, bson.M{
contactusmod.FN.ContactUs.Id: bson.M{"$in": ids},
}, bson.M{
"$set": bson.M{
contactusmod.FN.ContactUs.Status: req.Status,
contactusmod.FN.ContactUs.Remark: strings.TrimSpace(req.Remark),
contactusmod.FN.ContactUs.UpdatedAt: time.Now(),
},
})
if err != nil {
return nil, err
}
if matchedCount == 0 {
return nil, srvdef.New(srvdef.ErrNoData, "", "")
}
return &proto.ContactUsHandleResp{
MatchedCount: matchedCount,
ModifiedCount: modifiedCount,
}, nil
}
// buildContactUsListFilter 构造web端联系我们列表查询条件
func buildContactUsListFilter(req *proto.ContactUsListReq) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
if req.UserId != nil && *req.UserId > 0 {
filter[contactusmod.FN.ContactUs.UserId] = *req.UserId
}
if email := strings.TrimSpace(req.Email); email != "" {
filter[contactusmod.FN.ContactUs.Email] = bson.M{"$regex": regexp.QuoteMeta(email)}
}
if req.Type != nil {
if !isValidContactUsType(*req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "type无效")
}
filter[contactusmod.FN.ContactUs.QuestionType] = *req.Type
}
if req.Status != nil {
if *req.Status != 0 && *req.Status != 1 {
return nil, srvdef.New(srvdef.ErrParamError, "status无效")
}
filter[contactusmod.FN.ContactUs.Status] = *req.Status
}
createdAt, err := buildContactUsTimeFilter(req.CreateStartTime, req.CreateEndTime)
if err != nil {
return nil, err
}
if len(createdAt) > 0 {
filter[contactusmod.FN.ContactUs.CreatedAt] = createdAt
}
return filter, nil
}
// buildContactUsTimeFilter 按开始时间和结束时间构造createdAt范围查询
func buildContactUsTimeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
startTime = strings.TrimSpace(startTime)
endTime = strings.TrimSpace(endTime)
if startTime != "" {
startAt, err := parseContactUsWebTime(startTime)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "createStartTime格式错误")
}
filter["$gte"] = startAt
}
if endTime != "" {
endAt, err := parseContactUsWebTime(endTime)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "createEndTime格式错误")
}
filter["$lte"] = endAt
}
return filter, nil
}
func parseContactUsWebTime(value string) (time.Time, error) {
return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
}
func normalizeContactUsPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = contactUsDefaultPageSize
}
if pageSize > contactUsMaxPageSize {
pageSize = contactUsMaxPageSize
}
return pageNum, pageSize
}
func normalizeContactUsIds(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
}
func isValidContactUsType(questionType int32) bool {
return questionType >= 1 && questionType <= 5
}
// buildContactUsUserNameMap 批量查询用户名称,避免列表转换时逐条查询用户。
func buildContactUsUserNameMap(ctx context.Context, list contactusmod.ContactUsList) (map[int64]string, *srvdef.SrvErr) {
userIds := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.UserId <= 0 {
continue
}
if _, ok := seen[item.UserId]; ok {
continue
}
seen[item.UserId] = struct{}{}
userIds = append(userIds, item.UserId)
}
userNameMap := make(map[int64]string, len(userIds))
if len(userIds) == 0 {
return userNameMap, nil
}
users, err := usermod.FindManyUser(ctx, bson.M{
usermod.FN.User.Id: bson.M{"$in": userIds},
}, options.Find().SetProjection(bson.M{
usermod.FN.User.Id: 1,
usermod.FN.User.NickName: 1,
}))
if err != nil {
return nil, err
}
for _, user := range users {
if user != nil {
userNameMap[user.Id] = user.NickName
}
}
return userNameMap, nil
}
// contactUsListToWebResp 转换联系我们列表响应
func contactUsListToWebResp(list contactusmod.ContactUsList, userNameMap map[int64]string) []*proto.ContactUsListResp {
resp := make([]*proto.ContactUsListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
resp = append(resp, &proto.ContactUsListResp{
Id: item.Id,
UserId: item.UserId,
UserName: userNameMap[item.UserId],
Email: item.Email,
Type: item.QuestionType,
Title: item.Title,
Content: item.Content,
Attachments: item.Attachments,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
return resp
}
+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
}
+271
View File
@@ -0,0 +1,271 @@
package srv_feedback
import (
"context"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/feedbackmod"
"videoweb/pub/models/usermod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// feedbackDefaultPageSize web端意见反馈列表默认每页数量
feedbackDefaultPageSize int64 = 10
// feedbackMaxPageSize web端意见反馈列表最大每页数量,避免单次请求拉取过多数据
feedbackMaxPageSize int64 = 100
)
var (
// feedbackWebProjection 只读取web意见反馈列表需要展示的字段,减少MongoDB传输开销。
feedbackWebProjection = bson.D{
{Key: feedbackmod.FN.Feedback.Id, Value: 1},
{Key: feedbackmod.FN.Feedback.UserId, Value: 1},
{Key: feedbackmod.FN.Feedback.FeedbackType, Value: 1},
{Key: feedbackmod.FN.Feedback.Content, Value: 1},
{Key: feedbackmod.FN.Feedback.Screenshots, Value: 1},
{Key: feedbackmod.FN.Feedback.Contact, Value: 1},
{Key: feedbackmod.FN.Feedback.Status, Value: 1},
{Key: feedbackmod.FN.Feedback.Remark, Value: 1},
{Key: feedbackmod.FN.Feedback.CreatedAt, Value: 1},
{Key: feedbackmod.FN.Feedback.UpdatedAt, Value: 1},
}
// feedbackWebSort web端意见反馈列表按创建时间倒序返回。
feedbackWebSort = bson.D{
{Key: feedbackmod.FN.Feedback.CreatedAt, Value: -1},
{Key: feedbackmod.FN.Feedback.Id, Value: -1},
}
)
// ListFeedback 分页查询意见反馈列表
func ListFeedback(ctx context.Context, req *proto.FeedbackListReq) (*proto.FeedbackListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter, filterErr := buildFeedbackListFilter(req)
if filterErr != nil {
return nil, filterErr
}
pageNum, pageSize := normalizeFeedbackPage(req.PageNum, req.PageSize)
totalCount, err := feedbackmod.CountFeedback(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.FeedbackListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.FeedbackListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := feedbackmod.FindManyFeedback(ctx, filter, options.Find().
SetProjection(feedbackWebProjection).
SetSort(feedbackWebSort).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
userNameMap, err := buildFeedbackUserNameMap(ctx, list)
if err != nil {
return nil, err
}
resp.List = feedbackListToWebResp(list, userNameMap)
return resp, nil
}
// HandleFeedback 批量更新意见反馈处理状态
func HandleFeedback(ctx context.Context, req *proto.FeedbackHandleReq) (*proto.FeedbackHandleResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.Status != 0 && req.Status != 1 {
return nil, srvdef.New(srvdef.ErrParamError, "status无效")
}
ids, err := normalizeFeedbackIds(req.Ids)
if err != nil {
return nil, err
}
matchedCount, modifiedCount, err := feedbackmod.UpdateManyFeedbackWithResult(ctx, bson.M{
feedbackmod.FN.Feedback.Id: bson.M{"$in": ids},
}, bson.M{
"$set": bson.M{
feedbackmod.FN.Feedback.Status: req.Status,
feedbackmod.FN.Feedback.Remark: strings.TrimSpace(req.Remark),
feedbackmod.FN.Feedback.UpdatedAt: time.Now(),
},
})
if err != nil {
return nil, err
}
if matchedCount == 0 {
return nil, srvdef.New(srvdef.ErrNoData, "", "")
}
return &proto.FeedbackHandleResp{
MatchedCount: matchedCount,
ModifiedCount: modifiedCount,
}, nil
}
// buildFeedbackListFilter 构造web端意见反馈列表查询条件
func buildFeedbackListFilter(req *proto.FeedbackListReq) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
if req.UserId != nil && *req.UserId > 0 {
filter[feedbackmod.FN.Feedback.UserId] = *req.UserId
}
if req.Type != nil {
if !isValidFeedbackType(*req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "type无效")
}
filter[feedbackmod.FN.Feedback.FeedbackType] = *req.Type
}
if req.Status != nil {
if *req.Status != 0 && *req.Status != 1 {
return nil, srvdef.New(srvdef.ErrParamError, "status无效")
}
filter[feedbackmod.FN.Feedback.Status] = *req.Status
}
createdAt, err := buildFeedbackTimeFilter(req.CreateStartTime, req.CreateEndTime)
if err != nil {
return nil, err
}
if len(createdAt) > 0 {
filter[feedbackmod.FN.Feedback.CreatedAt] = createdAt
}
return filter, nil
}
// buildFeedbackTimeFilter 按开始时间和结束时间构造createdAt范围查询
func buildFeedbackTimeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) {
filter := bson.M{}
startTime = strings.TrimSpace(startTime)
endTime = strings.TrimSpace(endTime)
if startTime != "" {
startAt, err := parseFeedbackWebTime(startTime)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "createStartTime格式错误")
}
filter["$gte"] = startAt
}
if endTime != "" {
endAt, err := parseFeedbackWebTime(endTime)
if err != nil {
return nil, srvdef.New(srvdef.ErrParamError, "createEndTime格式错误")
}
filter["$lte"] = endAt
}
return filter, nil
}
func parseFeedbackWebTime(value string) (time.Time, error) {
return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
}
func normalizeFeedbackPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = feedbackDefaultPageSize
}
if pageSize > feedbackMaxPageSize {
pageSize = feedbackMaxPageSize
}
return pageNum, pageSize
}
func normalizeFeedbackIds(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
}
func isValidFeedbackType(feedbackType int32) bool {
return feedbackType >= 1 && feedbackType <= 5
}
// buildFeedbackUserNameMap 批量查询反馈用户名称,避免列表转换时逐条查询用户。
func buildFeedbackUserNameMap(ctx context.Context, list feedbackmod.FeedbackList) (map[int64]string, *srvdef.SrvErr) {
userIds := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, item := range list {
if item == nil || item.UserId <= 0 {
continue
}
if _, ok := seen[item.UserId]; ok {
continue
}
seen[item.UserId] = struct{}{}
userIds = append(userIds, item.UserId)
}
userNameMap := make(map[int64]string, len(userIds))
if len(userIds) == 0 {
return userNameMap, nil
}
users, err := usermod.FindManyUser(ctx, bson.M{
usermod.FN.User.Id: bson.M{"$in": userIds},
}, options.Find().SetProjection(bson.M{
usermod.FN.User.Id: 1,
usermod.FN.User.NickName: 1,
}))
if err != nil {
return nil, err
}
for _, user := range users {
if user != nil {
userNameMap[user.Id] = user.NickName
}
}
return userNameMap, nil
}
// feedbackListToWebResp 转换意见反馈列表响应
func feedbackListToWebResp(list feedbackmod.FeedbackList, userNameMap map[int64]string) []*proto.FeedbackListResp {
resp := make([]*proto.FeedbackListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
screenshots := item.Screenshots
if screenshots == nil {
screenshots = make([]string, 0)
}
resp = append(resp, &proto.FeedbackListResp{
Id: item.Id,
UserId: item.UserId,
UserName: userNameMap[item.UserId],
Type: item.FeedbackType,
Content: item.Content,
Screenshots: screenshots,
Contact: item.Contact,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
return resp
}
+238
View File
@@ -0,0 +1,238 @@
package srv_homebanner
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/playletcache"
"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
}
clearHomeBannerCache()
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
if err := homebannermod.UpdateOneHomeBanner(ctx,
bson.M{homebannermod.FN.HomeBanner.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearHomeBannerCache()
return nil
}
// 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, "", "")
}
clearHomeBannerCache()
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
}
// clearHomeBannerCache 清除短剧首页缓存
func clearHomeBannerCache() {
playletcache.ClearHomeCache()
}
+323
View File
@@ -0,0 +1,323 @@
package srv_homeconfig
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/sitemapdirty"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
defaultHomeConfigPageSize int64 = 10
maxHomeConfigPageSize int64 = 100
)
var homeConfigWebProjection = bson.D{
{Key: homeconfigmod.FN.HomeConfig.Id, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Name, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Slug, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.MoreCategoryId, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Desc, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Type, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Status, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1},
}
// ListHomeConfig 分页查询首页配置列表
func ListHomeConfig(ctx context.Context, req *proto.HomeConfigListReq) (*proto.HomeConfigListPageResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
filter := bson.M{}
if req.Id != nil && *req.Id > 0 {
filter[homeconfigmod.FN.HomeConfig.Id] = *req.Id
}
if req.Type != nil {
if !validHomeConfigType(*req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "")
}
filter[homeconfigmod.FN.HomeConfig.Type] = *req.Type
}
if req.SortType != nil {
if !validHomeConfigSortType(*req.SortType) {
return nil, srvdef.New(srvdef.ErrParamError, "排序字段无效", "")
}
filter[homeconfigmod.FN.HomeConfig.SortType] = *req.SortType
}
if req.Status != nil {
filter[homeconfigmod.FN.HomeConfig.Status] = *req.Status
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[homeconfigmod.FN.HomeConfig.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
if slug := strings.TrimSpace(req.Slug); slug != "" {
filter[homeconfigmod.FN.HomeConfig.Slug] = slug
}
pageNum, pageSize := normalizeHomeConfigPage(req.PageNum, req.PageSize)
totalCount, err := homeconfigmod.CountHomeConfig(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.HomeConfigListPageResp{
BasePageResp: response.BasePageResp{
TotalCount: totalCount,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]*proto.HomeConfigListResp, 0),
},
}
if totalCount == 0 {
return resp, nil
}
list, err := homeconfigmod.FindManyHomeConfig(ctx, filter, options.Find().
SetProjection(homeConfigWebProjection).
SetSort(bson.D{
{Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1},
{Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: -1},
{Key: homeconfigmod.FN.HomeConfig.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
items := make([]*proto.HomeConfigListResp, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.HomeConfigListResp{
Id: item.Id,
MoreCategoryId: item.MoreCategoryId,
Name: item.Name,
Slug: item.Slug,
Desc: item.Desc,
Type: item.Type,
Rank: item.Rank,
SortType: item.SortType,
Status: item.Status,
SeoInfo: normalizeHomeConfigSeoValue(item.SeoInfo),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
resp.List = items
return resp, nil
}
// AddHomeConfig 新增首页配置
func AddHomeConfig(ctx context.Context, req *proto.HomeConfigAddReq) (*proto.HomeConfigAddResp, *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 !validHomeConfigType(req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "")
}
if !validHomeConfigSortType(req.SortType) {
return nil, srvdef.New(srvdef.ErrParamError, "排序字段无效", "")
}
if req.Status == nil {
return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "")
}
if err := validateHomeMoreCategory(ctx, req.MoreCategoryId); err != nil {
return nil, err
}
doc := &homeconfigmod.HomeConfig{
Name: name,
MoreCategoryId: req.MoreCategoryId,
Slug: strings.TrimSpace(req.Slug),
Desc: strings.TrimSpace(req.Desc),
Type: req.Type,
Rank: req.Rank,
SortType: req.SortType,
Status: *req.Status,
SeoInfo: normalizeHomeConfigSeoValue(req.SeoInfo),
}
if err := ensureHomeConfigSlug(ctx, doc, 0); err != nil {
return nil, err
}
if err := homeconfigmod.InsertOneHomeConfig(ctx, doc); err != nil {
return nil, err
}
clearHomeConfigCache()
sitemapdirty.Mark(ctx)
return &proto.HomeConfigAddResp{Id: doc.Id}, nil
}
// UpdateHomeConfig 更新首页配置
func UpdateHomeConfig(ctx context.Context, req *proto.HomeConfigUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "首页配置 ID无效", "")
}
info, err := homeconfigmod.FindOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id})
if err != nil {
return err
}
setDoc := bson.M{homeconfigmod.FN.HomeConfig.UpdatedAt: time.Now()}
if req.MoreCategoryId != nil {
if err := validateHomeMoreCategory(ctx, *req.MoreCategoryId); err != nil {
return err
}
setDoc[homeconfigmod.FN.HomeConfig.MoreCategoryId] = *req.MoreCategoryId
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "名称不能为空", "")
}
setDoc[homeconfigmod.FN.HomeConfig.Name] = name
}
if req.Desc != nil {
setDoc[homeconfigmod.FN.HomeConfig.Desc] = strings.TrimSpace(*req.Desc)
}
if req.Type != nil {
if !validHomeConfigType(*req.Type) {
return srvdef.New(srvdef.ErrParamError, "类型无效", "")
}
setDoc[homeconfigmod.FN.HomeConfig.Type] = *req.Type
}
if req.Rank != nil {
setDoc[homeconfigmod.FN.HomeConfig.Rank] = *req.Rank
}
if req.SortType != nil {
if !validHomeConfigSortType(*req.SortType) {
return srvdef.New(srvdef.ErrParamError, "排序字段无效", "")
}
setDoc[homeconfigmod.FN.HomeConfig.SortType] = *req.SortType
}
if req.Status != nil {
setDoc[homeconfigmod.FN.HomeConfig.Status] = *req.Status
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeHomeConfigSeoValue(*req.SeoInfo)
}
if serr := prepareHomeConfigSlugOnUpdate(ctx, req.Id, info, req, setDoc); serr != nil {
return serr
}
if len(setDoc) <= 1 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
if err := homeconfigmod.UpdateOneHomeConfig(ctx,
bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id},
bson.M{"$set": setDoc},
); err != nil {
return err
}
clearHomeConfigCache()
sitemapdirty.Mark(ctx)
return nil
}
// DelHomeConfig 删除首页配置
func DelHomeConfig(ctx context.Context, req *proto.HomeConfigDelReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "首页配置 ID无效", "")
}
count, err := homeconfigmod.DelOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id})
if err != nil {
return err
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
clearHomeConfigCache()
sitemapdirty.Mark(ctx)
return nil
}
func validHomeConfigType(typ int32) bool {
return typ == homeconfigmod.TypePlaylet || typ == homeconfigmod.TypeTheme
}
func validateHomeMoreCategory(ctx context.Context, id int64) *srvdef.SrvErr {
if id < 0 {
return srvdef.New(srvdef.ErrParamError, "更多跳转目标ID无效", "")
}
if id == 0 {
return nil
}
fields := translationcategorymod.FN.TranslationCategory
category, err := translationcategorymod.FindOneTranslationCategory(ctx, bson.M{
fields.Id: id,
fields.Status: true,
fields.HomeMoreEnabled: true,
fields.CategoryType: pubdef.TranslationTypeUrl,
})
if err != nil {
if err.Code == srvdef.ErrNoData {
return srvdef.New(srvdef.ErrParamError, "更多跳转目标不可用", "")
}
return err
}
if strings.TrimSpace(category.UrlValue) == "" {
return srvdef.New(srvdef.ErrParamError, "更多跳转目标路径为空", "")
}
return nil
}
func validHomeConfigSortType(sortType int32) bool {
return sortType == homeconfigmod.SortTypeRecommend ||
sortType == homeconfigmod.SortTypeHot ||
sortType == homeconfigmod.SortTypeLatest ||
sortType == homeconfigmod.SortTypePreview ||
sortType == homeconfigmod.SortTypeCategory
}
func clearHomeConfigCache() {
playletcache.ClearHomeCache()
}
// normalizeHomeConfigPage 规范分页参数,限制单页数量避免大分页拖慢查询
func normalizeHomeConfigPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultHomeConfigPageSize
}
if pageSize > maxHomeConfigPageSize {
pageSize = maxHomeConfigPageSize
}
return pageNum, pageSize
}
// normalizeHomeConfigSeoValue 规范化 SEO 字段,避免 keywords 为 null
func normalizeHomeConfigSeoValue(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
}
+132
View File
@@ -0,0 +1,132 @@
package srv_homeconfig
import (
"context"
"strings"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/homeconfigmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ensureHomeConfigSlug(ctx context.Context, ac *homeconfigmod.HomeConfig, excludeId int64) *srvdef.SrvErr {
ac.Slug = utils.SanitizeSlug(ac.Slug)
if ac.Slug != "" {
if utils.IsSlugAllDigits(ac.Slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
filter := bson.M{homeconfigmod.FN.HomeConfig.Slug: ac.Slug}
if excludeId > 0 {
filter[homeconfigmod.FN.HomeConfig.Id] = bson.M{"$ne": excludeId}
}
exist, _ := homeconfigmod.FindOneHomeConfig(ctx, filter)
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
return nil
}
source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name)
ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool {
filter := bson.M{homeconfigmod.FN.HomeConfig.Slug: slug}
if excludeId > 0 {
filter[homeconfigmod.FN.HomeConfig.Id] = bson.M{"$ne": excludeId}
}
exist, _ := homeconfigmod.FindOneHomeConfig(ctx, filter)
return exist != nil
})
return nil
}
func prepareHomeConfigSlugOnUpdate(ctx context.Context, id int64, info *homeconfigmod.HomeConfig, req *proto.HomeConfigUpdateReq, setDoc bson.M) *srvdef.SrvErr {
slug := info.Slug
if req.Slug != nil {
slug = utils.SanitizeSlug(*req.Slug)
}
if slug != "" {
if req.Slug != nil {
if utils.IsSlugAllDigits(slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
exist, _ := homeconfigmod.FindOneHomeConfig(ctx, bson.M{
homeconfigmod.FN.HomeConfig.Slug: slug,
homeconfigmod.FN.HomeConfig.Id: bson.M{"$ne": id},
})
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
setDoc[homeconfigmod.FN.HomeConfig.Slug] = slug
}
return nil
}
name := info.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := info.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeHomeConfigSeoValue(*req.SeoInfo).Keyworld
}
source := utils.SlugSourceFromKeywordsOrTitle(keywords, name)
setDoc[homeconfigmod.FN.HomeConfig.Slug] = utils.MakeUniqueSlug(source, func(s string) bool {
exist, _ := homeconfigmod.FindOneHomeConfig(ctx, bson.M{
homeconfigmod.FN.HomeConfig.Slug: s,
homeconfigmod.FN.HomeConfig.Id: bson.M{"$ne": id},
})
return exist != nil
})
return nil
}
func emptyHomeConfigSlugFilter() bson.M {
return bson.M{
"$or": []bson.M{
{homeconfigmod.FN.HomeConfig.Slug: ""},
{homeconfigmod.FN.HomeConfig.Slug: bson.M{"$exists": false}},
},
}
}
// SyncEmptySlugs 为首页配置中空 slug 自动生成。
func SyncEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
if maxSize <= 0 {
return 0, false, nil
}
list, err := homeconfigmod.FindManyHomeConfig(ctx, emptyHomeConfigSlugFilter(), options.Find().
SetSort(bson.D{{Key: homeconfigmod.FN.HomeConfig.Id, Value: 1}}).
SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
hasMore := int64(len(list)) > maxSize
if hasMore {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := ensureHomeConfigSlug(ctx, item, item.Id); serr != nil {
return updated, hasMore, serr
}
if strings.TrimSpace(item.Slug) == "" {
continue
}
if serr := homeconfigmod.UpdateOneHomeConfig(ctx,
bson.M{homeconfigmod.FN.HomeConfig.Id: item.Id},
bson.M{"$set": bson.M{homeconfigmod.FN.HomeConfig.Slug: item.Slug}},
); serr != nil {
return updated, hasMore, serr
}
updated++
}
return updated, hasMore, nil
}
func HasEmptySlug(ctx context.Context) bool {
count, _ := homeconfigmod.CountHomeConfig(ctx, emptyHomeConfigSlugFilter())
return count > 0
}
+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
}
+38
View File
@@ -0,0 +1,38 @@
package srv_ipstat
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/utils"
"videoweb/pub/models/ipdailystatmod"
"videoweb/pub/pubdef"
)
// IpStatResp 今日和昨日访问 IP 去重数。
type IpStatResp struct {
TodayIP int64 `json:"todayIP"`
YesterdayIP int64 `json:"yesterdayIP"`
}
// FetchIpStat 返回 app 端同步到 MongoDB 的每日访问 IP 去重数。
func FetchIpStat(ctx context.Context) *IpStatResp {
resp := &IpStatResp{}
now := time.Now().In(utils.LocBJ)
today := now.Format("20060102")
yesterday := now.AddDate(0, 0, -1).Format("20060102")
if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, today); err == nil {
resp.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 {
resp.YesterdayIP = cnt
} else {
log.Warn("FetchIpStat: FindCount yesterday failed",
log.Any("appId", pubdef.AppId), log.E(err))
}
return resp
}
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,228 @@
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/common/playletcache"
"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
playletcache.ClearHomeCache()
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
}
// FillPlayletCompressedHorizontalCover 为指定短剧补写压缩横版封面,已有值则跳过。
func FillPlayletCompressedHorizontalCover(ctx context.Context, playlet *playletmod.Playlet) bool {
if playlet == nil || playlet.Id <= 0 {
return false
}
if strings.TrimSpace(playlet.CompressedHorizontalCover) != "" {
return false
}
cover := strings.TrimSpace(playlet.HorizontalCover)
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.CompressedHorizontalCover: bson.M{"$exists": false}},
{playletmod.FN.Playlet.CompressedHorizontalCover: ""},
{playletmod.FN.Playlet.CompressedHorizontalCover: nil},
},
},
bson.M{"$set": bson.M{
playletmod.FN.Playlet.CompressedHorizontalCover: pathStr,
playletmod.FN.Playlet.UpdatedAt: now,
}},
); err != nil {
log.Warn("回写短剧压缩横版封面失败", log.Any("playletId", playlet.Id), log.E(err))
return false
}
playlet.CompressedHorizontalCover = pathStr
playletcache.ClearHomeCache()
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.UploadAutopublishImage(outName, compressed)
if err != nil {
log.Warn("media上传压缩封面失败,回退图床", log.Any("cover", cover), log.E(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; cgdj-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,267 @@
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"
playletResourceJHImportQueueKeyFmt = "playlet:resource:jh:import:queue:%d"
playletResourceJHImportLockKeyFmt = "playlet:resource:jh: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)
}
func playletResourceJHImportQueueKey() string {
return fmt.Sprintf(playletResourceJHImportQueueKeyFmt, pubdef.AppId)
}
func playletResourceJHImportLockKey(mediaId uint) string {
return fmt.Sprintf(playletResourceJHImportLockKeyFmt, 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 runPlayletResourceJHImportWorker(ctx)
go runPlayletSerialSyncWorker(ctx)
go runPlayletCoverCompressWorker(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
}
// pushPlayletResourceJHImportJob 投递嘉华短剧媒资导入任务
func pushPlayletResourceJHImportJob(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(playletResourceJHImportQueueKey(), 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])
}
}
// runPlayletResourceJHImportWorker 循环消费嘉华短剧媒资导入队列
func runPlayletResourceJHImportWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
result, err := redis.Client().BLPop(playletResourceImportPopWait, playletResourceJHImportQueueKey()).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
}
handlePlayletResourceJHImportPayload(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))
}
}
// lockPlayletResourceJHImportMedia 锁定单条嘉华媒资导入
func lockPlayletResourceJHImportMedia(mediaId uint) (bool, error) {
return redis.Setnx_NewOK(playletResourceJHImportLockKey(mediaId), true, playletResourceImportLockTTL)
}
// unlockPlayletResourceJHImportMedia 释放单条嘉华媒资导入锁
func unlockPlayletResourceJHImportMedia(mediaId uint) {
if err := redis.Del(playletResourceJHImportLockKey(mediaId)); err != nil {
log.Error("释放嘉华短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err))
}
}
// handlePlayletResourceJHImportPayload 处理单个嘉华短剧媒资导入任务
func handlePlayletResourceJHImportPayload(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 := importPlayletJHResourceBatch(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))
}
@@ -0,0 +1,249 @@
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 短剧媒资列表接口统一错误文案
playletMediaListErrMsg = "短剧媒资列表查询失败"
// playletMediaSyncErrMsg 短剧媒资同步接口统一错误文案
playletMediaSyncErrMsg = "短剧媒资同步失败"
// playletMediaDeletedCode 媒资中心资源已删除业务码
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"` // 分页信息
}
// playletMediaJHListResp 媒资中心嘉华短剧列表响应 data 节点
type playletMediaJHListResp struct {
Data []*proto.PlayletResourceJHListItem `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
}
// mediaJHList 查询嘉华短剧媒资列表
func (m *playletMediaCenterService) mediaJHList(p *playletMediaListReq) (result playletMediaJHListResp, 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
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,528 @@
package srv_mediaresource
import (
"context"
"encoding/json"
"fmt"
"strings"
"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"
playletSerialSyncJHLockKeyFmt = "playlet:serial:sync:jh: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)
}
func playletSerialSyncJHLockKey(mediaId uint) string {
return fmt.Sprintf(playletSerialSyncJHLockKeyFmt, pubdef.AppId, mediaId)
}
type playletSerialSyncJob struct {
PlayletId int64 `json:"playletId"`
MediaCenterId uint `json:"mediaCenterId"`
MediaSource string `json:"mediaSource"`
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,
MediaSource: playlet.MediaSource,
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.MediaSource, 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,
MediaSource: playlet.MediaSource,
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) {
lockSource := job.MediaSource
locked, err := lockPlayletSerialSyncBySource(lockSource, job.MediaCenterId)
if err != nil {
log.Error("获取连载短剧同步锁失败", log.Any("job", job), log.E(err))
return 0, "获取同步锁失败"
}
if !locked {
return 0, "该媒资正在同步"
}
defer unlockPlayletSerialSyncBySource(lockSource, 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, ""
}
mediaSource := playlet.MediaSource
if mediaSource == "" {
mediaSource = job.MediaSource
}
ms := newPlayletMediaCenterBySource(mediaSource)
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, mediaSource, 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 title := strings.TrimSpace(playlet.Title); title != "" {
description := playletmod.BuildPlayNameDescription(playlet.Desc, title, updateStatus, int64(media.TotalEpisode))
if description != "" {
setDoc[playletmod.FN.Playlet.Description] = description
setDoc[playletmod.FN.Playlet.SeoInfo.Description] = description
}
}
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, mediaSource string, mediaContentList []playletMediaContent) (playletchaptermod.PlayletChapterList, string) {
exists, err := loadExistingPlayletChapterKeys(ctx, playletId)
if err != nil {
return nil, "查询已有章节失败"
}
chapters := buildPlayletChaptersFromMedia(mediaContentList, mediaSource)
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{}{}
}
}
// 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
}
// 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))
}
}
// lockPlayletSerialSyncBySource 按媒资来源锁定单个媒资ID
func lockPlayletSerialSyncBySource(mediaSource string, mediaId uint) (bool, error) {
if mediaSource == playletmod.MediaSourceHG {
return lockPlayletSerialSync(mediaId)
}
return redis.Setnx_NewOK(playletSerialSyncJHLockKey(mediaId), true, playletSerialSyncLockTTL)
}
// unlockPlayletSerialSyncBySource 按媒资来源释放单个媒资ID的同步锁
func unlockPlayletSerialSyncBySource(mediaSource string, mediaId uint) {
if mediaSource == playletmod.MediaSourceHG {
unlockPlayletSerialSync(mediaId)
return
}
if err := redis.Del(playletSerialSyncJHLockKey(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)
}
+8
View File
@@ -0,0 +1,8 @@
package srv_playlet
import "videoweb/pub/common/playletcache"
// clearAppPlayletHomeCache 清除app首页短剧缓存
func clearAppPlayletHomeCache() {
playletcache.ClearHomeCache()
}
+57
View File
@@ -0,0 +1,57 @@
package srv_playlet
import (
"context"
"videoweb/pub/models/mwacc"
"videoweb/pub/models/playletmod"
"videoweb/web/middleware/auth"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ResolveOperator 解析当前后台登录操作人,供短剧写操作与媒资入库共用。
func ResolveOperator(ctx context.Context) (int64, string) {
return resolvePlayletOperator(ctx)
}
// resolvePlayletOperator 从后台登录态解析当前操作人。
func resolvePlayletOperator(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
}
// applyPlayletOperatorToSetDoc 将当前操作人写入更新文档。
func applyPlayletOperatorToSetDoc(ctx context.Context, setDoc bson.M) {
if setDoc == nil {
return
}
operatorId, operatorName := resolvePlayletOperator(ctx)
setDoc[playletmod.FN.Playlet.OperatorId] = operatorId
setDoc[playletmod.FN.Playlet.OperatorName] = operatorName
}
// applyPlayletOperatorToDoc 将当前操作人写入新增文档。
func applyPlayletOperatorToDoc(ctx context.Context, doc *playletmod.Playlet) {
if doc == nil {
return
}
doc.OperatorId, doc.OperatorName = resolvePlayletOperator(ctx)
}
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
package srv_playlet
import (
"context"
"regexp"
"sort"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletchapterunlockmod"
"videoweb/pub/models/playletcollectmod"
"videoweb/pub/models/playletlikemod"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const playletSalesLimit = 50
type playletSalesAggRow struct {
PlayletId int64 `bson:"_id"`
Count int64 `bson:"count"`
Coins int64 `bson:"coins"`
}
type playletSalesRow struct {
Id int64
UnlockCount int64
UnlockCoins int64
LikeCount int64
CollectCount int64
}
// ListPlayletSales 按时间段汇总短剧金币解锁、点赞、收藏,最多返回 50 条。
func ListPlayletSales(ctx context.Context, req *proto.PlayletSalesReq) (*proto.PlayletSalesResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
start, end, serr := resolvePlayletSalesTimeRange(req.StartTime, req.EndTime)
if serr != nil {
return nil, serr
}
resp := &proto.PlayletSalesResp{
StartTime: start.In(utils.LocBJ).Format(pubdef.TimeISO),
EndTime: end.In(utils.LocBJ).Format(pubdef.TimeISO),
List: make([]*proto.PlayletSalesItem, 0),
}
filterIDs, playlets, serr := loadPlayletSalesFilter(ctx, req)
if serr != nil {
return nil, serr
}
if filterIDs != nil && len(filterIDs) == 0 {
return resp, nil
}
unlocks, serr := aggregatePlayletUnlocks(ctx, start, end, filterIDs)
if serr != nil {
return nil, serr
}
likes, serr := aggregatePlayletMetric(ctx, start, end, filterIDs, playletlikemod.FN.PlayletLike.CreatedAt, playletlikemod.FN.PlayletLike.PlayletId, playletlikemod.AggregatePlayletLike)
if serr != nil {
return nil, serr
}
collects, serr := aggregatePlayletMetric(ctx, start, end, filterIDs, playletcollectmod.FN.PlayletCollect.CreatedAt, playletcollectmod.FN.PlayletCollect.PlayletId, playletcollectmod.AggregatePlayletCollect)
if serr != nil {
return nil, serr
}
rows := mergePlayletSalesRows(filterIDs, unlocks, likes, collects)
sortPlayletSalesRows(rows, req.SortType)
rows = takePlayletSalesRows(rows, playletSalesLimit)
if len(rows) == 0 {
return resp, nil
}
if playlets == nil {
playlets, serr = loadPlayletsByIDs(ctx, playletSalesIDs(rows))
if serr != nil {
return nil, serr
}
}
for _, row := range rows {
item := &proto.PlayletSalesItem{
Id: row.Id,
UnlockCount: row.UnlockCount,
UnlockCoins: row.UnlockCoins,
LikeCount: row.LikeCount,
CollectCount: row.CollectCount,
}
if playlet := playlets[row.Id]; playlet != nil {
item.Title = playlet.Title
item.Cover = playlet.Cover
item.Slug = playlet.Slug
item.Status = playlet.Status
}
resp.List = append(resp.List, item)
}
return resp, nil
}
func resolvePlayletSalesTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) {
startRaw = strings.TrimSpace(startRaw)
endRaw = strings.TrimSpace(endRaw)
if startRaw == "" && endRaw == "" {
day := time.Now().In(utils.LocBJ).AddDate(0, 0, -1)
return utils.BeginOfTime(day), utils.EndOfTime(day), nil
}
if startRaw == "" || endRaw == "" {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间和结束时间需同时传入", "开始时间和结束时间需同时传入")
}
return parsePlayletSalesTimeRange(startRaw, endRaw)
}
func parsePlayletSalesTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) {
start, ok := parsePlayletSalesTime(startRaw, true)
if !ok {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间无效", "开始时间无效")
}
end, ok := parsePlayletSalesTime(endRaw, false)
if !ok {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间无效", "结束时间无效")
}
if end.Before(start) {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间不能早于开始时间", "结束时间不能早于开始时间")
}
return start, end, nil
}
func parsePlayletSalesTime(raw string, isStart bool) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
layouts := []string{
pubdef.TimeISO,
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700 MST",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, utils.LocBJ); err == nil {
return t, true
}
}
if t, err := time.ParseInLocation(pubdef.DateISO, raw, utils.LocBJ); err == nil {
if isStart {
return utils.BeginOfTime(t), true
}
return utils.EndOfTime(t), true
}
return time.Time{}, false
}
func loadPlayletSalesFilter(ctx context.Context, req *proto.PlayletSalesReq) ([]int64, map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
id := int64(0)
if req.Id != nil {
id = *req.Id
}
title := strings.TrimSpace(req.Title)
if id <= 0 && title == "" {
return nil, nil, nil
}
filter := bson.M{}
if id > 0 {
filter[playletmod.FN.Playlet.Id] = id
}
if title != "" {
pattern := regexp.QuoteMeta(title)
filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": pattern}
}
list, serr := playletmod.FindManyPlaylet(ctx, filter, options.Find().SetProjection(playletSalesProjection()))
if serr != nil {
return nil, nil, serr
}
ids := make([]int64, 0, len(list))
playlets := make(map[int64]*playletmod.Playlet, len(list))
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
ids = append(ids, item.Id)
playlets[item.Id] = item
}
return ids, playlets, nil
}
func playletSalesProjection() bson.D {
return bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
}
}
func loadPlayletsByIDs(ctx context.Context, ids []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) {
out := make(map[int64]*playletmod.Playlet, len(ids))
if len(ids) == 0 {
return out, nil
}
list, serr := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(playletSalesProjection()))
if serr != nil {
return nil, serr
}
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
out[item.Id] = item
}
return out, nil
}
func aggregatePlayletUnlocks(ctx context.Context, start, end time.Time, playletIDs []int64) (map[int64]playletSalesAggRow, *srvdef.SrvErr) {
match := bson.M{
playletchapterunlockmod.FN.PlayletChapterUnlock.CreatedAt: bson.M{
"$gte": start,
"$lte": end,
},
}
if len(playletIDs) > 0 {
match[playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId] = bson.M{"$in": playletIDs}
}
pipeline := []bson.M{
{"$match": match},
{"$group": bson.M{
"_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId,
"count": bson.M{"$sum": 1},
"coins": bson.M{"$sum": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Coins},
}},
}
rows := make([]playletSalesAggRow, 0)
if serr := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); serr != nil {
return nil, serr
}
return playletSalesAggMap(rows), nil
}
func aggregatePlayletMetric(ctx context.Context, start, end time.Time, playletIDs []int64, createdAtField, playletIDField string, agg func(context.Context, interface{}, []bson.M) *srvdef.SrvErr) (map[int64]playletSalesAggRow, *srvdef.SrvErr) {
match := bson.M{
createdAtField: bson.M{
"$gte": start,
"$lte": end,
},
}
if len(playletIDs) > 0 {
match[playletIDField] = bson.M{"$in": playletIDs}
}
pipeline := []bson.M{
{"$match": match},
{"$group": bson.M{
"_id": "$" + playletIDField,
"count": bson.M{"$sum": 1},
}},
}
rows := make([]playletSalesAggRow, 0)
if serr := agg(ctx, &rows, pipeline); serr != nil {
return nil, serr
}
return playletSalesAggMap(rows), nil
}
func playletSalesAggMap(rows []playletSalesAggRow) map[int64]playletSalesAggRow {
out := make(map[int64]playletSalesAggRow, len(rows))
for _, row := range rows {
if row.PlayletId <= 0 {
continue
}
out[row.PlayletId] = row
}
return out
}
func mergePlayletSalesRows(filterIDs []int64, unlocks, likes, collects map[int64]playletSalesAggRow) []playletSalesRow {
seen := make(map[int64]struct{})
if filterIDs != nil {
for _, id := range filterIDs {
if id > 0 {
seen[id] = struct{}{}
}
}
} else {
for id := range unlocks {
seen[id] = struct{}{}
}
for id := range likes {
seen[id] = struct{}{}
}
for id := range collects {
seen[id] = struct{}{}
}
}
rows := make([]playletSalesRow, 0, len(seen))
for id := range seen {
row := playletSalesRow{Id: id}
if item, ok := unlocks[id]; ok {
row.UnlockCount = item.Count
row.UnlockCoins = item.Coins
}
if item, ok := likes[id]; ok {
row.LikeCount = item.Count
}
if item, ok := collects[id]; ok {
row.CollectCount = item.Count
}
rows = append(rows, row)
}
return rows
}
func sortPlayletSalesRows(rows []playletSalesRow, sortType proto.PlayletSalesSortType) {
sort.SliceStable(rows, func(i, j int) bool {
left, right := rows[i], rows[j]
switch sortType {
case proto.PlayletSalesSortLike:
if left.LikeCount != right.LikeCount {
return left.LikeCount > right.LikeCount
}
case proto.PlayletSalesSortCollect:
if left.CollectCount != right.CollectCount {
return left.CollectCount > right.CollectCount
}
default:
if left.UnlockCount != right.UnlockCount {
return left.UnlockCount > right.UnlockCount
}
if left.UnlockCoins != right.UnlockCoins {
return left.UnlockCoins > right.UnlockCoins
}
}
if left.UnlockCount != right.UnlockCount {
return left.UnlockCount > right.UnlockCount
}
if left.LikeCount != right.LikeCount {
return left.LikeCount > right.LikeCount
}
if left.CollectCount != right.CollectCount {
return left.CollectCount > right.CollectCount
}
return left.Id > right.Id
})
}
func takePlayletSalesRows(rows []playletSalesRow, limit int) []playletSalesRow {
if limit <= 0 || len(rows) <= limit {
return rows
}
return rows[:limit]
}
func playletSalesIDs(rows []playletSalesRow) []int64 {
ids := make([]int64, 0, len(rows))
for _, row := range rows {
if row.Id > 0 {
ids = append(ids, row.Id)
}
}
return ids
}
+68
View File
@@ -0,0 +1,68 @@
package srv_playlet
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/models/playletmod"
"videoweb/pub/seo/sitemapdirty"
"videoweb/web/service/srv_seopush"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func markSitemapDirty(ctx context.Context) {
sitemapdirty.Mark(ctx)
}
// notifyPlayletSeoAfterWrite 短剧写入后:刷新 sitemap 标记。
// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。
// prevStatus 为变更前状态;nil 表示新增。
func notifyPlayletSeoAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status) {
markSitemapDirty(ctx)
item, 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.Status, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
}))
if err != nil || item == nil {
if err != nil {
log.Warn("seopush: load playlet after write failed", log.Any("playletId", playletId), log.E(err))
}
return
}
wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp
if item.Status == playletmod.StatusUp {
if !wasUp {
srv_seopush.EnqueuePlayletUpdated(ctx, item.Id, item.Status, item.OnlineAt)
}
return
}
if wasUp {
srv_seopush.EnqueuePlayletDeleted(ctx, item.Id)
}
}
// notifyPlayletSeoAfterStatus 批量上下架后的 SEO 通知
func notifyPlayletSeoAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) {
markSitemapDirty(ctx)
if status == playletmod.StatusUp {
for _, id := range ids {
srv_seopush.EnqueuePlayletUpdated(ctx, id, status, onlineAt)
}
return
}
for _, id := range ids {
srv_seopush.EnqueuePlayletDeleted(ctx, id)
}
}
// notifyPlayletSeoAfterDelete 物理删除后刷新 sitemap 标记,并对原上架短剧推送删除通知。
func notifyPlayletSeoAfterDelete(ctx context.Context, onlineIds []int64) {
markSitemapDirty(ctx)
for _, id := range onlineIds {
srv_seopush.EnqueuePlayletDeleted(ctx, id)
}
}
+416
View File
@@ -0,0 +1,416 @@
package srv_playlet
import (
"context"
"strings"
"time"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"videoweb/web/api/proto"
"videoweb/web/service/srv_homeconfig"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
syncSlugDefaultSize = int64(500)
syncSlugMaxSize = int64(2000)
)
func emptySlugFilter() bson.M {
return bson.M{
"$or": []bson.M{
{"slug": ""},
{"slug": bson.M{"$exists": false}},
},
}
}
func ensureThemeSlug(ctx context.Context, ac *playletthememod.PlayletTheme, excludeId int64) *srvdef.SrvErr {
ac.Slug = utils.SanitizeSlug(ac.Slug)
if ac.Slug != "" {
if utils.IsSlugAllDigits(ac.Slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
filter := bson.M{playletthememod.FN.PlayletTheme.Slug: ac.Slug}
if excludeId > 0 {
filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playletthememod.FindOnePlayletTheme(ctx, filter)
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
return nil
}
source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name)
ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool {
filter := bson.M{playletthememod.FN.PlayletTheme.Slug: slug}
if excludeId > 0 {
filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playletthememod.FindOnePlayletTheme(ctx, filter)
return exist != nil
})
return nil
}
func prepareThemeSlugOnUpdate(ctx context.Context, id int64, info *playletthememod.PlayletTheme, req *proto.PlayletThemeUpdateReq, setDoc bson.M) *srvdef.SrvErr {
slug := info.Slug
if req.Slug != nil {
slug = utils.SanitizeSlug(*req.Slug)
}
if slug != "" {
if req.Slug != nil {
if utils.IsSlugAllDigits(slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
exist, _ := playletthememod.FindOnePlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Slug: slug,
playletthememod.FN.PlayletTheme.Id: bson.M{"$ne": id},
})
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
setDoc[playletthememod.FN.PlayletTheme.Slug] = slug
}
return nil
}
name := info.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := info.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeSeoValue(*req.SeoInfo).Keyworld
}
source := utils.SlugSourceFromKeywordsOrTitle(keywords, name)
newSlug := utils.MakeUniqueSlug(source, func(s string) bool {
exist, _ := playletthememod.FindOnePlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Slug: s,
playletthememod.FN.PlayletTheme.Id: bson.M{"$ne": id},
})
return exist != nil
})
setDoc[playletthememod.FN.PlayletTheme.Slug] = newSlug
return nil
}
func ensureTagSlug(ctx context.Context, ac *playlettagmod.PlayletTag, excludeId int64) *srvdef.SrvErr {
ac.Slug = utils.SanitizeSlug(ac.Slug)
if ac.Slug != "" {
if utils.IsSlugAllDigits(ac.Slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
filter := bson.M{playlettagmod.FN.PlayletTag.Slug: ac.Slug}
if excludeId > 0 {
filter[playlettagmod.FN.PlayletTag.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playlettagmod.FindOnePlayletTag(ctx, filter)
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
return nil
}
source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name)
ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool {
filter := bson.M{playlettagmod.FN.PlayletTag.Slug: slug}
if excludeId > 0 {
filter[playlettagmod.FN.PlayletTag.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playlettagmod.FindOnePlayletTag(ctx, filter)
return exist != nil
})
return nil
}
func prepareTagSlugOnUpdate(ctx context.Context, id int64, info *playlettagmod.PlayletTag, req *proto.PlayletTagUpdateReq, setDoc bson.M) *srvdef.SrvErr {
slug := info.Slug
if req.Slug != nil {
slug = utils.SanitizeSlug(*req.Slug)
}
if slug != "" {
if req.Slug != nil {
if utils.IsSlugAllDigits(slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Slug: slug,
playlettagmod.FN.PlayletTag.Id: bson.M{"$ne": id},
})
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
setDoc[playlettagmod.FN.PlayletTag.Slug] = slug
}
return nil
}
name := info.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
keywords := info.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeSeoValue(*req.SeoInfo).Keyworld
}
source := utils.SlugSourceFromKeywordsOrTitle(keywords, name)
newSlug := utils.MakeUniqueSlug(source, func(s string) bool {
exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Slug: s,
playlettagmod.FN.PlayletTag.Id: bson.M{"$ne": id},
})
return exist != nil
})
setDoc[playlettagmod.FN.PlayletTag.Slug] = newSlug
return nil
}
func ensurePlayletSlug(ctx context.Context, ac *playletmod.Playlet, excludeId int64) *srvdef.SrvErr {
ac.Slug = utils.SanitizeSlug(ac.Slug)
if ac.Slug != "" {
if utils.IsSlugAllDigits(ac.Slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
filter := bson.M{playletmod.FN.Playlet.Slug: ac.Slug}
if excludeId > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playletmod.FindOnePlaylet(ctx, filter)
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
return nil
}
source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Title)
ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool {
filter := bson.M{playletmod.FN.Playlet.Slug: slug}
if excludeId > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": excludeId}
}
exist, _ := playletmod.FindOnePlaylet(ctx, filter)
return exist != nil
})
return nil
}
func preparePlayletSlugOnUpdate(ctx context.Context, id int64, info *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M) *srvdef.SrvErr {
slug := info.Slug
if req.Slug != nil {
slug = utils.SanitizeSlug(*req.Slug)
}
if slug != "" {
if req.Slug != nil {
if utils.IsSlugAllDigits(slug) {
return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字")
}
exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Slug: slug,
playletmod.FN.Playlet.Id: bson.M{"$ne": id},
})
if exist != nil {
return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在")
}
setDoc[playletmod.FN.Playlet.Slug] = slug
}
return nil
}
title := info.Title
if req.Title != nil {
title = strings.TrimSpace(*req.Title)
}
keywords := info.SeoInfo.Keyworld
if req.SeoInfo != nil {
keywords = normalizeSeoValue(*req.SeoInfo).Keyworld
}
source := utils.SlugSourceFromKeywordsOrTitle(keywords, title)
newSlug := utils.MakeUniqueSlug(source, func(s string) bool {
exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Slug: s,
playletmod.FN.Playlet.Id: bson.M{"$ne": id},
})
return exist != nil
})
setDoc[playletmod.FN.Playlet.Slug] = newSlug
return nil
}
// SyncEmptySlugs 为题材、标签、短剧中空 slug 自动生成。可重复调用。
func SyncEmptySlugs(ctx context.Context, req *proto.PlayletSlugSyncReq) (*proto.PlayletSlugSyncResp, *srvdef.SrvErr) {
maxSize := syncSlugDefaultSize
if req != nil && req.MaxSize > 0 {
maxSize = req.MaxSize
}
if maxSize > syncSlugMaxSize {
maxSize = syncSlugMaxSize
}
resp := &proto.PlayletSlugSyncResp{}
remain := maxSize
themeUpdated, themeMore, err := syncThemeEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.ThemeUpdated = themeUpdated
remain -= themeUpdated
if remain <= 0 {
resp.HasMore = themeMore || hasEmptySlug(ctx, "tag") || hasEmptySlug(ctx, "playlet") || srv_homeconfig.HasEmptySlug(ctx)
playletcache.ClearHomeCache()
return resp, nil
}
tagUpdated, tagMore, err := syncTagEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.TagUpdated = tagUpdated
remain -= tagUpdated
if remain <= 0 {
resp.HasMore = tagMore || hasEmptySlug(ctx, "playlet") || srv_homeconfig.HasEmptySlug(ctx)
playletcache.ClearHomeCache()
return resp, nil
}
playletUpdated, playletMore, err := syncPlayletEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.PlayletUpdated = playletUpdated
remain -= playletUpdated
if remain <= 0 {
resp.HasMore = playletMore || srv_homeconfig.HasEmptySlug(ctx)
playletcache.ClearHomeCache()
return resp, nil
}
homeUpdated, homeMore, err := srv_homeconfig.SyncEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.HomeConfigUpdated = homeUpdated
resp.HasMore = playletMore || homeMore
playletcache.ClearHomeCache()
return resp, nil
}
func hasEmptySlug(ctx context.Context, kind string) bool {
filter := emptySlugFilter()
switch kind {
case "theme":
count, _ := playletthememod.CountPlayletTheme(ctx, filter)
return count > 0
case "tag":
count, _ := playlettagmod.CountPlayletTag(ctx, filter)
return count > 0
case "playlet":
count, _ := playletmod.CountPlaylet(ctx, filter)
return count > 0
default:
return false
}
}
func syncThemeEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playletthememod.FindManyPlayletTheme(ctx, emptySlugFilter(), options.Find().
SetSort(bson.D{{Key: playletthememod.FN.PlayletTheme.Id, Value: 1}}).
SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
hasMore := int64(len(list)) > maxSize
if hasMore {
list = list[:maxSize]
}
var updated int64
now := time.Now()
for _, item := range list {
if item == nil {
continue
}
if serr := ensureThemeSlug(ctx, item, item.Id); serr != nil {
return updated, hasMore, serr
}
if err := playletthememod.UpdateOnePlayletTheme(ctx,
bson.M{playletthememod.FN.PlayletTheme.Id: item.Id},
bson.M{"$set": bson.M{
playletthememod.FN.PlayletTheme.Slug: item.Slug,
playletthememod.FN.PlayletTheme.UpdatedAt: now,
}},
); err != nil {
return updated, hasMore, err
}
updated++
}
return updated, hasMore, nil
}
func syncTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playlettagmod.FindManyPlayletTag(ctx, emptySlugFilter(), options.Find().
SetSort(bson.D{{Key: playlettagmod.FN.PlayletTag.Id, Value: 1}}).
SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
hasMore := int64(len(list)) > maxSize
if hasMore {
list = list[:maxSize]
}
var updated int64
now := time.Now()
for _, item := range list {
if item == nil {
continue
}
if serr := ensureTagSlug(ctx, item, item.Id); serr != nil {
return updated, hasMore, serr
}
if err := playlettagmod.UpdateOnePlayletTag(ctx,
bson.M{playlettagmod.FN.PlayletTag.Id: item.Id},
bson.M{"$set": bson.M{
playlettagmod.FN.PlayletTag.Slug: item.Slug,
playlettagmod.FN.PlayletTag.UpdatedAt: now,
}},
); err != nil {
return updated, hasMore, err
}
updated++
}
return updated, hasMore, nil
}
func syncPlayletEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := playletmod.FindManyPlaylet(ctx, emptySlugFilter(), options.Find().
SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}).
SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
hasMore := int64(len(list)) > maxSize
if hasMore {
list = list[:maxSize]
}
var updated int64
now := time.Now()
for _, item := range list {
if item == nil {
continue
}
if serr := ensurePlayletSlug(ctx, item, item.Id); serr != nil {
return updated, hasMore, serr
}
if err := playletmod.UpdateOnePlaylet(ctx,
bson.M{playletmod.FN.Playlet.Id: item.Id},
bson.M{"$set": bson.M{
playletmod.FN.Playlet.Slug: item.Slug,
playletmod.FN.Playlet.UpdatedAt: now,
}},
); err != nil {
return updated, hasMore, err
}
updated++
}
return updated, hasMore, nil
}
+170
View File
@@ -0,0 +1,170 @@
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)}
}
if slug := strings.TrimSpace(req.Slug); slug != "" {
filter[playlettagmod.FN.PlayletTag.Slug] = slug
}
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,
Slug: item.Slug,
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),
Slug: strings.TrimSpace(req.Slug),
Status: req.Status,
Rank: req.Rank,
SeoInfo: normalizeSeoValue(req.SeoInfo),
}
if err := ensureTagSlug(ctx, doc, 0); err != nil {
return nil, err
}
if err := playlettagmod.InsertOnePlayletTag(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
markSitemapDirty(ctx)
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无效", "")
}
info, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Id: req.Id})
if err != nil {
return err
}
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 err := prepareTagSlugOnUpdate(ctx, req.Id, info, req, setDoc); err != nil {
return err
}
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()
markSitemapDirty(ctx)
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()
markSitemapDirty(ctx)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package srv_playlet
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"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"
)
// 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 req.IsRecommend != nil {
filter[playletthememod.FN.PlayletTheme.IsRecommend] = *req.IsRecommend
}
if name := strings.TrimSpace(req.Name); name != "" {
filter[playletthememod.FN.PlayletTheme.Name] = bson.M{"$regex": regexp.QuoteMeta(name)}
}
if slug := strings.TrimSpace(req.Slug); slug != "" {
filter[playletthememod.FN.PlayletTheme.Slug] = slug
}
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().
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,
IsRecommend: item.IsRecommend,
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: strings.TrimSpace(req.Slug),
Icon: req.Icon,
Status: req.Status,
IsRecommend: req.IsRecommend,
Rank: req.Rank,
SeoInfo: normalizeSeoValue(req.SeoInfo),
}
if err := ensureThemeSlug(ctx, doc, 0); err != nil {
return nil, err
}
if err := playletthememod.InsertOnePlayletTheme(ctx, doc); err != nil {
return nil, err
}
clearAppPlayletHomeCache()
markSitemapDirty(ctx)
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无效", "")
}
info, 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] = *req.Icon
}
if req.Status != nil {
setDoc[playletthememod.FN.PlayletTheme.Status] = *req.Status
}
if req.IsRecommend != nil {
setDoc[playletthememod.FN.PlayletTheme.IsRecommend] = *req.IsRecommend
}
if req.Rank != nil {
setDoc[playletthememod.FN.PlayletTheme.Rank] = *req.Rank
}
if req.SeoInfo != nil {
setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo)
}
if err := prepareThemeSlugOnUpdate(ctx, req.Id, info, req, setDoc); err != nil {
return err
}
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()
markSitemapDirty(ctx)
return nil
}
// SetPlayletThemeRecommend 单/批量设置题材是否推荐
func SetPlayletThemeRecommend(ctx context.Context, req *proto.PlayletThemeRecommendSetReq) *srvdef.SrvErr {
if req == nil {
return srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
ids := dedupePositiveInt64Ids(req.Ids)
if len(ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "题材ID不能为空", "")
}
if err := playletthememod.UpdateManyPlayletTheme(ctx,
bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}},
bson.M{"$set": bson.M{
playletthememod.FN.PlayletTheme.IsRecommend: req.IsRecommend,
playletthememod.FN.PlayletTheme.UpdatedAt: time.Now(),
}},
); 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()
markSitemapDirty(ctx)
return nil
}
+308
View File
@@ -0,0 +1,308 @@
package srv_recharge
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/cointransmod"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/models/usermod"
"videoweb/pub/models/vipordermod"
"videoweb/pub/payment"
"videoweb/pub/pubdef"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
)
// BillCallbackParam 支付成功后的入账参数。签名校验由新支付中心回调完成。
type BillCallbackParam struct {
TradeNo string `json:"transNo" form:"transNo"`
Oid string `json:"oid" form:"oid"`
RealAmount int64 `json:"payMoney" form:"payMoney"`
PaymentAt time.Time `json:"paymentAt" form:"paymentAt"`
SuccessAt time.Time `json:"successAt" form:"successAt"`
}
// OnPayCenterCallback 统一支付回调入口
// 通过 vip_order 表查询订单,再按 OrderType 分支处理。
func OnPayCenterCallback(ctx *gin.Context, param *BillCallbackParam) *srvdef.SrvErr {
log.Info("srv_recharge:OnPayCenterCallback",
log.Any("tradeNo", param.TradeNo),
log.Any("oid", param.Oid),
log.Any("realAmount", param.RealAmount),
)
if param.TradeNo == "" {
return srvdef.New(srvdef.ErrParamError, "tradeNo 不能为空", "")
}
order, serr := vipordermod.FindOneVipOrder(ctx, bson.M{
vipordermod.FN.VipOrder.TradeNo: param.TradeNo,
})
if serr != nil {
log.Error("OnPayCenterCallback FindOneVipOrder failed",
log.Any("tradeNo", param.TradeNo), log.E(serr))
return serr
}
if order.Status == pubdef.VipOrderSuccess {
log.Info("OnPayCenterCallback already success", log.Any("tradeNo", param.TradeNo))
return nil
}
if order.Oid != "" && order.Oid != param.Oid {
return srvdef.New(srvdef.ErrParamError,
fmt.Sprintf("oid 不匹配: %s vs %s", order.Oid, param.Oid), "")
}
paymentAt := resolvePaymentAt(param)
if serr = vipordermod.UpdateOrderSuccess(ctx, param.TradeNo, param.Oid, param.RealAmount, paymentAt); serr != nil {
log.Error("OnPayCenterCallback UpdateOrderSuccess failed",
log.Any("tradeNo", param.TradeNo), log.E(serr))
return serr
}
orderSnap := *order
realAmount := param.RealAmount
oid := param.Oid
switch order.OrderType {
case vipordermod.OrderTypeCoin:
go handleCoinOrderSuccess(orderSnap, oid, realAmount)
default:
go handleVipOrderSuccess(orderSnap, oid, realAmount)
}
return nil
}
func handleVipOrderSuccess(order vipordermod.VipOrder, oid string, realAmount int64) {
ctx := context.Background()
pkg, err := findVipPackage(ctx, order.CardId)
if err != nil {
log.Error("handleVipOrderSuccess find package failed",
log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err))
return
}
expire, err := payment.ActivateVip(ctx, order.UserId, pkg)
if err != nil {
log.Error("handleVipOrderSuccess ActivateVip failed, retrying",
log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err))
time.Sleep(3 * time.Second)
expire, err = payment.ActivateVip(ctx, order.UserId, pkg)
if err != nil {
log.Error("handleVipOrderSuccess ActivateVip retry failed",
log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err))
}
}
log.Info("handleVipOrderSuccess done", log.Any("userId", order.UserId), log.Any("expire", expire))
if pkg.GiftCoin > 0 {
_ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{
UserId: order.UserId,
FlowDir: cointransmod.FlowIn,
TranType: cointransmod.TranTypeVipGiftCoin,
Amount: pkg.GiftCoin,
OrderNo: order.TradeNo,
Remark: payment.CoinTransRemark(cointransmod.TranTypeVipGiftCoin, pkg.Name),
})
}
reportVipOrderPaid(ctx, &order, oid, realAmount, expire)
}
func handleCoinOrderSuccess(order vipordermod.VipOrder, oid string, realAmount int64) {
ctx := context.Background()
if err := addUserCoins(ctx, order.UserId, order.Coin); err != nil {
log.Error("handleCoinOrderSuccess addUserCoins failed, retrying",
log.Any("userId", order.UserId), log.Any("coin", order.Coin), log.E(err))
time.Sleep(3 * time.Second)
if err = addUserCoins(ctx, order.UserId, order.Coin); err != nil {
log.Error("handleCoinOrderSuccess addUserCoins retry failed",
log.Any("userId", order.UserId), log.Any("coin", order.Coin), log.E(err))
}
}
log.Info("handleCoinOrderSuccess done", log.Any("userId", order.UserId), log.Any("coin", order.Coin))
writeCoinOrderTrans(ctx, order)
reportCoinOrderPaid(ctx, &order, oid, realAmount)
}
func writeCoinOrderTrans(ctx context.Context, order vipordermod.VipOrder) {
baseCoin, giftCoin := resolveCoinOrderSplit(ctx, order)
balanceAfter, ok := coinOrderBalanceAfter(ctx, order.UserId)
if !ok || giftCoin <= 0 {
_ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{
UserId: order.UserId,
FlowDir: cointransmod.FlowIn,
TranType: cointransmod.TranTypeCoinRecharge,
Amount: order.Coin,
OrderNo: order.TradeNo,
Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinRecharge, ""),
})
return
}
if baseCoin > 0 {
_ = payment.WriteCoinTrans(ctx, payment.WriteCoinTransOpt{
UserId: order.UserId,
FlowDir: cointransmod.FlowIn,
TranType: cointransmod.TranTypeCoinRecharge,
Amount: baseCoin,
OrderNo: order.TradeNo,
Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinRecharge, ""),
}, balanceAfter-giftCoin)
}
_ = payment.WriteCoinTrans(ctx, payment.WriteCoinTransOpt{
UserId: order.UserId,
FlowDir: cointransmod.FlowIn,
TranType: cointransmod.TranTypeCoinGift,
Amount: giftCoin,
OrderNo: order.TradeNo,
Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinGift, ""),
}, balanceAfter)
}
func resolveCoinOrderSplit(ctx context.Context, order vipordermod.VipOrder) (int64, int64) {
if order.BaseCoin+order.GiftCoin == order.Coin && (order.BaseCoin > 0 || order.GiftCoin > 0) {
return order.BaseCoin, order.GiftCoin
}
pkg, _ := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.Id: order.CoinCfgId,
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeCoin,
})
if pkg != nil && pkg.BaseCoin+pkg.GiftCoin == order.Coin {
return pkg.BaseCoin, pkg.GiftCoin
}
return order.Coin, 0
}
func coinOrderBalanceAfter(ctx context.Context, userId int64) (int64, bool) {
user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if serr != nil || user == nil {
return 0, false
}
return user.Recharge, true
}
func resolvePaymentAt(param *BillCallbackParam) time.Time {
if !param.PaymentAt.IsZero() {
return param.PaymentAt
}
if !param.SuccessAt.IsZero() {
return param.SuccessAt
}
return time.Now()
}
func findVipPackage(ctx context.Context, packageId int64) (*rechargepackagemod.RechargePackage, *srvdef.SrvErr) {
pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.Id: packageId,
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip,
})
if serr != nil || pkg == nil {
return nil, srvdef.New(srvdef.ErrNoData, "会员套餐不存在", "")
}
return pkg, nil
}
func addUserCoins(ctx context.Context, userId int64, coin int64) *srvdef.SrvErr {
return usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: userId},
bson.M{
"$inc": bson.M{usermod.FN.User.Recharge: coin},
"$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()},
},
)
}
func reportVipOrderPaid(ctx context.Context, order *vipordermod.VipOrder, oid string, realAmount int64, vipExpire time.Time) {
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId})
districtCode, ip := "", order.IPAddr
if user != nil {
districtCode = user.DistrictCode
}
ua := useragent.UA{SysType: order.SysType}
eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix())
cf := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderPaid, districtCode, ua, strconv.FormatInt(user.Id, 10), ip)
var expireTS int64
if !vipExpire.IsZero() {
expireTS = vipExpire.Unix()
}
cf.Payload = dataReport.JsonRawMessage(&dataReport.OrderPaidEvent{
OrderID: order.TradeNo,
OrderType: "vip_subscription",
ProductID: strconv.FormatInt(order.CardId, 10),
Amount: dataReport.JsonNumber(realAmount),
Currency: "CNY",
VIPExpirationTime: dataReport.JsonNumber(expireTS),
PayType: order.PayMode,
PayChannel: order.PayChannel,
TransactionID: oid,
CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()),
})
insertEvent(ctx, cf, order.TradeNo)
}
func reportCoinOrderPaid(ctx context.Context, order *vipordermod.VipOrder, oid string, realAmount int64) {
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId})
districtCode, ip := "", order.IPAddr
if user != nil {
districtCode = user.DistrictCode
}
ua := useragent.UA{SysType: order.SysType}
eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix())
cf := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderPaid, districtCode, ua, strconv.FormatInt(user.Id, 10), ip)
cf.Payload = dataReport.JsonRawMessage(&dataReport.OrderPaidEvent{
OrderID: order.TradeNo,
OrderType: "coin_purchase",
ProductID: strconv.FormatInt(order.CoinCfgId, 10),
Amount: dataReport.JsonNumber(realAmount),
Currency: "CNY",
CoinQuantity: dataReport.JsonNumber(order.Coin),
PayType: order.PayMode,
PayChannel: order.PayChannel,
TransactionID: oid,
CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()),
})
insertEvent(ctx, cf, order.TradeNo)
}
func insertEvent(ctx context.Context, cf dataReport.CommonField, tradeNo string) {
data, _ := json.Marshal(cf)
if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{
EventId: cf.EventID,
EventType: cf.Event,
Data: string(data),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("insertEvent failed", log.Any("tradeNo", tradeNo), log.E(err))
}
}
func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string {
input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime)
h := md5.New()
h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil))
}
+88
View File
@@ -0,0 +1,88 @@
package srv_rechargepackage
import (
"time"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/pubdef"
)
// RechargePackageListReq Web后台购买套餐分页列表请求
type RechargePackageListReq struct {
pubdef.Page
Id *int64 `json:"id"` // 套餐ID
PackageType *rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值
Status *rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用
Name string `json:"name"` // 展示标题,模糊搜索
}
// RechargePackageListResp Web后台购买套餐分页列表响应
type RechargePackageListResp struct {
Total int64 `json:"total"` // 总数量
List []*RechargePackageItem `json:"list"` // 当前页列表
}
// VipPackageOption 会员畅享下拉列表项。
type VipPackageOption struct {
Id int64 `json:"id"` // 套餐ID
Name string `json:"name"` // 套餐名称
}
// RechargePackageItem Web后台购买套餐列表项
type RechargePackageItem struct {
Id int64 `json:"id"` // 套餐ID
PackageType rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值
Name string `json:"name"` // 展示标题
SubTitle string `json:"subTitle"` // 展示副标题
Price int64 `json:"price"` // 售价,单位分
OriginalPrice int64 `json:"originalPrice"` // 原价,单位分
DurationDays int64 `json:"durationDays"` // 会员有效天数
DurationHours int64 `json:"durationHours"` // 会员有效小时数,由durationDays计算
BaseCoin int64 `json:"baseCoin"` // 基础金币数
GiftCoin int64 `json:"giftCoin"` // 赠送金币数
TotalCoin int64 `json:"totalCoin"` // 到账总金币数,由baseCoin+giftCoin计算
Sort int32 `json:"sort"` // 排序,越小越靠前
Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用
Remark string `json:"remark"` // 后台备注
CreatedAt time.Time `json:"createdAt"` // 创建时间
UpdatedAt time.Time `json:"updatedAt"` // 更新时间
}
// RechargePackageAddReq Web后台新增购买套餐请求
type RechargePackageAddReq struct {
PackageType rechargepackagemod.PackageType `json:"packageType" binding:"required"` // 套餐类型:1-会员畅享 2-金币充值
Name string `json:"name" binding:"required"` // 展示标题
Price int64 `json:"price" binding:"required"` // 售价,单位分
OriginalPrice int64 `json:"originalPrice"` // 原价,单位分
DurationDays int64 `json:"durationDays"` // 会员有效天数,金币套餐传0
BaseCoin int64 `json:"baseCoin"` // 基础金币数,会员套餐传0
GiftCoin int64 `json:"giftCoin"` // 赠送金币数
Sort int32 `json:"sort"` // 排序,越小越靠前
Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用,默认1
Remark string `json:"remark"` // 后台备注
}
// RechargePackageUpdateReq Web后台更新购买套餐请求
type RechargePackageUpdateReq struct {
Id int64 `json:"id" binding:"required"` // 套餐ID
PackageType *rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值
Name *string `json:"name"` // 展示标题
Price *int64 `json:"price"` // 售价,单位分
OriginalPrice *int64 `json:"originalPrice"` // 原价,单位分
DurationDays *int64 `json:"durationDays"` // 会员有效天数
BaseCoin *int64 `json:"baseCoin"` // 基础金币数
GiftCoin *int64 `json:"giftCoin"` // 赠送金币数
Sort *int32 `json:"sort"` // 排序
Status *rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用
Remark *string `json:"remark"` // 后台备注
}
// RechargePackageDelReq Web后台删除购买套餐请求。
type RechargePackageDelReq struct {
Id int64 `json:"id" binding:"required"` // 套餐ID
}
// RechargePackageStatusSetReq Web后台单/批量设置购买套餐状态请求。
type RechargePackageStatusSetReq struct {
Ids []int64 `json:"ids"` // 套餐ID集合
Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用
}
+343
View File
@@ -0,0 +1,343 @@
package srv_rechargepackage
import (
"context"
"fmt"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/rechargepackagemod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
defaultPageNum int64 = 1
defaultPageSize int64 = 10
maxPageSize int64 = 100
)
// ListRechargePackage 分页查询购买套餐配置。
func ListRechargePackage(ctx context.Context, req *RechargePackageListReq) (*RechargePackageListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
filter := buildListFilter(req)
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
total, err := rechargepackagemod.CountRechargePackage(ctx, filter)
if err != nil {
return nil, err
}
resp := &RechargePackageListResp{
Total: total,
List: make([]*RechargePackageItem, 0),
}
if total == 0 {
return resp, nil
}
list, err := rechargepackagemod.FindManyRechargePackage(ctx, filter, options.Find().
SetSort(bson.D{
{Key: rechargepackagemod.FN.RechargePackage.PackageType, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1},
}).
SetSkip((pageNum-1)*pageSize).
SetLimit(pageSize))
if err != nil {
return nil, err
}
for _, item := range list {
resp.List = append(resp.List, rechargePackageToWebItem(item))
}
return resp, nil
}
// ListVipPackageOptions 查询启用的会员畅享套餐选项,不分页。
func ListVipPackageOptions(ctx context.Context) ([]*VipPackageOption, *srvdef.SrvErr) {
list, err := rechargepackagemod.FindManyRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip,
rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled,
}, options.Find().
SetProjection(bson.D{
{Key: rechargepackagemod.FN.RechargePackage.Id, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Name, Value: 1},
}).
SetSort(bson.D{
{Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1},
}))
if err != nil {
return nil, err
}
result := make([]*VipPackageOption, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
result = append(result, &VipPackageOption{
Id: item.Id,
Name: item.Name,
})
}
return result, nil
}
// AddRechargePackage 新增购买套餐配置。
func AddRechargePackage(ctx context.Context, req *RechargePackageAddReq) *srvdef.SrvErr {
if req == nil {
return srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
doc := &rechargepackagemod.RechargePackage{
PackageType: req.PackageType,
Name: strings.TrimSpace(req.Name),
Price: req.Price,
OriginalPrice: req.OriginalPrice,
DurationDays: req.DurationDays,
BaseCoin: req.BaseCoin,
GiftCoin: req.GiftCoin,
Sort: req.Sort,
Status: req.Status,
Remark: strings.TrimSpace(req.Remark),
}
if doc.Status == 0 {
doc.Status = rechargepackagemod.StatusEnabled
}
normalizePackageFields(doc)
if err := validatePackage(doc); err != nil {
return err
}
doc.SubTitle = buildSubTitle(doc)
return rechargepackagemod.InsertOneRechargePackage(ctx, doc)
}
// UpdateRechargePackage 更新购买套餐配置。
func UpdateRechargePackage(ctx context.Context, req *RechargePackageUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "套餐ID不能为空", "")
}
exist, err := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{rechargepackagemod.FN.RechargePackage.Id: req.Id})
if err != nil {
return err
}
doc := *exist
applyUpdate(&doc, req)
normalizePackageFields(&doc)
if err := validatePackage(&doc); err != nil {
return err
}
doc.SubTitle = buildSubTitle(&doc)
now := time.Now()
setDoc := bson.M{
rechargepackagemod.FN.RechargePackage.PackageType: doc.PackageType,
rechargepackagemod.FN.RechargePackage.Name: doc.Name,
rechargepackagemod.FN.RechargePackage.SubTitle: doc.SubTitle,
rechargepackagemod.FN.RechargePackage.Price: doc.Price,
rechargepackagemod.FN.RechargePackage.OriginalPrice: doc.OriginalPrice,
rechargepackagemod.FN.RechargePackage.DurationDays: doc.DurationDays,
rechargepackagemod.FN.RechargePackage.BaseCoin: doc.BaseCoin,
rechargepackagemod.FN.RechargePackage.GiftCoin: doc.GiftCoin,
rechargepackagemod.FN.RechargePackage.Sort: doc.Sort,
rechargepackagemod.FN.RechargePackage.Status: doc.Status,
rechargepackagemod.FN.RechargePackage.Remark: doc.Remark,
rechargepackagemod.FN.RechargePackage.UpdatedAt: now,
}
return rechargepackagemod.UpdateOneRechargePackage(ctx,
bson.M{rechargepackagemod.FN.RechargePackage.Id: req.Id},
bson.M{"$set": setDoc},
)
}
// DeleteRechargePackage 删除购买套餐配置。
func DeleteRechargePackage(ctx context.Context, id int64) *srvdef.SrvErr {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "套餐ID不能为空", "")
}
return rechargepackagemod.DeleteOneRechargePackage(ctx, bson.M{rechargepackagemod.FN.RechargePackage.Id: id})
}
// SetRechargePackageStatus 单/批量设置购买套餐启停状态。
func SetRechargePackageStatus(ctx context.Context, req *RechargePackageStatusSetReq) *srvdef.SrvErr {
if req == nil {
return srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
ids := dedupePositiveInt64Ids(req.Ids)
if len(ids) == 0 {
return srvdef.New(srvdef.ErrParamError, "套餐ID不能为空", "")
}
if !isValidStatus(req.Status) {
return srvdef.New(srvdef.ErrParamError, "状态无效", "")
}
return rechargepackagemod.UpdateManyRechargePackage(ctx,
bson.M{rechargepackagemod.FN.RechargePackage.Id: bson.M{"$in": ids}},
bson.M{"$set": bson.M{
rechargepackagemod.FN.RechargePackage.Status: req.Status,
rechargepackagemod.FN.RechargePackage.UpdatedAt: time.Now(),
}},
)
}
func buildListFilter(req *RechargePackageListReq) bson.M {
filter := bson.M{}
if req.Id != nil {
filter[rechargepackagemod.FN.RechargePackage.Id] = *req.Id
}
if req.PackageType != nil {
filter[rechargepackagemod.FN.RechargePackage.PackageType] = *req.PackageType
}
if req.Status != nil {
filter[rechargepackagemod.FN.RechargePackage.Status] = *req.Status
}
if strings.TrimSpace(req.Name) != "" {
filter[rechargepackagemod.FN.RechargePackage.Name] = bson.M{
"$regex": primitive.Regex{Pattern: strings.TrimSpace(req.Name), Options: "i"},
}
}
return filter
}
// normalizePage 统一分页兜底,限制 pageSize 防止后台误传过大导致慢查询。
func normalizePage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = defaultPageNum
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return pageNum, pageSize
}
// normalizePackageFields 清理不同套餐类型不生效的字段。
// 会员套餐不使用金币字段;金币套餐不使用会员有效期字段。
func normalizePackageFields(doc *rechargepackagemod.RechargePackage) {
switch doc.PackageType {
case rechargepackagemod.PackageTypeVip:
doc.BaseCoin = 0
doc.GiftCoin = 0
case rechargepackagemod.PackageTypeCoin:
doc.DurationDays = 0
}
}
// validatePackage 校验购买套餐配置和套餐类型字段约束。
func validatePackage(doc *rechargepackagemod.RechargePackage) *srvdef.SrvErr {
if !isValidPackageType(doc.PackageType) {
return srvdef.New(srvdef.ErrParamError, "套餐类型无效", "")
}
if strings.TrimSpace(doc.Name) == "" {
return srvdef.New(srvdef.ErrParamError, "套餐标题不能为空", "")
}
if doc.Price <= 0 {
return srvdef.New(srvdef.ErrParamError, "套餐价格必须大于0", "")
}
if !isValidStatus(doc.Status) {
return srvdef.New(srvdef.ErrParamError, "状态无效", "")
}
if doc.PackageType == rechargepackagemod.PackageTypeVip && doc.DurationDays <= 0 {
return srvdef.New(srvdef.ErrParamError, "会员套餐有效天数必须大于0", "")
}
if doc.PackageType == rechargepackagemod.PackageTypeCoin && doc.BaseCoin+doc.GiftCoin <= 0 {
return srvdef.New(srvdef.ErrParamError, "金币套餐到账金币数必须大于0", "")
}
return nil
}
func isValidPackageType(packageType rechargepackagemod.PackageType) bool {
return packageType == rechargepackagemod.PackageTypeVip || packageType == rechargepackagemod.PackageTypeCoin
}
func isValidStatus(status rechargepackagemod.Status) bool {
return status == rechargepackagemod.StatusEnabled || status == rechargepackagemod.StatusDisabled
}
// applyUpdate 将更新请求合并到现有文档副本,再统一做类型归一化和校验。
func applyUpdate(doc *rechargepackagemod.RechargePackage, req *RechargePackageUpdateReq) {
if req.PackageType != nil {
doc.PackageType = *req.PackageType
}
if req.Name != nil {
doc.Name = strings.TrimSpace(*req.Name)
}
if req.Price != nil {
doc.Price = *req.Price
}
if req.OriginalPrice != nil {
doc.OriginalPrice = *req.OriginalPrice
}
if req.DurationDays != nil {
doc.DurationDays = *req.DurationDays
}
if req.BaseCoin != nil {
doc.BaseCoin = *req.BaseCoin
}
if req.GiftCoin != nil {
doc.GiftCoin = *req.GiftCoin
}
if req.Sort != nil {
doc.Sort = *req.Sort
}
if req.Status != nil {
doc.Status = *req.Status
}
if req.Remark != nil {
doc.Remark = strings.TrimSpace(*req.Remark)
}
}
// buildSubTitle 根据套餐类型和核心配置生成展示副标题,不由后台请求手动传入。
func buildSubTitle(doc *rechargepackagemod.RechargePackage) string {
switch doc.PackageType {
case rechargepackagemod.PackageTypeVip:
return fmt.Sprintf("%d × 24 小时会员", doc.DurationDays)
case rechargepackagemod.PackageTypeCoin:
if doc.GiftCoin > 0 {
return fmt.Sprintf("基础%d + 赠送%d", doc.BaseCoin, doc.GiftCoin)
}
return fmt.Sprintf("基础%d", doc.BaseCoin)
default:
return ""
}
}
// rechargePackageToWebItem 转换后台列表展示项,并补充派生字段。
func rechargePackageToWebItem(item *rechargepackagemod.RechargePackage) *RechargePackageItem {
return &RechargePackageItem{
Id: item.Id,
PackageType: item.PackageType,
Name: item.Name,
SubTitle: item.SubTitle,
Price: item.Price,
OriginalPrice: item.OriginalPrice,
DurationDays: item.DurationDays,
DurationHours: item.DurationDays * 24,
BaseCoin: item.BaseCoin,
GiftCoin: item.GiftCoin,
TotalCoin: item.BaseCoin + item.GiftCoin,
Sort: item.Sort,
Status: item.Status,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// dedupePositiveInt64Ids 去重并过滤非法ID,批量操作只处理正整数ID。
func dedupePositiveInt64Ids(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
}
+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
}
+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
}
+13
View File
@@ -0,0 +1,13 @@
package srv_search
import "videoweb/pub/common/log"
func StartES() {
log.Info("srv_search:Start...")
defer log.Info("srv_search:Start!!!")
}
func StopES() {
log.Info("srv_search:Stop...")
defer log.Info("srv_search:Stop!!!")
}
+247
View File
@@ -0,0 +1,247 @@
package srv_search
import (
"context"
"strconv"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
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.OperateType, 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.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.CategoryIds, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Description, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.Anchor, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.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.HeatValue, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1},
{Key: "seoInfo", Value: 1},
}
// SyncPlayletStatusToES 根据短剧上下架状态同步ES搜索数据
func SyncPlayletStatusToES(ctx context.Context, ids []int64, status playletmod.Status) *srvdef.SrvErr {
// ES只保留已上架短剧;上架写入/更新索引,下架从索引删除。
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
}
}
// upsertPlayletsToES 批量写入已上架短剧ES文档
func upsertPlayletsToES(ctx context.Context, ids []int64) *srvdef.SrvErr {
// 只查询已上架数据,避免并发状态变化后把已下架短剧重新写进ES。
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().SetProjection(playletESProjection))
if err != nil {
return err
}
if len(list) == 0 {
return nil
}
themeNameMap, err := buildPlayletESThemeNameMap(ctx, list)
if err != nil {
return err
}
tagNameMap, err := buildPlayletESTagNameMap(ctx, list)
if err != nil {
return err
}
esData := make(esmanager.M, len(list))
for _, item := range list {
if item == nil {
continue
}
esData[strconv.FormatInt(item.Id, 10)] = playletToESDoc(item, themeNameMap, tagNameMap)
}
if len(esData) == 0 {
return nil
}
if err = AddMultiDataToES(esmod.GetPlayletIndex(), esData); err != nil {
log.Error("批量写入短剧ES失败", log.Any("短剧ID", ids), log.E(err))
return err
}
log.Info("批量写入短剧ES成功", log.Any("数量", len(esData)), log.Any("短剧ID", ids))
return nil
}
// deletePlayletsFromES 批量删除短剧ES文档
func deletePlayletsFromES(ids []int64) *srvdef.SrvErr {
// ES文档ID和Mongo短剧业务ID保持一致,可以直接按ID批量删除。
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 err := batchDelFromES(esmod.GetPlayletIndex(), strIds); err != nil {
log.Error("批量删除短剧ES失败", log.Any("短剧ID", ids), log.E(err))
return err
}
log.Info("批量删除短剧ES成功", log.Any("数量", len(strIds)), log.Any("短剧ID", ids))
return nil
}
// playletToESDoc 转换短剧Mongo数据为ES文档
func playletToESDoc(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string) *esmod.ESPlaylet {
// ES文档保留题材名和标签名,app搜索可直接命中标题、题材名、简介和标签名。
return &esmod.ESPlaylet{
Id: item.Id,
Status: item.Status,
IsFeatured: item.IsFeatured,
IsRecommend: item.IsRecommend,
OperateType: playletmod.NormalizeOperateType(item.OperateType),
ShowOnBanner: item.ShowOnBanner,
Banner: item.Banner,
OnlineAt: item.OnlineAt,
ThemeIds: item.ThemeIds,
ThemeNames: playletESNamesByIds(item.ThemeIds, themeNameMap),
CategoryIds: item.CategoryIds,
Title: item.Title,
Description: item.Description,
Slug: item.Slug,
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.CountBrowse,
HeatValue: item.HeatValue,
CountCollect: item.CountCollect,
CountLike: item.CountLike + item.VirtualCountLike,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
SeoInfo: item.SeoInfo,
}
}
// 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, err := 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 err != nil {
return nil, err
}
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, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(bson.D{
{Key: playlettagmod.FN.PlayletTag.Id, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Name, Value: 1},
}))
if err != nil {
return nil, err
}
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
}
// 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
}
+71
View File
@@ -0,0 +1,71 @@
package srv_search
import (
"regexp"
"strings"
)
// 分隔符:空格、英文逗号、中文逗号、顿号、斜杠、竖线
var kwSplitRe = regexp.MustCompile(`[\s,,、/|]+`)
// ParseKeywords 解析搜索字符串为关键词列表。
// 规则:去除首尾空格,转小写,按空格/逗号/顿号/斜杠/竖线分割,过滤空串。
func ParseKeywords(q string) []string {
q = strings.TrimSpace(q)
if q == "" {
return nil
}
parts := kwSplitRe.Split(strings.ToLower(q), -1)
result := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
result = append(result, p)
}
}
return result
}
// buildMultiKeywordQuery 构建多关键词 ES bool should 查询(关键词之间 OR 关系)。
// 每个关键词同时生成两个子句:
// - 精确 multi_match(权重更高,boost=2)
// - 模糊 multi_match(fuzziness=AUTO,prefix_length=1),覆盖拼写错误/多余字符等场景
//
// 示例:ChatGPTT→ChatGPT(1次编辑)、midjorny→midjourney(2次编辑)、数智人→数字人(1次编辑)
func buildMultiKeywordQuery(keywords []string, fields []string, from, size int) map[string]interface{} {
shouldClauses := make([]map[string]interface{}, 0, len(keywords)*2)
for _, kw := range keywords {
// 精确匹配,分数更高
shouldClauses = append(shouldClauses, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": kw,
"fields": fields,
"type": "best_fields",
"boost": 2,
},
})
// 模糊匹配:AUTO fuzziness(长度≥6时允许2次编辑),prefix_length=1 避免过度误召回
shouldClauses = append(shouldClauses, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": kw,
"fields": fields,
"type": "best_fields",
"fuzziness": "AUTO",
"prefix_length": 1,
"fuzzy_rewrite": "constant_score_blended",
},
})
}
return map[string]interface{}{
"from": from,
"size": size,
"query": map[string]interface{}{
"bool": map[string]interface{}{
"should": shouldClauses,
"minimum_should_match": 1,
},
},
"sort": []map[string]interface{}{
{"_score": map[string]interface{}{"order": "desc"}},
},
}
}
+17
View File
@@ -0,0 +1,17 @@
package srv_search
import "videoweb/pub/common/log"
func Start() {
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()
}
+15
View File
@@ -0,0 +1,15 @@
package srv_search
import (
"strings"
"videoweb/pub/pubdef"
)
func flattenSeoInfo(seo pubdef.SeoValue) (title, keywords, desc string) {
title = seo.Title
desc = seo.Description
if len(seo.Keyworld) > 0 {
keywords = strings.Join(seo.Keyworld, " ")
}
return
}
+47
View File
@@ -0,0 +1,47 @@
package srv_search
import (
"context"
"time"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
)
const esSyncOverlap = time.Minute
// needFullESSync 判断是否需要全量同步 ES
func needFullESSync(ctx context.Context, esIndex string) (bool, *esmod.ESRecord) {
esRecord, serr := esmod.GetRecordTimeByESIndex(ctx, esIndex)
if serr != nil || esRecord == nil {
return true, esRecord
}
count, err := esmanager.Instance.Count(esIndex, esmanager.M{
"query": esmanager.M{"match_all": esmanager.M{}},
})
if err != nil || count == 0 {
return true, esRecord
}
now := time.Now()
if (now.Hour() >= 5 && esRecord.UpdatedAt.Local().Hour() < 5) ||
esRecord.UpdatedAt.Local().Day() < now.Day() {
return true, esRecord
}
return false, esRecord
}
// esSyncSinceTime 取增量同步起点(RecordTime 优先,回退 1 分钟避免边界遗漏)
func esSyncSinceTime(esRecord *esmod.ESRecord) time.Time {
if esRecord == nil {
return time.Time{}
}
since := esRecord.RecordTime
if since.IsZero() {
since = esRecord.UpdatedAt
}
if since.IsZero() {
return time.Time{}
}
return since.Add(-esSyncOverlap)
}
+223
View File
@@ -0,0 +1,223 @@
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))
}
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
}
+315
View File
@@ -0,0 +1,315 @@
// 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/models/playletmod"
"videoweb/pub/models/seopushmod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/googleindex"
"videoweb/pub/seo/h5path"
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"
)
// 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,
}
// PlayletDetailURL 拼装短剧播放页完整地址,路径与 H5 / sitemap 一致:/play/{slug},无 slug 回退 ID。
func PlayletDetailURL(webAddr, slug string, playletId int64) string {
path := h5path.Playlet(slug, playletId)
if path == "" {
return ""
}
return h5path.Abs(webAddr, path)
}
// PageVisible 判断详情页当前是否对外可访问。
func PageVisible(status playletmod.Status, onlineAt time.Time) bool {
if status != playletmod.StatusUp {
return false
}
if onlineAt.IsZero() {
return true
}
return !onlineAt.After(time.Now())
}
// 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
}
func canPushExternally() bool {
return pubdef.Enabled() && webg.Conf.RunMod == "prod"
}
// EnqueuePlayletUpdated 短剧新上架后登记并立即推送,仅在页面已可访问时生效
func EnqueuePlayletUpdated(ctx context.Context, playletId int64,
status playletmod.Status, onlineAt time.Time) {
if !pubdef.Enabled() || !PageVisible(status, onlineAt) {
return
}
enqueue(ctx, playletId, seopushmod.ActionUpdated, true)
}
// RegisterPlayletUpdated 只登记不立即推送,供批量补推使用
func RegisterPlayletUpdated(ctx context.Context, playletId int64,
status playletmod.Status, onlineAt time.Time) {
if !pubdef.Enabled() || !PageVisible(status, onlineAt) {
return
}
enqueue(ctx, playletId, seopushmod.ActionUpdated, false)
}
// EnqueuePlayletDeleted 短剧下架/删除后登记并立即推送
func EnqueuePlayletDeleted(ctx context.Context, playletId int64) {
if !pubdef.Enabled() {
return
}
enqueue(ctx, playletId, seopushmod.ActionDeleted, true)
}
func enqueue(ctx context.Context, playletId int64, action string, pushNow bool) {
webAddr := SEOBaseURL(ctx)
if webAddr == "" {
return
}
pageURL := PlayletDetailURL(webAddr, loadPlayletSlug(ctx, playletId), playletId)
if pageURL == "" {
return
}
now := time.Now()
task := &seopushmod.SeoPushTask{
Url: pageURL,
VideoId: playletId,
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 {
markPlayletQueued(ctx, playletId)
}
if !pushNow {
return
}
godef.Go(func() {
PushTask(context.Background(), webAddr, task)
})
}
func loadPlayletSlug(ctx context.Context, playletId int64) string {
if playletId <= 0 {
return ""
}
item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId},
options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}}))
if err != nil || item == nil {
return ""
}
return strings.TrimSpace(item.Slug)
}
func isObsoletePushURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return true
}
if strings.Contains(raw, "?") {
return true
}
if strings.Contains(raw, "/detail/") || strings.HasSuffix(raw, "/detail") {
return true
}
if strings.Contains(raw, "/lieyan/av/detail") {
return true
}
return false
}
func markPlayletQueued(ctx context.Context, playletId int64) {
if serr := playletmod.UpdateOnePlaylet(ctx,
bson.M{playletmod.FN.Playlet.Id: playletId},
bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}}); serr != nil {
log.Warn("seopush: mark playlet queued failed", log.Any("playletId", playletId), log.E(serr))
}
}
// PushTask 推送任务中尚未成功的通道
func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if task == nil || !pubdef.Enabled() {
return
}
// 旧站路径不再推送:查询参数、/detail/{slug}、/lieyan/av/detail。
if isObsoletePushURL(task.Url) {
log.Warn("seopush: drop obsolete-url task", log.Any("url", task.Url), log.Any("action", task.Action))
_, _ = seopushmod.DelManySeoPushTask(ctx, taskFilter(task))
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 daily quota exceeded, keep pending",
log.Any("url", task.Url), log.Any("resp", err.Error()))
return
}
if errors.Is(err, googleindex.ErrNotEnabled) {
log.Warn("seopush: google client not ready, keep pending", 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,
}
}
+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, serr := translationmod.CountTranslationInfos(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := translationmod.FindManyTranslationInfos(ctx, filter, opt)
if serr != nil {
return resp, serr
}
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,47 @@
package srv_translationcategory
import (
"time"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/pubdef"
)
type TranslationCategoryReq struct {
Id *int64 `json:"id"` // 分类ID,精确匹配
Name string `json:"name"` // 分类名称,模糊搜索
Status *bool `json:"status"` // 上架状态
CategoryType *pubdef.TranslationType `json:"categoryType"` // 导航类型
HomeMoreEnabled *bool `json:"homeMoreEnabled"` // 可作为首页“更多”跳转目标
pubdef.Page
}
type TranslationCategoryResp struct {
Total int64 `json:"total"`
List translationcategorymod.TranslationCategoryList `json:"list"`
}
// HomeMoreOption 首页“更多”跳转目标下拉选项。
type HomeMoreOption struct {
Id int64 `json:"id"`
Name string `json:"name"`
UrlValue string `json:"urlValue"`
}
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"` // 上架状态
HomeMoreEnabled *bool `json:"homeMoreEnabled" bson:"homeMoreEnabled,omitempty"` // 可作为首页“更多”跳转目标
Rank *int64 `json:"rank" bson:"rank,omitempty"` //
CategoryType *int `json:"categoryType" bson:"categoryType,omitempty"` // 导航类型 1=url跳转 2=二级导航 3=底部导航
UrlValue *string `json:"urlValue" bson:"urlValue,omitempty"` // 跳转URL
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
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,138 @@
package srv_translationcategory
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"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{}
if param.Id != nil && *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": regexp.QuoteMeta(name), "$options": "i"}
}
if param.Status != nil {
filter[translationcategorymod.FN.TranslationCategory.Status] = *param.Status
}
if param.CategoryType != nil {
filter[translationcategorymod.FN.TranslationCategory.CategoryType] = *param.CategoryType
}
if param.HomeMoreEnabled != nil {
filter[translationcategorymod.FN.TranslationCategory.HomeMoreEnabled] = *param.HomeMoreEnabled
}
opt := &options.FindOptions{}
resp := &TranslationCategoryResp{}
opt.SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := translationcategorymod.CountTranslationCategorys(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := translationcategorymod.FindManyTranslationCategorys(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// ListHomeMoreOptions 查询可作为首页“更多”目标的导航,不分页。
func ListHomeMoreOptions(ctx context.Context) ([]*HomeMoreOption, *srvdef.SrvErr) {
fields := translationcategorymod.FN.TranslationCategory
list, err := translationcategorymod.FindManyTranslationCategorys(ctx, bson.M{
fields.Status: true,
fields.HomeMoreEnabled: true,
fields.CategoryType: pubdef.TranslationTypeUrl,
}, options.Find().
SetProjection(bson.D{
{Key: fields.Id, Value: 1},
{Key: fields.Name, Value: 1},
{Key: fields.UrlValue, Value: 1},
}).
SetSort(bson.D{
{Key: fields.Rank, Value: -1},
{Key: fields.Id, Value: -1},
}))
if err != nil {
return nil, err
}
result := make([]*HomeMoreOption, 0, len(list))
for _, item := range list {
if item == nil || strings.TrimSpace(item.UrlValue) == "" {
continue
}
result = append(result, &HomeMoreOption{
Id: item.Id,
Name: item.Name,
UrlValue: item.UrlValue,
})
}
return result, 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
}
playletcache.ClearHomeCache()
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, "", "")
}
playletcache.ClearHomeCache()
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
}
playletcache.ClearHomeCache()
return nil
}
@@ -0,0 +1,23 @@
package srv_translationcategory
import (
"testing"
"go.mongodb.org/mongo-driver/bson"
)
func TestHomeMoreEnabledUpdatePreservesOtherFields(t *testing.T) {
for _, enabled := range []bool{true, false} {
data, err := bson.Marshal(TranslationCategoryUpdate{HomeMoreEnabled: &enabled})
if err != nil {
t.Fatal(err)
}
var fields bson.M
if err := bson.Unmarshal(data, &fields); err != nil {
t.Fatal(err)
}
if len(fields) != 1 || fields["homeMoreEnabled"] != enabled {
t.Fatalf("unexpected update fields for %t: %#v", enabled, fields)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package srv_user
import (
"videoweb/pub/models/usermod"
"videoweb/pub/pubdef"
)
type UserListReq struct {
pubdef.Page
Id *int64 `json:"id"` // 用户ID
AccountNo string `json:"accountNo"` // 账号(精确)
Email string `json:"email"` // 邮箱(精确)
NickName string `json:"nickName"` // 昵称(模糊)
DistrictCode string `json:"districtCode"` // 渠道码
Status *pubdef.UserStatus `json:"status"` // 状态
Type *pubdef.UserType `json:"type"` // 用户类型
}
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"`
}
type GiftCoinReq struct {
UserId int64 `json:"userId" binding:"required"` // 目标用户ID
Coin int64 `json:"coin" binding:"required"` // 赠送金币数(正数=增加,负数=扣除)
Remark string `json:"remark"` // 备注(记录在流水表)
}
type UpdateVipReq struct {
UserId int64 `json:"userId" binding:"required"` // 用户ID
PackageId int64 `json:"packageId" binding:"required"` // 会员畅享套餐ID
}
type ResetPasswordReq struct {
UserId int64 `json:"userId" binding:"required"` // 用户ID
Password string `json:"password" binding:"required"` // 明文密码
}
+189
View File
@@ -0,0 +1,189 @@
package srv_user
import (
"context"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/cointransmod"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/models/usermod"
"videoweb/pub/payment"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/crypto/bcrypt"
)
// 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 accountNo := strings.TrimSpace(req.AccountNo); accountNo != "" {
filter[usermod.FN.User.AccountNo] = accountNo
}
if email := strings.TrimSpace(req.Email); email != "" {
filter[usermod.FN.User.Email] = email
}
if req.NickName != "" {
filter[usermod.FN.User.NickName] = bson.M{"$regex": req.NickName}
}
if districtCode := strings.TrimSpace(req.DistrictCode); districtCode != "" {
filter[usermod.FN.User.DistrictCode] = districtCode
}
if req.Status != nil {
filter[usermod.FN.User.Status] = *req.Status
}
if req.Type != nil {
filter[usermod.FN.User.Type] = *req.Type
}
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},
)
}
// GiftCoin 给用户赠送/扣除金币,并写入流水记录
// coin 为正数时增加余额,为负数时扣减(不校验余额是否充足,由调用方确保)
func GiftCoin(ctx context.Context, req *GiftCoinReq) *srvdef.SrvErr {
if req.Coin == 0 {
return srvdef.New(srvdef.ErrParamError, "金币数量不能为 0", "")
}
// 原子更新余额
if serr := usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: req.UserId},
bson.M{
"$inc": bson.M{usermod.FN.User.Recharge: req.Coin},
"$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()},
},
); serr != nil {
return serr
}
// 写流水(更新后再查余额,作为 balanceAfter)
flowDir := cointransmod.FlowIn
amount := req.Coin
if req.Coin < 0 {
flowDir = cointransmod.FlowOut
amount = -req.Coin
}
remark := req.Remark
if remark == "" {
remark = payment.CoinTransRemark(cointransmod.TranTypeAdminAdjust, "")
}
_ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{
UserId: req.UserId,
FlowDir: flowDir,
TranType: cointransmod.TranTypeAdminAdjust,
Amount: amount,
Remark: remark,
})
return nil
}
// UpdateVip 根据会员畅享套餐修改用户会员信息。
func UpdateVip(ctx context.Context, req *UpdateVipReq) *srvdef.SrvErr {
if req == nil || req.UserId <= 0 || req.PackageId <= 0 {
return srvdef.New(srvdef.ErrParamError, "用户ID和套餐ID不能为空", "")
}
user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId})
if serr != nil || user == nil {
return srvdef.New(srvdef.ErrUserNotExsit, "", "")
}
pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.Id: req.PackageId,
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip,
rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled,
})
if serr != nil || pkg == nil {
return srvdef.New(srvdef.ErrNoData, "会员套餐不存在或未启用", "")
}
if pkg.DurationDays <= 0 {
return srvdef.New(srvdef.ErrParamError, "会员套餐有效天数无效", "")
}
now := time.Now()
baseTime := now
if user.CurrentVipExpire.After(now) {
baseTime = user.CurrentVipExpire
}
newExpire := baseTime.Add(time.Duration(pkg.DurationDays) * 24 * time.Hour)
updateDoc := bson.M{
"$set": bson.M{
usermod.FN.User.VipType: vipTypeFromDurationDays(pkg.DurationDays),
usermod.FN.User.ShowCardId: pkg.Id,
usermod.FN.User.ShowCardName: pkg.Name,
usermod.FN.User.CurrentVipExpire: newExpire,
usermod.FN.User.UpdatedAt: now,
},
}
if serr = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId}, updateDoc); serr != nil {
return serr
}
return nil
}
// ResetPassword 按用户ID重置登陆密码,入库为 bcrypt 哈希,与 app 登录校验一致。
func ResetPassword(ctx context.Context, req *ResetPasswordReq) *srvdef.SrvErr {
if req == nil || req.UserId <= 0 {
return srvdef.New(srvdef.ErrParamError, "用户ID不能为空", "")
}
password := strings.TrimSpace(req.Password)
if password == "" {
return srvdef.New(srvdef.ErrParamError, "密码不能为空", "")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return srvdef.New(srvdef.Failure, "密码处理失败", "")
}
return usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: req.UserId},
bson.M{"$set": bson.M{
usermod.FN.User.Password: string(hashed),
usermod.FN.User.UpdatedAt: time.Now(),
}},
)
}
func vipTypeFromDurationDays(days int64) pubdef.VipType {
if days >= 365 {
return pubdef.VipTypeYear
}
if days > 0 {
return pubdef.VipTypeMonth
}
return pubdef.VipTypeNone
}
+44
View File
@@ -0,0 +1,44 @@
package srv_viporder
import (
"time"
"videoweb/pub/models/vipordermod"
"videoweb/pub/pubdef"
)
type VipOrderReq struct {
pubdef.Page
UserId *int64 `json:"userId"` // 用户ID
OrderType *vipordermod.OrderType `json:"orderType"` // 订单类型:1会员 2金币
Status *pubdef.VipOrderStatus `json:"status"` // 订单状态
TradeNo *string `json:"tradeNo"` // 订单号
DistrictCode string `json:"districtCode"` // 渠道码
StartTime *string `json:"startTime"` // 创建时间-开始
EndTime *string `json:"endTime"` // 创建时间-结束
}
type VipOrderResp struct {
Total int64 `json:"total"`
List vipordermod.VipOrderList `json:"list"`
}
type SyncDistrictCodeReq struct {
MaxSize int64 `json:"maxSize"` // 本次最多扫描的空渠道码订单数,默认500,最大2000
}
type SyncDistrictCodeResp struct {
Scanned int64 `json:"scanned"` // 本次扫描的空渠道码订单数
OrderUpdated int64 `json:"orderUpdated"` // 成功回填的订单数
UserUpdated int64 `json:"userUpdated"` // 成功回填的用户数
UserSkipped int64 `json:"userSkipped"` // 用户本身没有渠道码
HasMore bool `json:"hasMore"` // 是否还有未处理的历史订单
}
type timeLayout = string
const layout timeLayout = "2006-01-02 15:04:05"
func parseTime(s string) (time.Time, bool) {
t, err := time.ParseInLocation(layout, s, time.Local)
return t, err == nil
}
+234
View File
@@ -0,0 +1,234 @@
package srv_viporder
import (
"context"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/usermod"
"videoweb/pub/models/vipordermod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVipOrders 分页查询会员购买记录(固定 OrderType=会员)
func GetVipOrders(ctx context.Context, req *VipOrderReq) (*VipOrderResp, *srvdef.SrvErr) {
orderType := vipordermod.OrderTypeVip
reqCopy := *req
reqCopy.OrderType = &orderType
return GetOrders(ctx, &reqCopy)
}
// GetOrders 分页查询充值订单,支持按 OrderType 筛选(不传则查全部类型)
func GetOrders(ctx context.Context, req *VipOrderReq) (*VipOrderResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.OrderType != nil {
filter[vipordermod.FN.VipOrder.OrderType] = *req.OrderType
}
if req.UserId != nil && *req.UserId > 0 {
filter[vipordermod.FN.VipOrder.UserId] = *req.UserId
}
if req.Status != nil {
filter[vipordermod.FN.VipOrder.Status] = *req.Status
}
if req.TradeNo != nil && *req.TradeNo != "" {
filter[vipordermod.FN.VipOrder.TradeNo] = *req.TradeNo
}
if districtCode := strings.TrimSpace(req.DistrictCode); districtCode != "" {
filter[vipordermod.FN.VipOrder.DistrictCode] = districtCode
}
timeFilter := bson.M{}
if req.StartTime != nil && *req.StartTime != "" {
if t, ok := parseTime(*req.StartTime); ok {
timeFilter["$gte"] = t
}
}
if req.EndTime != nil && *req.EndTime != "" {
if t, ok := parseTime(*req.EndTime); ok {
timeFilter["$lte"] = t
}
}
if len(timeFilter) > 0 {
filter[vipordermod.FN.VipOrder.CreatedAt] = timeFilter
}
total, serr := vipordermod.CountVipOrder(ctx, filter)
if serr != nil {
return nil, serr
}
resp := &VipOrderResp{Total: total}
if total == 0 {
resp.List = vipordermod.VipOrderList{}
return resp, nil
}
opt := options.Find().
SetSort(bson.D{{Key: vipordermod.FN.VipOrder.CreatedAt, Value: -1}}).
SetSkip((req.PageNum - 1) * req.PageSize).
SetLimit(req.PageSize)
list, serr := vipordermod.FindManyVipOrder(ctx, filter, opt)
if serr != nil {
return nil, serr
}
resp.List = list
return resp, nil
}
const (
syncDistrictDefaultSize = int64(500)
syncDistrictMaxSize = int64(2000)
syncDistrictBatchSize = int64(200)
)
// SyncDistrictCode 将历史订单中空的渠道码,按用户当前 districtCode 回填。
func SyncDistrictCode(ctx context.Context, req *SyncDistrictCodeReq) (*SyncDistrictCodeResp, *srvdef.SrvErr) {
maxSize := syncDistrictDefaultSize
if req != nil && req.MaxSize > 0 {
maxSize = req.MaxSize
}
if maxSize > syncDistrictMaxSize {
maxSize = syncDistrictMaxSize
}
resp := &SyncDistrictCodeResp{}
lastId := int64(0)
updatedUsers := make(map[int64]struct{})
skippedUsers := make(map[int64]struct{})
for resp.Scanned < maxSize {
limit := syncDistrictBatchSize
if remain := maxSize - resp.Scanned; remain < limit {
limit = remain
}
filter := emptyDistrictSyncFilter(lastId, skippedUsers)
list, serr := vipordermod.FindManyVipOrder(ctx, filter, options.Find().
SetProjection(bson.D{
{Key: vipordermod.FN.VipOrder.Id, Value: 1},
{Key: vipordermod.FN.VipOrder.UserId, Value: 1},
}).
SetSort(bson.D{{Key: vipordermod.FN.VipOrder.Id, Value: 1}}).
SetLimit(limit))
if serr != nil {
return nil, serr
}
if len(list) == 0 {
break
}
resp.Scanned += int64(len(list))
lastId = list[len(list)-1].Id
if int64(len(list)) == limit && resp.Scanned >= maxSize {
resp.HasMore = true
}
userIds := make([]int64, 0, len(list))
seen := make(map[int64]struct{}, len(list))
for _, order := range list {
if order == nil || order.UserId <= 0 {
continue
}
if _, ok := seen[order.UserId]; ok {
continue
}
seen[order.UserId] = struct{}{}
userIds = append(userIds, order.UserId)
}
if len(userIds) == 0 {
continue
}
users, serr := usermod.FindManyUser(ctx, bson.M{
usermod.FN.User.Id: bson.M{"$in": userIds},
}, options.Find().SetProjection(bson.D{
{Key: usermod.FN.User.Id, Value: 1},
{Key: usermod.FN.User.DistrictCode, Value: 1},
}))
if serr != nil {
return nil, serr
}
userMap := make(map[int64]*usermod.User, len(users))
for _, user := range users {
if user != nil {
userMap[user.Id] = user
}
}
now := time.Now()
for _, userId := range userIds {
user := userMap[userId]
code := ""
if user != nil {
code = strings.TrimSpace(user.DistrictCode)
}
if code == "" {
skippedUsers[userId] = struct{}{}
continue
}
modified, serr := vipordermod.UpdateManyVipOrder(ctx,
mergeEmptyDistrictFilter(bson.M{vipordermod.FN.VipOrder.UserId: userId}),
bson.M{"$set": bson.M{
vipordermod.FN.VipOrder.DistrictCode: code,
vipordermod.FN.VipOrder.UpdatedAt: now,
}},
)
if serr != nil {
return nil, serr
}
if modified > 0 {
updatedUsers[userId] = struct{}{}
resp.OrderUpdated += modified
}
}
}
resp.UserUpdated = int64(len(updatedUsers))
resp.UserSkipped = int64(len(skippedUsers))
if !resp.HasMore {
remain, serr := vipordermod.FindManyVipOrder(ctx, emptyDistrictSyncFilter(lastId, skippedUsers),
options.Find().SetProjection(bson.D{{Key: vipordermod.FN.VipOrder.Id, Value: 1}}).SetLimit(1))
if serr != nil {
return nil, serr
}
resp.HasMore = len(remain) > 0
}
return resp, nil
}
func emptyDistrictSyncFilter(lastId int64, skippedUsers map[int64]struct{}) bson.M {
conds := []bson.M{vipordermod.EmptyDistrictFilter()}
if lastId > 0 {
conds = append(conds, bson.M{vipordermod.FN.VipOrder.Id: bson.M{"$gt": lastId}})
}
if skipIds := mapKeys(skippedUsers); len(skipIds) > 0 {
conds = append(conds, bson.M{vipordermod.FN.VipOrder.UserId: bson.M{"$nin": skipIds}})
}
if len(conds) == 1 {
return conds[0]
}
return bson.M{"$and": conds}
}
func mergeEmptyDistrictFilter(extra bson.M) bson.M {
filter := vipordermod.EmptyDistrictFilter()
if len(extra) == 0 {
return filter
}
conds := []bson.M{filter}
for key, value := range extra {
conds = append(conds, bson.M{key: value})
}
return bson.M{"$and": conds}
}
func mapKeys(m map[int64]struct{}) []int64 {
if len(m) == 0 {
return nil
}
keys := make([]int64, 0, len(m))
for id := range m {
keys = append(keys, id)
}
return keys
}
+165
View File
@@ -0,0 +1,165 @@
package srv_viporder
import (
"context"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/vipordermod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
)
// RechargeSummaryReq 充值汇总请求,时间按北京时间解析。
type RechargeSummaryReq struct {
StartTime string `form:"startTime" json:"startTime"` // 开始时间,北京时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339
EndTime string `form:"endTime" json:"endTime"` // 结束时间,北京时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339
}
// RechargeSummaryResp 充值汇总结果,金额单位为分。
type RechargeSummaryResp struct {
StartTime string `json:"startTime"` // 实际统计开始时间(北京时间)
EndTime string `json:"endTime"` // 实际统计结束时间(北京时间)
OrderCount int64 `json:"orderCount"` // 成功订单数
TotalAmount int64 `json:"totalAmount"` // 成功订单金额合计(分)
VipOrderCount int64 `json:"vipOrderCount"` // 会员成功订单数
VipAmount int64 `json:"vipAmount"` // 会员成功订单金额(分)
CoinOrderCount int64 `json:"coinOrderCount"` // 金币成功订单数
CoinAmount int64 `json:"coinAmount"` // 金币成功订单金额(分)
UserCount int64 `json:"userCount"` // 成功充值用户数(去重)
}
type rechargeSummaryAgg struct {
OrderCount int64 `bson:"orderCount"`
TotalAmount int64 `bson:"totalAmount"`
VipOrderCount int64 `bson:"vipOrderCount"`
VipAmount int64 `bson:"vipAmount"`
CoinOrderCount int64 `bson:"coinOrderCount"`
CoinAmount int64 `bson:"coinAmount"`
UserCount int64 `bson:"userCount"`
}
// GetRechargeSummary 按支付完成时间汇总成功充值订单。
func GetRechargeSummary(ctx context.Context, req *RechargeSummaryReq) (*RechargeSummaryResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空")
}
start, end, err := parseBeijingTimeRange(req.StartTime, req.EndTime)
if err != nil {
return nil, err
}
resp := &RechargeSummaryResp{
StartTime: start.In(utils.LocBJ).Format(pubdef.TimeISO),
EndTime: end.In(utils.LocBJ).Format(pubdef.TimeISO),
}
list := make([]rechargeSummaryAgg, 0)
if serr := vipordermod.AggregateVipOrder(ctx, &list, rechargeSummaryPipeline(start, end)); serr != nil {
return nil, serr
}
if len(list) == 0 {
return resp, nil
}
item := list[0]
resp.OrderCount = item.OrderCount
resp.TotalAmount = item.TotalAmount
resp.VipOrderCount = item.VipOrderCount
resp.VipAmount = item.VipAmount
resp.CoinOrderCount = item.CoinOrderCount
resp.CoinAmount = item.CoinAmount
resp.UserCount = item.UserCount
return resp, nil
}
func rechargeSummaryPipeline(start, end time.Time) []bson.M {
vipType := bson.M{"$eq": bson.A{bson.M{"$ifNull": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeVip}}, vipordermod.OrderTypeVip}}
return []bson.M{
{
"$match": bson.M{
vipordermod.FN.VipOrder.Status: pubdef.VipOrderSuccess,
vipordermod.FN.VipOrder.FinishedAt: bson.M{
"$gte": start,
"$lte": end,
},
},
},
{
"$group": bson.M{
"_id": nil,
"orderCount": bson.M{"$sum": 1},
"totalAmount": bson.M{"$sum": "$" + vipordermod.FN.VipOrder.Amount},
"vipOrderCount": bson.M{"$sum": bson.M{"$cond": bson.A{vipType, 1, 0}}},
"vipAmount": bson.M{"$sum": bson.M{"$cond": bson.A{
vipType,
"$" + vipordermod.FN.VipOrder.Amount,
0,
}}},
"coinOrderCount": bson.M{"$sum": bson.M{"$cond": bson.A{
bson.M{"$eq": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeCoin}},
1,
0,
}}},
"coinAmount": bson.M{"$sum": bson.M{"$cond": bson.A{
bson.M{"$eq": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeCoin}},
"$" + vipordermod.FN.VipOrder.Amount,
0,
}}},
"userIds": bson.M{"$addToSet": "$" + vipordermod.FN.VipOrder.UserId},
},
},
{
"$project": bson.M{
"_id": 0,
"orderCount": 1,
"totalAmount": 1,
"vipOrderCount": 1,
"vipAmount": 1,
"coinOrderCount": 1,
"coinAmount": 1,
"userCount": bson.M{"$size": "$userIds"},
},
},
}
}
func parseBeijingTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) {
start, ok := parseBeijingTime(startRaw, true)
if !ok {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间无效")
}
end, ok := parseBeijingTime(endRaw, false)
if !ok {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间无效")
}
if end.Before(start) {
return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间不能早于开始时间")
}
return start, end, nil
}
func parseBeijingTime(raw string, isStart bool) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
layouts := []string{
pubdef.TimeISO,
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700 MST",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, utils.LocBJ); err == nil {
return t, true
}
}
if t, err := time.ParseInLocation(pubdef.DateISO, raw, utils.LocBJ); err == nil {
if isStart {
return utils.BeginOfTime(t), true
}
return utils.EndOfTime(t), true
}
return time.Time{}, false
}