package service import ( "encoding/json" "errors" "fmt" "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" ) var activeTaskStatuses = []string{"pending_submission", "submitting", "submitted", "processing", "result_ready", "downloading", "cancel_requested"} func (s *Creative) InsertStoryboard(userID, projectID, storyboardID uuid.UUID, position string) (*model.EpisodeStoryboard, error) { if position != "before" && position != "after" { return nil, errors.New("插入位置无效") } created := &model.EpisodeStoryboard{} err := s.DB.Transaction(func(tx *gorm.DB) error { var target model.EpisodeStoryboard if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("episode_storyboards sb").Select("sb.*"). Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID). Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID).Take(&target).Error; err != nil { return err } parentColumn, parentID := storyboardParent(target) sequence, startMS := target.SequenceNo, target.StartMS if position == "after" { sequence, startMS = target.SequenceNo+1, target.EndMS } if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no+100000 WHERE `+parentColumn+`=? AND sequence_no>=? AND deleted_at IS NULL`, parentID, sequence).Error; err != nil { return err } if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no-99999,start_ms=start_ms+5000,end_ms=end_ms+5000 WHERE `+parentColumn+`=? AND sequence_no>=? AND deleted_at IS NULL`, parentID, sequence+100000).Error; err != nil { return err } created = &model.EpisodeStoryboard{ EpisodeID: target.EpisodeID, ProjectID: target.ProjectID, SequenceNo: sequence, StableKey: "manual-" + uuid.NewString(), StartMS: startMS, EndMS: startMS + 5000, DurationSeconds: 5, Title: fmt.Sprintf("分镜 %d", sequence), Dialogue: json.RawMessage(`[]`), AssetRefs: json.RawMessage(`[]`), Status: "idle", } return tx.Create(created).Error }) return created, err } func (s *Creative) DeleteStoryboard(userID, projectID, storyboardID uuid.UUID) ([]string, error) { media := newDeletionMediaSet() err := s.DB.Transaction(func(tx *gorm.DB) error { var storyboard model.EpisodeStoryboard if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("episode_storyboards sb").Select("sb.*"). Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID). Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID).Take(&storyboard).Error; err != nil { return err } parentColumn, parentID := storyboardParent(storyboard) var active int64 if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND status IN ?", storyboardID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前分镜存在进行中的任务,暂时不能删除") } 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 gt ON gt.id=output.task_id"). Where("gt.storyboard_id=? AND (gt.task_type IN ('video_generation','prompt_reverse') OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID)); err != nil { return err } if storyboard.ThumbnailAssetID != nil { if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *storyboard.ThumbnailAssetID)); err != nil { return err } } storyboardMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/storyboards/%%-%s/%%", projectID, storyboardID) if storyboard.EpisodeID != nil { storyboardMediaPattern = fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%s/storyboards/%%-%s/%%", projectID, *storyboard.EpisodeID, storyboardID) } if err := collectMediaByObjectKey(tx, media, storyboardMediaPattern); err != nil { return err } if err := tx.Model(&storyboard).Update("active_output_id", nil).Error; err != nil { return err } if err := tx.Exec(`DELETE FROM generation_outputs WHERE task_id IN ( SELECT id FROM generation_tasks WHERE storyboard_id=? AND (task_type IN ('video_generation','prompt_reverse') OR (task_type='image_generation' AND input_data->>'target_type'='storyboard')))`, storyboardID).Error; err != nil { return err } if err := tx.Exec(`DELETE FROM generation_tasks WHERE storyboard_id=? AND (task_type IN ('video_generation','prompt_reverse') OR (task_type='image_generation' AND input_data->>'target_type'='storyboard'))`, storyboardID).Error; err != nil { return err } if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=?", storyboardID).Update("storyboard_id", nil).Error; err != nil { return err } if err := tx.Exec("DELETE FROM episode_storyboards WHERE id=?", storyboardID).Error; err != nil { return err } if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no+100000 WHERE `+parentColumn+`=? AND sequence_no>? AND deleted_at IS NULL`, parentID, storyboard.SequenceNo).Error; err != nil { return err } shiftMS := int64(storyboard.DurationSeconds * 1000) if err := tx.Exec(`UPDATE episode_storyboards SET sequence_no=sequence_no-100001, start_ms=GREATEST(0,start_ms-?),end_ms=GREATEST(1,end_ms-?) WHERE `+parentColumn+`=? AND sequence_no>? AND deleted_at IS NULL`, shiftMS, shiftMS, parentID, storyboard.SequenceNo+100000).Error; err != nil { return err } return media.deleteRows(tx) }) return media.objectKeys(), err } func storyboardParent(storyboard model.EpisodeStoryboard) (string, uuid.UUID) { if storyboard.ProjectID != nil { return "project_id", *storyboard.ProjectID } return "episode_id", *storyboard.EpisodeID } func (s *Creative) DeleteEpisode(userID, projectID, episodeID uuid.UUID) ([]string, error) { media := newDeletionMediaSet() err := s.DB.Transaction(func(tx *gorm.DB) error { var episode model.ProjectEpisode if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*"). Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil { return err } var active int64 if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("剧集存在排队中或处理中的任务,暂时不能删除") } if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("剧集存在进行中的剧本解析任务,暂时不能删除") } if err := removeEpisodeDerivedContent(tx, projectID, episodeID, media); err != nil { return err } if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key"). Joins(`JOIN project_episodes episode ON media.id=episode.cover_asset_id OR media.id=episode.source_video_asset_id OR media.id=episode.subtitle_asset_id`). Where("episode.id=?", episodeID)); err != nil { return err } // 如果删除的是第一集,将项目封面切换到新的第一集封面。 if episode.CoverAssetID != nil { var nextEpisode struct{ CoverAssetID *uuid.UUID } if err := tx.Table("project_episodes").Select("cover_asset_id").Where("project_id=? AND id<>? AND deleted_at IS NULL AND cover_asset_id IS NOT NULL", projectID, episodeID). Order("episode_no").Limit(1).Find(&nextEpisode).Error; err != nil { return err } if err := tx.Model(&model.CreativeProject{}).Where("id=?", projectID).Update("cover_asset_id", nextEpisode.CoverAssetID).Error; err != nil { return err } } episodeMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/episodes/%%%s/%%", projectID, episodeID) if err := collectMediaByObjectKey(tx, media, episodeMediaPattern); err != nil { return err } if err := tx.Exec("UPDATE generation_tasks SET episode_id=NULL WHERE episode_id=?", episodeID).Error; err != nil { return err } if err := tx.Exec("UPDATE project_assets SET source_episode_id=NULL WHERE source_episode_id=?", episodeID).Error; err != nil { return err } if err := tx.Exec("UPDATE drama_parse_batches SET current_episode_id=NULL WHERE current_episode_id=?", episodeID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM project_episodes WHERE id=?", episodeID).Error; err != nil { return err } return media.deleteRows(tx) }) return media.objectKeys(), err } // ResetEpisodeAnalysis permanently removes generated storyboards, prompts and their task history. // Project assets are intentionally left untouched so they can be reused for a new analysis. func (s *Creative) ResetEpisodeAnalysis(userID, projectID, episodeID uuid.UUID) ([]string, error) { media := newDeletionMediaSet() err := s.DB.Transaction(func(tx *gorm.DB) error { var episode model.ProjectEpisode if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*"). Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil { return err } var active int64 if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧集存在进行中的生成任务,暂时不能重新解析") } if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil { return err } if active > 0 { return errors.New("当前剧集存在进行中的剧本解析任务,暂时不能重新解析") } if err := removeEpisodeDerivedContent(tx, projectID, episodeID, media); err != nil { return err } if err := media.deleteRows(tx); err != nil { return err } return tx.Model(&episode).Updates(map[string]any{"status": "uploaded", "analysis_message": nil, "redraw_script": nil}).Error }) return media.objectKeys(), err } func (s *Creative) ListTasks(userID, projectID uuid.UUID, episodeID *uuid.UUID) ([]map[string]any, error) { items := make([]map[string]any, 0) query := s.DB.Table("generation_tasks gt"). Select(`gt.id,gt.request_id,gt.task_type,gt.status,gt.episode_id,gt.storyboard_id,gt.input_data->>'mode' AS mode,gt.input_data->>'phase_started_at' AS phase_started_at,gt.estimated_points::text AS estimated_points, gt.actual_points::text AS actual_points,gt.error_code,gt.error_message,gt.created_at,gt.submitted_at,gt.finished_at, coalesce(sb.sequence_no,(gt.input_data->>'current_sequence')::int,0) AS sequence_no,gt.input_data->>'asset_id' AS asset_id,media.public_url AS result_url, (SELECT max(progress.created_at) FROM generation_outputs progress WHERE progress.task_id=gt.id) AS last_result_at`). Joins("JOIN creative_projects p ON p.id=gt.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Joins("LEFT JOIN episode_storyboards sb ON sb.id=gt.storyboard_id"). Joins("LEFT JOIN generation_outputs output ON output.task_id=gt.id AND output.sequence_no=1"). Joins("LEFT JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL"). Where("gt.project_id=?", projectID) if episodeID != nil { query = query.Where("gt.episode_id=?", *episodeID) } return items, query.Order("gt.created_at DESC").Limit(500).Find(&items).Error } func (s *Creative) CancelTask(userID, projectID, taskID uuid.UUID) error { var channelID *uuid.UUID err := s.DB.Transaction(func(tx *gorm.DB) error { var task model.GenerationTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND project_id=? AND user_id=?", taskID, projectID, userID).First(&task).Error; err != nil { return err } var projectType string if err := tx.Table("creative_projects").Where("id=?", projectID).Pluck("project_type", &projectType).Error; err != nil { return err } channelID = task.ChannelID if task.TaskType == "prompt_reverse" && (task.Status == "pending_submission" || task.Status == "submitted" || task.Status == "processing" || task.Status == "cancel_requested") { return cancelPromptReverseTask(tx, &task) } switch task.Status { case "pending_submission": refunded := false chargeCancellation := task.TaskType == "video_generation" || projectType == "premium_drama" if !chargeCancellation { var err error refunded, err = billing.RefundGenerationTask(tx, &task, "视频生成失败返还") if err != nil { return err } } actual := "0.00" if chargeCancellation { actual = task.PrepaidPoints } updates := map[string]any{"status": "cancelled", "actual_points": actual, "finished_at": time.Now(), "error_code": nil, "error_message": "用户取消"} if refunded { updates["cost_refunded"] = true } if err := tx.Model(&task).Updates(updates).Error; err != nil { return err } return resetRelatedStatus(tx, task) case "submitted", "processing": if task.TaskType != "prompt_reverse" && strings.TrimSpace(task.UpstreamTaskID) == "" { return errors.New("上游任务标识尚未就绪,请稍后重试取消") } return tx.Model(&task).Updates(map[string]any{"status": "cancel_requested", "error_code": nil, "error_message": "等待当前处理安全结束"}).Error case "submitting": return errors.New("任务正在提交,请稍后重试取消") case "cancel_requested": return nil case "result_ready", "downloading", "succeeded": return errors.New("生成已经完成,不能取消") case "failed", "cancelled": return nil default: return fmt.Errorf("任务状态 %s 不允许取消", task.Status) } }) if err == nil && channelID != nil && s.Queue != nil { _ = queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, *channelID, 0) } return err } func cancelPromptReverseTask(tx *gorm.DB, task *model.GenerationTask) error { var input struct { Mode string `json:"mode"` } _ = json.Unmarshal(task.InputData, &input) updates := map[string]any{ "status": "cancelled", "actual_points": task.PrepaidPoints, "finished_at": time.Now(), "error_code": nil, "error_message": "用户取消,已扣积分不退", } if err := tx.Model(task).Updates(updates).Error; err != nil { return err } if task.StoryboardID != nil { return nil } if task.EpisodeID == nil && task.ProjectID != nil { if input.Mode == "script" { return tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID). Updates(map[string]any{"redraw_status": "review", "analysis_message": "剧本反推已取消"}).Error } var storyboardCount int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", *task.ProjectID).Count(&storyboardCount).Error; err != nil { return err } status := "uploaded" if storyboardCount > 0 { status = "review" } return tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID).Updates(map[string]any{"redraw_status": status, "analysis_message": "视频分析已取消"}).Error } if task.EpisodeID == nil { return nil } var storyboardCount int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", *task.EpisodeID).Count(&storyboardCount).Error; err != nil { return err } status := "uploaded" if storyboardCount > 0 { status = "review" } return tx.Model(&model.ProjectEpisode{}).Where("id=?", *task.EpisodeID).Updates(map[string]any{"status": status, "analysis_message": "视频分析已取消"}).Error } func (s *Creative) failQueuedTask(taskID uuid.UUID, code, message string) error { return s.failQueuedTasks([]uuid.UUID{taskID}, code, message) } func (s *Creative) failQueuedTasks(taskIDs []uuid.UUID, code, message string) error { return s.DB.Transaction(func(tx *gorm.DB) error { for _, taskID := range taskIDs { var task model.GenerationTask if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).First(&task).Error; err != nil { return err } if task.Status == "succeeded" || task.Status == "failed" || task.Status == "cancelled" { continue } refundRemark := map[string]string{"prompt_reverse": "视频反推失败返还", "script_analysis": "剧本分析失败返还", "image_generation": "图片生成失败返还", "video_generation": "视频生成失败返还"}[task.TaskType] var refunded bool var err error if task.TaskType == "prompt_reverse" || task.TaskType == "script_analysis" { refunded, err = billing.RefundTextGenerationTask(tx, &task, refundRemark) } else { refunded, err = billing.RefundGenerationTask(tx, &task, refundRemark) } if err != nil { return err } updates := map[string]any{ "status": "failed", "actual_points": "0.00", "finished_at": time.Now(), "error_code": code, "error_message": message, } if refunded { updates["cost_refunded"] = true } if err := tx.Model(&task).Updates(updates).Error; err != nil { return err } if task.TaskType == "video_generation" && task.StoryboardID != nil { if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", *task.StoryboardID).Update("status", "idle").Error; err != nil { return err } } if task.TaskType == "prompt_reverse" && task.StoryboardID == nil && task.EpisodeID != nil { var storyboardCount int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL", *task.EpisodeID).Count(&storyboardCount).Error; err != nil { return err } status := "uploaded" if storyboardCount > 0 { status = "review" } if err := tx.Model(&model.ProjectEpisode{}).Where("id=?", *task.EpisodeID).Updates(map[string]any{"status": status, "analysis_message": message}).Error; err != nil { return err } } else if task.TaskType == "prompt_reverse" && task.StoryboardID == nil && task.ProjectID != nil { var storyboardCount int64 if err := tx.Model(&model.EpisodeStoryboard{}).Where("project_id=? AND deleted_at IS NULL", *task.ProjectID).Count(&storyboardCount).Error; err != nil { return err } status := "uploaded" if storyboardCount > 0 { status = "review" } if err := tx.Model(&model.CreativeProject{}).Where("id=?", *task.ProjectID).Updates(map[string]any{"redraw_status": status, "analysis_message": message}).Error; err != nil { return err } } } return nil }) } func resetRelatedStatus(tx *gorm.DB, task model.GenerationTask) error { if task.TaskType == "video_generation" && task.StoryboardID != nil { var active int64 if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type='video_generation' AND status IN ?", *task.StoryboardID, activeTaskStatuses).Count(&active).Error; err != nil { return err } if active == 0 { return tx.Model(&model.EpisodeStoryboard{}).Where("id=?", *task.StoryboardID).Update("status", "idle").Error } } return nil } func (s *Creative) ListStoryboardOutputs(userID, projectID, storyboardID uuid.UUID) ([]map[string]any, error) { items := make([]map[string]any, 0) err := s.DB.Table("generation_outputs output"). Select(`output.id,output.media_asset_id,output.output_type,output.sequence_no,output.metadata,output.created_at,output.task_id, media.public_url,media.mime_type,media.size_bytes,gt.estimated_points::text AS estimated_points, gt.actual_points::text AS actual_points,CASE WHEN gt.task_type='video_generation' THEN sb.active_output_id=output.id ELSE sb.thumbnail_asset_id=output.media_asset_id END AS active, CASE WHEN gt.task_type='video_generation' THEN sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' ELSE false END AS candidate`). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID). Joins("JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL"). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL"). Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID). Order("output.created_at DESC").Find(&items).Error return items, err } // RemoveStoryboardImage 清空分镜主图引用,保留生成结果以便在历史记录中恢复。 func (s *Creative) RemoveStoryboardImage(userID, projectID, storyboardID uuid.UUID) error { ownedStoryboard := `(project_id=? AND EXISTS (SELECT 1 FROM creative_projects p WHERE p.id=? AND p.user_id=? AND p.deleted_at IS NULL)) OR (episode_id IN (SELECT e.id FROM project_episodes e JOIN creative_projects p ON p.id=e.project_id WHERE e.project_id=? AND p.user_id=? AND e.deleted_at IS NULL AND p.deleted_at IS NULL))` result := s.DB.Model(&model.EpisodeStoryboard{}). Where("id=? AND thumbnail_asset_id IS NOT NULL AND deleted_at IS NULL AND ("+ownedStoryboard+")", storyboardID, projectID, projectID, userID, projectID, userID). Updates(map[string]any{"thumbnail_asset_id": nil, "status": "idle", "user_edited": true}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { var exists int64 if err := s.DB.Model(&model.EpisodeStoryboard{}). Where("id=? AND deleted_at IS NULL AND ("+ownedStoryboard+")", storyboardID, projectID, projectID, userID, projectID, userID). Count(&exists).Error; err != nil { return err } if exists == 0 { return gorm.ErrRecordNotFound } } return nil } func (s *Creative) AddStoryboardCandidate(userID, projectID, storyboardID, outputID uuid.UUID) error { return s.DB.Transaction(func(tx *gorm.DB) error { var row struct{ Candidate bool } if err := tx.Table("generation_outputs output"). Select("sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL"). Joins("JOIN project_episodes e ON e.id=sb.episode_id AND e.deleted_at IS NULL"). Joins("JOIN creative_projects p ON p.id=e.project_id AND p.id=? AND p.user_id=? AND p.project_type='premium_drama' AND p.deleted_at IS NULL", projectID, userID). Where("output.id=?", outputID).Take(&row).Error; err != nil { return err } if row.Candidate { return nil } var count int64 if err := tx.Table("generation_outputs output"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id"). Where("sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true'").Count(&count).Error; err != nil { return err } if count >= 3 { return errors.New("当前分镜最多保留 3 个备选视频,请先删除一个备选视频") } var activeTasks int64 if err := tx.Model(&model.GenerationTask{}). Where("storyboard_id=? AND task_type='video_generation' AND status IN ?", storyboardID, activeTaskStatuses). Count(&activeTasks).Error; err != nil { return err } if count+activeTasks >= 3 { return errors.New("生成中的视频已占用备选位置,请等待生成完成或先删除一个备选视频") } return tx.Model(&model.GenerationOutput{}).Where("id=?", outputID). Update("metadata", gorm.Expr("jsonb_set(coalesce(metadata,'{}'::jsonb),'{candidate}','true'::jsonb,true)")).Error }) } func (s *Creative) RemoveStoryboardCandidate(userID, projectID, storyboardID, outputID uuid.UUID) error { return s.DB.Transaction(func(tx *gorm.DB) error { var row struct { Active bool Candidate bool } if err := tx.Table("generation_outputs output"). Select("sb.active_output_id=output.id AS active,sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL"). Joins("JOIN project_episodes e ON e.id=sb.episode_id AND e.deleted_at IS NULL"). Joins("JOIN creative_projects p ON p.id=e.project_id AND p.id=? AND p.user_id=? AND p.project_type='premium_drama' AND p.deleted_at IS NULL", projectID, userID). Where("output.id=?", outputID).Take(&row).Error; err != nil { return err } if !row.Candidate { return errors.New("该视频不在备选列表中") } if row.Active { var replacement struct{ ID uuid.UUID } if err := tx.Table("generation_outputs output").Select("output.id"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND gt.task_type='video_generation'", storyboardID). Where("output.id<>? AND coalesce(output.metadata->>'candidate','false')='true'", outputID). Order("output.created_at DESC").Limit(1).Scan(&replacement).Error; err != nil { return err } var replacementID *uuid.UUID if replacement.ID != uuid.Nil { replacementID = &replacement.ID } status := "idle" if replacementID != nil { status = "completed" } if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", storyboardID). Updates(map[string]any{"active_output_id": replacementID, "status": status}).Error; err != nil { return err } } return tx.Model(&model.GenerationOutput{}).Where("id=?", outputID). Update("metadata", gorm.Expr("coalesce(metadata,'{}'::jsonb)-'candidate'")).Error }) } func (s *Creative) ActivateStoryboardOutput(userID, projectID, storyboardID, outputID uuid.UUID) error { var output struct { MediaAssetID uuid.UUID TaskType string } if err := s.DB.Table("generation_outputs output").Select("output.media_asset_id,gt.task_type"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.deleted_at IS NULL"). Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID). Where("output.id=?", outputID).Take(&output).Error; err != nil { return err } updates := map[string]any{"status": "completed", "updated_at": time.Now()} if output.TaskType == "video_generation" { updates["active_output_id"] = outputID } else { updates["thumbnail_asset_id"] = output.MediaAssetID } result := s.DB.Model(&model.EpisodeStoryboard{}).Where("id=? AND deleted_at IS NULL", storyboardID).Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *Creative) DeleteStoryboardOutput(userID, projectID, storyboardID, outputID uuid.UUID) (string, error) { var objectKey string err := s.DB.Transaction(func(tx *gorm.DB) error { var row struct { TaskID uuid.UUID MediaID uuid.UUID ObjectKey string Status string Candidate bool } if err := tx.Table("generation_outputs output").Select("output.task_id,output.media_asset_id AS media_id,media.object_key,gt.status,sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true' AS candidate"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.storyboard_id=? AND (gt.task_type='video_generation' OR (gt.task_type='image_generation' AND gt.input_data->>'target_type'='storyboard'))", storyboardID). Joins("JOIN media_assets media ON media.id=output.media_asset_id"). Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id"). Joins(`JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL))`, projectID, userID). Where("output.id=?", outputID).Take(&row).Error; err != nil { return err } if row.Status == "pending_submission" || row.Status == "submitting" || row.Status == "submitted" || row.Status == "processing" || row.Status == "cancel_requested" { return errors.New("视频仍在生成中,暂时不能删除历史记录") } if row.Candidate { return errors.New("备选视频不能直接删除,请先移入历史记录") } if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL,status='idle' WHERE id=? AND active_output_id=?", storyboardID, outputID).Error; err != nil { return err } if err := tx.Exec("UPDATE episode_storyboards SET thumbnail_asset_id=NULL,status='idle' WHERE id=? AND thumbnail_asset_id=?", storyboardID, row.MediaID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_outputs WHERE id=?", outputID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_tasks WHERE id=?", row.TaskID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id=?", row.MediaID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM media_assets WHERE id=?", row.MediaID).Error; err != nil { return err } objectKey = row.ObjectKey return nil }) return objectKey, err } func (s *Creative) ListAssetOutputs(userID, projectID, assetID uuid.UUID) ([]map[string]any, error) { items := make([]map[string]any, 0) err := s.DB.Table("generation_outputs output"). Select(`output.id,output.media_asset_id,output.output_type,output.sequence_no,output.metadata,output.created_at,output.task_id, media.public_url,media.mime_type,media.size_bytes,gt.estimated_points::text AS estimated_points, gt.actual_points::text AS actual_points,(a.image_asset_id=output.media_asset_id) AS active`). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=?", assetID.String()). Joins("JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL"). Joins("JOIN project_assets a ON a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID). Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Order("output.created_at DESC").Find(&items).Error return items, err } func (s *Creative) ActivateAssetOutput(userID, projectID, assetID, outputID uuid.UUID) error { result := s.DB.Exec(`UPDATE project_assets a SET image_asset_id=(SELECT media_asset_id FROM generation_outputs WHERE id=?),updated_at=CURRENT_TIMESTAMP WHERE a.id=? AND a.project_id=? AND a.deleted_at IS NULL AND EXISTS ( SELECT 1 FROM generation_outputs output JOIN generation_tasks gt ON gt.id=output.task_id JOIN creative_projects p ON p.id=a.project_id WHERE output.id=? AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=? AND p.user_id=? AND p.deleted_at IS NULL)`, outputID, assetID, projectID, outputID, assetID.String(), userID) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *Creative) DeleteAssetOutput(userID, projectID, assetID, outputID uuid.UUID) (string, error) { var objectKey string err := s.DB.Transaction(func(tx *gorm.DB) error { var row struct { TaskID uuid.UUID MediaID uuid.UUID ObjectKey string Status string } if err := tx.Table("generation_outputs output").Select("output.task_id,output.media_asset_id AS media_id,media.object_key,gt.status"). Joins("JOIN generation_tasks gt ON gt.id=output.task_id AND gt.task_type='image_generation' AND gt.input_data->>'asset_id'=?", assetID.String()). Joins("JOIN media_assets media ON media.id=output.media_asset_id"). Joins("JOIN project_assets a ON a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID). Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Where("output.id=?", outputID).Take(&row).Error; err != nil { return err } for _, status := range activeTaskStatuses { if status == row.Status { return errors.New("鍥剧墖浠嶅湪鐢熸垚涓紝鏆傛椂涓嶈兘鍒犻櫎鍘嗗彶璁板綍") } } if err := tx.Exec("UPDATE project_assets SET image_asset_id=NULL WHERE id=? AND image_asset_id=?", assetID, row.MediaID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_outputs WHERE id=?", outputID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM generation_tasks WHERE id=?", row.TaskID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id=?", row.MediaID).Error; err != nil { return err } if err := tx.Exec("DELETE FROM media_assets WHERE id=?", row.MediaID).Error; err != nil { return err } objectKey = row.ObjectKey return nil }) return objectKey, err } func (s *Creative) UpdateEpisodeAnalysisSettings(userID, projectID, episodeID uuid.UUID, audioSource, sourceLanguage string) error { if audioSource != "video_audio" && audioSource != "subtitle_file" { return errors.New("音频来源无效") } sourceLanguage = strings.TrimSpace(sourceLanguage) if len(sourceLanguage) > 16 { return errors.New("源语言设置无效") } return s.DB.Transaction(func(tx *gorm.DB) error { var episode model.ProjectEpisode if err := tx.Table("project_episodes e").Select("e.*"). Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID). Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil { return err } // Selecting subtitle mode is persisted even before the file is uploaded. // The workbench disables analysis until a subtitle asset is present. updates := map[string]any{"audio_source": audioSource, "source_language": nil} if sourceLanguage != "" { updates["source_language"] = sourceLanguage } return tx.Model(&episode).Updates(updates).Error }) }