package service import ( "encoding/json" "errors" "fmt" "path/filepath" "strings" "time" "juhe-factory/api/internal/billing" "juhe-factory/api/internal/model" queuepkg "juhe-factory/api/internal/queue" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type ScriptAnalysisInput struct { Name string `json:"name"` SourceContent string `json:"source_content"` ResultContent string `json:"result_content"` } type ScriptAnalysisImportProject struct { ID uuid.UUID `json:"id"` Name string `json:"name"` EpisodeCount int `json:"episode_count"` CharCount int `json:"char_count"` UpdatedAt time.Time `json:"updated_at"` } func (s *Creative) ListScriptAnalyses(userID uuid.UUID) ([]model.ScriptAnalysis, error) { items := make([]model.ScriptAnalysis, 0) err := s.DB.Where("user_id=? AND deleted_at IS NULL", userID).Order("updated_at DESC").Find(&items).Error return items, err } func (s *Creative) CreateScriptAnalysis(userID uuid.UUID) (*model.ScriptAnalysis, error) { item := &model.ScriptAnalysis{UserID: userID, Name: "未命名剧本", AnalysisResult: json.RawMessage(`{}`), AnalysisStatus: "idle"} if err := s.DB.Create(item).Error; err != nil { return nil, err } return item, nil } func (s *Creative) ListScriptAnalysisImportProjects(userID uuid.UUID) ([]ScriptAnalysisImportProject, error) { items := make([]ScriptAnalysisImportProject, 0) err := s.DB.Table("creative_projects project"). Select(`project.id,project.name,project.updated_at, count(episode.id) FILTER (WHERE episode.deleted_at IS NULL AND episode.status='review' AND btrim(coalesce(episode.redraw_script,'')) <> '') AS episode_count, coalesce(sum(char_length(episode.redraw_script)) FILTER (WHERE episode.deleted_at IS NULL AND episode.status='review' AND btrim(coalesce(episode.redraw_script,'')) <> ''),0) AS char_count`). Joins("LEFT JOIN project_episodes episode ON episode.project_id=project.id"). Where(`project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL AND EXISTS ( SELECT 1 FROM project_episodes source_episode WHERE source_episode.project_id=project.id AND source_episode.deleted_at IS NULL AND source_episode.status='review' AND btrim(coalesce(source_episode.redraw_script,'')) <> '' )`, userID). Group("project.id").Order("project.updated_at DESC").Scan(&items).Error return items, err } func (s *Creative) ImportScriptAnalysisProject(userID, analysisID, projectID uuid.UUID) (*model.ScriptAnalysis, error) { err := s.DB.Transaction(func(tx *gorm.DB) error { var project model.CreativeProject if err := tx.Select("id", "name"). Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID). Take(&project).Error; err != nil { return err } var episodes []model.ProjectEpisode if err := tx.Select("episode_no", "redraw_script"). Where("project_id=? AND deleted_at IS NULL AND status='review' AND btrim(coalesce(redraw_script,'')) <> ''", projectID). Order("episode_no").Find(&episodes).Error; err != nil { return err } if len(episodes) == 0 { return errors.New("该项目暂无可导入的剧本") } parts := make([]string, 0, len(episodes)) for _, episode := range episodes { parts = append(parts, fmt.Sprintf("【第%d集】\n%s", episode.EpisodeNo, strings.TrimSpace(episode.RedrawScript))) } name := project.Name return replaceScriptAnalysisSource(tx, userID, analysisID, name, strings.Join(parts, "\n\n")) }) if err != nil { return nil, err } return s.GetScriptAnalysis(userID, analysisID) } func (s *Creative) ImportScriptAnalysisFile(userID, analysisID uuid.UUID, filename string, data []byte) (*model.ScriptAnalysis, error) { extension := strings.ToLower(filepath.Ext(filename)) if extension != ".txt" && extension != ".doc" { return nil, errors.New("仅支持TXT和DOC文件") } if len(data) > maxDramaImportBytes { return nil, errors.New("剧本文件不能超过3MB") } content, _, err := parseDramaSource(filename, data, "") if err != nil { return nil, err } content = normalizeDramaText(content) if content == "" { return nil, errors.New("未读取到有效剧本正文") } name := strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) if name == "" { name = "未命名剧本" } if err := s.DB.Transaction(func(tx *gorm.DB) error { return replaceScriptAnalysisSource(tx, userID, analysisID, name, content) }); err != nil { return nil, err } return s.GetScriptAnalysis(userID, analysisID) } func replaceScriptAnalysisSource(tx *gorm.DB, userID, analysisID uuid.UUID, name, content string) error { var item model.ScriptAnalysis if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id=? AND user_id=? AND deleted_at IS NULL", analysisID, userID).Take(&item).Error; err != nil { return err } if item.AnalysisStatus == "queued" || item.AnalysisStatus == "running" { return errors.New("剧本正在分析,暂时不能导入") } if err := tx.Where("script_analysis_id=?", analysisID).Delete(&model.ScriptAnalysisCharacter{}).Error; err != nil { return err } return tx.Model(&item).Updates(map[string]any{ "name": strings.TrimSpace(name), "source_content": content, "result_content": "", "analysis_result": json.RawMessage(`{}`), "analysis_status": "idle", "analysis_message": "", }).Error } func (s *Creative) GetScriptAnalysis(userID, id uuid.UUID) (*model.ScriptAnalysis, error) { var item model.ScriptAnalysis if err := s.DB.Preload("Characters", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order,name") }). Where("id=? AND user_id=? AND deleted_at IS NULL", id, userID).Take(&item).Error; err != nil { return nil, err } return &item, nil } type scriptAnalysisModelConfig struct { ModelID uuid.UUID ChannelID uuid.UUID ModelName string BaseURL string APIKeyCiphertext string Pricing billing.TextPricing `gorm:"-"` } func (s *Creative) scriptAnalysisModel(userID uuid.UUID) (scriptAnalysisModelConfig, error) { var config scriptAnalysisModelConfig result := s.DB.Raw(`SELECT model.id AS model_id,channel.id AS channel_id,model.name AS model_name, channel.base_url,channel.api_key_ciphertext FROM user_model_configs preference JOIN models model ON model.id=preference.model_id AND model.model_type='text' AND model.enabled=true AND model.deleted_at IS NULL JOIN channels channel ON channel.id=model.channel_id AND channel.enabled=true AND channel.deleted_at IS NULL WHERE preference.user_id=? AND preference.project_type='script_analysis' AND preference.model_type='text'`, userID).Scan(&config) if result.Error != nil { return config, result.Error } if result.RowsAffected == 0 { return config, errors.New("请先配置剧本分析文本模型") } pricing, err := billing.LoadTextPricing(s.DB, config.ModelID) if err != nil { return config, errors.New("剧本分析文本模型计费配置无效") } config.Pricing = pricing return config, nil } func (s *Creative) QueueScriptAnalysis(userID, analysisID uuid.UUID) (*model.GenerationTask, error) { if s.Queue == nil { return nil, errors.New("剧本分析任务队列不可用") } config, err := s.scriptAnalysisModel(userID) if err != nil { return nil, err } task := &model.GenerationTask{ RequestID: "script_analysis_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ScriptAnalysisID: &analysisID, TaskType: "script_analysis", Status: "submitted", EstimatedPoints: "0.00", PrepaidPoints: "0.00", } err = s.DB.Transaction(func(tx *gorm.DB) error { var item model.ScriptAnalysis if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id=? AND user_id=? AND deleted_at IS NULL", analysisID, userID).Take(&item).Error; err != nil { return err } if strings.TrimSpace(item.SourceContent) == "" { return errors.New("请先填写剧本原文") } var active int64 if err := tx.Model(&model.GenerationTask{}). Where("script_analysis_id=? AND status IN ?", analysisID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧本正在分析,请勿重复提交") } prompt, err := selectedPromptContent(tx, userID, "剧本分析") if err != nil { return err } task.PromptSnapshot = prompt task.InputData = mustJSON(map[string]string{"source_content": item.SourceContent}) if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "剧本分析"); err != nil { return err } return tx.Model(&item).Updates(map[string]any{"analysis_status": "queued", "analysis_message": "剧本分析排队中"}).Error }) if err != nil { return nil, err } if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeScript, task.ID, 0); err != nil { if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "剧本分析任务入队失败"); settleErr != nil { return nil, fmt.Errorf("剧本分析任务入队失败且预扣返还失败: %w", settleErr) } _ = s.DB.Model(&model.ScriptAnalysis{}).Where("id=?", analysisID). Updates(map[string]any{"analysis_status": "failed", "analysis_message": "剧本分析任务入队失败"}).Error return nil, errors.New("剧本分析任务队列暂时不可用,请稍后重试") } return task, nil } func (s *Creative) CancelScriptAnalysis(userID, analysisID uuid.UUID) error { return s.DB.Transaction(func(tx *gorm.DB) error { var task model.GenerationTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("script_analysis_id=? AND user_id=? AND task_type='script_analysis' AND status IN ?", analysisID, userID, []string{"submitted", "processing", "cancel_requested"}). Order("created_at DESC").Take(&task).Error; err != nil { return err } if task.Status == "cancel_requested" { return nil } if task.Status == "submitted" { if err := tx.Model(&task).Updates(map[string]any{ "status": "cancelled", "actual_points": task.PrepaidPoints, "finished_at": time.Now(), "error_code": "user_cancelled", "error_message": "用户取消,已扣积分不退", }).Error; err != nil { return err } return tx.Model(&model.ScriptAnalysis{}).Where("id=? AND user_id=? AND deleted_at IS NULL", analysisID, userID).Updates(map[string]any{ "analysis_status": "idle", "analysis_message": "剧本分析已取消,已扣积分不退", }).Error } if err := tx.Model(&task).Updates(map[string]any{ "status": "cancel_requested", "error_code": "user_cancelled", "error_message": "正在取消分析", }).Error; err != nil { return err } return tx.Model(&model.ScriptAnalysis{}).Where("id=? AND user_id=? AND deleted_at IS NULL", analysisID, userID). Update("analysis_message", "正在取消分析").Error }) } func (s *Creative) UpdateScriptAnalysis(userID, id uuid.UUID, input ScriptAnalysisInput) (*model.ScriptAnalysis, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, errors.New("剧本名称不能为空") } result := s.DB.Model(&model.ScriptAnalysis{}). Where("id=? AND user_id=? AND deleted_at IS NULL", id, userID). Updates(map[string]any{"name": name, "source_content": input.SourceContent, "result_content": input.ResultContent}) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 0 { return nil, gorm.ErrRecordNotFound } return s.GetScriptAnalysis(userID, id) } func (s *Creative) DeleteScriptAnalysis(userID, id uuid.UUID) error { result := s.DB.Model(&model.ScriptAnalysis{}). Where("id=? AND user_id=? AND deleted_at IS NULL", id, userID). Update("deleted_at", gorm.Expr("CURRENT_TIMESTAMP")) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil }