init: 初始化 web_hgdjb_server 项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"` // 开始时间
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package srv_acc
|
||||
|
||||
import (
|
||||
"videoweb/pub/models/oplog"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// constructFilterAndOpts 根据搜索参数构建查询filter和options
|
||||
func constructFilterAndOpts(param *OpLogSearch) (
|
||||
bson.M, *options.FindOptions) {
|
||||
|
||||
filter := bson.M{}
|
||||
if param.Path != nil && *param.Path != "" {
|
||||
filter[oplog.FN.OpLog.Path] = bson.M{"$regex": param.Path}
|
||||
}
|
||||
if param.Action != nil && *param.Action != "" {
|
||||
filter[oplog.FN.OpLog.Action] = param.Action
|
||||
}
|
||||
if param.ActId != nil && *param.ActId > 0 {
|
||||
filter[oplog.FN.OpLog.ActId] = param.ActId
|
||||
}
|
||||
if param.StartTime != nil && param.EndTime != nil {
|
||||
filter[oplog.FN.OpLog.CreatedAt] = bson.M{"$gte": param.StartTime, "$lt": param.EndTime}
|
||||
}
|
||||
sort := bson.M{oplog.FN.OpLog.CreatedAt: -1}
|
||||
opt := &options.FindOptions{}
|
||||
opt.SetSort(sort)
|
||||
skip := (param.PageNum - 1) * param.PageSize
|
||||
opt.SetSkip(skip)
|
||||
opt.SetLimit(param.PageSize)
|
||||
return filter, opt
|
||||
}
|
||||
@@ -0,0 +1,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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"` // 排序
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"` //浮窗图片
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package srv_playlet
|
||||
|
||||
import "videoweb/pub/common/playletcache"
|
||||
|
||||
// clearAppPlayletHomeCache 清除app首页短剧缓存
|
||||
func clearAppPlayletHomeCache() {
|
||||
playletcache.ClearHomeCache()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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-停用
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ×tamp, &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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// 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 一致:/drama/{slug},无 slug 回退 ID。
|
||||
func PlayletDetailURL(webAddr, slug string, playletId int64) string {
|
||||
path := h5path.Drama(slug, playletId)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return h5path.SitemapAbs(webAddr, path)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
path := requestPath(raw)
|
||||
if path == "/detail" || strings.HasPrefix(path, "/detail/") {
|
||||
return true
|
||||
}
|
||||
// 旧推送路径 /play/{slug}。/series-play 是视频页,不能当成短剧旧地址。
|
||||
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}、/detail/{slug}、/lieyan/av/detail。
|
||||
if isObsoletePushURL(task.Url) {
|
||||
log.Warn("seopush: drop obsolete-url task", log.Any("url", task.Url), log.Any("action", task.Action))
|
||||
_, _ = seopushmod.DelManySeoPushTask(ctx, taskFilter(task))
|
||||
return
|
||||
}
|
||||
if !canPushExternally() {
|
||||
dryRun(ctx, task)
|
||||
return
|
||||
}
|
||||
if task.GoogleStatus != seopushmod.PushStatusSuccess {
|
||||
pushGoogle(ctx, task)
|
||||
}
|
||||
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
|
||||
pushIndexNow(ctx, webAddr, task)
|
||||
}
|
||||
}
|
||||
|
||||
func dryRun(ctx context.Context, task *seopushmod.SeoPushTask) {
|
||||
log.Info("seopush: dry-run, external push skipped in non-prod",
|
||||
log.Any("url", task.Url), log.Any("action", task.Action))
|
||||
if task.GoogleStatus != seopushmod.PushStatusSuccess {
|
||||
markSuccess(ctx, task, googleChannel)
|
||||
}
|
||||
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
|
||||
markSuccess(ctx, task, indexNowChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func pushGoogle(ctx context.Context, task *seopushmod.SeoPushTask) {
|
||||
notifyType := googleindex.TypeUpdated
|
||||
if task.Action == seopushmod.ActionDeleted {
|
||||
notifyType = googleindex.TypeDeleted
|
||||
}
|
||||
|
||||
resp, err := googleindex.Publish(ctx, task.Url, notifyType)
|
||||
if err == nil {
|
||||
markSuccess(ctx, task, googleChannel)
|
||||
log.Info("seopush: google pushed",
|
||||
log.Any("url", task.Url),
|
||||
log.Any("type", notifyType),
|
||||
log.Any("resp", resp))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, googleindex.ErrQuotaExceeded) {
|
||||
log.Warn("seopush: google daily quota exceeded, keep pending",
|
||||
log.Any("url", task.Url), log.Any("resp", err.Error()))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, googleindex.ErrNotEnabled) {
|
||||
log.Warn("seopush: google client not ready, keep pending", log.Any("url", task.Url))
|
||||
return
|
||||
}
|
||||
markFailed(ctx, task, googleChannel, err)
|
||||
}
|
||||
|
||||
func pushIndexNow(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
|
||||
if strings.TrimSpace(webAddr) == "" {
|
||||
return
|
||||
}
|
||||
config, err := seoindexnow.BuildConfig(webAddr)
|
||||
if err != nil {
|
||||
log.Warn("seopush: build indexnow config failed", log.Any("webAddr", webAddr), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
result := seoindexnow.NewBatchIndexNowSubmitter(config).SubmitBatch([]string{task.Url})
|
||||
if result.SuccessCount > 0 {
|
||||
markSuccess(ctx, task, indexNowChannel)
|
||||
log.Info("seopush: indexnow pushed",
|
||||
log.Any("url", task.Url),
|
||||
log.Any("statusCode", result.StatusCodes))
|
||||
return
|
||||
}
|
||||
markFailed(ctx, task, indexNowChannel, errors.New(strings.Join(result.Errors, "; ")))
|
||||
}
|
||||
|
||||
func markSuccess(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields) {
|
||||
now := time.Now()
|
||||
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
|
||||
"$set": bson.M{
|
||||
ch.status: seopushmod.PushStatusSuccess,
|
||||
ch.errMsg: "",
|
||||
ch.at: now,
|
||||
seopushmod.FN.SeoPushTask.UpdatedAt: now,
|
||||
},
|
||||
}); serr != nil {
|
||||
log.Warn("seopush: mark success failed",
|
||||
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
|
||||
}
|
||||
}
|
||||
|
||||
func markFailed(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields, pushErr error) {
|
||||
now := time.Now()
|
||||
errText := ""
|
||||
if pushErr != nil {
|
||||
errText = pushErr.Error()
|
||||
}
|
||||
log.Warn("seopush: push failed",
|
||||
log.Any("channel", ch.name), log.Any("url", task.Url), log.Any("err", errText))
|
||||
|
||||
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
|
||||
"$set": bson.M{
|
||||
ch.errMsg: errText,
|
||||
ch.at: now,
|
||||
seopushmod.FN.SeoPushTask.UpdatedAt: now,
|
||||
},
|
||||
"$inc": bson.M{ch.retry: 1},
|
||||
}); serr != nil {
|
||||
log.Warn("seopush: mark failed failed",
|
||||
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
|
||||
}
|
||||
}
|
||||
|
||||
func taskFilter(task *seopushmod.SeoPushTask) bson.M {
|
||||
return bson.M{
|
||||
seopushmod.FN.SeoPushTask.Url: task.Url,
|
||||
seopushmod.FN.SeoPushTask.Action: task.Action,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"` // 明文密码
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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集合,可为空;不传或空数组表示清空
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user