457 lines
12 KiB
Go
457 lines
12 KiB
Go
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])
|
|
}
|