init: web_hgdjc_server

This commit is contained in:
2026-11-07 10:46:18 +08:00
commit b2ff6ac8ff
707 changed files with 147645 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"videoweb/web/service/srv_aicomment"
"videoweb/web/service/srv_ipblack"
"videoweb/web/service/srv_mediaresource"
"videoweb/web/skd"
"videoweb/web/webg"
)
func Start() {
url, appID, secret := webg.Conf.AiComment.Url, webg.Conf.AiComment.AppId, webg.Conf.AiComment.Secret
if url == "" && appID == "" && secret == "" {
url, appID, secret = webg.Conf.Customer.Url, webg.Conf.Customer.AppId, webg.Conf.Customer.Secret
}
srv_aicomment.Init(url, appID, secret)
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
}
+456
View File
@@ -0,0 +1,456 @@
package srv_aicomment
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"videoweb/pub/common/crypt"
"videoweb/pub/common/log"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
minCommentLengthLimit = 20
maxCommentLengthLimit = 50
maxSubmitVideoCount = 100
maxVideoTitleRunes = 100
maxVideoDescriptionRunes = 100
maxVideoSubtitleRunes = 500
maxVideoTags = 20
minSubmitVideoCommentCount = 1
minSubmitRequestInterval = 10 * time.Second
objectTypePlaylet = 10
)
type VideoItem struct {
VideoId string `json:"videoId"`
Title string `json:"title"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
CommentCount int `json:"commentCount"`
ObjType int64 `json:"-"`
RetainCount int `json:"-"` // 本次任务结果需要保留的评论数
TargetCount int `json:"-"` // 当前内容固定的目标AI评论总数
CommentTotal int `json:"-"` // 本次续传还需要生成的评论数
CurrentCount int64 `json:"-"` // 内容当前评论数,用于保留已有真实互动
}
type SubmitRequest struct {
AppID string `json:"appId"`
Sign string `json:"sign"`
Videos []VideoItem `json:"videos"`
}
type SubmitResult struct {
ID string `json:"id"`
VideoId string `json:"videoId"`
}
type SubmitResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Results []SubmitResult `json:"results"`
} `json:"data"`
}
type ResultRequest struct {
AppID string `json:"appId"`
Sign string `json:"sign"`
IDs []string `json:"ids"`
}
type Comment struct {
Type string `json:"type"`
Text string `json:"text"`
}
type ResultItem struct {
ID string `json:"id"`
VideoId string `json:"videoId"`
Status int `json:"status,omitempty"`
Comments []Comment `json:"comments"`
ErrorMsg string `json:"errorMsg,omitempty"`
}
type ResultResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Results []ResultItem `json:"results"`
} `json:"data"`
}
type AiCommentService struct {
baseURL string
appID string
appSecret string
client *http.Client
signMu sync.Mutex
lastSignUnixTime int64
submitMu sync.Mutex
lastSubmitAt time.Time
}
var aiCommentService *AiCommentService
func Init(baseURL, appID, secret string) {
aiCommentService = &AiCommentService{
baseURL: normalizeBaseURL(baseURL),
appID: strings.TrimSpace(appID),
appSecret: strings.TrimSpace(secret),
client: &http.Client{
Timeout: 30 * time.Second,
},
}
if aiCommentService.Configured() {
log.Info("自动评论服务初始化完成", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID))
return
}
log.Warn("自动评论服务配置不完整,已跳过启用", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID))
}
func GetAiCommentService() *AiCommentService {
if aiCommentService == nil {
Init("", "", "")
}
return aiCommentService
}
func (s *AiCommentService) Configured() bool {
return s != nil && s.baseURL != "" && s.appID != "" && s.appSecret != ""
}
func normalizeBaseURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return ""
}
if strings.HasSuffix(baseURL, "/app/customer") {
return baseURL
}
if strings.HasSuffix(baseURL, "/app") {
return baseURL + "/customer"
}
return baseURL + "/app/customer"
}
func (s *AiCommentService) generateSign(ctx context.Context) (string, int64, error) {
s.signMu.Lock()
defer s.signMu.Unlock()
timestamp := time.Now().Unix()
if timestamp <= s.lastSignUnixTime {
timer := time.NewTimer(time.Until(time.Unix(s.lastSignUnixTime+1, 0)))
defer timer.Stop()
select {
case <-ctx.Done():
return "", 0, ctx.Err()
case <-timer.C:
}
timestamp = time.Now().Unix()
}
params := url.Values{
"appId": {s.appID},
"timestamp": {strconv.FormatInt(timestamp, 10)},
}
cipherBytes, err := crypt.CoreAesEncrypt([]byte(params.Encode()), s.appSecret)
if err != nil {
return "", 0, fmt.Errorf("AES加密失败: %w", err)
}
s.lastSignUnixTime = timestamp
return hex.EncodeToString(cipherBytes), timestamp, nil
}
func (s *AiCommentService) postJSON(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error {
if !s.Configured() {
return errors.New("自动评论服务未配置")
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return err
}
reqURL := s.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
log.Info("自动评论HTTP请求", log.Any("url", reqURL), log.Any("bodySize", len(jsonData)))
resp, err := s.client.Do(req)
if err != nil {
log.Error("自动评论HTTP请求失败", log.E(err), log.Any("url", reqURL))
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
log.Info("自动评论HTTP响应", log.Any("url", reqURL), log.Any("statusCode", resp.StatusCode), log.Any("body", string(body)))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("自动评论HTTP状态异常: %d", resp.StatusCode)
}
if err = json.Unmarshal(body, respBody); err != nil {
return err
}
return nil
}
func (s *AiCommentService) SubmitVideos(ctx context.Context, videos []VideoItem) ([]SubmitResult, error) {
videos = normalizeVideoItems(videos)
if len(videos) == 0 {
return []SubmitResult{}, nil
}
if len(videos) > maxSubmitVideoCount {
return nil, fmt.Errorf("单次提交最多100个视频,当前%d个", len(videos))
}
if err := s.waitForSubmitSlot(ctx); err != nil {
return nil, err
}
sign, timestamp, err := s.generateSign(ctx)
if err != nil {
log.Error("自动评论生成签名失败", log.E(err))
return nil, err
}
reqBody := SubmitRequest{AppID: s.appID, Sign: sign, Videos: videos}
var submitResp SubmitResponse
if err = s.postJSON(ctx, "/videoComments/submit", reqBody, &submitResp); err != nil {
return nil, err
}
if submitResp.Code != http.StatusOK {
return nil, fmt.Errorf("提交任务失败: code=%d, msg=%s, timestamp=%d", submitResp.Code, submitResp.Msg, timestamp)
}
return submitResp.Data.Results, nil
}
func (s *AiCommentService) waitForSubmitSlot(ctx context.Context) error {
s.submitMu.Lock()
defer s.submitMu.Unlock()
wait := time.Until(s.lastSubmitAt.Add(minSubmitRequestInterval))
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
}
}
s.lastSubmitAt = time.Now()
return nil
}
func (s *AiCommentService) QueryResults(ctx context.Context, taskIDs []string) ([]ResultItem, error) {
if len(taskIDs) == 0 {
return []ResultItem{}, nil
}
if len(taskIDs) > 500 {
return nil, fmt.Errorf("单次查询最多500个任务,当前%d个", len(taskIDs))
}
sign, timestamp, err := s.generateSign(ctx)
if err != nil {
return nil, err
}
reqBody := ResultRequest{AppID: s.appID, Sign: sign, IDs: taskIDs}
var resultResp ResultResponse
if err = s.postJSON(ctx, "/videoComments/result", reqBody, &resultResp); err != nil {
return nil, err
}
if resultResp.Code != http.StatusOK {
return nil, fmt.Errorf("查询结果失败: code=%d, msg=%s, timestamp=%d", resultResp.Code, resultResp.Msg, timestamp)
}
return resultResp.Data.Results, nil
}
func (s *AiCommentService) SubmitAndRecord(ctx context.Context, videos []VideoItem) error {
videos = normalizeVideoItems(videos)
if len(videos) == 0 {
return nil
}
reserved := make([]VideoItem, 0, len(videos))
for _, video := range videos {
record := &aicommentrecord.AiCommentRecord{
Key: aiCommentRecordKey(video),
Vid: video.VideoId,
Title: video.Title,
ObjType: video.ObjType,
RetainCount: video.RetainCount,
TargetCount: video.TargetCount,
Desc: video.Description,
}
claimed, serr := aicommentrecord.Reserve(ctx, record)
if serr != nil {
return fmt.Errorf("预留自动评论任务失败: %w", serr)
}
if claimed {
reserved = append(reserved, video)
}
}
if len(reserved) == 0 {
return nil
}
results, err := s.SubmitVideos(ctx, reserved)
if err != nil {
for _, video := range reserved {
_ = aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), "", aicommentrecord.StatusFailed, err.Error())
}
return err
}
resultByVid := make(map[string]string, len(results))
for _, result := range results {
resultByVid[result.VideoId] = result.ID
}
missing := 0
for _, video := range reserved {
taskID := resultByVid[video.VideoId]
status := aicommentrecord.StatusSubmitted
desc := video.Description
if taskID == "" {
status = aicommentrecord.StatusFailed
desc = "自动评论服务未返回任务ID"
missing++
}
if serr := aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), taskID, status, desc); serr != nil {
return fmt.Errorf("保存自动评论任务记录失败: %w", serr)
}
}
if missing > 0 {
return fmt.Errorf("自动评论服务缺少%d个任务ID", missing)
}
log.Info("自动评论提交并记录成功", log.Any("videoCount", len(reserved)))
return nil
}
func aiCommentRecordKey(video VideoItem) string {
return strconv.FormatInt(video.ObjType, 10) + ":" + video.VideoId
}
func AutoProcessUnsubmittedPlaylets(ctx context.Context, playletIDs []int64) error {
if len(playletIDs) == 0 {
return nil
}
service := GetAiCommentService()
if !service.Configured() {
log.Warn("自动评论服务未配置,跳过提交", log.Any("playletIDs", playletIDs))
return nil
}
videos, err := buildPendingPlayletVideoItems(ctx, playletIDs)
if err != nil {
return err
}
if err = submitAndRecordInBatches(ctx, service, videos); err != nil {
return fmt.Errorf("提交AI评论任务失败: %w", err)
}
log.Info("自动评论处理短剧完成", log.Any("requested", len(playletIDs)), log.Any("pending", len(videos)))
return nil
}
func buildPendingPlayletVideoItems(ctx context.Context, playletIDs []int64) ([]VideoItem, error) {
if len(playletIDs) == 0 {
return []VideoItem{}, nil
}
playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": playletIDs},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().SetProjection(homepagePlayletProjection))
if serr != nil {
return nil, serr
}
pending, serr := buildHomepagePlayletVideoItems(ctx, playlets)
if serr != nil {
return nil, serr
}
return pending, nil
}
func normalizeVideoItems(videos []VideoItem) []VideoItem {
if len(videos) == 0 {
return []VideoItem{}
}
result := make([]VideoItem, 0, len(videos))
for _, video := range videos {
video = normalizeVideoItem(video)
if video.VideoId == "" {
continue
}
result = append(result, video)
}
return result
}
func normalizeVideoItem(video VideoItem) VideoItem {
video.VideoId = strings.TrimSpace(video.VideoId)
video.Title = truncateRunes(strings.TrimSpace(video.Title), maxVideoTitleRunes)
if video.Title == "" {
video.Title = video.VideoId
}
video.Description = truncateRunes(strings.TrimSpace(video.Description), maxVideoDescriptionRunes)
video.Subtitle = truncateRunes(strings.TrimSpace(video.Subtitle), maxVideoSubtitleRunes)
video.Tags = normalizeVideoTags(video.Tags)
if video.CommentCount < minSubmitVideoCommentCount {
video.CommentCount = minSubmitVideoCommentCount
}
if video.CommentCount > maxCommentLengthLimit {
video.CommentCount = maxCommentLengthLimit
}
if video.ObjType <= 0 {
video.ObjType = objectTypePlaylet
}
return video
}
func randomCommentLengthLimit() int {
return randomIntInclusive(minCommentLengthLimit, maxCommentLengthLimit)
}
func normalizeVideoTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
result := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
result = append(result, tag)
if len(result) >= maxVideoTags {
break
}
}
return result
}
func truncateRunes(text string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen])
}
+355
View File
@@ -0,0 +1,355 @@
package srv_aicomment
import (
"context"
"fmt"
"strconv"
"strings"
"sync/atomic"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"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"
)
const (
homepagePlayletCandidateLimit int64 = 60
homepagePlayletFeedThemeLimit int64 = 30
maxSubmitRequestsPerRun = 7
)
var homepagePlayletProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.IsFeatured, Value: 1},
{Key: playletmod.FN.Playlet.IsRecommend, Value: 1},
{Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1},
{Key: playletmod.FN.Playlet.TopRank, Value: 1},
{Key: playletmod.FN.Playlet.RecommendRank, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.CountComment, Value: 1},
}
var homepageSubmitRunning atomic.Bool
func AutoProcessHomepageVisibleContent(ctx context.Context) error {
if !homepageSubmitRunning.CompareAndSwap(false, true) {
log.Debug("首页内容自动评论补提交正在执行中,本次跳过")
return nil
}
defer homepageSubmitRunning.Store(false)
service := GetAiCommentService()
if !service.Configured() {
log.Warn("自动评论服务未配置,跳过首页内容补提交")
return nil
}
playlets, serr := collectHomepagePlaylets(ctx)
if serr != nil {
return fmt.Errorf("查询首页短剧失败: %w", serr)
}
videos, serr := buildHomepagePlayletVideoItems(ctx, playlets)
if serr != nil {
return fmt.Errorf("组装首页短剧AI评论参数失败: %w", serr)
}
if err := submitAndRecordInBatches(ctx, service, videos); err != nil {
return fmt.Errorf("提交首页短剧AI评论任务失败: %w", err)
}
log.Info("自动评论首页内容补提交完成",
log.Any("playletCandidates", len(playlets)),
log.Any("playletPending", len(videos)),
)
return nil
}
func submitAndRecordInBatches(ctx context.Context, service *AiCommentService, videos []VideoItem) error {
batches, deferred := buildAICommentSubmissionBatches(videos, maxSubmitRequestsPerRun)
for _, batch := range batches {
if err := service.SubmitAndRecord(ctx, batch); err != nil {
return err
}
}
if deferred {
log.Info("自动评论达到单轮提交请求上限,剩余任务等待下次续传",
log.Any("requestLimit", maxSubmitRequestsPerRun),
log.Any("videoCount", len(videos)))
}
return nil
}
func buildAICommentSubmissionBatches(videos []VideoItem, requestLimit int) ([][]VideoItem, bool) {
batches := make([][]VideoItem, 0)
for _, round := range buildAICommentSubmissionRounds(videos) {
for start := 0; start < len(round); start += maxSubmitVideoCount {
end := start + maxSubmitVideoCount
if end > len(round) {
end = len(round)
}
batches = append(batches, round[start:end])
}
}
if requestLimit <= 0 || len(batches) <= requestLimit {
return batches, false
}
return batches[:requestLimit], true
}
func buildAICommentSubmissionRounds(videos []VideoItem) [][]VideoItem {
pending := append([]VideoItem(nil), videos...)
rounds := make([][]VideoItem, 0)
for len(pending) > 0 {
round := make([]VideoItem, 0, len(pending))
next := make([]VideoItem, 0, len(pending))
for _, video := range pending {
remaining := video.CommentTotal
if remaining <= 0 {
remaining = commentsPerGenerationTask
}
retainCount := min(remaining, commentsPerGenerationTask)
taskVideo := video
taskVideo.CommentCount = randomCommentLengthLimit()
taskVideo.RetainCount = retainCount
taskVideo.CommentTotal = 0
round = append(round, taskVideo)
remaining -= retainCount
if remaining > 0 {
video.CommentTotal = remaining
next = append(next, video)
}
}
rounds = append(rounds, round)
pending = next
}
return rounds
}
func collectHomepagePlaylets(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) {
playlets := make(playletmod.PlayletList, 0, maxSubmitVideoCount)
seen := make(map[int64]struct{})
if serr := appendHomepagePlaylets(ctx, &playlets, seen,
homepagePlayletFilter(bson.M{playletmod.FN.Playlet.ShowOnBanner: true}),
bson.D{
{Key: playletmod.FN.Playlet.TopRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
6,
); serr != nil {
return nil, serr
}
if serr := appendHomepageFeedThemePlaylets(ctx, &playlets, seen); serr != nil {
return nil, serr
}
queries := []struct {
filter bson.M
sort bson.D
limit int64
}{
{
filter: homepagePlayletFilter(bson.M{playletmod.FN.Playlet.IsRecommend: true}),
sort: bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.CountBrowse, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
}
for _, query := range queries {
if serr := appendHomepagePlaylets(ctx, &playlets, seen, query.filter, query.sort, query.limit); serr != nil {
return nil, serr
}
}
return playlets, nil
}
func appendHomepageFeedThemePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}) *srvdef.SrvErr {
themes, serr := playletthememod.FindManyPlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Status: true,
}, options.Find().
SetProjection(bson.D{{Key: playletthememod.FN.PlayletTheme.Id, Value: 1}}).
SetSort(bson.D{
{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1},
{Key: playletthememod.FN.PlayletTheme.Id, Value: -1},
}),
)
if serr != nil {
return serr
}
sort := bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
for _, theme := range themes {
if theme == nil || theme.Id <= 0 {
continue
}
filter := homepagePlayletFilter(bson.M{
playletmod.FN.Playlet.ThemeIds: bson.M{"$in": []int64{theme.Id}},
})
if serr := appendHomepagePlaylets(ctx, playlets, seen, filter, sort, homepagePlayletFeedThemeLimit); serr != nil {
return serr
}
}
return nil
}
func appendHomepagePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}, filter bson.M, sort bson.D, limit int64) *srvdef.SrvErr {
list, serr := playletmod.FindManyPlaylet(ctx, filter, options.Find().
SetProjection(homepagePlayletProjection).
SetSort(sort).
SetLimit(limit),
)
if serr != nil {
return serr
}
for _, playlet := range list {
if playlet == nil || playlet.Id <= 0 {
continue
}
if _, ok := seen[playlet.Id]; ok {
continue
}
seen[playlet.Id] = struct{}{}
*playlets = append(*playlets, playlet)
}
return nil
}
func buildHomepagePlayletVideoItems(ctx context.Context, playlets playletmod.PlayletList) ([]VideoItem, *srvdef.SrvErr) {
tagNames := loadPlayletTagNames(ctx, playlets)
videos := make([]VideoItem, 0, len(playlets))
for _, playlet := range playlets {
if playlet == nil {
continue
}
videos = append(videos, VideoItem{
VideoId: strconv.FormatInt(playlet.Id, 10),
Title: playlet.Title,
Tags: playletTagNames(playlet.TagIds, tagNames),
Description: playlet.Desc,
CommentCount: randomCommentLengthLimit(),
ObjType: objectTypePlaylet,
CommentTotal: aiCommentPlayletTotal,
CurrentCount: playlet.CountComment,
})
}
pending, serr := preparePendingAICommentVideos(ctx, videos)
if serr != nil {
return nil, serr
}
return pending, nil
}
func homepagePlayletFilter(extra bson.M) bson.M {
filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}
for key, value := range extra {
filter[key] = value
}
return filter
}
func loadPlayletTagNames(ctx context.Context, playlets playletmod.PlayletList) map[int64]string {
tagIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, playlet := range playlets {
if playlet == nil {
continue
}
for _, tagID := range playlet.TagIds {
if tagID <= 0 {
continue
}
if _, ok := seen[tagID]; ok {
continue
}
seen[tagID] = struct{}{}
tagIDs = append(tagIDs, tagID)
}
}
if len(tagIDs) == 0 {
return map[int64]string{}
}
tags, serr := playlettagmod.FindManyPlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Id: bson.M{"$in": tagIDs},
}, options.Find().SetProjection(bson.D{
{Key: playlettagmod.FN.PlayletTag.Id, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Name, Value: 1},
}))
if serr != nil {
log.Warn("自动评论查询短剧标签失败", log.E(serr), log.Any("tagIDs", tagIDs))
return map[int64]string{}
}
result := make(map[int64]string, len(tags))
for _, tag := range tags {
if tag != nil {
result[tag.Id] = tag.Name
}
}
return result
}
func playletTagNames(tagIDs []int64, tagNames map[int64]string) []string {
result := make([]string, 0, len(tagIDs))
seen := make(map[string]struct{}, len(tagIDs))
for _, tagID := range tagIDs {
name := strings.TrimSpace(tagNames[tagID])
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
result = append(result, name)
if len(result) >= maxVideoTags {
break
}
}
return result
}
+299
View File
@@ -0,0 +1,299 @@
package srv_aicomment
import (
"context"
"math/rand"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode/utf8"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/commentmod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/usermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var pollingRunning atomic.Bool
const (
maxPollTaskCount int64 = 500
commentLevelFirst int64 = 1
commentReviewPass int = 1
)
func PollAiCommentResults(ctx context.Context) {
service := GetAiCommentService()
if !service.Configured() {
return
}
if !pollingRunning.CompareAndSwap(false, true) {
log.Debug("自动评论轮询正在执行中,本次跳过")
return
}
defer pollingRunning.Store(false)
if serr := expireStaleSubmittedTasks(ctx); serr != nil {
log.Error("自动评论轮询释放超时任务失败", log.E(serr))
return
}
records, serr := aicommentrecord.FindByStatus(ctx, aicommentrecord.StatusSubmitted, maxPollTaskCount)
if serr != nil {
log.Error("自动评论轮询查询任务记录失败", log.E(serr))
return
}
if len(records) == 0 {
return
}
taskIDs := make([]string, 0, len(records))
recordMap := make(map[string]*aicommentrecord.AiCommentRecord, len(records))
for _, record := range records {
if record.TaskId == "" {
continue
}
taskIDs = append(taskIDs, record.TaskId)
recordMap[record.TaskId] = record
}
results, err := service.QueryResults(ctx, taskIDs)
if err != nil {
log.Error("自动评论轮询查询生成结果失败", log.E(err), log.Any("taskIDs", taskIDs))
return
}
processAndInsertAiComments(ctx, results, recordMap)
}
func processAndInsertAiComments(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) {
users, err := robotUsers(ctx)
if err != nil || len(users) == 0 {
log.Error("自动评论机器人用户池为空或查询失败", log.E(err))
return
}
playletMap := loadPlayletsByResults(ctx, results, recordMap)
successTaskIDs := make([]string, 0)
for _, result := range results {
record, ok := recordMap[result.ID]
if !ok {
log.Warn("自动评论轮询未找到任务记录", log.Any("taskID", result.ID))
continue
}
if result.ErrorMsg != "" || result.Status == 2 {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, result.ErrorMsg)
log.Error("自动评论生成结果失败", log.Any("taskID", result.ID), log.Any("videoId", result.VideoId), log.Any("errorMsg", result.ErrorMsg))
continue
}
if len(result.Comments) == 0 {
continue
}
playletID, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || playletID <= 0 {
log.Warn("自动评论短剧ID无效", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.E(err))
continue
}
playlet := playletMap[playletID]
if playlet == nil || playlet.Status != playletmod.StatusUp {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusAbandoned, "短剧已下架或不存在")
continue
}
objectType := record.ObjType
if objectType <= 0 {
objectType = objectTypePlaylet
}
retainCount := record.RetainCount
if retainCount <= 0 {
retainCount = len(result.Comments)
}
selectedComments := selectAIComments(result.Comments, retainCount)
currentCount, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: playletID,
commentmod.FN.Comment.ObjectType: objectTypePlaylet,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ParentsId: 0,
})
if serr != nil {
log.Error("自动评论统计现有评论失败", log.E(serr), log.Any("playletID", playletID))
continue
}
insertedCount := 0
now := time.Now()
publishedAt := PlayletPublishedAt(playlet)
objectName := ""
if playlet != nil {
objectName = playlet.Title
}
if objectName == "" {
objectName = record.Title
}
for index, comment := range selectedComments {
if currentCount >= int64(aiCommentPlayletTotal) {
break
}
comment.Text = strings.TrimSpace(comment.Text)
if comment.Text == "" || utf8.RuneCountInString(comment.Text) > 200 {
continue
}
user := users[rand.Intn(len(users))]
commentID := mongos.Lid.Comment.Get()
item := &commentmod.CommentModel{
Id: commentID,
ObjectId: playletID,
ObjectType: objectType,
ObjectName: objectName,
UserId: user.Id,
UserName: commentUserName(user),
UserAvatar: user.Avatar,
Level: commentLevelFirst,
Text: comment.Text,
ReviewStatus: commentReviewPass,
IsAi: true,
AiTaskId: result.ID,
AiIndex: index,
CreatedAt: ScatteredCommentTime(publishedAt, now, playletID, commentID, index, len(selectedComments)),
UpdatedAt: now,
}
inserted, insertErr := commentmod.InsertAICommentOnce(ctx, item)
if insertErr != nil {
log.Error("自动评论写入失败", log.E(insertErr), log.Any("taskID", result.ID), log.Any("index", index))
return
}
if inserted {
currentCount++
insertedCount++
}
}
if serr := refreshPlayletCommentCount(ctx, playletID); serr != nil {
continue
}
if currentCount < int64(aiCommentPlayletTotal) {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, "生成评论不足,等待重试")
continue
}
successTaskIDs = append(successTaskIDs, result.ID)
log.Info("自动评论写入完成", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.Any("count", insertedCount))
}
if len(successTaskIDs) > 0 {
if serr := aicommentrecord.BatchUpdateStatus(ctx, successTaskIDs, aicommentrecord.StatusSuccess); serr != nil {
log.Error("自动评论更新成功任务状态失败", log.E(serr), log.Any("taskIDs", successTaskIDs))
}
}
}
func loadPlayletsByResults(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) map[int64]*playletmod.Playlet {
ids := make([]int64, 0, len(results))
seen := make(map[int64]struct{})
for _, result := range results {
if len(result.Comments) == 0 {
continue
}
record := recordMap[result.ID]
if record == nil {
continue
}
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return map[int64]*playletmod.Playlet{}
}
list, serr := playletmod.FindManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}})
if serr != nil {
log.Warn("自动评论查询短剧上架时间失败", log.E(serr), log.Any("ids", ids))
return map[int64]*playletmod.Playlet{}
}
res := make(map[int64]*playletmod.Playlet, len(list))
for _, playlet := range list {
res[playlet.Id] = playlet
}
return res
}
func robotUsers(ctx context.Context) (usermod.UserList, error) {
users, serr := usermod.FindManyUser(ctx, bson.M{
usermod.FN.User.Status: pubdef.UserStatusNormal,
usermod.FN.User.Id: bson.M{
"$gte": pubdef.RobotId_Min,
"$lte": pubdef.RobotId_Max,
},
}, options.Find().SetLimit(1000))
if serr != nil {
return nil, serr
}
if len(users) > 0 {
return users, nil
}
user := createFallbackRobotUser()
if serr = usermod.InsertOneUser(ctx, user); serr != nil {
return nil, serr
}
return usermod.UserList{user}, nil
}
func createFallbackRobotUser() *usermod.User {
now := time.Now()
return &usermod.User{
Id: mongos.Lid.User.Get(),
Type: pubdef.UserTypeGuest,
Status: pubdef.UserStatusNormal,
NickName: "游客" + strconv.FormatInt(now.Unix(), 10),
Device: "robot",
SysType: "ios",
LoginType: pubdef.LoginTypeDevice,
LastLoginAt: now,
CreatedAt: now,
UpdatedAt: now,
}
}
func commentUserName(user *usermod.User) string {
if user == nil {
return ""
}
if name := strings.TrimSpace(user.NickName); name != "" {
return name
}
if name := strings.TrimSpace(user.AccountNo); name != "" {
return name
}
return "用户" + strconv.FormatInt(user.Id, 10)
}
func refreshPlayletCommentCount(ctx context.Context, playletID int64) *srvdef.SrvErr {
if playletID <= 0 {
return nil
}
count, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: playletID,
commentmod.FN.Comment.ObjectType: objectTypePlaylet,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ParentsId: 0,
})
if serr != nil {
log.Warn("自动评论统计短剧评论数失败", log.E(serr), log.Any("playletId", playletID))
return serr
}
if serr = playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playletID,
}, bson.M{
"$set": bson.M{playletmod.FN.Playlet.CountComment: count},
}); serr != nil {
log.Warn("自动评论更新短剧评论数失败", log.E(serr), log.Any("playletId", playletID))
return serr
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package srv_aicomment
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/aicommentrecord"
)
const (
staleSubmittedTimeout = 60 * time.Minute
staleSubmittedDesc = "自动评论任务提交超过60分钟未返回,已释放并等待补交"
)
type aiCommentContentKey struct {
objType int64
vid string
}
func preparePendingAICommentVideos(ctx context.Context, videos []VideoItem) ([]VideoItem, *srvdef.SrvErr) {
if serr := expireStaleSubmittedTasks(ctx); serr != nil {
return nil, serr
}
vidsByType := make(map[int64][]string)
for _, video := range videos {
if video.VideoId != "" {
vidsByType[video.ObjType] = append(vidsByType[video.ObjType], video.VideoId)
}
}
recordsByContent := make(map[aiCommentContentKey]aicommentrecord.AiCommentRecordList)
for objType, vids := range vidsByType {
records, serr := aicommentrecord.FindByVIDsAndObjType(ctx, objType, vids)
if serr != nil {
return nil, serr
}
for _, record := range records {
if record == nil {
continue
}
key := aiCommentContentKey{objType: objType, vid: record.Vid}
recordsByContent[key] = append(recordsByContent[key], record)
}
}
pending := make([]VideoItem, 0, len(videos))
for _, video := range videos {
key := aiCommentContentKey{objType: video.ObjType, vid: video.VideoId}
if resumed, ok := resumeAICommentVideo(video, recordsByContent[key]); ok {
pending = append(pending, resumed)
}
}
return pending, nil
}
func expireStaleSubmittedTasks(ctx context.Context) *srvdef.SrvErr {
deadline := time.Now().Add(-staleSubmittedTimeout)
expired, serr := aicommentrecord.ExpireSubmittedBefore(ctx, deadline, staleSubmittedDesc)
if serr != nil {
return serr
}
if expired > 0 {
log.Warn("自动评论释放超时提交任务", log.Any("count", expired), log.Any("deadline", deadline))
}
return nil
}
func resumeAICommentVideo(video VideoItem, records aicommentrecord.AiCommentRecordList) (VideoItem, bool) {
targetCount := aiCommentPlayletTotal
if video.CurrentCount >= int64(targetCount) {
return VideoItem{}, false
}
accountedCount := 0
for _, record := range records {
if record == nil {
continue
}
if record.Status != aicommentrecord.StatusSubmitted && record.Status != aicommentrecord.StatusSuccess {
continue
}
retainCount := record.RetainCount
if retainCount <= 0 {
retainCount = commentsPerGenerationTask
}
accountedCount += retainCount
}
if video.CurrentCount > 0 && accountedCount < int(video.CurrentCount) {
accountedCount = int(video.CurrentCount)
}
if accountedCount >= targetCount {
return VideoItem{}, false
}
video.TargetCount = targetCount
video.CommentTotal = targetCount - accountedCount
return video, true
}
@@ -0,0 +1,31 @@
package srv_aicomment
import (
"testing"
"videoweb/pub/models/aicommentrecord"
)
func TestResumeAICommentVideo(t *testing.T) {
tests := []struct {
name string
current int64
records aicommentrecord.AiCommentRecordList
want int
ok bool
}{
{name: "new playlet", want: 3, ok: true},
{name: "existing comments", current: 2, want: 1, ok: true},
{name: "already full", current: 3},
{name: "submitted task", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusSubmitted, RetainCount: 3}}},
{name: "failed task can retry", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusFailed, RetainCount: 3}}, want: 3, ok: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := resumeAICommentVideo(VideoItem{CurrentCount: tt.current}, tt.records)
if ok != tt.ok || (ok && got.CommentTotal != tt.want) {
t.Fatalf("resumeAICommentVideo() = (%d, %v), want (%d, %v)", got.CommentTotal, ok, tt.want, tt.ok)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package srv_aicomment
import (
"context"
"strconv"
"videoweb/pub/common/log"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
)
// RetryFailedPlaylets retries a bounded batch, oldest failure first.
func RetryFailedPlaylets(ctx context.Context) {
if !GetAiCommentService().Configured() {
return
}
records, serr := aicommentrecord.FindFailedForRetry(ctx, 100)
if serr != nil || len(records) == 0 {
return
}
ids := make([]int64, 0, len(records))
for _, record := range records {
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
_ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧ID无效")
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return
}
playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
})
if serr != nil {
log.Warn("查询待重试短剧失败", log.E(serr))
return
}
active := make(map[int64]struct{}, len(playlets))
for _, playlet := range playlets {
active[playlet.Id] = struct{}{}
}
for _, record := range records {
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := active[id]; !ok {
_ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧已下架或不存在")
}
}
if len(active) == 0 {
return
}
activeIDs := make([]int64, 0, len(active))
for id := range active {
activeIDs = append(activeIDs, id)
}
if err := AutoProcessUnsubmittedPlaylets(ctx, activeIDs); err != nil {
log.Warn("重试短剧自动评论失败", log.E(err), log.Any("count", len(activeIDs)))
}
}
+55
View File
@@ -0,0 +1,55 @@
package srv_aicomment
import (
"time"
"videoweb/pub/models/playletmod"
)
func PlayletPublishedAt(playlet *playletmod.Playlet) time.Time {
if playlet == nil {
return time.Time{}
}
if !playlet.OnlineAt.IsZero() {
return playlet.OnlineAt
}
if !playlet.ReviewAt.IsZero() {
return playlet.ReviewAt
}
return playlet.CreatedAt
}
func ScatteredCommentTime(publishedAt, endAt time.Time, objectID, commentID int64, index, total int) time.Time {
if total <= 0 || index < 0 || index >= total || publishedAt.IsZero() || !publishedAt.Before(endAt) {
return endAt
}
span := endAt.Sub(publishedAt)
segment := span / time.Duration(total)
segmentStart := segment * time.Duration(index)
segmentEnd := segment * time.Duration(index+1)
if index == total-1 {
segmentEnd = span
}
width := segmentEnd - segmentStart
if width <= 0 {
return publishedAt.Add(segmentStart)
}
seed := scatterSeed(objectID, commentID, int64(index))
offset := time.Duration(seed % uint64(width))
return publishedAt.Add(segmentStart + offset)
}
func scatterSeed(values ...int64) uint64 {
const (
offsetBasis = uint64(1469598103934665603)
prime = uint64(1099511628211)
)
hash := offsetBasis
for _, value := range values {
hash ^= uint64(value)
hash *= prime
}
return hash
}
+33
View File
@@ -0,0 +1,33 @@
package srv_aicomment
import (
"math/rand"
)
const (
commentsPerGenerationTask = 8
aiCommentPlayletTotal = 3
)
func selectAIComments(comments []Comment, count int) []Comment {
available := make([]Comment, 0, len(comments))
for _, comment := range comments {
if comment.Text != "" {
available = append(available, comment)
}
}
if count <= 0 || len(available) <= count {
return available
}
rand.Shuffle(len(available), func(i, j int) {
available[i], available[j] = available[j], available[i]
})
return available[:count]
}
func randomIntInclusive(minValue, maxValue int) int {
if maxValue <= minValue {
return minValue
}
return minValue + rand.Intn(maxValue-minValue+1)
}
@@ -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"`
}
+43
View File
@@ -0,0 +1,43 @@
package srv_app
import (
"time"
)
type AppCategoryInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"` // Id
Name string `json:"name" bson:"name"` // 分类名称
Title string `json:"title" bson:"title"` // APP显示
Desc string `json:"desc" bson:"desc"` // 描述
Rank int64 `json:"rank" bson:"rank"` // 排序
}
type AppCreatorInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Name string `json:"name" bson:"name"` // 帖子标题
Sort int64 `json:"sort" bson:"sort"` // 排序
}
type AppTagInfo struct {
Id int64 `json:"id" bson:"_id,omitempty"`
Title string `json:"title" bson:"title"` // 帖子标题
Sort int64 `json:"sort" bson:"sort"` // 排序
}
// AppVideoInfoSyncReq APP数据同步请求
type AppVideoInfoSyncReq struct {
AppId int64 `json:"appId" bson:"appId"`
LastSyncTime time.Time `json:"lastSyncTime" bson:"lastSyncTime"` // 最后同步时间
PageSize int64 `json:"pageSize" bson:"pageSize"` // 页大小
}
type AppVideoCollectionSyncReq struct {
AppId int64 `json:"appId" bson:"appId"`
CollectionId []int64 `json:"collectionId" bson:"collectionId"`
}
type AppVideoCollectionSyncInfo struct {
CollectionID int64 `json:"collectionId" bson:"collectionId"` // 合集ID
VideoId int64 `json:"videoId" bson:"videoId"` // 视频ID
Rank int64 `json:"rank" bson:"rank"` // 排序
}
+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
}
+429
View File
@@ -0,0 +1,429 @@
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/models/videoinformationmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
commentObjectTypePlaylet int64 = 10 // 短剧评论
commentObjectTypePlayletChapter int64 = 11 // 短剧剧集评论
commentObjectTypeVideo int64 = 20 // 视频评论
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
}
case commentObjectTypeVideo:
serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: objectId,
}, bson.M{
"$set": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: 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) || objectType == commentObjectTypeVideo
}
+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-短剧剧集 20-视频
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-短剧剧集 20-视频
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()
}
+368
View File
@@ -0,0 +1,368 @@
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/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.Desc, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Keyword, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Label, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Style, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CardBackgroundImage, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.TopicIds, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CategoryId, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.RecommendType, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Type, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Limit, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.Status, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1},
{Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1},
}
// 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{
homeconfigmod.FN.HomeConfig.Type: bson.M{"$in": []int32{homeconfigmod.TypePlaylet, homeconfigmod.TypeTheme}},
}
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,
Name: item.Name,
Slug: item.Slug,
Desc: item.Desc,
Keyword: homeconfigmod.NormalizeKeywords(item.Keyword),
Label: item.Label,
Style: item.Style,
CardBackgroundImage: item.CardBackgroundImage,
TopicIds: item.TopicIds,
CategoryId: item.CategoryId,
RecommendType: item.RecommendType,
Type: item.Type,
Rank: item.Rank,
Limit: item.Limit,
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.Limit < 0 {
return nil, srvdef.New(srvdef.ErrParamError, "展示数量不能小于0", "")
}
if req.Status == nil {
return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "")
}
doc := &homeconfigmod.HomeConfig{
Name: name,
Slug: strings.TrimSpace(req.Slug),
Desc: strings.TrimSpace(req.Desc),
Keyword: homeconfigmod.NormalizeKeywords(req.Keyword),
Label: strings.TrimSpace(req.Label),
Style: strings.ToUpper(strings.TrimSpace(req.Style)),
CardBackgroundImage: strings.TrimSpace(req.CardBackgroundImage),
TopicIds: req.TopicIds,
CategoryId: req.CategoryId,
RecommendType: req.RecommendType,
Type: req.Type,
Rank: req.Rank,
Limit: req.Limit,
SortType: req.SortType,
Status: *req.Status,
SeoInfo: normalizeHomeConfigSeoValue(req.SeoInfo),
}
if err := ensureHomeConfigSlug(ctx, doc, 0); err != nil {
return nil, err
}
if err := validateHomeConfigTopics(ctx, doc); 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.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.Keyword != nil {
setDoc[homeconfigmod.FN.HomeConfig.Keyword] = homeconfigmod.NormalizeKeywords(*req.Keyword)
}
if req.Label != nil {
setDoc[homeconfigmod.FN.HomeConfig.Label] = strings.TrimSpace(*req.Label)
}
if req.Style != nil {
setDoc[homeconfigmod.FN.HomeConfig.Style] = strings.ToUpper(strings.TrimSpace(*req.Style))
}
if req.CardBackgroundImage != nil {
setDoc[homeconfigmod.FN.HomeConfig.CardBackgroundImage] = strings.TrimSpace(*req.CardBackgroundImage)
}
if req.TopicIds != nil {
setDoc[homeconfigmod.FN.HomeConfig.TopicIds] = *req.TopicIds
}
if req.CategoryId != nil {
setDoc[homeconfigmod.FN.HomeConfig.CategoryId] = *req.CategoryId
}
if req.RecommendType != nil {
setDoc[homeconfigmod.FN.HomeConfig.RecommendType] = *req.RecommendType
}
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.Limit != nil {
if *req.Limit < 0 {
return srvdef.New(srvdef.ErrParamError, "展示数量不能小于0", "")
}
setDoc[homeconfigmod.FN.HomeConfig.Limit] = *req.Limit
}
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
}
merged := *info
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Type]; ok {
merged.Type = v.(int32)
}
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Status]; ok {
merged.Status = v.(bool)
}
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.TopicIds]; ok {
merged.TopicIds = v.([]int64)
}
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Style]; ok {
merged.Style = v.(string)
}
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.CategoryId]; ok {
merged.CategoryId = v.(int64)
}
if v, ok := setDoc[homeconfigmod.FN.HomeConfig.RecommendType]; ok {
merged.RecommendType = v.(int64)
}
if err := validateHomeConfigTopics(ctx, &merged); err != nil {
return err
}
if req.TopicIds != nil {
setDoc[homeconfigmod.FN.HomeConfig.TopicIds] = merged.TopicIds
}
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 validHomeConfigSortType(sortType int32) bool {
return sortType == homeconfigmod.SortTypeRecommend ||
sortType == homeconfigmod.SortTypeHot ||
sortType == homeconfigmod.SortTypeLatest ||
sortType == homeconfigmod.SortTypeOnlineAt ||
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
}
+142
View File
@@ -0,0 +1,142 @@
package srv_homeconfig
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/playletthememod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// validateHomeConfigTopics 校验题材配置、去重,并阻止启用模块重复占用题材
func validateHomeConfigTopics(ctx context.Context, config *homeconfigmod.HomeConfig) *srvdef.SrvErr {
if config.Style != "" && (len(config.Style) != 1 || config.Style[0] < 'A' || config.Style[0] > 'H') {
return srvdef.New(srvdef.ErrParamError, "样式仅支持 A-H", "")
}
if config.CategoryId < 0 || config.RecommendType < 0 || config.RecommendType > 5 {
return srvdef.New(srvdef.ErrParamError, "推荐配置无效", "")
}
if config.RecommendType == 5 && config.CategoryId == 0 {
return srvdef.New(srvdef.ErrParamError, "按分类推荐时必须填写分类ID", "")
}
if config.Type != homeconfigmod.TypeTheme {
if len(config.TopicIds) != 0 {
return srvdef.New(srvdef.ErrParamError, "短剧模块不能配置题材ID", "")
}
return nil
}
seen := map[int64]bool{}
ids := make([]int64, 0, len(config.TopicIds))
for _, id := range config.TopicIds {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "题材ID无效", "")
}
if !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
config.TopicIds = ids
if !config.Status {
return nil
}
if len(ids) == 0 {
if config.Id == 0 {
return srvdef.New(srvdef.ErrParamError, "启用的题材模块必须配置题材ID", "")
}
return nil // 历史题材配置沿用自动选题材
}
themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}, playletthememod.FN.PlayletTheme.Status: true})
if err != nil {
return err
}
if len(themes) != len(ids) {
return srvdef.New(srvdef.ErrParamError, "题材不存在或未上架", "")
}
others, err := homeconfigmod.FindManyHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Status: true, homeconfigmod.FN.HomeConfig.Type: homeconfigmod.TypeTheme, homeconfigmod.FN.HomeConfig.TopicIds: bson.M{"$in": ids}})
if err != nil {
return err
}
for _, other := range others {
if other != nil && other.Id != config.Id {
return srvdef.New(srvdef.ErrParamError, "题材已被其他启用模块使用", "")
}
}
return nil
}
// ListHomeTopicOptions 分页查询可选的已上架短剧题材,并回显当前配置已选题材
func ListHomeTopicOptions(ctx context.Context, req *proto.HomeTopicOptionsReq) (*proto.HomeTopicOptionsResp, *srvdef.SrvErr) {
if req == nil || req.ConfigId < 0 {
return nil, srvdef.New(srvdef.ErrParamError, "配置ID无效", "")
}
pageNum, pageSize := normalizeHomeConfigPage(req.PageNum, req.PageSize)
resp := &proto.HomeTopicOptionsResp{List: make([]*proto.HomeTopicOption, 0), SelectedList: make([]*proto.HomeTopicOption, 0), PageNum: pageNum, PageSize: pageSize}
var selected []int64
if req.ConfigId > 0 {
current, err := homeconfigmod.FindOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.ConfigId})
if err != nil {
return nil, err
}
if current.Type != homeconfigmod.TypeTheme {
return nil, srvdef.New(srvdef.ErrParamError, "当前配置不是题材模块", "")
}
selected = current.TopicIds
}
occupied := map[int64]bool{}
configs, err := homeconfigmod.FindManyHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Status: true, homeconfigmod.FN.HomeConfig.Type: homeconfigmod.TypeTheme})
if err != nil {
return nil, err
}
for _, config := range configs {
if config != nil && config.Id != req.ConfigId {
for _, id := range config.TopicIds {
occupied[id] = true
}
}
}
filter := bson.M{playletthememod.FN.PlayletTheme.Status: true}
if len(occupied) > 0 {
ids := make([]int64, 0, len(occupied))
for id := range occupied {
ids = append(ids, id)
}
filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$nin": ids}
}
resp.TotalCount, err = playletthememod.CountPlayletTheme(ctx, filter)
if err != nil {
return nil, err
}
list, err := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find().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
}
for _, item := range list {
if item != nil {
resp.List = append(resp.List, &proto.HomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status})
}
}
if len(selected) > 0 {
themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": selected}})
if err != nil {
return nil, err
}
byID := map[int64]*proto.HomeTopicOption{}
for _, item := range themes {
if item != nil {
byID[item.Id] = &proto.HomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}
}
}
for _, id := range selected {
if item := byID[id]; item != nil {
resp.SelectedList = append(resp.SelectedList, item)
} else {
resp.SelectedList = append(resp.SelectedList, &proto.HomeTopicOption{Id: id})
}
}
}
return resp, nil
}
+25
View File
@@ -0,0 +1,25 @@
package srv_ipblack
import (
"videoweb/pub/models/ipblackmod"
"videoweb/pub/pubdef"
)
type GetIPBlackListReq struct {
pubdef.Page
IP *string `json:"ip"`
}
type GetIPBlackListResp struct {
Total int64 `json:"total"`
List []*ipblackmod.IPBlackModel `json:"list"`
}
type AddIPBlackListReq struct {
Ip string `json:"ip"`
Desc string `json:"desc" bson:"desc"` // 备注
}
type DelIpBlackListReq struct {
Ip string `json:"ip"`
}
+102
View File
@@ -0,0 +1,102 @@
package srv_ipblack
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
"sync"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/ipblackmod"
)
var (
ipBlackMap map[string]struct{}
lock sync.RWMutex
)
func Init() {
ctx := context.Background()
list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{})
if err != nil {
log.Error("ipBlackInit FindManyIPBlackModel error", log.E(err))
return
}
tempMaps := make(map[string]struct{})
for _, v := range list {
tempMaps[v.Ip] = struct{}{}
}
lock.Lock()
defer lock.Unlock()
ipBlackMap = tempMaps
}
func CheckIp(ip string) bool {
lock.RLock()
defer lock.RUnlock()
if _, ok := ipBlackMap[ip]; ok {
return true
}
return false
}
func GetIpBlackList(ctx context.Context, req *GetIPBlackListReq) (*GetIPBlackListResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.IP != nil && len(*req.IP) > 0 {
filter[ipblackmod.FN.IPBlackModel.Ip] = req.IP
}
count, err := ipblackmod.CountIPBlackModel(ctx, filter)
if err != nil {
return nil, err
}
if count == 0 {
return &GetIPBlackListResp{
Total: 0,
List: make([]*ipblackmod.IPBlackModel, 0),
}, nil
}
opts := options.FindOptions{}
opts.SetSort(bson.M{ipblackmod.FN.IPBlackModel.CreatedAt: -1})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, err := ipblackmod.FindManyIPBlackModel(ctx, filter, &opts)
if err != nil {
return nil, err
}
resp := &GetIPBlackListResp{
Total: count,
List: list,
}
return resp, nil
}
func AddIpBlack(ctx context.Context, req *AddIPBlackListReq) *srvdef.SrvErr {
err := ipblackmod.InsertOneIPBlackModel(ctx, &ipblackmod.IPBlackModel{
ID: primitive.NewObjectID(),
Ip: req.Ip,
Desc: req.Desc,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err == nil {
lock.Lock()
defer lock.Unlock()
ipBlackMap[req.Ip] = struct{}{}
}
return err
}
func DelIpBlack(ctx context.Context, req *DelIpBlackListReq) *srvdef.SrvErr {
_, err := ipblackmod.DelOneIPBlackModel(ctx, bson.M{
ipblackmod.FN.IPBlackModel.Ip: req.Ip,
})
if err != nil {
lock.Lock()
defer lock.Unlock()
delete(ipBlackMap, req.Ip)
ipBlackMap[req.Ip] = struct{}{}
}
return err
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
package srv_mediaresource
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/pub/redis"
goredis "github.com/go-redis/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// PlayletCoverCompressCronSpec 历史空压缩封面扫描频率:每5分钟投递一批
PlayletCoverCompressCronSpec = "0 */5 * * * ?"
playletCoverCompressQueueKeyFmt = "playlet:cover:compress:queue:%d"
playletCoverCompressLockKeyFmt = "playlet:cover:compress:lock:%d:%d"
playletCoverCompressFailKeyFmt = "playlet:cover:compress:fail:v2:%d:%d"
playletCoverCompressLockTTL = 10 * time.Minute
playletCoverCompressFailTTL = 10 * time.Minute
playletCoverCompressPopWait = 5 * time.Second
playletCoverCompressRetryWait = time.Second
playletCoverCompressScanSize = int64(200)
playletCoverCompressBatchSize = 50
)
func playletCoverCompressQueueKey() string {
return fmt.Sprintf(playletCoverCompressQueueKeyFmt, pubdef.AppId)
}
func playletCoverCompressLockKey(playletId int64) string {
return fmt.Sprintf(playletCoverCompressLockKeyFmt, pubdef.AppId, playletId)
}
func playletCoverCompressFailKey(playletId int64) string {
return fmt.Sprintf(playletCoverCompressFailKeyFmt, pubdef.AppId, playletId)
}
type playletCoverCompressJob struct {
PlayletId int64 `json:"playletId"`
CreateTime int64 `json:"createTime"`
}
// EnqueuePlayletCoverCompressJobs 扫描压缩封面为空的短剧并投递后台任务
func EnqueuePlayletCoverCompressJobs() {
ctx := context.Background()
enqueued := 0
scanned := 0
var lastId int64
for enqueued < playletCoverCompressBatchSize {
filter := emptyCompressedCoverFilter()
if lastId > 0 {
filter[playletmod.FN.Playlet.Id] = bson.M{"$gt": lastId}
}
opts := options.Find().
SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
}).
SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}).
SetLimit(playletCoverCompressScanSize)
list, err := playletmod.FindManyPlaylet(ctx, filter, opts)
if err != nil {
log.Error("扫描待压缩短剧封面失败", log.E(err))
return
}
if len(list) == 0 {
break
}
scanned += len(list)
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
lastId = item.Id
if enqueued >= playletCoverCompressBatchSize {
break
}
if has, _ := redis.Exists(playletCoverCompressFailKey(item.Id)); has {
continue
}
if err := pushPlayletCoverCompressJob(ctx, item.Id); err != nil {
log.Error("投递短剧封面压缩任务失败", log.Any("playletId", item.Id), log.E(err))
continue
}
enqueued++
}
if int64(len(list)) < playletCoverCompressScanSize {
break
}
}
log.Info("短剧压缩封面任务扫描完成", log.Any("scanCount", scanned), log.Any("enqueued", enqueued))
}
func emptyCompressedCoverFilter() bson.M {
return bson.M{
playletmod.FN.Playlet.Cover: bson.M{"$nin": []interface{}{"", nil}},
"$or": []bson.M{
{playletmod.FN.Playlet.CompressedCover: bson.M{"$exists": false}},
{playletmod.FN.Playlet.CompressedCover: ""},
{playletmod.FN.Playlet.CompressedCover: nil},
},
}
}
func pushPlayletCoverCompressJob(ctx context.Context, playletId int64) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
payload, err := json.Marshal(playletCoverCompressJob{
PlayletId: playletId,
CreateTime: time.Now().Unix(),
})
if err != nil {
return err
}
return redis.RPush(playletCoverCompressQueueKey(), string(payload))
}
func runPlayletCoverCompressWorker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
result, err := redis.Client().BLPop(playletCoverCompressPopWait, playletCoverCompressQueueKey()).Result()
if err != nil {
if err == goredis.Nil {
continue
}
log.Error("消费短剧封面压缩任务失败", log.E(err))
time.Sleep(playletCoverCompressRetryWait)
continue
}
if len(result) < 2 {
log.Error("短剧封面压缩任务格式异常", log.Any("result", result))
continue
}
handlePlayletCoverCompressPayload(ctx, result[1])
}
}
func handlePlayletCoverCompressPayload(ctx context.Context, payload string) {
defer func() {
if err := recover(); err != nil {
log.Error("处理短剧封面压缩任务异常", log.Any("err", err), log.Any("payload", payload))
}
}()
var job playletCoverCompressJob
if err := json.Unmarshal([]byte(payload), &job); err != nil {
log.Error("解析短剧封面压缩任务失败", log.Any("payload", payload), log.E(err))
return
}
if job.PlayletId <= 0 {
return
}
locked, err := redis.Setnx_NewOK(playletCoverCompressLockKey(job.PlayletId), true, playletCoverCompressLockTTL)
if err != nil {
log.Error("获取短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err))
return
}
if !locked {
return
}
defer func() {
if err := redis.Del(playletCoverCompressLockKey(job.PlayletId)); err != nil {
log.Error("释放短剧封面压缩锁失败", log.Any("playletId", job.PlayletId), log.E(err))
}
}()
playlet, serr := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: job.PlayletId}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
}))
if serr != nil || playlet == nil {
if serr != nil {
log.Warn("查询待压缩短剧失败", log.Any("playletId", job.PlayletId), log.E(serr))
}
return
}
if FillPlayletCompressedCover(ctx, playlet) {
log.Info("历史短剧压缩封面完成", log.Any("playletId", playlet.Id))
return
}
if strings.TrimSpace(playlet.CompressedCover) == "" && strings.TrimSpace(playlet.Cover) != "" {
if err := redis.Set(playletCoverCompressFailKey(playlet.Id), "1", playletCoverCompressFailTTL); err != nil {
log.Warn("记录短剧封面压缩失败冷却失败", log.Any("playletId", playlet.Id), log.E(err))
}
}
}
@@ -0,0 +1,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 playName := strings.TrimSpace(playlet.PlayName); playName != "" {
description := playletmod.BuildPlayNameDescription(playlet.Desc, playName, 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
+371
View File
@@ -0,0 +1,371 @@
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.PlayName = playlet.PlayName
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["$or"] = []bson.M{
{playletmod.FN.Playlet.Title: bson.M{"$regex": pattern}},
{playletmod.FN.Playlet.PlayName: 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.PlayName, 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
}
+125
View File
@@ -0,0 +1,125 @@
package srv_playlet
import (
"context"
"strings"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletthememod"
"videoweb/pub/models/playlettopicgroupmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ListPlayletTopicGroups 查询后台专题区块
func ListPlayletTopicGroups(ctx context.Context, req *proto.PlayletTopicGroupListReq) (*proto.PlayletTopicGroupListResp, *srvdef.SrvErr) {
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePage(req.PageNum, req.PageSize)
filter := bson.M{}
if req.Status != nil {
filter["status"] = *req.Status
}
totalCount, err := playlettopicgroupmod.Count(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletTopicGroupListResp{
TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize),
List: make(playlettopicgroupmod.PlayletTopicGroupList, 0),
}
if totalCount == 0 || (pageNum-1)*pageSize >= totalCount {
return resp, nil
}
list, err := playlettopicgroupmod.FindMany(ctx, filter, options.Find().SetSkip((pageNum-1)*pageSize).SetLimit(pageSize))
if err != nil {
return nil, err
}
resp.List = list
return resp, nil
}
// AddPlayletTopicGroup 新增专题区块
func AddPlayletTopicGroup(ctx context.Context, req *proto.PlayletTopicGroupAddReq) (*proto.PlayletTopicGroupAddResp, *srvdef.SrvErr) {
if req == nil || strings.TrimSpace(req.Name) == "" || req.Status == nil {
return nil, srvdef.New(srvdef.ErrParamError, "专题区块参数无效", "")
}
ids, err := validateTopicGroupIDs(ctx, req.TopicIds)
if err != nil {
return nil, err
}
doc := &playlettopicgroupmod.PlayletTopicGroup{
Name: strings.TrimSpace(req.Name), Desc: strings.TrimSpace(req.Desc),
TopicIds: ids, Rank: req.Rank, Status: *req.Status,
}
if err := playlettopicgroupmod.Insert(ctx, doc); err != nil {
return nil, err
}
return &proto.PlayletTopicGroupAddResp{Id: doc.Id}, nil
}
// UpdatePlayletTopicGroup 更新专题区块
func UpdatePlayletTopicGroup(ctx context.Context, req *proto.PlayletTopicGroupUpdateReq) *srvdef.SrvErr {
if req == nil || req.Id <= 0 {
return srvdef.New(srvdef.ErrParamError, "专题区块ID无效", "")
}
set := bson.M{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return srvdef.New(srvdef.ErrParamError, "专题区块名称不能为空", "")
}
set["name"] = name
}
if req.Desc != nil {
set["desc"] = strings.TrimSpace(*req.Desc)
}
if req.TopicIds != nil {
ids, err := validateTopicGroupIDs(ctx, *req.TopicIds)
if err != nil {
return err
}
set["topicIds"] = ids
}
if req.Rank != nil {
set["rank"] = *req.Rank
}
if req.Status != nil {
set["status"] = *req.Status
}
if len(set) == 0 {
return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "")
}
return playlettopicgroupmod.Update(ctx, req.Id, set)
}
// DeletePlayletTopicGroup 删除专题区块
func DeletePlayletTopicGroup(ctx context.Context, id int64) *srvdef.SrvErr {
if id <= 0 {
return srvdef.New(srvdef.ErrParamError, "专题区块ID无效", "")
}
return playlettopicgroupmod.Delete(ctx, id)
}
func validateTopicGroupIDs(ctx context.Context, ids []int64) ([]int64, *srvdef.SrvErr) {
if len(ids) == 0 {
return []int64{}, nil
}
seen := make(map[int64]bool, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效或重复", "")
}
seen[id] = true
}
count, err := playletthememod.CountPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}})
if err != nil {
return nil, err
}
if count != int64(len(ids)) {
return nil, srvdef.New(srvdef.ErrParamError, "专题不存在", "")
}
return ids, nil
}
+307
View File
@@ -0,0 +1,307 @@
package srv_recharge
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"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 := order.Client.UA(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 := order.Client.UA(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
}
+73
View File
@@ -0,0 +1,73 @@
package srv_report
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videoinformationmod"
"go.mongodb.org/mongo-driver/bson"
)
// ReportVideoStatusChangeEvent 视频状态变更事件上报
func ReportVideoStatusChangeEvent(media *videoinformationmod.VideoInformation, flag, userId int64, ua useragent.UA) {
if media == nil || media.Id == 0 || flag == 0 {
return
}
var lsjId string
var videoTypeID = "default"
var videoTypeName = "默认分类"
if len(media.CategoryIds) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
category, _ := videocategorymod.FindOneVideoCategory(ctx, bson.M{
videocategorymod.FN.VideoCategory.Id: media.CategoryIds[0],
})
if category != nil {
videoTypeID = fmt.Sprintf("%v", category.Id)
videoTypeName = category.Name
}
}
event := dataReport.NewCommonField("", dataReport.EventTypeVideoStatusChange, "", ua, strconv.FormatInt(userId, 10), "")
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoStatusChangeEvent{
MediaId: lsjId,
VideoID: fmt.Sprintf("%v", media.Id),
VideoTitle: media.Title,
VideoTypeID: videoTypeID,
VideoTypeName: videoTypeName,
Flag: dataReport.JsonNumber(flag),
})
b, _ := json.Marshal(event)
if err := eventmod.InsertOneEvent(context.Background(), &eventmod.Event{
EventId: event.EventID,
EventType: event.Event,
Data: string(b),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("ReportVideoStatusChangeEvent fail", log.Any("mediaId", media.Id), log.E(err))
}
}
// VideoStatusFlag 将媒体状态映射为上报flag
func VideoStatusFlag(status videoinformationmod.InformationSatus) int64 {
switch status {
case videoinformationmod.InformationSatusVerifyOK:
return 1 // 上架
case videoinformationmod.InformationSatusVerifyCancel:
return 2 // 未通过
default:
return 0
}
}
+38
View File
@@ -0,0 +1,38 @@
package srv_richtextcontent
import (
"time"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/pubdef"
)
type GetRichTextContentListReq struct {
Title *string `json:"title" bson:"title"` // 标题
Status *bool `json:"status" bson:"status"` // 是否启用
pubdef.Page
}
type GetRichTextContentResp struct {
Total int64 `json:"total" bson:"total"`
List richtextcontentmod.RichTextContentModelList `json:"list" bson:"list"`
}
type DelRichTextContentReq struct {
Id int64 `bson:"_id,omitempty"`
}
type UpdateRichTextContentData struct {
Id int64 `bson:"_id,omitempty" json:"_id,omitempty"`
Title *string `json:"title" bson:"title,omitempty"` // 标题
Content *string `json:"content" bson:"content,omitempty"` // 内容
Location *[]string `json:"location" bson:"location,omitempty"` // 位置
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序值
Status *bool `json:"status" bson:"status,omitempty"` // 是否启用
Desc *string `json:"desc" bson:"desc,omitempty"` // 标题(app)
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
}
type UpdateRichTextContentReq struct {
Id int64 `json:"id" binding:"required"`
Param UpdateRichTextContentData `json:"param" bson:"param"` // appId,唯一id
}
@@ -0,0 +1,71 @@
package srv_richtextcontent
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetRichTextContentList(ctx context.Context, req *GetRichTextContentListReq) (*GetRichTextContentResp, *srvdef.SrvErr) {
filter := bson.M{}
if req.Title != nil {
filter[richtextcontentmod.FN.RichTextContentModel.Title] = bson.M{"$regex": *req.Title, "$options": "i"}
}
if req.Status != nil {
filter[richtextcontentmod.FN.RichTextContentModel.Status] = *req.Status
}
total, err := richtextcontentmod.CountRichTextContentModelModels(ctx, filter)
if err != nil {
return nil, err
}
resp := &GetRichTextContentResp{}
if total == 0 {
return resp, nil
}
opts := &options.FindOptions{}
opts.SetSort(bson.M{richtextcontentmod.FN.RichTextContentModel.UpdatedAt: -1})
opts.SetLimit(req.PageSize)
opts.SetSkip((req.PageNum - 1) * req.PageSize)
list, err := richtextcontentmod.FindManyRichTextContentModelModel(ctx, filter, opts)
if err != nil {
return resp, err
}
resp.Total = total
resp.List = list
return resp, nil
}
func AddRichTextContent(ctx context.Context, ac *richtextcontentmod.RichTextContentModel) *srvdef.SrvErr {
ac.Id = mongos.Lid.HtmlText.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
return richtextcontentmod.InsertOneRichTextContentModelModel(ctx, ac)
}
func UpdateRichTextContent(ctx context.Context, id int64, param *UpdateRichTextContentData) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{richtextcontentmod.FN.RichTextContentModel.Id: id}
updateDoc := bson.M{"$set": param}
serr := richtextcontentmod.UpdateOneRichTextContentModelModel(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
func DelRichTextContent(ctx context.Context, req *DelRichTextContentReq) *srvdef.SrvErr {
_, err := richtextcontentmod.DelOneRichTextContentModelModel(ctx, bson.M{
richtextcontentmod.FN.RichTextContentModel.Id: req.Id,
})
return err
}
+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
}
+177
View File
@@ -0,0 +1,177 @@
package srv_search
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"videoweb/pub/models/videoinformationmod"
"go.mongodb.org/mongo-driver/bson/primitive"
)
var syncIsRuining = false
func StartES() {
log.Info("srv_search:Start...")
defer log.Info("srv_search:Start!!!")
// 初始化 ES 索引 mapping(已存在时报错可忽略)
//InitArticleESIndex()
//InitToolESIndex()
startSyncEs()
_, _ = skdCron.AddFunc("0 0/17 * * * ?", func() {
startSyncEs()
})
skdCron.Start()
}
func startSyncEs() {
ctx := context.Background()
updateEsData(ctx, esmod.GetVideoIndex(), getVideoEsData)
}
func StopES() {
log.Info("srv_search:Stop...")
defer log.Info("srv_search:Stop!!!")
}
func updateEsData(ctx context.Context, esIndex string, fn func(ctx context.Context, startTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr)) {
log.Info("srv_search:do...", log.Any("ESIndex", esIndex))
defer log.Info("srv_search:do end", log.Any("EsIndex", esIndex))
if fn == nil {
return
}
esRecord, serr := esmod.GetRecordTimeByESIndex(ctx, esIndex)
if serr != nil {
log.Error(fmt.Sprintf("srv_search:startEngine GetRecordByESIndex failed! Index:%v,err:%+v:", esIndex, serr))
return
}
isNew := false
if esRecord == nil {
isNew = true
esRecord = &esmod.ESRecord{
Id: primitive.NewObjectID(),
EsIndex: esIndex,
RecordTime: time.Time{},
Timestamp: esmod.ESTimestamp{},
UpdatedAt: time.Time{},
CreatedAt: time.Now(),
}
}
var now = time.Now()
var esData esmanager.M
// 跨天凌晨5点重置所有数据
if isNew || ((now.Hour() >= 5 && esRecord.UpdatedAt.Local().Hour() < 5) || esRecord.UpdatedAt.Local().Day() < now.Day()) {
esData, serr = fn(ctx, nil, nil, esIndex)
if serr != nil {
log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr))
return
}
_ = delAllFromES(esIndex)
isUpdate, err := commit(esData, esIndex, time.Now())
if err != nil {
log.Error("Elastic sync video data RunSync commit err! ", log.E(err))
return
}
if isUpdate {
// 有成功过就算成功
if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil {
return
}
}
} else {
timestamp := esRecord.RecordTime
var recentMinute = utils.RecentMinute(now, esmod.FiveMinuteScale)
for recentMinute.After(timestamp) {
var end = timestamp.Add(esmod.FiveMinuteScale * time.Minute)
if end.After(recentMinute) {
end = recentMinute
}
esData, serr = fn(ctx, &timestamp, &end, esIndex)
if serr != nil {
log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr))
return
}
if esData != nil && len(esData) > 0 {
isUpdate, err := commit(esData, esIndex, time.Now())
if err != nil {
log.Error("Elastic sync video data RunSync commit err! ", log.E(err))
return
}
if isUpdate {
// 有成功过就算成功
if err := esmod.SetOneEsRecordTime(nil, esIndex, now); err != nil {
return
}
}
}
timestamp = end
}
}
}
func commit(data esmanager.M, esIndex string, syncTime time.Time) (bool, error) {
bulkData := handleBulkCountLimit(data)
isHaveSuccess := false
if bulkData != nil && len(bulkData) > 0 {
for _, doc := range bulkData {
if err := esmanager.Instance.Bulk(esIndex, doc); err != nil {
log.Warn(fmt.Sprintf("Elastic sync commit video data SyncDataToES failed!, err:%+v\n", err))
// 出错继续
} else {
isHaveSuccess = true
}
}
}
if isHaveSuccess {
return isHaveSuccess, nil
} else {
return isHaveSuccess, errors.New("Elastic sync commit video data UpsertOneTrans failed!")
}
}
func handleBulkCountLimit(data esmanager.M) []esmanager.M {
if data == nil || len(data) == 0 {
return make([]esmanager.M, 0)
}
rsl := make([]esmanager.M, 0)
rsl = append(rsl, make(esmanager.M, batchSize))
index := 0
for k, v := range data {
rsl[index][k] = v
if len(rsl[index]) >= batchSize {
index++
rsl = append(rsl, make(esmanager.M, batchSize))
}
}
return rsl
}
// DelVideoFromES 从 ES 实时删除单条视频(视频删除/下架时调用)
func DelVideoFromES(id int64) {
if esmanager.Instance == nil {
return
}
strId := strconv.FormatInt(id, 10)
if err := batchDelFromES(esmod.GetVideoIndex(), []string{strId}); err != nil {
log.Warn("srv_search:DelVideoFromES failed", log.Any("id", id), log.E(err))
}
}
// UpsertVideoToES 将单条视频实时写入/更新 ES(视频上架时调用)
func UpsertVideoToES(info *videoinformationmod.VideoInformation) {
if esmanager.Instance == nil {
return
}
esData := videoToEsData([]*videoinformationmod.VideoInformation{info})
if err := AddMultiDataToES(esmod.GetVideoIndex(), esData); err != nil {
log.Warn("srv_search:UpsertVideoToES failed", log.Any("id", info.Id), log.E(err))
}
}
+249
View File
@@ -0,0 +1,249 @@
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.PlayName, Value: 1},
{Key: playletmod.FN.Playlet.Description, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.Anchor, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.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,
PlayName: item.PlayName,
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
}
+98
View File
@@ -0,0 +1,98 @@
package srv_search
import (
"context"
"strconv"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/esmanager"
"videoweb/pub/models/esmod"
"videoweb/pub/models/videoinformationmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func getVideoEsData(ctx context.Context, minTime, endTime *time.Time, esIndex string) (esmanager.M, *srvdef.SrvErr) {
data, serr := getVideoData(ctx, minTime, endTime)
if serr != nil {
return nil, serr
}
return videoToEsData(data), nil
}
func getVideoData(ctx context.Context, minTime, endTime *time.Time) ([]*videoinformationmod.VideoInformation, *srvdef.SrvErr) {
pageNumber := 1
pageSize := 1000
rsl := make([]*videoinformationmod.VideoInformation, 0)
filter := bson.M{
videoinformationmod.FN.VideoInformation.MediaType: bson.M{
"$in": []interface{}{
videoinformationmod.MediaType_Video,
videoinformationmod.MediaType_VideoV2,
},
},
}
if minTime != nil && endTime != nil {
filter[videoinformationmod.FN.VideoInformation.UpdatedAt] = bson.M{
"$gte": minTime,
"$lte": endTime,
}
}
for {
opts := &options.FindOptions{}
opts.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1})
opts.SetSkip(int64(pageSize * (pageNumber - 1)))
opts.SetLimit(int64(pageSize))
data, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if serr != nil {
return nil, serr
}
rsl = append(rsl, data...)
if len(data) < pageSize {
break
}
pageNumber++
}
return rsl, nil
}
func videoToEsData(data []*videoinformationmod.VideoInformation) esmanager.M {
rsl := make(map[string]interface{}, len(data))
for _, v := range data {
esInfo := esmod.ESVideo{
Id: v.Id,
Title: v.Title,
Slug: v.Slug,
MediaType: v.MediaType,
AddedTime: v.AddedTime,
CreatorName: v.CreatorName,
Keyworlds: "",
Desc: v.Desc,
Remark: v.Remark,
Status: v.Status,
IsHot: v.IsHot,
Sort: v.Sort,
WatchTimes: v.Real.WatchTimes,
ShowTimes: v.Real.ShowTimes,
Comments: v.Real.Comments,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,
Keyworld: v.Keyworld,
CreatorId: v.CreatorId,
FirstTag: v.FirstTag,
SecondTags: v.SecondTags,
CoverImg: v.CoverImg,
Nodes: v.Nodes,
Video: v.Video,
CategoryIds: v.CategoryIds,
Tags: v.Tags,
Real: v.Real,
VideoCount: v.VideoCount,
}
if v.Keyworld != nil && len(v.Keyworld) > 0 {
esInfo.Keyworlds = strings.Join(v.Keyworld, " ")
}
rsl[strconv.FormatInt(v.Id, 10)] = esInfo
}
return rsl
}
+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"}},
},
}
}
+27
View File
@@ -0,0 +1,27 @@
package srv_search
import (
"github.com/robfig/cron/v3"
"videoweb/pub/common/log"
)
var (
skdCron *cron.Cron
batchSize int = 100
opMaxDiff int32 = 10
)
func Start() {
skdCron = cron.New(cron.WithSeconds())
log.Info("srv_search:Start...")
defer log.Info("srv_search:Start!!!")
go func() {
StartES()
}()
}
func Stop() {
log.Info("srv_search:Stop...")
defer log.Info("srv_search:Stop!!!")
StopES()
}
+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
}
+424
View File
@@ -0,0 +1,424 @@
// 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/models/videoinformationmod"
"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 拼装短剧详情页完整地址,与 sitemap 一致:/detail/{slug}/,无 slug 回退 ID。
func PlayletDetailURL(webAddr, slug string, playletId int64) string {
if h5path.Segment(slug, playletId) == "" {
return ""
}
return h5path.SitemapAbs(webAddr, h5path.Entity("/detail", slug, playletId))
}
// VideoPlayURL 拼装视频播放页完整地址,路径与 H5 / sitemap 一致:/series-play/{slug}。
func VideoPlayURL(webAddr, slug string, videoId int64) string {
path := h5path.VideoPlay(slug, videoId)
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)
}
// VideoPageVisible 判断视频播放页当前是否对外可访问。
func VideoPageVisible(status videoinformationmod.InformationSatus) bool {
return status == videoinformationmod.InformationSatusVerifyOK
}
// EnqueueVideoUpdated 视频上架后登记并立即推送。
func EnqueueVideoUpdated(ctx context.Context, videoId int64, status videoinformationmod.InformationSatus) {
if !pubdef.Enabled() || !VideoPageVisible(status) {
return
}
enqueueVideo(ctx, videoId, seopushmod.ActionUpdated, true)
}
// EnqueueVideoDeleted 视频下架/删除后登记并立即推送。
func EnqueueVideoDeleted(ctx context.Context, videoId int64) {
if !pubdef.Enabled() {
return
}
enqueueVideo(ctx, videoId, 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 enqueueVideo(ctx context.Context, videoId int64, action string, pushNow bool) {
webAddr := SEOBaseURL(ctx)
if webAddr == "" {
return
}
pageURL := VideoPlayURL(webAddr, loadVideoSlug(ctx, videoId), videoId)
if pageURL == "" {
return
}
now := time.Now()
task := &seopushmod.SeoPushTask{
Url: pageURL,
VideoId: videoId,
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 video task failed", log.Any("url", pageURL), log.E(serr))
return
}
if action == seopushmod.ActionUpdated {
markVideoQueued(ctx, videoId)
}
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 loadVideoSlug(ctx context.Context, videoId int64) string {
if videoId <= 0 {
return ""
}
item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: videoId},
options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.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, "/lieyan/av/detail") {
return true
}
// 旧推送路径 /play/{slug}。/series-play 是视频页,/detail/{slug}/ 是当前详情页。
path := requestPath(raw)
return path == "/play" || strings.HasPrefix(path, "/play/")
}
func requestPath(raw string) string {
raw = strings.TrimSpace(raw)
if i := strings.IndexAny(raw, "?#"); i >= 0 {
raw = raw[:i]
}
if i := strings.Index(raw, "://"); i >= 0 {
raw = raw[i+3:]
if j := strings.Index(raw, "/"); j >= 0 {
return raw[j:]
}
return "/"
}
if raw == "" {
return "/"
}
if !strings.HasPrefix(raw, "/") {
return "/" + raw
}
return raw
}
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))
}
}
func markVideoQueued(ctx context.Context, videoId int64) {
if serr := videoinformationmod.UpdateOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: videoId},
bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}}); serr != nil {
log.Warn("seopush: mark video queued failed", log.Any("videoId", videoId), log.E(serr))
}
}
// PushTask 推送任务中尚未成功的通道
func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if task == nil || !pubdef.Enabled() {
return
}
// 旧路径不再推送:查询参数、/play/{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,36 @@
package srv_translationcategory
import (
"time"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/pubdef"
)
type TranslationCategoryReq struct {
Id *int64 `json:"id"` // 分类ID,精确匹配
Name string `json:"name"` // 分类名称,模糊搜索
pubdef.Page
}
type TranslationCategoryResp struct {
Total int64 `json:"total"`
List translationcategorymod.TranslationCategoryList `json:"list"`
}
type TranslationCategoryUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Name *string `json:"name" bson:"name,omitempty"` // 名称
Icon *string `json:"icon" bson:"icon,omitempty"` // Icon
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Rank *int64 `json:"rank" bson:"rank,omitempty"` //
CategoryType *int `json:"categoryType" bson:"categoryType"` // 导航类型 1=url跳转 2=二级导航 3=底部导航
UrlValue *string `json:"urlValue" bson:"urlValue"` // 跳转URL
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,90 @@
package srv_translationcategory
import (
"context"
"regexp"
"strings"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/mongos"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetTranslationCategorys 按照参数搜索导航分类
func GetTranslationCategorys(ctx context.Context, param *TranslationCategoryReq) (*TranslationCategoryResp, *srvdef.SrvErr) {
filter := bson.M{}
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"}
}
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
}
// AddTranslationCategory 添加一视频标签
func AddTranslationCategory(ctx context.Context,
ac *translationcategorymod.TranslationCategory) *srvdef.SrvErr {
ac.Id = mongos.Lid.TransCategory.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
serr := translationcategorymod.InsertOneTranslationCategory(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelTranslationCategory 删除指定视频标签
func DelTranslationCategory(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id}
//删除
count, serr := translationcategorymod.DelOneTranslationCategory(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateTranslationCategory 更新指定视频标签
func UpdateTranslationCategory(ctx context.Context, id int64, param TranslationCategoryUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
adFilter := bson.M{translationcategorymod.FN.TranslationCategory.Id: id}
updateDoc := bson.M{"$set": param}
serr := translationcategorymod.UpdateOneTranslationCategory(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+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
}
+126
View File
@@ -0,0 +1,126 @@
package srv_videocategory
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/mongos"
"videoweb/web/service/srv_videoslug"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoCategorys 按照参数搜索视频分类
func GetVideoCategorys(ctx context.Context, param *VideoCategoryReq) (*VideoCategoryResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &VideoCategoryResp{}
if param.ID > 0 {
filter[videocategorymod.FN.VideoCategory.Id] = param.ID
}
if param.Name != "" {
filter[videocategorymod.FN.VideoCategory.Name] = bson.M{
"$regex": param.Name,
}
}
opt.SetSort(bson.M{videocategorymod.FN.VideoCategory.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videocategorymod.CountVideoCategorys(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videocategorymod.FindManyVideoCategorys(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoCategory 添加一个视频分类
func AddVideoCategory(ctx context.Context,
ac *videocategorymod.VideoCategory) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoCategory.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
if ac.Name == "" {
return srvdef.New(srvdef.ErrParamError, "", "")
}
if serr := srv_videoslug.EnsureCategory(ctx, ac, 0); serr != nil {
return serr
}
serr := videocategorymod.InsertOneVideoCategory(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoCategory 删除指定视频分类
func DelVideoCategory(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id}
//删除
count, serr := videocategorymod.DelOneVideoCategory(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoCategory 更新指定视频分类
func UpdateVideoCategory(ctx context.Context, id int64, param VideoCategoryUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
info, serr := videocategorymod.FindOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: id})
if serr != nil {
return serr
}
name := info.Name
if param.Name != nil {
name = *param.Name
}
keywords := info.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
slug, changed, slugErr := srv_videoslug.PrepareCategoryOnUpdate(ctx, info, param.Slug, name, keywords)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videocategorymod.UpdateOneVideoCategory(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package srv_videocategory
import (
"time"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/pubdef"
)
type VideoCategoryReq struct {
pubdef.Page
ID int64 `json:"id"` //分类ID
Name string `json:"name"` //分类名称
}
type VideoCategoryResp struct {
Total int64 `json:"total"`
List videocategorymod.VideoCategoryList `json:"list"`
}
type VideoCategoryUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Name *string `json:"name" bson:"name,omitempty"` // 名称
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Title *string `json:"title" bson:"title,omitempty"` // APP显示
Desc *string `json:"desc" bson:"desc,omitempty"` // 描述
SortType *int64 `json:"sortType" bson:"sortType,omitempty"` // 排序方式 0:上线时间 1:观看次数 2排序
ContentType *int64 `json:"contentType" bson:"contentType,omitempty"` // 内容:0: 默认 1:首页 2:今日
Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
}
type VideoCategoryUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoCategoryUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoCategoryDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+128
View File
@@ -0,0 +1,128 @@
package srv_videocreater
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/mongos"
"videoweb/web/service/srv_videoslug"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoCreaters 按照参数搜索视频标签
func GetVideoCreaters(ctx context.Context, param *VideoCreaterReq) (*VideoCreaterResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &VideoCreaterResp{}
if param.ID > 0 {
filter[videocreatermod.FN.VideoCreaterInfo.Id] = param.ID
}
if param.Name != "" {
filter[videocreatermod.FN.VideoCreaterInfo.Name] = bson.M{
"$regex": param.Name,
}
}
opt.SetSort(bson.M{videocreatermod.FN.VideoCreaterInfo.Id: 1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videocreatermod.CountVideoCreaterInfos(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videocreatermod.FindManyVideoCreaterInfos(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoCreater 添加一视频标签
func AddVideoCreater(ctx context.Context,
ac *videocreatermod.VideoCreaterInfo) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoCreator.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
if ac.Name == "" {
return srvdef.New(srvdef.ErrParamError, "", "")
}
if serr := srv_videoslug.EnsureCreater(ctx, ac, 0); serr != nil {
return serr
}
log.Info("InsertOneVideoCreaterInfo", log.Any("", *ac))
serr := videocreatermod.InsertOneVideoCreaterInfo(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoCreater 删除指定视频标签
func DelVideoCreater(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id}
//删除
count, serr := videocreatermod.DelOneVideoCreaterInfo(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoCreater 更新指定视频标签
func UpdateVideoCreater(ctx context.Context, id int64, param VideoCreaterUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
info, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id})
if serr != nil {
return serr
}
name := info.Name
if param.Name != nil {
name = *param.Name
}
keywords := info.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
slug, changed, slugErr := srv_videoslug.PrepareCreaterOnUpdate(ctx, info, param.Slug, name, keywords)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videocreatermod.UpdateOneVideoCreaterInfo(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package srv_videocreater
import (
"time"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/pubdef"
)
type VideoCreaterReq struct {
pubdef.Page
ID int64 `json:"id"` //分类ID
Name string `json:"name"` //分类名称
}
type VideoCreaterResp struct {
Total int64 `json:"total"`
List videocreatermod.VideoCreaterInfoList `json:"list"`
}
type VideoCreaterUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Name *string `json:"name" bson:"name,omitempty"` // 名称
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
}
type VideoCreaterUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoCreaterUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoCreaterDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+113
View File
@@ -0,0 +1,113 @@
package srv_videoinfo
import (
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
)
type VideoInfoReq struct {
Id *int64 `json:"id"` // Id
Title *string `json:"title"` // 标题
CreatorId *int64 `json:"creatorId"` // 作者Id
Status *videoinformationmod.InformationSatus `json:"status"` // 帖子上架状态
CategoryId *int64 `json:"categoryId"` // 分类Id
TopicId *int64 `json:"topicId"` // 专题Id
Tag *int64 `json:"tag" ` // 标签
IsHot *bool `json:"isHot"` // 是否热门
MediaType videoinformationmod.MediaType `json:"mediaType"` // 资源类型
CreatorName *string `json:"creatorName"` // 作者名称
Keyworld *string `json:"keyworld"` // 关键字
VideoTaskId *string `json:"videoTaskId"` // 视频任务id
VideoFileId *string `json:"videoFileId"` // 切片id(切片列表的ID)
IsTop *bool `json:"isTop" bson:"isTop"` // 是否置顶
IsH265 *bool `json:"isH265"` // 是否支持 H265
Sort int64 `json:"sort"` // 排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数
pubdef.Page
StartTime *string `json:"startTime"` // 创建时间-开始
EndTime *string `json:"endTime"` // 创建时间-截止
OptionAccount *string `json:"optionAccount"` // 最后操作人
}
type VideoInfoResp struct {
Total int64 `json:"total"`
List videoinformationmod.VideoInformationList `json:"list"`
}
type AddVideoReq struct {
FileIds []string `json:"fileId"` // 资源id
}
type VideoInfoUpdate struct {
Title *string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题
Slug *string `json:"slug,omitempty" bson:"slug,omitempty"` // slug
AddedTime *time.Time `json:"addedTime,omitempty" bson:"addedTime,omitempty"`
CreatorId *int64 `json:"creatorId,omitempty" bson:"creatorId,omitempty"` // 作者Id
Keyworld *[]string `bson:"keyworld,omitempty" json:"keyworld"` //
MediaType *videoinformationmod.MediaType `json:"mediaType,omitempty" bson:"mediaType,omitempty"` // 资源类型
FirstTag *string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签
SecondTags *[]string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签
CoverImg *string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面
UnencryptedImage *string `bson:"unencryptedImage,omitempty" json:"unencryptedImage"` // 不加密封面
Desc *string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述
Remark *string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注
Nodes *[]videoinformationmod.Node `json:"nodes,omitempty" bson:"nodes,omitempty"` // 节点
Status *videoinformationmod.InformationSatus `json:"status,omitempty" bson:"status,omitempty"` // 帖子上架状态
Sort *int64 `json:"sort,omitempty" bson:"sort,omitempty"` // 排序
Cover *string `json:"cover,omitempty" bson:"cover,omitempty"` // 视频封面
Height *int64 `json:"height,omitempty" bson:"height,omitempty"` // 分辨率高(视频才会存在)
Width *int64 `json:"width,omitempty" bson:"width,omitempty"` // 分辨率宽(视频才会存在)
VideoTime *int32 `bson:"videoTime,omitempty" json:"videoTime,omitempty"` // 影片长度
CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空
TopicIds *[]int64 `json:"topicIds,omitempty" bson:"topicIds,omitempty"` // 专题ID
Tags *[]int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签
IsHot *bool `json:"isHot,omitempty" bson:"isHot,omitempty"` // 是否热门
UpdatedAt *time.Time `json:"updatedAt,omitempty" bson:"updatedAt,omitempty"` // 更新时间
CreatorName *string `bson:"creatorName,omitempty"` // 作者名
IsTop *bool `json:"isTop,omitempty" bson:"isTop,omitempty"` // 是否置顶
Video *videoinformationmod.VideoInfo `bson:"video,omitempty"` // 视频
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
Content *string `json:"content,omitempty" bson:"content,omitempty"`
SecondUpdatedAt *time.Time `json:"secondUpdatedAt,omitempty" bson:"secondUpdatedAt,omitempty"` // 二次修改时间
}
type VideoInfoUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoInfoUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoInfoDelReq struct {
Id int64 `json:"id" binding:"required"`
}
type AddVideoCollectionReq struct {
Title string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题
Slug string `json:"slug,omitempty" bson:"slug,omitempty"` // slug
Keyworld []string `bson:"keyworld,omitempty" json:"keyworld,omitempty"` // 关键字
FirstTag string `bson:"firstTag,omitempty" json:"firstTag,omitempty"` // 一级标签
SecondTags []string `bson:"secondTags,omitempty" json:"secondTags,omitempty"` // 二级标签标签
CoverImg string `bson:"coverImg,omitempty" json:"coverImg,omitempty"` // 封面
Desc string `bson:"desc,omitempty" json:"desc,omitempty"` // 详细描述
Remark string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注
Tags []int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签
CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空
}
type SetFakeWatchTimesReq struct {
Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量
WatchTimesMin int64 `json:"watchTimesMin"` // 假观看次数随机最小值
WatchTimesMax int64 `json:"watchTimesMax"` // 假观看次数随机最大值
}
type SetFakeCountResp struct {
MatchedCount int64 `json:"matchedCount"` // 匹配到的视频数量
ModifiedCount int64 `json:"modifiedCount"` // 实际修改的视频数量
}
// SetVideoCategoryReq 单/批量设置视频分类请求
type SetVideoCategoryReq struct {
Ids []int64 `json:"ids"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置
CategoryIds []int64 `json:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空
}
+160
View File
@@ -0,0 +1,160 @@
package srv_videoinfo
import (
"context"
"fmt"
"strings"
"videoweb/pub/common/laosiji"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/videoinformationmod"
"videoweb/web/webg"
"go.mongodb.org/mongo-driver/bson"
)
// TranscodeVideo 手动提交主视频 H265 转码。
func TranscodeVideo(ctx context.Context, videoId int64) *srvdef.SrvErr {
info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
})
if serr != nil {
return serr
}
src := strings.TrimSpace(info.Video.Url)
if src == "" {
return srvdef.New(srvdef.Err, "", "该视频没有可转码的源地址")
}
if laosiji.LaosijiClient == nil || laosiji.LaosijiDomains == nil {
return srvdef.New(srvdef.Err, "", "转码服务未就绪")
}
if info.HevcStatus == videoinformationmod.HevcStatusPending {
return srvdef.New(srvdef.Err, "", "转码请求已提交,正在转码中,请勿重复提交")
}
if err := SubmitTranscodeVideo(ctx, videoId, src, info.Video.FileId); err != nil {
return srvdef.New(srvdef.Err, "", "提交转码失败:"+err.Error())
}
return nil
}
func buildTranscodeSourceURL(videoUrl string) string {
name := strings.TrimSpace(videoUrl)
if name == "" {
return ""
}
if !strings.Contains(name, ".m3u8") {
name += ".m3u8"
}
host := strings.TrimSpace(webg.Conf.WebM3u8Host)
if host == "" {
return ""
}
return utils.JoinUrlPath(host, "/api/web/m3u8/"+name)
}
// SubmitTranscodeVideo 提交主视频转码任务。
func SubmitTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error {
fileURL := buildTranscodeSourceURL(src)
if fileURL == "" {
return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host)
}
sd, err := laosiji.LaosijiDomains.Get(ctx)
if err != nil {
return err
}
result, err := laosiji.LaosijiClient.SubmitTranscoding(ctx, fileURL, fileId, *sd, videoId)
if err != nil {
return err
}
return applyTranscodeResult(ctx, videoId, result)
}
// RefreshTranscodeVideo 查询转码任务状态并回写。
func RefreshTranscodeVideo(ctx context.Context, videoId int64, src, fileId string) error {
fileURL := buildTranscodeSourceURL(src)
if fileURL == "" {
return fmt.Errorf("empty transcode source url, videoUrl:%s host:%s", src, webg.Conf.WebM3u8Host)
}
sd, err := laosiji.LaosijiDomains.Get(ctx)
if err != nil {
return err
}
result, found, err := laosiji.LaosijiClient.QueryTranscoding(ctx, fileURL, fileId, *sd)
if err != nil {
return err
}
if !found {
return nil
}
return applyTranscodeResult(ctx, videoId, result)
}
func applyTranscodeResult(ctx context.Context, videoId int64, result laosiji.TranscodeResult) error {
log.Infof("==applyTranscodeResult===> result", log.Any("videoId", videoId), log.Any("result", result))
var update bson.M
switch {
case result.Done:
meta := toVideoMetaInfo(result.HevcMeta)
if meta == nil {
meta = &videoinformationmod.VideoMetaInfo{}
}
h265Url := normalizeH265UrlForSave(result.HevcURL)
meta.M3u8URL = h265Url
set := bson.M{
videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusSuccess,
videoinformationmod.FN.VideoInformation.HevcMetaInfo: meta,
videoinformationmod.FN.VideoInformation.Video.H265Url: h265Url,
}
if result.PreviewVtt != "" {
set[videoinformationmod.FN.VideoInformation.PreviewVtt] = result.PreviewVtt
}
if result.AudioFile != "" {
set[videoinformationmod.FN.VideoInformation.AudioFile] = result.AudioFile
}
if result.SmartCover != "" {
set[videoinformationmod.FN.VideoInformation.SmartCover] = result.SmartCover
}
update = bson.M{"$set": set}
case result.Failed:
update = bson.M{
"$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusFailed},
"$inc": bson.M{videoinformationmod.FN.VideoInformation.HevcFailCount: 1},
}
default:
update = bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.HevcStatus: videoinformationmod.HevcStatusPending}}
}
if serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: videoId,
}, update); serr != nil {
return fmt.Errorf("update video transcode status: %v", serr)
}
return nil
}
func normalizeH265UrlForSave(url string) string {
url = strings.TrimSpace(url)
return strings.TrimPrefix(url, "/")
}
func toVideoMetaInfo(m *laosiji.MetaInfo) *videoinformationmod.VideoMetaInfo {
if m == nil {
return nil
}
return &videoinformationmod.VideoMetaInfo{
Codec: m.Codec,
Profile: m.Profile,
Width: m.Width,
Height: m.Height,
Fps: m.Fps,
VideoBitrate: m.VideoBitrate,
PixFmt: m.PixFmt,
AudioCodec: m.AudioCodec,
AudioBitrate: m.AudioBitrate,
AudioSampleRate: m.AudioSampleRate,
AudioChannels: m.AudioChannels,
Bitrate: m.Bitrate,
Size: m.Size,
Duration: m.Duration,
}
}
+232
View File
@@ -0,0 +1,232 @@
package srv_videoinfo
import (
"context"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/storage/proxy"
"videoweb/web/service/srv_videoslug"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func IsPlayletMedia(ctx context.Context, media proxy.MediaResp) bool {
return playletchaptermod.IsLinkedMedia(ctx, media.IsPlaylet, media.PlayletKeys()...)
}
func SyncOneVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr {
if IsPlayletMedia(ctx, media) {
log.Info("VideoSync skip playlet media", log.Any("mediaId", media.Id.Hex()), log.Any("mediaSourceId", media.MediaSourceId.Hex()))
return nil
}
filter := bson.M{
"$or": []bson.M{
{videoinformationmod.FN.VideoInformation.Video.FileId: media.Id.Hex()},
{videoinformationmod.FN.VideoInformation.SyncId: media.Id.Hex()},
},
}
videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, filter)
if serr != nil {
// 新加入的视频
if serr.Code == srvdef.ErrNoData {
videoInfo = media.ToVideoInfo()
if videoInfo == nil {
log.Error("VideoSync FindOneVideoInformation", log.Any("MediaId", media.Id))
return srvdef.New(srvdef.ErrNoData, "videoInfo is nil")
}
videoInfo.IndexNow = 0
if len(media.Publish) > 0 {
creater, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
videocreatermod.FN.VideoCreaterInfo.Name: media.Publish,
})
if serr != nil && serr.Code != srvdef.ErrNoData {
log.Error("VideoSync.FindOneVideoCreaterInfo", log.Any("media", media), log.E(serr))
return serr
}
if creater == nil {
createrId := mongos.Lid.VideoCreator.Get()
creater = &videocreatermod.VideoCreaterInfo{
Id: createrId,
Name: media.Publish,
Status: true,
Sort: 0,
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
if slugErr := srv_videoslug.EnsureCreater(ctx, creater, 0); slugErr != nil {
return slugErr
}
serr = videocreatermod.InsertOneVideoCreaterInfo(ctx, creater)
if serr != nil {
log.Error("VideoSync.InsertOneVideoCreaterInfo", log.Any("media", media), log.E(serr))
return serr
}
}
videoInfo.CreatorId = creater.Id
videoInfo.CreatorName = creater.Name
}
if slugErr := srv_videoslug.EnsureVideo(ctx, videoInfo, 0); slugErr != nil {
return slugErr
}
serr = videoinformationmod.InsertOneVideoInformation(ctx, videoInfo)
if serr != nil {
log.Error("VideoSync InsertOneVideoInformation", log.Any("videoInfo", videoInfo), log.E(serr))
return serr
}
} else {
log.Error("VideoSync FindOneVideoInformation", log.Any("filter", filter), log.E(serr))
return serr
}
} else {
// 更新资源
nodes := make([]videoinformationmod.Node, 0)
if videoInfo != nil {
// 更新节点里面图片水印 以本地视频列表的节点为主
for _, node := range videoInfo.Nodes {
isFind := false
if len(node.NodeId) > 0 {
for _, v := range media.Nodes {
if node.NodeId == v.NodeId {
isFind = true
node.Images = v.Images
nodes = append(nodes, node)
break
}
}
}
if !isFind {
nodes = append(nodes, node)
}
}
} else {
nodes = media.Nodes
}
setDoc := bson.M{
videoinformationmod.FN.VideoInformation.Nodes: nodes,
videoinformationmod.FN.VideoInformation.Keyworld: media.Keyworld,
videoinformationmod.FN.VideoInformation.FirstTag: media.FirstTag,
videoinformationmod.FN.VideoInformation.SecondTags: media.SecondTags,
videoinformationmod.FN.VideoInformation.CoverImg: media.CoverImg,
videoinformationmod.FN.VideoInformation.UnencryptedImage: media.UnencryptedImage,
}
if len(media.FieldNameFs) > 0 {
video := videoinformationmod.VideoInfo{
TaskId: media.TaskID,
FileId: media.Id.Hex(),
Url: media.FieldNameFs,
Cover: media.CoverImg,
Height: media.Height,
Width: media.Width,
VideoTime: media.VideoTime,
UnencryptedPreMp4: media.UnencryptedPreMp4,
PreMp4FileNameFs: media.PreMp4FileNameFs,
}
if !strings.HasSuffix(video.Url, ".m3u8") {
video.Url += ".m3u8"
}
setDoc[videoinformationmod.FN.VideoInformation.Video.TaskId] = video.TaskId
setDoc[videoinformationmod.FN.VideoInformation.Video.FileId] = video.FileId
setDoc[videoinformationmod.FN.VideoInformation.Video.Url] = video.Url
setDoc[videoinformationmod.FN.VideoInformation.Video.Cover] = video.Cover
setDoc[videoinformationmod.FN.VideoInformation.Video.Height] = video.Height
setDoc[videoinformationmod.FN.VideoInformation.Video.Width] = video.Width
setDoc[videoinformationmod.FN.VideoInformation.Video.VideoTime] = video.VideoTime
setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = video.UnencryptedPreMp4
}
if strings.TrimSpace(videoInfo.Slug) == "" {
if slugErr := srv_videoslug.EnsureVideo(ctx, videoInfo, videoInfo.Id); slugErr == nil && strings.TrimSpace(videoInfo.Slug) != "" {
setDoc[videoinformationmod.FN.VideoInformation.Slug] = videoInfo.Slug
}
}
serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id}, bson.M{"$set": setDoc})
if serr != nil {
return serr
}
}
_ = syncNodeVideo(ctx, media)
return nil
}
// SyncUnencryptedOne 仅补齐单条资源的 UnencryptedImage / UnencryptedPreMp4 字段。
// 源数据对应字段为空时跳过该字段(避免覆盖本地已有值);本地无对应资源时直接返回。
// 幂等:可重复执行,用于历史数据补齐。
func SyncUnencryptedOne(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr {
if media.UnencryptedImage == "" && media.UnencryptedPreMp4 == "" {
return nil
}
filter := bson.M{videoinformationmod.FN.VideoInformation.Video.FileId: media.Id.Hex()}
videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, filter)
if serr != nil {
return serr
}
setDoc := bson.M{}
if media.UnencryptedImage != "" {
setDoc[videoinformationmod.FN.VideoInformation.UnencryptedImage] = media.UnencryptedImage
}
// 仅当源端有主视频文件时,才补 Video.UnencryptedPreMp4(避免向空的 Video 子结构写字段)
if media.UnencryptedPreMp4 != "" && len(media.FieldNameFs) > 0 {
setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = media.UnencryptedPreMp4
}
if len(setDoc) == 0 {
return nil
}
return videoinformationmod.UpdateOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id},
bson.M{"$set": setDoc})
}
// syncNodeVideo 同步节点里的视频
func syncNodeVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr {
filter := bson.M{"nodes.nodeId": media.Id.Hex()}
opts := &options.FindOptions{}
opts.SetLimit(100)
videoInfo, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
if serr != nil {
log.Warn("syncNodeVideo", log.Any("filter", filter), log.E(serr))
return serr
}
if !strings.HasSuffix(media.FieldNameFs, ".m3u8") {
media.FieldNameFs += ".m3u8"
}
if len(videoInfo) > 0 {
// 更新视频路径
for _, video := range videoInfo {
if video.Nodes != nil && len(video.Nodes) > 0 {
isFind := false
for i, node := range video.Nodes {
if node.Videos != nil && len(node.Videos) > 0 {
for j, v := range node.Videos {
if v.FileId == media.Id.Hex() {
node.Videos[j].Url = media.FieldNameFs
isFind = true
break
}
}
}
if isFind {
video.Nodes[i] = node
break
}
}
if isFind {
serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: video.Id}, bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(),
videoinformationmod.FN.VideoInformation.Nodes: video.Nodes,
}})
if serr != nil {
log.Warn("syncNodeVideo UpdateOneVideoInformation failed", log.Any("filter", filter), log.E(serr))
continue
}
}
}
}
}
return nil
}
+450
View File
@@ -0,0 +1,450 @@
package srv_videoinfo
import (
"context"
"math/rand"
"net/http"
"time"
"videoweb/pub/common/godef"
"videoweb/pub/common/log"
"videoweb/pub/common/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/common/utils"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/seo/sitemapdirty"
"videoweb/pub/storage"
"videoweb/pub/storage/proxy"
"videoweb/web/service/srv_report"
"videoweb/web/service/srv_search"
"videoweb/web/service/srv_seopush"
"videoweb/web/service/srv_videoslug"
"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"
)
// GetVideoInfos 按照参数搜索APP
func GetVideoInfos(ctx context.Context, param *VideoInfoReq) (*VideoInfoResp, *srvdef.SrvErr) {
filter := bson.M{}
filter[videoinformationmod.FN.VideoInformation.MediaType] = param.MediaType
if param.Id != nil && *param.Id > 0 {
filter[videoinformationmod.FN.VideoInformation.Id] = *param.Id
}
if param.Title != nil && len(*param.Title) > 0 {
filter[videoinformationmod.FN.VideoInformation.Title] = bson.M{"$regex": *param.Title}
}
if param.CreatorId != nil && *param.CreatorId > 0 {
filter[videoinformationmod.FN.VideoInformation.CreatorId] = *param.CreatorId
}
if param.TopicId != nil && *param.TopicId > 0 {
filter[videoinformationmod.FN.VideoInformation.TopicIds] = bson.M{"$in": []int64{*param.TopicId}}
}
if param.Status != nil {
filter[videoinformationmod.FN.VideoInformation.Status] = *param.Status
}
if param.CategoryId != nil && *param.CategoryId > 0 {
filter[videoinformationmod.FN.VideoInformation.CategoryIds] = bson.M{"$in": []int64{*param.CategoryId}}
}
if param.Tag != nil && *param.Tag > 0 {
filter[videoinformationmod.FN.VideoInformation.Tags] = bson.M{"$in": []int64{*param.Tag}}
}
if param.IsHot != nil {
filter[videoinformationmod.FN.VideoInformation.IsHot] = *param.IsHot
}
if param.CreatorName != nil && len(*param.CreatorName) > 0 {
filter[videoinformationmod.FN.VideoInformation.CreatorName] = bson.M{"$regex": *param.CreatorName}
}
if param.Keyworld != nil && len(*param.Keyworld) > 0 {
filter[videoinformationmod.FN.VideoInformation.Keyworld] = bson.M{"$in": []string{*param.Keyworld}}
}
if param.VideoTaskId != nil && len(*param.VideoTaskId) > 0 {
filter[videoinformationmod.FN.VideoInformation.Video.TaskId] = *param.VideoTaskId
}
if param.VideoFileId != nil && len(*param.VideoFileId) > 0 {
filter[videoinformationmod.FN.VideoInformation.Video.FileId] = *param.VideoFileId
}
if param.IsTop != nil {
filter[videoinformationmod.FN.VideoInformation.IsTop] = *param.IsTop
}
if param.IsH265 != nil {
if *param.IsH265 {
filter[videoinformationmod.FN.VideoInformation.HevcStatus] = videoinformationmod.HevcStatusSuccess
} else {
filter[videoinformationmod.FN.VideoInformation.HevcStatus] = bson.M{"$in": []videoinformationmod.HevcStatus{
videoinformationmod.HevcStatusNone,
videoinformationmod.HevcStatusPending,
videoinformationmod.HevcStatusFailed,
}}
}
}
if param.StartTime != nil && param.EndTime != nil {
start := utils.GetDayBeginByTime(*param.StartTime)
end := utils.GetDayEndByTime(*param.EndTime)
if end.Unix() > start.Unix() {
filter[videoinformationmod.FN.VideoInformation.CreatedAt] = bson.M{
"$gte": start, "$lte": end,
}
}
}
if param.OptionAccount != nil {
filter[videoinformationmod.FN.VideoInformation.OptionAccount] = *param.OptionAccount
}
opt := &options.FindOptions{}
resp := &VideoInfoResp{
Total: 0,
List: make(videoinformationmod.VideoInformationList, 0),
}
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: -1})
switch param.Sort {
case 0:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.CreatedAt: -1})
case 1:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.AddedTime: -1})
case 2:
opt.SetSort(bson.M{videoinformationmod.FN.VideoInformation.UpdatedAt: -1})
case 3:
opt.SetSort(bson.D{
{videoinformationmod.FN.VideoInformation.Sort, -1},
{videoinformationmod.FN.VideoInformation.AddedTime, -1},
})
case 4:
opt.SetSort(bson.D{
{videoinformationmod.FN.VideoInformation.Real.WatchTimes, -1},
{videoinformationmod.FN.VideoInformation.AddedTime, -1},
})
}
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videoinformationmod.CountVideoInformations(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// SyncVideoInfo 同步视频
func SyncVideoInfo(ctx context.Context,
req *AddVideoReq) *srvdef.SrvErr {
ids := make([]string, 0)
for _, id := range req.FileIds {
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
queryMediaReq := &proxy.MediaQueryesReq{
Ids: ids,
}
resp, err := storage.QueryMedias(ctx, queryMediaReq, "")
if err != nil || resp.Code != http.StatusOK {
if resp != nil {
log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err), log.Any("respCode", resp.Code))
} else {
log.Warn("AddVideoInfo.QueryMedias failed", log.Any("filter", req.FileIds), log.E(err))
}
return srvdef.New(srvdef.ErrMediaNotExsit, err.Error(), err.Error())
}
if len(resp.Data) == 0 {
return nil
}
for _, media := range resp.Data {
serr := SyncOneVideo(ctx, media)
if serr != nil {
log.Warn("SyncVideoInfo SYncOneVideo failed", log.Any("filter", req.FileIds), log.E(serr))
continue
}
}
return nil
}
// DelVideoInfo 删除指定APP
func DelVideoInfo(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id}
info, _ := videoinformationmod.FindOneVideoInformation(ctx, adFilter)
//删除
count, serr := videoinformationmod.DelOneVideoInformation(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
// 同步删除 ES 中的数据,避免搜索到已删除的视频
go srv_search.DelVideoFromES(id)
sitemapdirty.Mark(ctx)
if info != nil && info.Status == videoinformationmod.InformationSatusVerifyOK {
srv_seopush.EnqueueVideoDeleted(ctx, id)
}
return nil
}
// SetVideoFakeWatchTimes 单/批量随机设置视频假观看次数
func SetVideoFakeWatchTimes(ctx context.Context, req *SetFakeWatchTimesReq) (*SetFakeCountResp, *srvdef.SrvErr) {
if req == nil || len(req.Ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
if req.WatchTimesMin < 0 || req.WatchTimesMax < req.WatchTimesMin {
return nil, srvdef.New(srvdef.ErrParamError, "观看次数区间无效")
}
ids, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return nil, err
}
return setVideoFakeField(ctx, ids, videoinformationmod.FN.VideoInformation.Fake.WatchTimes, req.WatchTimesMin, req.WatchTimesMax)
}
// setVideoFakeField 使用BulkWrite批量设置假流量字段,避免批量请求逐条访问数据库
func setVideoFakeField(ctx context.Context, ids []int64, field string, min, max int64) (*SetFakeCountResp, *srvdef.SrvErr) {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
docs := make([]mongo.WriteModel, 0, len(ids))
for _, id := range ids {
docs = append(docs, mongo.NewUpdateOneModel().
SetFilter(bson.M{videoinformationmod.FN.VideoInformation.Id: id}).
SetUpdate(bson.M{"$set": bson.M{field: randomFakeFlowValue(r, min, max)}}))
}
result, err := videoinformationmod.BulkWriteVideoInformation(ctx, docs, options.BulkWrite().SetOrdered(false))
if err != nil {
return nil, err
}
if result.MatchedCount == 0 {
return nil, srvdef.New(srvdef.ErrNoData, "", "")
}
return &SetFakeCountResp{MatchedCount: result.MatchedCount, ModifiedCount: result.ModifiedCount}, nil
}
// normalizeFakeFlowIds 校验并去重视频ID,减少无效批量写入
func normalizeFakeFlowIds(rawIds []int64) ([]int64, *srvdef.SrvErr) {
ids := make([]int64, 0, len(rawIds))
seen := make(map[int64]struct{}, len(rawIds))
for _, id := range rawIds {
if id <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids包含无效id")
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空")
}
return ids, nil
}
func randomFakeFlowValue(r *rand.Rand, min, max int64) int64 {
if min == max {
return min
}
return min + r.Int63n(max-min+1)
}
// UpdateVideoInfo 更新指定APP
func UpdateVideoInfo(ctx context.Context, id, userId int64, param VideoInfoUpdate, ua useragent.UA) *srvdef.SrvErr {
now := time.Now()
info, serr := videoinformationmod.FindOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: id})
if serr != nil {
return serr
}
param.UpdatedAt = &now
param.SecondUpdatedAt = &now
if param.CreatorId != nil {
name := ""
if *param.CreatorId == 0 {
param.CreatorName = &name
} else {
// 查询作者
c, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
videocreatermod.FN.VideoCreaterInfo.Id: *param.CreatorId,
})
if serr != nil {
return serr
}
name = c.Name
param.CreatorName = &name
}
}
if param.Nodes != nil && len(*param.Nodes) > 0 {
nodes := *param.Nodes
for i, node := range nodes {
if len(node.NodeId) == 0 {
node.NodeId = primitive.NewObjectID().Hex()
nodes[i] = node
}
}
param.Nodes = &nodes
}
title := info.Title
if param.Title != nil {
title = *param.Title
}
keyworld := info.Keyworld
if param.Keyworld != nil {
keyworld = *param.Keyworld
}
slug, changed, slugErr := srv_videoslug.PrepareVideoOnUpdate(ctx, info, param.Slug, title, keyworld)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
param.CategoryIds = dedupePositiveInt64Ids(param.CategoryIds)
if param.Cover != nil && len(*param.Cover) > 0 {
if len(info.Video.Url) > 0 {
info.Video.Cover = *param.Cover
param.Video = &info.Video
}
}
//if param.Status != nil && *param.Status != info.Status {
// if *param.Status == videoinformationmod.InformationSatusVerifyOK {
// param.AddedTime = &now
// }
//}
adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id}
param.IndexNow = nil
if (param.Status != nil && *param.Status == videoinformationmod.InformationSatusVerifyOK) ||
(param.IsReIndexNow != nil && *param.IsReIndexNow) {
indexNow := int64(0)
param.IndexNow = &indexNow
}
serr = videoinformationmod.UpdateOneVideoInformation(ctx, adFilter, bson.M{"$set": param})
if serr != nil {
return serr
}
playletcache.ClearHomeCache()
// status 发生变更时实时同步 ES,避免搜索结果与 MongoDB 不一致
if param.Status != nil {
newStatus := *param.Status
go func() {
if newStatus != videoinformationmod.InformationSatusVerifyOK {
// 下架/重置状态:立即从 ES 删除
srv_search.DelVideoFromES(id)
} else {
// 上架:从 MongoDB 读取最新完整数据后写入 ES
latest, serr := videoinformationmod.FindOneVideoInformation(context.Background(), adFilter)
if serr != nil {
log.Warn("UpdateVideoInfo: read latest for ES upsert failed", log.Any("id", id), log.E(serr))
return
}
srv_search.UpsertVideoToES(latest)
}
}()
}
if param.Status != nil && info.Status != *param.Status {
if flag := srv_report.VideoStatusFlag(*param.Status); flag != 0 {
mediaCopy := *info
mediaCopy.Status = *param.Status
godef.Go(func() {
srv_report.ReportVideoStatusChangeEvent(&mediaCopy, flag, userId, ua)
})
}
}
sitemapdirty.Mark(ctx)
if param.Status != nil {
if *param.Status == videoinformationmod.InformationSatusVerifyOK &&
info.Status != videoinformationmod.InformationSatusVerifyOK {
srv_seopush.EnqueueVideoUpdated(ctx, id, *param.Status)
} else if info.Status == videoinformationmod.InformationSatusVerifyOK &&
*param.Status != videoinformationmod.InformationSatusVerifyOK {
srv_seopush.EnqueueVideoDeleted(ctx, id)
}
}
return nil
}
func AddVideoCollection(ctx context.Context, param *AddVideoCollectionReq) *srvdef.SrvErr {
info := videoinformationmod.VideoInformation{
Id: mongos.Lid.VideoInfo.Get(),
Title: param.Title,
MediaType: videoinformationmod.MediaType_Collection,
AddedTime: time.Now(),
Keyworld: param.Keyworld,
FirstTag: param.FirstTag,
SecondTags: param.SecondTags,
CoverImg: param.CoverImg,
Desc: param.Desc,
Remark: param.Remark,
Nodes: make([]videoinformationmod.Node, 0),
Status: videoinformationmod.InformationSatusWaiting,
Video: videoinformationmod.VideoInfo{},
CategoryIds: dedupePositiveInt64Ids(param.CategoryIds),
Tags: param.Tags,
Real: videoinformationmod.Flow{},
VideoCount: 0,
IndexNow: 0,
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
Slug: param.Slug,
}
if serr := srv_videoslug.EnsureVideo(ctx, &info, 0); serr != nil {
return serr
}
if serr := videoinformationmod.InsertOneVideoInformation(ctx, &info); serr != nil {
return serr
}
playletcache.ClearHomeCache()
sitemapdirty.Mark(ctx)
return nil
}
// SetVideoCategory 单/批量设置视频分类
func SetVideoCategory(ctx context.Context, req *SetVideoCategoryReq) *srvdef.SrvErr {
if req == nil {
return srvdef.New(srvdef.ErrParamError, "参数不能为空", "")
}
ids, err := normalizeFakeFlowIds(req.Ids)
if err != nil {
return err
}
if err := videoinformationmod.UpdateManyVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids}},
bson.M{"$set": bson.M{
videoinformationmod.FN.VideoInformation.CategoryIds: dedupePositiveInt64Ids(req.CategoryIds),
videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(),
}},
); err != nil {
return err
}
playletcache.ClearHomeCache()
return nil
}
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
}
+344
View File
@@ -0,0 +1,344 @@
package srv_videoslug
import (
"context"
"strings"
"videoweb/pub/common/slugx"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/videocategorymod"
"videoweb/pub/models/videocreatermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/models/videotagmod"
"videoweb/pub/models/videotopicmod"
"videoweb/web/api/proto"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
syncSlugDefaultSize = int64(500)
syncSlugMaxSize = int64(2000)
)
func videoExists(ctx context.Context, slug string, excludeId int64) bool {
item, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter(
videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, slug, excludeId))
return item != nil
}
func tagExists(ctx context.Context, slug string, excludeId int64) bool {
item, _ := videotagmod.FindOneVideoTagInfo(ctx, slugx.Filter(
videotagmod.FN.VideoTagInfo.Slug, videotagmod.FN.VideoTagInfo.Id, slug, excludeId))
return item != nil
}
func topicExists(ctx context.Context, slug string, excludeId int64) bool {
item, _ := videotopicmod.FindOneVideoTopicModel(ctx, slugx.Filter(
videotopicmod.FN.VideoTopicModel.Slug, videotopicmod.FN.VideoTopicModel.Id, slug, excludeId))
return item != nil
}
func createrExists(ctx context.Context, slug string, excludeId int64) bool {
item, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter(
videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, slug, excludeId))
return item != nil
}
func categoryExists(ctx context.Context, slug string, excludeId int64) bool {
item, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter(
videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, slug, excludeId))
return item != nil
}
func EnsureVideo(ctx context.Context, info *videoinformationmod.VideoInformation, excludeId int64) *srvdef.SrvErr {
if info == nil {
return nil
}
return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.Keyworld, info.Title),
func(s string) bool { return videoExists(ctx, s, excludeId) })
}
func PrepareVideoOnUpdate(ctx context.Context, info *videoinformationmod.VideoInformation, reqSlug *string, title string, keyworld []string) (string, bool, *srvdef.SrvErr) {
if info == nil {
return "", false, nil
}
return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keyworld, title),
func(s string) bool { return videoExists(ctx, s, info.Id) })
}
func EnsureTag(ctx context.Context, info *videotagmod.VideoTagInfo, excludeId int64) *srvdef.SrvErr {
if info == nil {
return nil
}
return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Title),
func(s string) bool { return tagExists(ctx, s, excludeId) })
}
func PrepareTagOnUpdate(ctx context.Context, info *videotagmod.VideoTagInfo, reqSlug *string, title string, keywords []string) (string, bool, *srvdef.SrvErr) {
if info == nil {
return "", false, nil
}
return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, title),
func(s string) bool { return tagExists(ctx, s, info.Id) })
}
func EnsureTopic(ctx context.Context, info *videotopicmod.VideoTopicModel, excludeId int64) *srvdef.SrvErr {
if info == nil {
return nil
}
return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name),
func(s string) bool { return topicExists(ctx, s, excludeId) })
}
func PrepareTopicOnUpdate(ctx context.Context, info *videotopicmod.VideoTopicModel, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) {
if info == nil {
return "", false, nil
}
return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name),
func(s string) bool { return topicExists(ctx, s, info.Id) })
}
func EnsureCreater(ctx context.Context, info *videocreatermod.VideoCreaterInfo, excludeId int64) *srvdef.SrvErr {
if info == nil {
return nil
}
return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name),
func(s string) bool { return createrExists(ctx, s, excludeId) })
}
func PrepareCreaterOnUpdate(ctx context.Context, info *videocreatermod.VideoCreaterInfo, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) {
if info == nil {
return "", false, nil
}
return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name),
func(s string) bool { return createrExists(ctx, s, info.Id) })
}
func EnsureCategory(ctx context.Context, info *videocategorymod.VideoCategory, excludeId int64) *srvdef.SrvErr {
if info == nil {
return nil
}
return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name),
func(s string) bool { return categoryExists(ctx, s, excludeId) })
}
func PrepareCategoryOnUpdate(ctx context.Context, info *videocategorymod.VideoCategory, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) {
if info == nil {
return "", false, nil
}
return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name),
func(s string) bool { return categoryExists(ctx, s, info.Id) })
}
// SyncEmptySlugs 为视频、标签、题材、作者、分类中空 slug 自动生成。
func SyncEmptySlugs(ctx context.Context, req *proto.VideoSlugSyncReq) (*proto.VideoSlugSyncResp, *srvdef.SrvErr) {
maxSize := syncSlugDefaultSize
if req != nil && req.MaxSize > 0 {
maxSize = req.MaxSize
}
if maxSize > syncSlugMaxSize {
maxSize = syncSlugMaxSize
}
resp := &proto.VideoSlugSyncResp{}
remain := maxSize
categoryUpdated, categoryMore, err := syncCategoryEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.CategoryUpdated = categoryUpdated
remain -= categoryUpdated
if remain <= 0 {
resp.HasMore = categoryMore || hasEmpty(ctx, "tag") || hasEmpty(ctx, "topic") || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video")
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 || hasEmpty(ctx, "topic") || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video")
return resp, nil
}
topicUpdated, topicMore, err := syncTopicEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.TopicUpdated = topicUpdated
remain -= topicUpdated
if remain <= 0 {
resp.HasMore = topicMore || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video")
return resp, nil
}
createrUpdated, createrMore, err := syncCreaterEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.CreaterUpdated = createrUpdated
remain -= createrUpdated
if remain <= 0 {
resp.HasMore = createrMore || hasEmpty(ctx, "video")
return resp, nil
}
videoUpdated, videoMore, err := syncVideoEmptySlugs(ctx, remain)
if err != nil {
return nil, err
}
resp.VideoUpdated = videoUpdated
resp.HasMore = videoMore
return resp, nil
}
func hasEmpty(ctx context.Context, kind string) bool {
filter := slugx.EmptyFilter()
switch kind {
case "tag":
n, _ := videotagmod.CountVideoTagInfos(ctx, filter)
return n > 0
case "topic":
n, _ := videotopicmod.CountVideoTopicModels(ctx, filter)
return n > 0
case "creater":
n, _ := videocreatermod.CountVideoCreaterInfos(ctx, filter)
return n > 0
case "video":
n, _ := videoinformationmod.CountVideoInformations(ctx, filter)
return n > 0
default:
n, _ := videocategorymod.CountVideoCategorys(ctx, filter)
return n > 0
}
}
func syncCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videocategorymod.FindManyVideoCategorys(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
more := int64(len(list)) > maxSize
if more {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := EnsureCategory(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" {
continue
}
_ = videocategorymod.UpdateOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: item.Id}, bson.M{
"$set": bson.M{videocategorymod.FN.VideoCategory.Slug: item.Slug},
})
updated++
}
return updated, more, nil
}
func syncTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videotagmod.FindManyVideoTagInfos(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
more := int64(len(list)) > maxSize
if more {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := EnsureTag(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" {
continue
}
_ = videotagmod.UpdateOneVideoTagInfo(ctx, bson.M{videotagmod.FN.VideoTagInfo.Id: item.Id}, bson.M{
"$set": bson.M{videotagmod.FN.VideoTagInfo.Slug: item.Slug},
})
updated++
}
return updated, more, nil
}
func syncTopicEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videotopicmod.FindManyVideoTopicModels(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
more := int64(len(list)) > maxSize
if more {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := EnsureTopic(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" {
continue
}
_ = videotopicmod.UpdateOneVideoTopicModel(ctx, bson.M{videotopicmod.FN.VideoTopicModel.Id: item.Id}, bson.M{
"$set": bson.M{videotopicmod.FN.VideoTopicModel.Slug: item.Slug},
})
updated++
}
return updated, more, nil
}
func syncCreaterEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
more := int64(len(list)) > maxSize
if more {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := EnsureCreater(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" {
continue
}
_ = videocreatermod.UpdateOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: item.Id}, bson.M{
"$set": bson.M{videocreatermod.FN.VideoCreaterInfo.Slug: item.Slug},
})
updated++
}
return updated, more, nil
}
func syncVideoEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) {
list, err := videoinformationmod.FindManyVideoInformations(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1))
if err != nil {
return 0, false, err
}
more := int64(len(list)) > maxSize
if more {
list = list[:maxSize]
}
var updated int64
for _, item := range list {
if item == nil {
continue
}
if serr := EnsureVideo(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" {
continue
}
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}, bson.M{
"$set": bson.M{videoinformationmod.FN.VideoInformation.Slug: item.Slug},
})
updated++
}
return updated, more, nil
}
+36
View File
@@ -0,0 +1,36 @@
package srv_videotag
import (
"time"
"videoweb/pub/models/videotagmod"
"videoweb/pub/pubdef"
)
type VideoTagInfoReq struct {
pubdef.Page
Title *string `json:"title"`
Id *int64 `json:"id"`
}
type VideoTagInfoResp struct {
Total int64 `json:"total"`
List videotagmod.VideoTagInfoList `json:"list"`
}
type VideoTagInfoUpdate struct {
AppId *int32 `json:"appId" bson:"appId,omitempty"` // appId,唯一id
Title *string `json:"title" bson:"title,omitempty"` // 帖子标题
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
Sort *int64 `json:"sort" bson:"sort,omitempty"` // 排序
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
}
type VideoTagInfoUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoTagInfoUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoTagInfoDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+97
View File
@@ -0,0 +1,97 @@
package srv_videotag
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/ipdailystatmod"
"videoweb/pub/models/videotagmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// HotTagReq 热门标签请求参数
type HotTagReq struct {
Limit int64 `form:"limit" json:"limit"` // 返回条数,默认 10,最大 1000
}
// HotTagItem 热门标签结果
type HotTagItem struct {
TagId int64 `json:"tagId"`
Title string `json:"title"`
WatchTimes int64 `json:"watchTimes"`
}
// HotTagResp 热门标签返回
type HotTagResp struct {
List []*HotTagItem `json:"list"`
}
// GetHotTagsByPlayData 按预计算的 watchTimes 直接取 TopN(O(logN),依赖 {status:1, watchTimes:-1} 索引)
// watchTimes 由 app/service/videoser.VideoDetails 在用户进入视频详情页时对关联标签各 $inc:1 写入
func GetHotTagsByPlayData(ctx context.Context, req *HotTagReq) (*HotTagResp, *srvdef.SrvErr) {
limit := req.Limit
if limit <= 0 {
limit = 10
}
if limit > 1000 {
limit = 1000
}
resp := &HotTagResp{List: []*HotTagItem{}}
filter := bson.M{
videotagmod.FN.VideoTagInfo.Status: true,
videotagmod.FN.VideoTagInfo.WatchTimes: bson.M{"$gt": 0},
}
opts := options.Find().
SetSort(bson.D{
{Key: videotagmod.FN.VideoTagInfo.WatchTimes, Value: -1},
{Key: videotagmod.FN.VideoTagInfo.Id, Value: 1},
}).
SetLimit(limit)
tags, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opts)
if serr != nil {
return resp, serr
}
for _, t := range tags {
resp.List = append(resp.List, &HotTagItem{
TagId: t.Id,
Title: t.Title,
WatchTimes: t.WatchTimes,
})
}
return resp, nil
}
type IpStatResp struct {
TodayIP int64 `json:"todayIP"`
YesterdayIP int64 `json:"yesterdayIP"`
}
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
}
+123
View File
@@ -0,0 +1,123 @@
package srv_videotag
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videotagmod"
"videoweb/pub/mongos"
"videoweb/web/service/srv_videoslug"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoTagInfos 按照参数搜索视频标签
func GetVideoTagInfos(ctx context.Context, param *VideoTagInfoReq) (*VideoTagInfoResp, *srvdef.SrvErr) {
filter := bson.M{}
opt := &options.FindOptions{}
resp := &VideoTagInfoResp{}
if param.Id != nil && *param.Id > 0 {
filter[videotagmod.FN.VideoTagInfo.Id] = *param.Id
}
if param.Title != nil && *param.Title != "" {
filter[videotagmod.FN.VideoTagInfo.Title] = bson.M{"$regex": *param.Title}
}
opt.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Id: 1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videotagmod.CountVideoTagInfos(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videotagmod.FindManyVideoTagInfos(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoTagInfo 添加一视频标签
func AddVideoTagInfo(ctx context.Context,
ac *videotagmod.VideoTagInfo) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoTag.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
if ac.Title == "" {
return srvdef.New(srvdef.ErrParamError, "", "")
}
if serr := srv_videoslug.EnsureTag(ctx, ac, 0); serr != nil {
return serr
}
serr := videotagmod.InsertOneVideoTagInfo(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoTagInfo 删除指定视频标签
func DelVideoTagInfo(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id}
//删除
count, serr := videotagmod.DelOneVideoTagInfo(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoTagInfo 更新指定视频标签
func UpdateVideoTagInfo(ctx context.Context, id int64, param VideoTagInfoUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
info, serr := videotagmod.FindOneVideoTagInfo(ctx, bson.M{videotagmod.FN.VideoTagInfo.Id: id})
if serr != nil {
return serr
}
title := info.Title
if param.Title != nil {
title = *param.Title
}
keywords := info.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
slug, changed, slugErr := srv_videoslug.PrepareTagOnUpdate(ctx, info, param.Slug, title, keywords)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
adFilter := bson.M{videotagmod.FN.VideoTagInfo.Id: id}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videotagmod.UpdateOneVideoTagInfo(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package srv_videotopic
import (
"time"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/models/videotopicmod"
"videoweb/pub/pubdef"
)
type VideoTopicReq struct {
VideoCategoryId *int64 `json:"videoCategoryId" bson:"videoCategoryId"`
pubdef.Page
}
type VideoTopicResp struct {
Total int64 `json:"total"`
List videotopicmod.VideoTopicModelList `json:"list"`
}
type VideoTopicUpdate struct {
Name *string `json:"name" bson:"name,omitempty"` // 名称
Slug *string `json:"slug" bson:"slug,omitempty"` // slug
Title *string `json:"title" bson:"title,omitempty"` // APP显示
Desc *string `json:"desc" bson:"desc,omitempty"` // 描述
Rank *int64 `json:"rank" bson:"rank,omitempty"` //
SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息
Status *bool `json:"status" bson:"status,omitempty"` // 上架状态
UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"`
IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow,omitempty"` // 是否重新提交索引
IndexNow *int64 `bson:"indexNow,omitempty"`
Image *string `json:"image" bson:"image,omitempty"` // 背景
VideoCategoryId *int64 `json:"videoCategoryId" bson:"videoCategoryId,omitempty"` // 视频分类id
VideoTopicType *videoinformationmod.MediaType `json:"videoTopicType" bson:"videoTopicType"` // 视频类型
}
type VideoTopicUpdateReq struct {
Id int64 `json:"id" binding:"required"`
Param VideoTopicUpdate `json:"param" bson:"param"` // appId,唯一id
}
type VideoTopicDelReq struct {
Id int64 `json:"id" binding:"required"`
}
+116
View File
@@ -0,0 +1,116 @@
package srv_videotopic
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videotopicmod"
"videoweb/pub/mongos"
"videoweb/web/service/srv_videoslug"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetVideoTopics 按照参数搜索视频分类
func GetVideoTopics(ctx context.Context, param *VideoTopicReq) (*VideoTopicResp, *srvdef.SrvErr) {
filter := bson.M{}
if param.VideoCategoryId != nil {
filter[videotopicmod.FN.VideoTopicModel.VideoCategoryId] = *param.VideoCategoryId
}
opt := &options.FindOptions{}
resp := &VideoTopicResp{}
opt.SetSort(bson.M{videotopicmod.FN.VideoTopicModel.Rank: -1})
opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize)
total, serr := videotopicmod.CountVideoTopicModels(ctx, filter)
if serr != nil {
return resp, serr
}
resp.Total = total
if total == 0 {
return resp, nil
}
ads, serr := videotopicmod.FindManyVideoTopicModels(ctx, filter, opt)
if serr != nil {
return resp, serr
}
resp.List = ads
return resp, nil
}
// AddVideoTopic 添加一个视频分类
func AddVideoTopic(ctx context.Context,
ac *videotopicmod.VideoTopicModel) *srvdef.SrvErr {
ac.Id = mongos.Lid.VideoTopic.Get()
now := time.Now()
ac.CreatedAt = now
ac.UpdatedAt = now
if serr := srv_videoslug.EnsureTopic(ctx, ac, 0); serr != nil {
return serr
}
serr := videotopicmod.InsertOneVideoTopicModel(ctx, ac)
if serr != nil {
return serr
}
return nil
}
// DelVideoTopic 删除指定视频分类
func DelVideoTopic(ctx context.Context, id int64) *srvdef.SrvErr {
adFilter := bson.M{videotopicmod.FN.VideoTopicModel.Id: id}
//删除
count, serr := videotopicmod.DelOneVideoTopicModel(ctx, adFilter)
if serr != nil {
return serr
}
if count == 0 {
return srvdef.New(srvdef.ErrNoData, "", "")
}
return nil
}
// UpdateVideoTopic 更新指定视频分类
func UpdateVideoTopic(ctx context.Context, id int64, param VideoTopicUpdate) *srvdef.SrvErr {
now := time.Now()
param.UpdatedAt = now
info, serr := videotopicmod.FindOneVideoTopicModel(ctx, bson.M{videotopicmod.FN.VideoTopicModel.Id: id})
if serr != nil {
return serr
}
name := info.Name
if param.Name != nil {
name = *param.Name
}
keywords := info.SeoInfo.Keyworld
if param.SeoInfo != nil {
keywords = param.SeoInfo.Keyworld
}
slug, changed, slugErr := srv_videoslug.PrepareTopicOnUpdate(ctx, info, param.Slug, name, keywords)
if slugErr != nil {
return slugErr
}
if changed {
param.Slug = &slug
} else {
param.Slug = nil
}
adFilter := bson.M{videotopicmod.FN.VideoTopicModel.Id: id}
param.IndexNow = nil
if param.IsReIndexNow != nil && *param.IsReIndexNow {
indexNow := int64(0)
param.IndexNow = &indexNow
}
updateDoc := bson.M{"$set": param}
serr = videotopicmod.UpdateOneVideoTopicModel(ctx, adFilter, updateDoc)
if serr != nil {
return serr
}
return nil
}
+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
}