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

299 lines
12 KiB
Go

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
}