init: web_hgdjc_server

This commit is contained in:
2026-11-07 10:46:18 +08:00
commit b2ff6ac8ff
707 changed files with 147645 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])
}