init: web_hgdjc_server
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user