package service import ( "encoding/json" "errors" "fmt" "strings" "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" ) func (s *Creative) GetRedrawWorkbench(userID, projectID uuid.UUID) (map[string]any, error) { project, err := s.GetProject(userID, projectID) if err != nil { return nil, err } source := map[string]any{} if err := s.DB.Table("creative_projects project"). Select(`project.source_video_asset_id,source.public_url AS source_video_url,source.original_name AS source_video_original_name, project.subtitle_asset_id,subtitle.public_url AS subtitle_url,subtitle.original_name AS subtitle_original_name, project.audio_source,project.source_language,project.redraw_status AS status,project.analysis_message`). Joins("LEFT JOIN media_assets source ON source.id=project.source_video_asset_id AND source.deleted_at IS NULL"). Joins("LEFT JOIN media_assets subtitle ON subtitle.id=project.subtitle_asset_id AND subtitle.deleted_at IS NULL"). Where("project.id=? AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", projectID, userID). Take(&source).Error; err != nil { return nil, err } assets := make([]map[string]any, 0) if err := s.DB.Table("project_assets asset"). Select("asset.id,asset.project_id,asset.asset_type,asset.name,asset.user_edited"). Where("asset.project_id=? AND asset.deleted_at IS NULL", projectID). Order("asset.asset_type,asset.created_at").Find(&assets).Error; err != nil { return nil, err } storyboards := make([]map[string]any, 0) if err := s.DB.Table("episode_storyboards storyboard"). Select(`storyboard.*,thumbnail.public_url AS thumbnail_url,0 AS history_count`). Joins("LEFT JOIN media_assets thumbnail ON thumbnail.id=storyboard.thumbnail_asset_id"). Where("storyboard.project_id=? AND storyboard.deleted_at IS NULL", projectID). Order("storyboard.sequence_no").Find(&storyboards).Error; err != nil { return nil, err } var scriptRow struct { RedrawScript string } if err := s.DB.Table("creative_projects").Select("coalesce(redraw_script,'') AS redraw_script"). Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID). Take(&scriptRow).Error; err != nil { return nil, err } return map[string]any{"project": project, "source": source, "assets": assets, "storyboards": storyboards, "script_content": scriptRow.RedrawScript}, nil } func (s *Creative) GetRedrawEpisodeWorkbench(userID, projectID, episodeID uuid.UUID) (map[string]any, error) { data, err := s.GetWorkbench(userID, projectID, episodeID) if err != nil { return nil, err } episode, ok := data["episode"].(map[string]any) if !ok { return nil, errors.New("剧集数据无效") } var script string if err := s.DB.Table("project_episodes episode"). Select("coalesce(episode.redraw_script,'')"). Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID). Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID). Scan(&script).Error; err != nil { return nil, err } return map[string]any{ "project": data["project"], "episode": episode, "source": episode, "assets": data["assets"], "storyboards": data["storyboards"], "script_content": script, }, nil } // SaveRedrawEpisodeScript 校验项目归属和类型后保存用户编辑的剧集反推剧本。 func (s *Creative) SaveRedrawEpisodeScript(userID, projectID, episodeID uuid.UUID, content string) error { return s.DB.Transaction(func(tx *gorm.DB) error { var episode model.ProjectEpisode if err := tx.Table("project_episodes episode").Select("episode.id"). Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID). Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID). Take(&episode).Error; err != nil { return err } return tx.Model(&model.ProjectEpisode{}).Where("id=?", episode.ID).Update("redraw_script", content).Error }) } func (s *Creative) QueueRedrawEpisodeScript(userID, projectID, episodeID uuid.UUID) (*model.GenerationTask, error) { if s.Queue == nil { return nil, errors.New("反推任务队列不可用") } config, err := s.analysisModel(projectID) if err != nil { return nil, err } task := &model.GenerationTask{ RequestID: "script_reverse_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{"mode":"script"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00", } if err := s.DB.Transaction(func(tx *gorm.DB) error { scriptPrompt, err := selectedPromptContent(tx, userID, "剧本反推") if err != nil { return err } task.InputData = mustJSON(map[string]string{"mode": "script", "prompt": scriptPrompt}) var episode model.ProjectEpisode if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes episode").Select("episode.*"). Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.project_type='video_redraw' AND project.deleted_at IS NULL", userID). Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil { return err } if episode.Status != "review" { return errors.New("请等待全部分镜提示词反推完成") } var total, incomplete int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", episodeID).Count(&total).Error; err != nil { return err } if total == 0 { return errors.New("暂无可用于反推剧本的分镜提示词") } if err := tx.Model(&model.EpisodeStoryboard{}). Where("episode_id=? AND deleted_at IS NULL AND trim(coalesce(prompt_content,''))=''", episodeID). Count(&incomplete).Error; err != nil { return err } if incomplete > 0 { return errors.New("请等待所有分镜提示词反推完成") } var prompts []struct { SequenceNo int `json:"sequence_no"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` PromptContent string `json:"prompt_content"` } if err := tx.Model(&model.EpisodeStoryboard{}).Select("sequence_no,start_ms,end_ms,prompt_content"). Where("episode_id=? AND deleted_at IS NULL", episodeID).Order("sequence_no").Find(&prompts).Error; err != nil { return err } task.PromptSnapshot = string(mustJSON(prompts)) var active int64 if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND task_type='prompt_reverse' AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧集已有反推任务正在执行") } if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "剧本反推"); err != nil { return err } return tx.Model(&episode).Updates(map[string]any{"status": "generating", "analysis_message": "正在反推完整剧本"}).Error }); err != nil { return nil, err } if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, 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.ProjectEpisode{}).Where("id=?", episodeID).Updates(map[string]any{"status": "review", "analysis_message": "剧本反推任务入队失败"}).Error return nil, errors.New("反推任务队列暂时不可用,请稍后重试") } return task, nil } func mustJSON(value any) []byte { result, _ := json.Marshal(value) return result } // selectedPromptContent 按用户偏好选取指定类型的提示词内容,未命中用户偏好时兜底取最新系统提示词。 // 用户未设置偏好或系统未配置均属于正常业务情况,使用 Limit(1).Find 避免触发 GORM 的 record not found 日志。 func selectedPromptContent(tx *gorm.DB, userID uuid.UUID, promptType string) (string, error) { var selected struct{ Content string } if err := tx.Table("prompts p").Select("p.content"). Joins("JOIN user_prompt_preferences pref ON pref.prompt_id=p.id AND pref.user_id=? AND pref.prompt_type=?", userID, promptType). Where("p.type=? AND p.deleted_at IS NULL AND (p.scope='system' OR (p.scope='user' AND p.owner_user_id=?))", promptType, userID). Limit(1).Find(&selected).Error; err != nil { return "", err } if strings.TrimSpace(selected.Content) == "" { if err := tx.Table("prompts").Select("content"). Where("type=? AND scope='system' AND deleted_at IS NULL", promptType). Order("updated_at DESC").Limit(1).Find(&selected).Error; err != nil { return "", err } if strings.TrimSpace(selected.Content) == "" { return "", fmt.Errorf("请先在提示词管理中配置%s提示词", promptType) } } return strings.TrimSpace(selected.Content), nil } func (s *Creative) UpdateRedrawAnalysisSettings(userID, projectID uuid.UUID, audioSource, sourceLanguage string) error { if audioSource != "video_audio" && audioSource != "subtitle_file" { return errors.New("台词来源无效") } sourceLanguage = strings.TrimSpace(sourceLanguage) var language any if sourceLanguage != "" { language = sourceLanguage } result := s.DB.Table("creative_projects"). Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID). Updates(map[string]any{"audio_source": audioSource, "source_language": language}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *Creative) QueueRedrawAnalysis(userID, projectID uuid.UUID) (*model.GenerationTask, error) { return s.queueRedrawAnalysis(userID, projectID, nil) } func (s *Creative) QueueRedrawStoryboardAnalysis(userID, projectID, storyboardID uuid.UUID) (*model.GenerationTask, error) { return s.queueRedrawAnalysis(userID, projectID, &storyboardID) } func (s *Creative) QueueRedrawScript(userID, projectID uuid.UUID) (*model.GenerationTask, error) { if s.Queue == nil { return nil, errors.New("反推任务队列不可用") } config, err := s.analysisModel(projectID) if err != nil { return nil, err } task := &model.GenerationTask{ RequestID: "script_reverse_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{"mode":"script"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00", } if err := s.DB.Transaction(func(tx *gorm.DB) error { scriptPrompt, err := selectedPromptContent(tx, userID, "剧本反推") if err != nil { return err } task.InputData = mustJSON(map[string]string{"mode": "script", "prompt": scriptPrompt}) var project model.CreativeProject if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID). Take(&project).Error; err != nil { return err } if project.RedrawStatus != "review" { return errors.New("请等待全部分镜提示词反推完成") } var total, incomplete int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", projectID).Count(&total).Error; err != nil { return err } if total == 0 { return errors.New("暂无可用于反推剧本的分镜提示词") } if err := tx.Model(&model.EpisodeStoryboard{}). Where("project_id=? AND deleted_at IS NULL AND trim(coalesce(prompt_content,''))=''", projectID). Count(&incomplete).Error; err != nil { return err } if incomplete > 0 { return errors.New("请等待所有分镜提示词反推完成") } var storyboardPrompts []struct { SequenceNo int `json:"sequence_no"` StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` PromptContent string `json:"prompt_content"` } if err := tx.Model(&model.EpisodeStoryboard{}).Select("sequence_no,start_ms,end_ms,prompt_content"). Where("project_id=? AND deleted_at IS NULL", projectID).Order("sequence_no").Find(&storyboardPrompts).Error; err != nil { return err } promptSnapshot, err := json.Marshal(storyboardPrompts) if err != nil { return err } task.PromptSnapshot = string(promptSnapshot) var active int64 if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND task_type='prompt_reverse' AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧本已有反推任务正在执行") } if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "剧本反推"); err != nil { return err } return tx.Model(&project).Updates(map[string]any{"redraw_status": "generating", "analysis_message": "正在反推完整剧本"}).Error }); err != nil { return nil, err } if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, 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.CreativeProject{}).Where("id=?", projectID).Updates(map[string]any{"redraw_status": "review", "analysis_message": "剧本反推任务入队失败"}).Error return nil, errors.New("反推任务队列暂时不可用,请稍后重试") } return task, nil } func (s *Creative) queueRedrawAnalysis(userID, projectID uuid.UUID, storyboardID *uuid.UUID) (*model.GenerationTask, error) { if s.Queue == nil { return nil, errors.New("反推任务队列不可用") } config, err := s.analysisModel(projectID) if err != nil { return nil, err } requestPrefix := "analysis_" input := json.RawMessage(`{}`) if storyboardID != nil { requestPrefix = "storyboard_analysis_" input = json.RawMessage(`{"mode":"storyboard"}`) } task := &model.GenerationTask{ RequestID: requestPrefix + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, StoryboardID: storyboardID, TaskType: "prompt_reverse", Status: "submitted", InputData: input, EstimatedPoints: "0.00", PrepaidPoints: "0.00", } if err := s.DB.Transaction(func(tx *gorm.DB) error { var project model.CreativeProject if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id=? AND user_id=? AND project_type='video_redraw' AND deleted_at IS NULL", projectID, userID). Take(&project).Error; err != nil { return err } if project.SourceVideoAssetID == nil { return errors.New("请先上传原视频") } if project.AudioSource == "subtitle_file" && project.SubtitleAssetID == nil { return errors.New("当前选择字幕文件识别,请先上传字幕") } if storyboardID != nil { var storyboard model.EpisodeStoryboard if err := tx.Where("id=? AND project_id=? AND deleted_at IS NULL", *storyboardID, projectID).Take(&storyboard).Error; err != nil { return err } if storyboard.Locked { return errors.New("当前分镜已保护,无法重新反推") } } var active int64 if err := tx.Model(&model.GenerationTask{}). Where("project_id=? AND task_type='prompt_reverse' AND status IN ?", projectID, activeTaskStatuses). Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧本已有反推任务正在执行") } if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "视频反推"); err != nil { return err } if storyboardID == nil { return tx.Model(&model.CreativeProject{}).Where("id=?", projectID). Updates(map[string]any{"redraw_status": "analyzing", "analysis_message": "等待视频分析"}).Error } return nil }); err != nil { return nil, err } if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil { if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "反推任务入队失败"); settleErr != nil { return nil, fmt.Errorf("反推任务入队失败且预扣返还失败: %w", settleErr) } return nil, errors.New("反推任务队列暂时不可用,请稍后重试") } return task, nil } func (s *Creative) ResetRedrawAnalysis(userID, projectID uuid.UUID) ([]string, error) { media := newDeletionMediaSet() err := s.DB.Transaction(func(tx *gorm.DB) error { var project model.CreativeProject if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). 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 active int64 if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧本存在进行中的任务,暂时不能重新反推") } if err := removeRedrawDerivedContent(tx, projectID, media); err != nil { return err } if err := media.deleteRows(tx); err != nil { return err } return tx.Model(&project).Updates(map[string]any{"redraw_status": "uploaded", "analysis_message": nil, "redraw_script": nil}).Error }) return media.objectKeys(), err } func removeRedrawDerivedContent(tx *gorm.DB, projectID uuid.UUID, media *deletionMediaSet) error { taskScope := `project_id=? AND (task_type IN ('prompt_reverse','video_generation') OR (task_type='image_generation' AND input_data->>'target_type'='storyboard'))` if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key"). Joins("JOIN generation_outputs output ON output.media_asset_id=media.id"). Joins("JOIN generation_tasks task ON task.id=output.task_id").Where(taskScope, projectID)); err != nil { return err } if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key"). Joins("JOIN episode_storyboards storyboard ON storyboard.thumbnail_asset_id=media.id"). Where("storyboard.project_id=?", projectID)); err != nil { return err } if err := collectMediaByObjectKey(tx, media, fmt.Sprintf("juyou_ran/video-redraw/projects/%s/storyboards/%%", projectID)); err != nil { return err } if err := collectMediaByObjectKey(tx, media, fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%%/storyboards/%%", projectID)); err != nil { return err } if err := tx.Model(&model.CreativeProject{}).Where("id=?", projectID).Update("cover_asset_id", nil).Error; err != nil { return err } if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL WHERE project_id=?", projectID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE "+taskScope+")", projectID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_tasks WHERE "+taskScope, projectID).Error; err != nil { return err } return tx.Exec("DELETE FROM episode_storyboards WHERE project_id=?", projectID).Error }