Files
2026-08-25 17:59:42 +08:00

503 lines
18 KiB
Go

package worker
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"juhe-factory/api/internal/billing"
"juhe-factory/api/internal/model"
"juhe-factory/api/internal/provider/apimart"
queuepkg "juhe-factory/api/internal/queue"
"juhe-factory/api/internal/security"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ScriptAnalysis struct {
DB *gorm.DB
Queue *asynq.Client
Encryptor *security.Encryptor
Provider apimart.Client
}
type scriptAnalysisSnapshot struct {
Task model.GenerationTask
AnalysisID uuid.UUID
SourceContent string
ModelName string
BaseURL string
APIKeyCiphertext string
}
type scriptHighlight struct {
Type string `json:"type"`
Content string `json:"content"`
}
type scriptEmotionStage struct {
Stage string `json:"stage"`
Emotion string `json:"emotion"`
Description string `json:"description"`
}
type scriptEpisodeOutline struct {
EpisodeNo int `json:"episode_no"`
Title string `json:"title"`
Outline string `json:"outline"`
Emotion string `json:"emotion"`
}
type scriptAnalysisCharacter struct {
Name string `json:"name"`
Faction string `json:"faction"`
Biography string `json:"biography"`
Motivation string `json:"motivation"`
Relationship string `json:"relationship"`
}
type scriptEpisodeHook struct {
EpisodeNo int `json:"episode_no"`
HookType string `json:"hook_type"`
Hook string `json:"hook"`
Basis string `json:"basis"`
}
type scriptPaywallHook struct {
HookType string `json:"hook_type"`
Content string `json:"content"`
Appeal string `json:"appeal"`
}
type scriptCustomAnalysis struct {
Objective string `json:"objective"`
Conclusion string `json:"conclusion"`
Evidence []string `json:"evidence"`
}
type scriptUnmatchedObjective struct {
Objective string `json:"objective"`
Reason string `json:"reason"`
}
type scriptAnalysisResult struct {
Tags struct {
DramaTags []string `json:"drama_tags"`
ApplicableSceneTags []string `json:"applicable_scene_tags"`
} `json:"tags"`
SellingPoint struct {
Summary string `json:"summary"`
Highlights []scriptHighlight `json:"highlights"`
} `json:"selling_point"`
StoryStructure struct {
Synopsis string `json:"synopsis"`
StoryOutline []string `json:"story_outline"`
EmotionOutline []scriptEmotionStage `json:"emotion_outline"`
EpisodeOutlines []scriptEpisodeOutline `json:"episode_outlines"`
Characters []scriptAnalysisCharacter `json:"characters"`
} `json:"story_structure"`
OpeningHook struct {
FirstScene string `json:"first_scene"`
FirstLine string `json:"first_line"`
AttentionMethods []string `json:"attention_methods"`
Analysis string `json:"analysis"`
Optimization string `json:"optimization"`
} `json:"opening_hook"`
EpisodeHooks []scriptEpisodeHook `json:"episode_hooks"`
Episode10PaywallHooks []scriptPaywallHook `json:"episode_10_paywall_hooks"`
CustomAnalysis []scriptCustomAnalysis `json:"custom_analysis"`
UnmatchedObjectives []scriptUnmatchedObjective `json:"unmatched_objectives"`
}
func NewScriptAnalysis(db *gorm.DB, queue *asynq.Client, encryptor *security.Encryptor, client *http.Client) *ScriptAnalysis {
return &ScriptAnalysis{DB: db, Queue: queue, Encryptor: encryptor, Provider: apimart.NewClient(dramaHTTPClient(client))}
}
func (w *ScriptAnalysis) Register(mux *asynq.ServeMux) {
mux.HandleFunc(queuepkg.TypeAnalyzeScript, w.analyze)
}
func (w *ScriptAnalysis) Recover(ctx context.Context) error {
if err := w.DB.WithContext(ctx).Model(&model.GenerationTask{}).
Where("task_type='script_analysis' AND status='processing'").Update("status", "submitted").Error; err != nil {
return err
}
var ids []uuid.UUID
if err := w.DB.WithContext(ctx).Model(&model.GenerationTask{}).
Where("task_type='script_analysis' AND status='submitted'").Pluck("id", &ids).Error; err != nil {
return err
}
var firstErr error
for _, id := range ids {
if err := queuepkg.EnqueueID(w.Queue, queuepkg.TypeAnalyzeScript, id, 0); err != nil && firstErr == nil {
firstErr = err
}
}
var cancelledIDs []uuid.UUID
if err := w.DB.WithContext(ctx).Model(&model.GenerationTask{}).
Where("task_type='script_analysis' AND status='cancel_requested'").Pluck("id", &cancelledIDs).Error; err != nil {
return err
}
for _, id := range cancelledIDs {
if err := w.finishCancellation(ctx, id, uuid.Nil); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (w *ScriptAnalysis) analyze(ctx context.Context, raw *asynq.Task) error {
taskID, err := decodeID(raw)
if err != nil {
return err
}
snapshot, err := w.claim(ctx, taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return w.fail(ctx, taskID, uuid.Nil, err)
}
apiKey, err := w.Encryptor.Decrypt(snapshot.APIKeyCiphertext)
if err != nil {
return w.fail(ctx, taskID, snapshot.AnalysisID, errors.New("剧本分析渠道密钥不可用"))
}
workCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
defer cancel()
var cancelled atomic.Bool
go w.watchCancellation(workCtx, cancel, taskID, &cancelled)
result, chat, inputTokens, outputTokens, tokenSource, err := w.callModel(workCtx, snapshot, apiKey)
if cancelled.Load() || w.cancellationRequested(ctx, taskID) {
return w.finishCancellation(ctx, taskID, snapshot.AnalysisID)
}
if err != nil {
return w.fail(ctx, taskID, snapshot.AnalysisID, err)
}
if err := w.persist(ctx, snapshot, result, chat, inputTokens, outputTokens, tokenSource); err != nil {
return w.fail(ctx, taskID, snapshot.AnalysisID, err)
}
return nil
}
func (w *ScriptAnalysis) watchCancellation(ctx context.Context, cancel context.CancelFunc, taskID uuid.UUID, flag *atomic.Bool) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if w.cancellationRequested(ctx, taskID) {
flag.Store(true)
cancel()
return
}
}
}
}
func (w *ScriptAnalysis) cancellationRequested(ctx context.Context, taskID uuid.UUID) bool {
var status string
return w.DB.WithContext(ctx).Model(&model.GenerationTask{}).Where("id=?", taskID).Pluck("status", &status).Error == nil &&
status == "cancel_requested"
}
func (w *ScriptAnalysis) claim(ctx context.Context, taskID uuid.UUID) (scriptAnalysisSnapshot, error) {
var snapshot scriptAnalysisSnapshot
err := w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id=? AND task_type='script_analysis' AND status='submitted'", taskID).Take(&snapshot.Task).Error; err != nil {
return err
}
if snapshot.Task.ScriptAnalysisID == nil {
return errors.New("剧本分析任务缺少剧本标识")
}
snapshot.AnalysisID = *snapshot.Task.ScriptAnalysisID
var input struct {
SourceContent string `json:"source_content"`
}
if err := json.Unmarshal(snapshot.Task.InputData, &input); err != nil || strings.TrimSpace(input.SourceContent) == "" {
return errors.New("剧本分析原文快照无效")
}
snapshot.SourceContent = input.SourceContent
var config struct {
ModelName string
BaseURL string
APIKeyCiphertext string
}
result := tx.Raw(`SELECT model.name AS model_name,channel.base_url,channel.api_key_ciphertext
FROM models model JOIN channels channel ON channel.id=model.channel_id
WHERE model.id=? AND channel.id=? AND model.model_type='text'
AND model.enabled=true AND model.deleted_at IS NULL AND channel.enabled=true AND channel.deleted_at IS NULL`,
snapshot.Task.ModelID, snapshot.Task.ChannelID).Scan(&config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("剧本分析模型或渠道不可用")
}
snapshot.ModelName, snapshot.BaseURL, snapshot.APIKeyCiphertext = config.ModelName, config.BaseURL, config.APIKeyCiphertext
if err := tx.Model(&snapshot.Task).Update("status", "processing").Error; err != nil {
return err
}
return tx.Model(&model.ScriptAnalysis{}).Where("id=?", snapshot.AnalysisID).
Updates(map[string]any{"analysis_status": "running", "analysis_message": "正在分析剧本"}).Error
})
return snapshot, err
}
func (w *ScriptAnalysis) callModel(ctx context.Context, snapshot scriptAnalysisSnapshot, apiKey string) (scriptAnalysisResult, apimart.ChatResult, int64, int64, string, error) {
var parsed scriptAnalysisResult
var chat apimart.ChatResult
pricing, err := billing.ParseTextPricingSnapshot(snapshot.Task.BillingSnapshot)
if err != nil {
return parsed, chat, 0, 0, "", err
}
systemPrompt := scriptAnalysisSystemPrompt(snapshot.Task.PromptSnapshot)
userPrompt := "请分析以下剧本原文:\n\n" + snapshot.SourceContent
if pricing.Mode == billing.TextBillingPerToken {
estimated := billing.EstimateTextTokens(systemPrompt + userPrompt)
if err := w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, reserveErr := billing.ReserveTextCall(tx, snapshot.Task.UserID, snapshot.Task.ID.String(), "analysis", pricing, estimated, "剧本分析")
return reserveErr
}); err != nil {
return parsed, chat, 0, 0, "", err
}
}
payload := map[string]any{
"model": snapshot.ModelName,
"messages": []map[string]any{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"temperature": 0.2,
"response_format": map[string]any{"type": "json_object"},
}
var parseErr error
for attempt := 0; attempt < 2; attempt++ {
if attempt > 0 {
payload["temperature"] = 0
payload["messages"] = []map[string]any{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt + "\n\n上次返回未通过结构校验。请严格补齐固定协议的全部字段,并按分析目标决定标准字段、补充分析或无法完成的目标,只返回完整合法 JSON。"},
}
}
chat, err = w.Provider.ChatWithUsage(ctx, snapshot.BaseURL, apiKey, payload)
if err != nil {
return parsed, chat, 0, 0, "", err
}
parsed = scriptAnalysisResult{}
parseErr = json.Unmarshal([]byte(stripJSONFence(chat.Content)), &parsed)
if parseErr == nil {
parseErr = validateScriptAnalysisResult(parsed)
}
if parseErr == nil {
break
}
}
if parseErr != nil {
finishReason := strings.TrimSpace(chat.FinishReason)
if finishReason == "" {
finishReason = "未知"
}
return parsed, chat, 0, 0, "", fmt.Errorf("文本模型连续两次返回无法解析(结束原因:%s): %w", finishReason, parseErr)
}
inputTokens, outputTokens := chat.InputTokens, chat.OutputTokens
tokenSource := "upstream"
if inputTokens == 0 {
inputTokens = billing.EstimateTextTokens(systemPrompt + userPrompt)
tokenSource = "local"
}
if outputTokens == 0 {
outputTokens = billing.EstimateTextTokens(chat.Content)
if tokenSource == "upstream" {
tokenSource = "mixed"
}
}
if pricing.Mode == billing.TextBillingPerToken {
if err := w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
settled, settleErr := billing.SettleTextCall(tx, snapshot.Task.UserID, snapshot.Task.ID.String(), "analysis", pricing,
billing.TextUsage{Input: inputTokens, Output: outputTokens}, "剧本分析")
if settleErr != nil {
return settleErr
}
return tx.Model(&model.GenerationTask{}).Where("id=?", snapshot.Task.ID).Updates(map[string]any{
"estimated_points": gorm.Expr("estimated_points+?::numeric", settled),
"prepaid_points": gorm.Expr("prepaid_points+?::numeric", settled),
}).Error
}); err != nil {
return parsed, chat, inputTokens, outputTokens, tokenSource, err
}
}
return parsed, chat, inputTokens, outputTokens, tokenSource, nil
}
func validateScriptAnalysisResult(result scriptAnalysisResult) error {
if strings.TrimSpace(result.SellingPoint.Summary) == "" && strings.TrimSpace(result.StoryStructure.Synopsis) == "" &&
len(result.CustomAnalysis) == 0 && len(result.UnmatchedObjectives) == 0 {
return errors.New("缺少标准分析结果、通用分析结果或无法匹配目标说明")
}
if len(result.EpisodeHooks) != 0 && len(result.EpisodeHooks) != 10 {
return errors.New("前十集钩子数量必须为10")
}
for index, hook := range result.EpisodeHooks {
if hook.EpisodeNo != index+1 {
return errors.New("前十集钩子集数或顺序无效")
}
}
if len(result.Episode10PaywallHooks) != 0 &&
(len(result.Episode10PaywallHooks) < 3 || len(result.Episode10PaywallHooks) > 5) {
return errors.New("第十集付费卡点候选必须为3至5个")
}
for _, character := range result.StoryStructure.Characters {
if strings.TrimSpace(character.Name) == "" {
return errors.New("人物名称不能为空")
}
if character.Faction != "正派" && character.Faction != "反派" && character.Faction != "中立派" {
return errors.New("人物阵营无效")
}
}
return nil
}
func (w *ScriptAnalysis) persist(ctx context.Context, snapshot scriptAnalysisSnapshot, result scriptAnalysisResult, chat apimart.ChatResult, inputTokens, outputTokens int64, tokenSource string) error {
encoded, err := json.Marshal(result)
if err != nil {
return err
}
pretty, _ := json.MarshalIndent(result, "", " ")
return w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task model.GenerationTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", snapshot.Task.ID).Take(&task).Error; err != nil {
return err
}
if task.Status == "cancel_requested" {
return w.finishCancellationLocked(tx, &task, snapshot.AnalysisID)
}
if task.Status != "processing" {
return errors.New("剧本分析任务状态已变化")
}
if err := tx.Where("script_analysis_id=?", snapshot.AnalysisID).Delete(&model.ScriptAnalysisCharacter{}).Error; err != nil {
return err
}
seen := make(map[string]bool)
for index, character := range result.StoryStructure.Characters {
name := strings.TrimSpace(character.Name)
key := strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
item := model.ScriptAnalysisCharacter{
ScriptAnalysisID: snapshot.AnalysisID, Name: name, Faction: character.Faction,
Biography: strings.TrimSpace(character.Biography), Motivation: strings.TrimSpace(character.Motivation),
Relationship: strings.TrimSpace(character.Relationship), SortOrder: index,
}
if err := tx.Create(&item).Error; err != nil {
return err
}
}
taskUpdates := map[string]any{
"status": "succeeded", "finished_at": time.Now(), "actual_points": task.PrepaidPoints,
"input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": inputTokens + outputTokens,
"token_count_source": tokenSource, "usage_raw": chat.UsageRaw, "result_data": gorm.Expr("?::jsonb", string(encoded)),
"error_code": nil, "error_message": nil,
}
if err := tx.Model(&task).Updates(taskUpdates).Error; err != nil {
return err
}
return tx.Model(&model.ScriptAnalysis{}).Where("id=?", snapshot.AnalysisID).Updates(map[string]any{
"analysis_result": gorm.Expr("?::jsonb", string(encoded)), "result_content": string(pretty),
"analysis_status": "succeeded", "analysis_message": "剧本分析完成",
}).Error
})
}
func (w *ScriptAnalysis) fail(ctx context.Context, taskID, analysisID uuid.UUID, taskErr error) error {
return w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task model.GenerationTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).Take(&task).Error; err != nil {
return err
}
if task.Status == "succeeded" || task.Status == "failed" || task.Status == "cancelled" {
return nil
}
if task.Status == "cancel_requested" {
return w.finishCancellationLocked(tx, &task, analysisID)
}
refunded, err := billing.RefundTextGenerationTask(tx, &task, "剧本分析失败返还")
if err != nil {
return err
}
updates := map[string]any{
"status": "failed", "actual_points": "0.00", "finished_at": time.Now(),
"error_code": "script_analysis_failed", "error_message": truncate(taskErr.Error(), 4000),
}
if refunded {
updates["cost_refunded"] = true
}
if err := tx.Model(&task).Updates(updates).Error; err != nil {
return err
}
if analysisID == uuid.Nil && task.ScriptAnalysisID != nil {
analysisID = *task.ScriptAnalysisID
}
if analysisID == uuid.Nil {
return nil
}
return tx.Model(&model.ScriptAnalysis{}).Where("id=?", analysisID).Updates(map[string]any{
"analysis_status": "failed", "analysis_message": truncate(taskErr.Error(), 1000),
}).Error
})
}
func (w *ScriptAnalysis) finishCancellation(ctx context.Context, taskID, analysisID uuid.UUID) error {
return w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task model.GenerationTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).Take(&task).Error; err != nil {
return err
}
return w.finishCancellationLocked(tx, &task, analysisID)
})
}
func (w *ScriptAnalysis) finishCancellationLocked(tx *gorm.DB, task *model.GenerationTask, analysisID uuid.UUID) error {
if task.Status == "cancelled" {
return nil
}
if task.Status == "succeeded" || task.Status == "failed" {
return nil
}
actual := task.PrepaidPoints
committed, err := billing.CommitTextReserves(tx, task.UserID, task.ID.String(), "剧本分析取消")
if err != nil {
return err
}
if committed != "" && committed != "0" && committed != "0.00" {
actual = committed
}
if err := tx.Model(task).Updates(map[string]any{
"status": "cancelled", "prepaid_points": actual, "actual_points": actual, "finished_at": time.Now(),
"error_code": "user_cancelled", "error_message": "用户取消,已扣积分不退",
}).Error; err != nil {
return err
}
if analysisID == uuid.Nil && task.ScriptAnalysisID != nil {
analysisID = *task.ScriptAnalysisID
}
if analysisID == uuid.Nil {
return nil
}
return tx.Model(&model.ScriptAnalysis{}).Where("id=?", analysisID).Updates(map[string]any{
"analysis_status": "idle", "analysis_message": "剧本分析已取消,已扣积分不退",
}).Error
}