init: 初始化 web_hgdjb_server 项目

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:47:32 +08:00
co-authored by Claude Opus 4.7
commit f13d33af4d
707 changed files with 147705 additions and 0 deletions
+456
View File
@@ -0,0 +1,456 @@
package srv_aicomment
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"videoweb/pub/common/crypt"
"videoweb/pub/common/log"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
minCommentLengthLimit = 20
maxCommentLengthLimit = 50
maxSubmitVideoCount = 100
maxVideoTitleRunes = 100
maxVideoDescriptionRunes = 100
maxVideoSubtitleRunes = 500
maxVideoTags = 20
minSubmitVideoCommentCount = 1
minSubmitRequestInterval = 10 * time.Second
objectTypePlaylet = 10
)
type VideoItem struct {
VideoId string `json:"videoId"`
Title string `json:"title"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
CommentCount int `json:"commentCount"`
ObjType int64 `json:"-"`
RetainCount int `json:"-"` // 本次任务结果需要保留的评论数
TargetCount int `json:"-"` // 当前内容固定的目标AI评论总数
CommentTotal int `json:"-"` // 本次续传还需要生成的评论数
CurrentCount int64 `json:"-"` // 内容当前评论数,用于保留已有真实互动
}
type SubmitRequest struct {
AppID string `json:"appId"`
Sign string `json:"sign"`
Videos []VideoItem `json:"videos"`
}
type SubmitResult struct {
ID string `json:"id"`
VideoId string `json:"videoId"`
}
type SubmitResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Results []SubmitResult `json:"results"`
} `json:"data"`
}
type ResultRequest struct {
AppID string `json:"appId"`
Sign string `json:"sign"`
IDs []string `json:"ids"`
}
type Comment struct {
Type string `json:"type"`
Text string `json:"text"`
}
type ResultItem struct {
ID string `json:"id"`
VideoId string `json:"videoId"`
Status int `json:"status,omitempty"`
Comments []Comment `json:"comments"`
ErrorMsg string `json:"errorMsg,omitempty"`
}
type ResultResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Results []ResultItem `json:"results"`
} `json:"data"`
}
type AiCommentService struct {
baseURL string
appID string
appSecret string
client *http.Client
signMu sync.Mutex
lastSignUnixTime int64
submitMu sync.Mutex
lastSubmitAt time.Time
}
var aiCommentService *AiCommentService
func Init(baseURL, appID, secret string) {
aiCommentService = &AiCommentService{
baseURL: normalizeBaseURL(baseURL),
appID: strings.TrimSpace(appID),
appSecret: strings.TrimSpace(secret),
client: &http.Client{
Timeout: 30 * time.Second,
},
}
if aiCommentService.Configured() {
log.Info("自动评论服务初始化完成", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID))
return
}
log.Warn("自动评论服务配置不完整,已跳过启用", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID))
}
func GetAiCommentService() *AiCommentService {
if aiCommentService == nil {
Init("", "", "")
}
return aiCommentService
}
func (s *AiCommentService) Configured() bool {
return s != nil && s.baseURL != "" && s.appID != "" && s.appSecret != ""
}
func normalizeBaseURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return ""
}
if strings.HasSuffix(baseURL, "/app/customer") {
return baseURL
}
if strings.HasSuffix(baseURL, "/app") {
return baseURL + "/customer"
}
return baseURL + "/app/customer"
}
func (s *AiCommentService) generateSign(ctx context.Context) (string, int64, error) {
s.signMu.Lock()
defer s.signMu.Unlock()
timestamp := time.Now().Unix()
if timestamp <= s.lastSignUnixTime {
timer := time.NewTimer(time.Until(time.Unix(s.lastSignUnixTime+1, 0)))
defer timer.Stop()
select {
case <-ctx.Done():
return "", 0, ctx.Err()
case <-timer.C:
}
timestamp = time.Now().Unix()
}
params := url.Values{
"appId": {s.appID},
"timestamp": {strconv.FormatInt(timestamp, 10)},
}
cipherBytes, err := crypt.CoreAesEncrypt([]byte(params.Encode()), s.appSecret)
if err != nil {
return "", 0, fmt.Errorf("AES加密失败: %w", err)
}
s.lastSignUnixTime = timestamp
return hex.EncodeToString(cipherBytes), timestamp, nil
}
func (s *AiCommentService) postJSON(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error {
if !s.Configured() {
return errors.New("自动评论服务未配置")
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return err
}
reqURL := s.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
log.Info("自动评论HTTP请求", log.Any("url", reqURL), log.Any("bodySize", len(jsonData)))
resp, err := s.client.Do(req)
if err != nil {
log.Error("自动评论HTTP请求失败", log.E(err), log.Any("url", reqURL))
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
log.Info("自动评论HTTP响应", log.Any("url", reqURL), log.Any("statusCode", resp.StatusCode), log.Any("body", string(body)))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("自动评论HTTP状态异常: %d", resp.StatusCode)
}
if err = json.Unmarshal(body, respBody); err != nil {
return err
}
return nil
}
func (s *AiCommentService) SubmitVideos(ctx context.Context, videos []VideoItem) ([]SubmitResult, error) {
videos = normalizeVideoItems(videos)
if len(videos) == 0 {
return []SubmitResult{}, nil
}
if len(videos) > maxSubmitVideoCount {
return nil, fmt.Errorf("单次提交最多100个视频,当前%d个", len(videos))
}
if err := s.waitForSubmitSlot(ctx); err != nil {
return nil, err
}
sign, timestamp, err := s.generateSign(ctx)
if err != nil {
log.Error("自动评论生成签名失败", log.E(err))
return nil, err
}
reqBody := SubmitRequest{AppID: s.appID, Sign: sign, Videos: videos}
var submitResp SubmitResponse
if err = s.postJSON(ctx, "/videoComments/submit", reqBody, &submitResp); err != nil {
return nil, err
}
if submitResp.Code != http.StatusOK {
return nil, fmt.Errorf("提交任务失败: code=%d, msg=%s, timestamp=%d", submitResp.Code, submitResp.Msg, timestamp)
}
return submitResp.Data.Results, nil
}
func (s *AiCommentService) waitForSubmitSlot(ctx context.Context) error {
s.submitMu.Lock()
defer s.submitMu.Unlock()
wait := time.Until(s.lastSubmitAt.Add(minSubmitRequestInterval))
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
}
}
s.lastSubmitAt = time.Now()
return nil
}
func (s *AiCommentService) QueryResults(ctx context.Context, taskIDs []string) ([]ResultItem, error) {
if len(taskIDs) == 0 {
return []ResultItem{}, nil
}
if len(taskIDs) > 500 {
return nil, fmt.Errorf("单次查询最多500个任务,当前%d个", len(taskIDs))
}
sign, timestamp, err := s.generateSign(ctx)
if err != nil {
return nil, err
}
reqBody := ResultRequest{AppID: s.appID, Sign: sign, IDs: taskIDs}
var resultResp ResultResponse
if err = s.postJSON(ctx, "/videoComments/result", reqBody, &resultResp); err != nil {
return nil, err
}
if resultResp.Code != http.StatusOK {
return nil, fmt.Errorf("查询结果失败: code=%d, msg=%s, timestamp=%d", resultResp.Code, resultResp.Msg, timestamp)
}
return resultResp.Data.Results, nil
}
func (s *AiCommentService) SubmitAndRecord(ctx context.Context, videos []VideoItem) error {
videos = normalizeVideoItems(videos)
if len(videos) == 0 {
return nil
}
reserved := make([]VideoItem, 0, len(videos))
for _, video := range videos {
record := &aicommentrecord.AiCommentRecord{
Key: aiCommentRecordKey(video),
Vid: video.VideoId,
Title: video.Title,
ObjType: video.ObjType,
RetainCount: video.RetainCount,
TargetCount: video.TargetCount,
Desc: video.Description,
}
claimed, serr := aicommentrecord.Reserve(ctx, record)
if serr != nil {
return fmt.Errorf("预留自动评论任务失败: %w", serr)
}
if claimed {
reserved = append(reserved, video)
}
}
if len(reserved) == 0 {
return nil
}
results, err := s.SubmitVideos(ctx, reserved)
if err != nil {
for _, video := range reserved {
_ = aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), "", aicommentrecord.StatusFailed, err.Error())
}
return err
}
resultByVid := make(map[string]string, len(results))
for _, result := range results {
resultByVid[result.VideoId] = result.ID
}
missing := 0
for _, video := range reserved {
taskID := resultByVid[video.VideoId]
status := aicommentrecord.StatusSubmitted
desc := video.Description
if taskID == "" {
status = aicommentrecord.StatusFailed
desc = "自动评论服务未返回任务ID"
missing++
}
if serr := aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), taskID, status, desc); serr != nil {
return fmt.Errorf("保存自动评论任务记录失败: %w", serr)
}
}
if missing > 0 {
return fmt.Errorf("自动评论服务缺少%d个任务ID", missing)
}
log.Info("自动评论提交并记录成功", log.Any("videoCount", len(reserved)))
return nil
}
func aiCommentRecordKey(video VideoItem) string {
return strconv.FormatInt(video.ObjType, 10) + ":" + video.VideoId
}
func AutoProcessUnsubmittedPlaylets(ctx context.Context, playletIDs []int64) error {
if len(playletIDs) == 0 {
return nil
}
service := GetAiCommentService()
if !service.Configured() {
log.Warn("自动评论服务未配置,跳过提交", log.Any("playletIDs", playletIDs))
return nil
}
videos, err := buildPendingPlayletVideoItems(ctx, playletIDs)
if err != nil {
return err
}
if err = submitAndRecordInBatches(ctx, service, videos); err != nil {
return fmt.Errorf("提交AI评论任务失败: %w", err)
}
log.Info("自动评论处理短剧完成", log.Any("requested", len(playletIDs)), log.Any("pending", len(videos)))
return nil
}
func buildPendingPlayletVideoItems(ctx context.Context, playletIDs []int64) ([]VideoItem, error) {
if len(playletIDs) == 0 {
return []VideoItem{}, nil
}
playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": playletIDs},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().SetProjection(homepagePlayletProjection))
if serr != nil {
return nil, serr
}
pending, serr := buildHomepagePlayletVideoItems(ctx, playlets)
if serr != nil {
return nil, serr
}
return pending, nil
}
func normalizeVideoItems(videos []VideoItem) []VideoItem {
if len(videos) == 0 {
return []VideoItem{}
}
result := make([]VideoItem, 0, len(videos))
for _, video := range videos {
video = normalizeVideoItem(video)
if video.VideoId == "" {
continue
}
result = append(result, video)
}
return result
}
func normalizeVideoItem(video VideoItem) VideoItem {
video.VideoId = strings.TrimSpace(video.VideoId)
video.Title = truncateRunes(strings.TrimSpace(video.Title), maxVideoTitleRunes)
if video.Title == "" {
video.Title = video.VideoId
}
video.Description = truncateRunes(strings.TrimSpace(video.Description), maxVideoDescriptionRunes)
video.Subtitle = truncateRunes(strings.TrimSpace(video.Subtitle), maxVideoSubtitleRunes)
video.Tags = normalizeVideoTags(video.Tags)
if video.CommentCount < minSubmitVideoCommentCount {
video.CommentCount = minSubmitVideoCommentCount
}
if video.CommentCount > maxCommentLengthLimit {
video.CommentCount = maxCommentLengthLimit
}
if video.ObjType <= 0 {
video.ObjType = objectTypePlaylet
}
return video
}
func randomCommentLengthLimit() int {
return randomIntInclusive(minCommentLengthLimit, maxCommentLengthLimit)
}
func normalizeVideoTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
result := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
result = append(result, tag)
if len(result) >= maxVideoTags {
break
}
}
return result
}
func truncateRunes(text string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= maxLen {
return text
}
return string(runes[:maxLen])
}
+355
View File
@@ -0,0 +1,355 @@
package srv_aicomment
import (
"context"
"fmt"
"strconv"
"strings"
"sync/atomic"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
homepagePlayletCandidateLimit int64 = 60
homepagePlayletFeedThemeLimit int64 = 30
maxSubmitRequestsPerRun = 7
)
var homepagePlayletProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.IsFeatured, Value: 1},
{Key: playletmod.FN.Playlet.IsRecommend, Value: 1},
{Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1},
{Key: playletmod.FN.Playlet.TopRank, Value: 1},
{Key: playletmod.FN.Playlet.RecommendRank, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.CountComment, Value: 1},
}
var homepageSubmitRunning atomic.Bool
func AutoProcessHomepageVisibleContent(ctx context.Context) error {
if !homepageSubmitRunning.CompareAndSwap(false, true) {
log.Debug("首页内容自动评论补提交正在执行中,本次跳过")
return nil
}
defer homepageSubmitRunning.Store(false)
service := GetAiCommentService()
if !service.Configured() {
log.Warn("自动评论服务未配置,跳过首页内容补提交")
return nil
}
playlets, serr := collectHomepagePlaylets(ctx)
if serr != nil {
return fmt.Errorf("查询首页短剧失败: %w", serr)
}
videos, serr := buildHomepagePlayletVideoItems(ctx, playlets)
if serr != nil {
return fmt.Errorf("组装首页短剧AI评论参数失败: %w", serr)
}
if err := submitAndRecordInBatches(ctx, service, videos); err != nil {
return fmt.Errorf("提交首页短剧AI评论任务失败: %w", err)
}
log.Info("自动评论首页内容补提交完成",
log.Any("playletCandidates", len(playlets)),
log.Any("playletPending", len(videos)),
)
return nil
}
func submitAndRecordInBatches(ctx context.Context, service *AiCommentService, videos []VideoItem) error {
batches, deferred := buildAICommentSubmissionBatches(videos, maxSubmitRequestsPerRun)
for _, batch := range batches {
if err := service.SubmitAndRecord(ctx, batch); err != nil {
return err
}
}
if deferred {
log.Info("自动评论达到单轮提交请求上限,剩余任务等待下次续传",
log.Any("requestLimit", maxSubmitRequestsPerRun),
log.Any("videoCount", len(videos)))
}
return nil
}
func buildAICommentSubmissionBatches(videos []VideoItem, requestLimit int) ([][]VideoItem, bool) {
batches := make([][]VideoItem, 0)
for _, round := range buildAICommentSubmissionRounds(videos) {
for start := 0; start < len(round); start += maxSubmitVideoCount {
end := start + maxSubmitVideoCount
if end > len(round) {
end = len(round)
}
batches = append(batches, round[start:end])
}
}
if requestLimit <= 0 || len(batches) <= requestLimit {
return batches, false
}
return batches[:requestLimit], true
}
func buildAICommentSubmissionRounds(videos []VideoItem) [][]VideoItem {
pending := append([]VideoItem(nil), videos...)
rounds := make([][]VideoItem, 0)
for len(pending) > 0 {
round := make([]VideoItem, 0, len(pending))
next := make([]VideoItem, 0, len(pending))
for _, video := range pending {
remaining := video.CommentTotal
if remaining <= 0 {
remaining = commentsPerGenerationTask
}
retainCount := min(remaining, commentsPerGenerationTask)
taskVideo := video
taskVideo.CommentCount = randomCommentLengthLimit()
taskVideo.RetainCount = retainCount
taskVideo.CommentTotal = 0
round = append(round, taskVideo)
remaining -= retainCount
if remaining > 0 {
video.CommentTotal = remaining
next = append(next, video)
}
}
rounds = append(rounds, round)
pending = next
}
return rounds
}
func collectHomepagePlaylets(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) {
playlets := make(playletmod.PlayletList, 0, maxSubmitVideoCount)
seen := make(map[int64]struct{})
if serr := appendHomepagePlaylets(ctx, &playlets, seen,
homepagePlayletFilter(bson.M{playletmod.FN.Playlet.ShowOnBanner: true}),
bson.D{
{Key: playletmod.FN.Playlet.TopRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
6,
); serr != nil {
return nil, serr
}
if serr := appendHomepageFeedThemePlaylets(ctx, &playlets, seen); serr != nil {
return nil, serr
}
queries := []struct {
filter bson.M
sort bson.D
limit int64
}{
{
filter: homepagePlayletFilter(bson.M{playletmod.FN.Playlet.IsRecommend: true}),
sort: bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.UpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
{
filter: homepagePlayletFilter(nil),
sort: bson.D{
{Key: playletmod.FN.Playlet.CountBrowse, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
},
limit: homepagePlayletCandidateLimit,
},
}
for _, query := range queries {
if serr := appendHomepagePlaylets(ctx, &playlets, seen, query.filter, query.sort, query.limit); serr != nil {
return nil, serr
}
}
return playlets, nil
}
func appendHomepageFeedThemePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}) *srvdef.SrvErr {
themes, serr := playletthememod.FindManyPlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Status: true,
}, options.Find().
SetProjection(bson.D{{Key: playletthememod.FN.PlayletTheme.Id, Value: 1}}).
SetSort(bson.D{
{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1},
{Key: playletthememod.FN.PlayletTheme.Id, Value: -1},
}),
)
if serr != nil {
return serr
}
sort := bson.D{
{Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
for _, theme := range themes {
if theme == nil || theme.Id <= 0 {
continue
}
filter := homepagePlayletFilter(bson.M{
playletmod.FN.Playlet.ThemeIds: bson.M{"$in": []int64{theme.Id}},
})
if serr := appendHomepagePlaylets(ctx, playlets, seen, filter, sort, homepagePlayletFeedThemeLimit); serr != nil {
return serr
}
}
return nil
}
func appendHomepagePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}, filter bson.M, sort bson.D, limit int64) *srvdef.SrvErr {
list, serr := playletmod.FindManyPlaylet(ctx, filter, options.Find().
SetProjection(homepagePlayletProjection).
SetSort(sort).
SetLimit(limit),
)
if serr != nil {
return serr
}
for _, playlet := range list {
if playlet == nil || playlet.Id <= 0 {
continue
}
if _, ok := seen[playlet.Id]; ok {
continue
}
seen[playlet.Id] = struct{}{}
*playlets = append(*playlets, playlet)
}
return nil
}
func buildHomepagePlayletVideoItems(ctx context.Context, playlets playletmod.PlayletList) ([]VideoItem, *srvdef.SrvErr) {
tagNames := loadPlayletTagNames(ctx, playlets)
videos := make([]VideoItem, 0, len(playlets))
for _, playlet := range playlets {
if playlet == nil {
continue
}
videos = append(videos, VideoItem{
VideoId: strconv.FormatInt(playlet.Id, 10),
Title: playlet.Title,
Tags: playletTagNames(playlet.TagIds, tagNames),
Description: playlet.Desc,
CommentCount: randomCommentLengthLimit(),
ObjType: objectTypePlaylet,
CommentTotal: aiCommentPlayletTotal,
CurrentCount: playlet.CountComment,
})
}
pending, serr := preparePendingAICommentVideos(ctx, videos)
if serr != nil {
return nil, serr
}
return pending, nil
}
func homepagePlayletFilter(extra bson.M) bson.M {
filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}
for key, value := range extra {
filter[key] = value
}
return filter
}
func loadPlayletTagNames(ctx context.Context, playlets playletmod.PlayletList) map[int64]string {
tagIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, playlet := range playlets {
if playlet == nil {
continue
}
for _, tagID := range playlet.TagIds {
if tagID <= 0 {
continue
}
if _, ok := seen[tagID]; ok {
continue
}
seen[tagID] = struct{}{}
tagIDs = append(tagIDs, tagID)
}
}
if len(tagIDs) == 0 {
return map[int64]string{}
}
tags, serr := playlettagmod.FindManyPlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Id: bson.M{"$in": tagIDs},
}, options.Find().SetProjection(bson.D{
{Key: playlettagmod.FN.PlayletTag.Id, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Name, Value: 1},
}))
if serr != nil {
log.Warn("自动评论查询短剧标签失败", log.E(serr), log.Any("tagIDs", tagIDs))
return map[int64]string{}
}
result := make(map[int64]string, len(tags))
for _, tag := range tags {
if tag != nil {
result[tag.Id] = tag.Name
}
}
return result
}
func playletTagNames(tagIDs []int64, tagNames map[int64]string) []string {
result := make([]string, 0, len(tagIDs))
seen := make(map[string]struct{}, len(tagIDs))
for _, tagID := range tagIDs {
name := strings.TrimSpace(tagNames[tagID])
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
result = append(result, name)
if len(result) >= maxVideoTags {
break
}
}
return result
}
+299
View File
@@ -0,0 +1,299 @@
package srv_aicomment
import (
"context"
"math/rand"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode/utf8"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/commentmod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/usermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var pollingRunning atomic.Bool
const (
maxPollTaskCount int64 = 500
commentLevelFirst int64 = 1
commentReviewPass int = 1
)
func PollAiCommentResults(ctx context.Context) {
service := GetAiCommentService()
if !service.Configured() {
return
}
if !pollingRunning.CompareAndSwap(false, true) {
log.Debug("自动评论轮询正在执行中,本次跳过")
return
}
defer pollingRunning.Store(false)
if serr := expireStaleSubmittedTasks(ctx); serr != nil {
log.Error("自动评论轮询释放超时任务失败", log.E(serr))
return
}
records, serr := aicommentrecord.FindByStatus(ctx, aicommentrecord.StatusSubmitted, maxPollTaskCount)
if serr != nil {
log.Error("自动评论轮询查询任务记录失败", log.E(serr))
return
}
if len(records) == 0 {
return
}
taskIDs := make([]string, 0, len(records))
recordMap := make(map[string]*aicommentrecord.AiCommentRecord, len(records))
for _, record := range records {
if record.TaskId == "" {
continue
}
taskIDs = append(taskIDs, record.TaskId)
recordMap[record.TaskId] = record
}
results, err := service.QueryResults(ctx, taskIDs)
if err != nil {
log.Error("自动评论轮询查询生成结果失败", log.E(err), log.Any("taskIDs", taskIDs))
return
}
processAndInsertAiComments(ctx, results, recordMap)
}
func processAndInsertAiComments(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) {
users, err := robotUsers(ctx)
if err != nil || len(users) == 0 {
log.Error("自动评论机器人用户池为空或查询失败", log.E(err))
return
}
playletMap := loadPlayletsByResults(ctx, results, recordMap)
successTaskIDs := make([]string, 0)
for _, result := range results {
record, ok := recordMap[result.ID]
if !ok {
log.Warn("自动评论轮询未找到任务记录", log.Any("taskID", result.ID))
continue
}
if result.ErrorMsg != "" || result.Status == 2 {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, result.ErrorMsg)
log.Error("自动评论生成结果失败", log.Any("taskID", result.ID), log.Any("videoId", result.VideoId), log.Any("errorMsg", result.ErrorMsg))
continue
}
if len(result.Comments) == 0 {
continue
}
playletID, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || playletID <= 0 {
log.Warn("自动评论短剧ID无效", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.E(err))
continue
}
playlet := playletMap[playletID]
if playlet == nil || playlet.Status != playletmod.StatusUp {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusAbandoned, "短剧已下架或不存在")
continue
}
objectType := record.ObjType
if objectType <= 0 {
objectType = objectTypePlaylet
}
retainCount := record.RetainCount
if retainCount <= 0 {
retainCount = len(result.Comments)
}
selectedComments := selectAIComments(result.Comments, retainCount)
currentCount, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: playletID,
commentmod.FN.Comment.ObjectType: objectTypePlaylet,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ParentsId: 0,
})
if serr != nil {
log.Error("自动评论统计现有评论失败", log.E(serr), log.Any("playletID", playletID))
continue
}
insertedCount := 0
now := time.Now()
publishedAt := PlayletPublishedAt(playlet)
objectName := ""
if playlet != nil {
objectName = playlet.Title
}
if objectName == "" {
objectName = record.Title
}
for index, comment := range selectedComments {
if currentCount >= int64(aiCommentPlayletTotal) {
break
}
comment.Text = strings.TrimSpace(comment.Text)
if comment.Text == "" || utf8.RuneCountInString(comment.Text) > 200 {
continue
}
user := users[rand.Intn(len(users))]
commentID := mongos.Lid.Comment.Get()
item := &commentmod.CommentModel{
Id: commentID,
ObjectId: playletID,
ObjectType: objectType,
ObjectName: objectName,
UserId: user.Id,
UserName: commentUserName(user),
UserAvatar: user.Avatar,
Level: commentLevelFirst,
Text: comment.Text,
ReviewStatus: commentReviewPass,
IsAi: true,
AiTaskId: result.ID,
AiIndex: index,
CreatedAt: ScatteredCommentTime(publishedAt, now, playletID, commentID, index, len(selectedComments)),
UpdatedAt: now,
}
inserted, insertErr := commentmod.InsertAICommentOnce(ctx, item)
if insertErr != nil {
log.Error("自动评论写入失败", log.E(insertErr), log.Any("taskID", result.ID), log.Any("index", index))
return
}
if inserted {
currentCount++
insertedCount++
}
}
if serr := refreshPlayletCommentCount(ctx, playletID); serr != nil {
continue
}
if currentCount < int64(aiCommentPlayletTotal) {
_ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, "生成评论不足,等待重试")
continue
}
successTaskIDs = append(successTaskIDs, result.ID)
log.Info("自动评论写入完成", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.Any("count", insertedCount))
}
if len(successTaskIDs) > 0 {
if serr := aicommentrecord.BatchUpdateStatus(ctx, successTaskIDs, aicommentrecord.StatusSuccess); serr != nil {
log.Error("自动评论更新成功任务状态失败", log.E(serr), log.Any("taskIDs", successTaskIDs))
}
}
}
func loadPlayletsByResults(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) map[int64]*playletmod.Playlet {
ids := make([]int64, 0, len(results))
seen := make(map[int64]struct{})
for _, result := range results {
if len(result.Comments) == 0 {
continue
}
record := recordMap[result.ID]
if record == nil {
continue
}
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return map[int64]*playletmod.Playlet{}
}
list, serr := playletmod.FindManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}})
if serr != nil {
log.Warn("自动评论查询短剧上架时间失败", log.E(serr), log.Any("ids", ids))
return map[int64]*playletmod.Playlet{}
}
res := make(map[int64]*playletmod.Playlet, len(list))
for _, playlet := range list {
res[playlet.Id] = playlet
}
return res
}
func robotUsers(ctx context.Context) (usermod.UserList, error) {
users, serr := usermod.FindManyUser(ctx, bson.M{
usermod.FN.User.Status: pubdef.UserStatusNormal,
usermod.FN.User.Id: bson.M{
"$gte": pubdef.RobotId_Min,
"$lte": pubdef.RobotId_Max,
},
}, options.Find().SetLimit(1000))
if serr != nil {
return nil, serr
}
if len(users) > 0 {
return users, nil
}
user := createFallbackRobotUser()
if serr = usermod.InsertOneUser(ctx, user); serr != nil {
return nil, serr
}
return usermod.UserList{user}, nil
}
func createFallbackRobotUser() *usermod.User {
now := time.Now()
return &usermod.User{
Id: mongos.Lid.User.Get(),
Type: pubdef.UserTypeGuest,
Status: pubdef.UserStatusNormal,
NickName: "游客" + strconv.FormatInt(now.Unix(), 10),
Device: "robot",
SysType: "ios",
LoginType: pubdef.LoginTypeDevice,
LastLoginAt: now,
CreatedAt: now,
UpdatedAt: now,
}
}
func commentUserName(user *usermod.User) string {
if user == nil {
return ""
}
if name := strings.TrimSpace(user.NickName); name != "" {
return name
}
if name := strings.TrimSpace(user.AccountNo); name != "" {
return name
}
return "用户" + strconv.FormatInt(user.Id, 10)
}
func refreshPlayletCommentCount(ctx context.Context, playletID int64) *srvdef.SrvErr {
if playletID <= 0 {
return nil
}
count, serr := commentmod.CountComments(ctx, bson.M{
commentmod.FN.Comment.ObjectId: playletID,
commentmod.FN.Comment.ObjectType: objectTypePlaylet,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ParentsId: 0,
})
if serr != nil {
log.Warn("自动评论统计短剧评论数失败", log.E(serr), log.Any("playletId", playletID))
return serr
}
if serr = playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playletID,
}, bson.M{
"$set": bson.M{playletmod.FN.Playlet.CountComment: count},
}); serr != nil {
log.Warn("自动评论更新短剧评论数失败", log.E(serr), log.Any("playletId", playletID))
return serr
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package srv_aicomment
import (
"context"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/aicommentrecord"
)
const (
staleSubmittedTimeout = 60 * time.Minute
staleSubmittedDesc = "自动评论任务提交超过60分钟未返回,已释放并等待补交"
)
type aiCommentContentKey struct {
objType int64
vid string
}
func preparePendingAICommentVideos(ctx context.Context, videos []VideoItem) ([]VideoItem, *srvdef.SrvErr) {
if serr := expireStaleSubmittedTasks(ctx); serr != nil {
return nil, serr
}
vidsByType := make(map[int64][]string)
for _, video := range videos {
if video.VideoId != "" {
vidsByType[video.ObjType] = append(vidsByType[video.ObjType], video.VideoId)
}
}
recordsByContent := make(map[aiCommentContentKey]aicommentrecord.AiCommentRecordList)
for objType, vids := range vidsByType {
records, serr := aicommentrecord.FindByVIDsAndObjType(ctx, objType, vids)
if serr != nil {
return nil, serr
}
for _, record := range records {
if record == nil {
continue
}
key := aiCommentContentKey{objType: objType, vid: record.Vid}
recordsByContent[key] = append(recordsByContent[key], record)
}
}
pending := make([]VideoItem, 0, len(videos))
for _, video := range videos {
key := aiCommentContentKey{objType: video.ObjType, vid: video.VideoId}
if resumed, ok := resumeAICommentVideo(video, recordsByContent[key]); ok {
pending = append(pending, resumed)
}
}
return pending, nil
}
func expireStaleSubmittedTasks(ctx context.Context) *srvdef.SrvErr {
deadline := time.Now().Add(-staleSubmittedTimeout)
expired, serr := aicommentrecord.ExpireSubmittedBefore(ctx, deadline, staleSubmittedDesc)
if serr != nil {
return serr
}
if expired > 0 {
log.Warn("自动评论释放超时提交任务", log.Any("count", expired), log.Any("deadline", deadline))
}
return nil
}
func resumeAICommentVideo(video VideoItem, records aicommentrecord.AiCommentRecordList) (VideoItem, bool) {
targetCount := aiCommentPlayletTotal
if video.CurrentCount >= int64(targetCount) {
return VideoItem{}, false
}
accountedCount := 0
for _, record := range records {
if record == nil {
continue
}
if record.Status != aicommentrecord.StatusSubmitted && record.Status != aicommentrecord.StatusSuccess {
continue
}
retainCount := record.RetainCount
if retainCount <= 0 {
retainCount = commentsPerGenerationTask
}
accountedCount += retainCount
}
if video.CurrentCount > 0 && accountedCount < int(video.CurrentCount) {
accountedCount = int(video.CurrentCount)
}
if accountedCount >= targetCount {
return VideoItem{}, false
}
video.TargetCount = targetCount
video.CommentTotal = targetCount - accountedCount
return video, true
}
@@ -0,0 +1,31 @@
package srv_aicomment
import (
"testing"
"videoweb/pub/models/aicommentrecord"
)
func TestResumeAICommentVideo(t *testing.T) {
tests := []struct {
name string
current int64
records aicommentrecord.AiCommentRecordList
want int
ok bool
}{
{name: "new playlet", want: 3, ok: true},
{name: "existing comments", current: 2, want: 1, ok: true},
{name: "already full", current: 3},
{name: "submitted task", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusSubmitted, RetainCount: 3}}},
{name: "failed task can retry", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusFailed, RetainCount: 3}}, want: 3, ok: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := resumeAICommentVideo(VideoItem{CurrentCount: tt.current}, tt.records)
if ok != tt.ok || (ok && got.CommentTotal != tt.want) {
t.Fatalf("resumeAICommentVideo() = (%d, %v), want (%d, %v)", got.CommentTotal, ok, tt.want, tt.ok)
}
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package srv_aicomment
import (
"context"
"strconv"
"videoweb/pub/common/log"
"videoweb/pub/models/aicommentrecord"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
)
// RetryFailedPlaylets retries a bounded batch, oldest failure first.
func RetryFailedPlaylets(ctx context.Context) {
if !GetAiCommentService().Configured() {
return
}
records, serr := aicommentrecord.FindFailedForRetry(ctx, 100)
if serr != nil || len(records) == 0 {
return
}
ids := make([]int64, 0, len(records))
for _, record := range records {
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
_ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧ID无效")
continue
}
ids = append(ids, id)
}
if len(ids) == 0 {
return
}
playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
playletmod.FN.Playlet.Status: playletmod.StatusUp,
})
if serr != nil {
log.Warn("查询待重试短剧失败", log.E(serr))
return
}
active := make(map[int64]struct{}, len(playlets))
for _, playlet := range playlets {
active[playlet.Id] = struct{}{}
}
for _, record := range records {
id, err := strconv.ParseInt(record.Vid, 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := active[id]; !ok {
_ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧已下架或不存在")
}
}
if len(active) == 0 {
return
}
activeIDs := make([]int64, 0, len(active))
for id := range active {
activeIDs = append(activeIDs, id)
}
if err := AutoProcessUnsubmittedPlaylets(ctx, activeIDs); err != nil {
log.Warn("重试短剧自动评论失败", log.E(err), log.Any("count", len(activeIDs)))
}
}
+55
View File
@@ -0,0 +1,55 @@
package srv_aicomment
import (
"time"
"videoweb/pub/models/playletmod"
)
func PlayletPublishedAt(playlet *playletmod.Playlet) time.Time {
if playlet == nil {
return time.Time{}
}
if !playlet.OnlineAt.IsZero() {
return playlet.OnlineAt
}
if !playlet.ReviewAt.IsZero() {
return playlet.ReviewAt
}
return playlet.CreatedAt
}
func ScatteredCommentTime(publishedAt, endAt time.Time, objectID, commentID int64, index, total int) time.Time {
if total <= 0 || index < 0 || index >= total || publishedAt.IsZero() || !publishedAt.Before(endAt) {
return endAt
}
span := endAt.Sub(publishedAt)
segment := span / time.Duration(total)
segmentStart := segment * time.Duration(index)
segmentEnd := segment * time.Duration(index+1)
if index == total-1 {
segmentEnd = span
}
width := segmentEnd - segmentStart
if width <= 0 {
return publishedAt.Add(segmentStart)
}
seed := scatterSeed(objectID, commentID, int64(index))
offset := time.Duration(seed % uint64(width))
return publishedAt.Add(segmentStart + offset)
}
func scatterSeed(values ...int64) uint64 {
const (
offsetBasis = uint64(1469598103934665603)
prime = uint64(1099511628211)
)
hash := offsetBasis
for _, value := range values {
hash ^= uint64(value)
hash *= prime
}
return hash
}
+33
View File
@@ -0,0 +1,33 @@
package srv_aicomment
import (
"math/rand"
)
const (
commentsPerGenerationTask = 8
aiCommentPlayletTotal = 3
)
func selectAIComments(comments []Comment, count int) []Comment {
available := make([]Comment, 0, len(comments))
for _, comment := range comments {
if comment.Text != "" {
available = append(available, comment)
}
}
if count <= 0 || len(available) <= count {
return available
}
rand.Shuffle(len(available), func(i, j int) {
available[i], available[j] = available[j], available[i]
})
return available[:count]
}
func randomIntInclusive(minValue, maxValue int) int {
if maxValue <= minValue {
return minValue
}
return minValue + rand.Intn(maxValue-minValue+1)
}