@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user