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]) }