初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+665
View File
@@ -0,0 +1,665 @@
package worker
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"sync/atomic"
"time"
"juhe-factory/api/internal/billing"
dramapkg "juhe-factory/api/internal/drama"
"juhe-factory/api/internal/model"
"juhe-factory/api/internal/provider"
"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"
)
var (
errDramaCancelled = errors.New("drama parse cancelled")
errDramaInactive = errors.New("drama parse inactive")
)
const maxDramaModelAttempts = 5
type DramaParse struct {
DB *gorm.DB
Queue *asynq.Client
Encryptor *security.Encryptor
Provider apimart.Client
WorkerID string
}
type dramaParseSnapshot struct {
Task model.DramaParseTask
Source model.EpisodeSource
EpisodeNo int
EpisodeName string
ProjectName string
StyleName string
EraType string
CustomEra string
ModelName string
BaseURL string
APIKeyCiphertext string
}
type dramaParseConfig struct {
EpisodeNo int
EpisodeName string
ProjectName string
StyleName string
EraType string
CustomEra string
ModelName string
BaseURL string
APIKeyCiphertext string
}
type parsedEntity struct {
CanonicalName string `json:"canonical_name"`
Name string `json:"name"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
ImagePrompt string `json:"image_prompt"`
Attributes map[string]any `json:"attributes"`
}
type parsedEntities struct {
Characters []parsedEntity `json:"characters"`
Roles []parsedEntity `json:"roles"`
Scenes []parsedEntity `json:"scenes"`
Props []parsedEntity `json:"props"`
}
type parsedStoryboard struct {
Title string `json:"title"`
ScriptContent string `json:"script_content"`
Content string `json:"content"`
PromptContent string `json:"prompt_content"`
Dialogue []map[string]any `json:"dialogue"`
AssetNames struct {
Characters []string `json:"characters"`
Scenes []string `json:"scenes"`
Props []string `json:"props"`
} `json:"asset_names"`
DurationSeconds int `json:"duration_seconds"`
SourceExcerpt string `json:"source_excerpt"`
}
type dramaParsedResult struct {
Entities parsedEntities `json:"entities"`
Storyboards []parsedStoryboard `json:"storyboards"`
}
type tokenTotals struct {
Input, Output int64
Source string
Segments []map[string]any
}
func NewDramaParse(db *gorm.DB, queue *asynq.Client, encryptor *security.Encryptor, client *http.Client) *DramaParse {
return &DramaParse{DB: db, Queue: queue, Encryptor: encryptor, Provider: apimart.NewClient(dramaHTTPClient(client)), WorkerID: uuid.NewString()}
}
func dramaHTTPClient(client *http.Client) *http.Client {
cloned := *client
if transport, ok := client.Transport.(*http.Transport); ok {
transport = transport.Clone()
transport.TLSHandshakeTimeout = 30 * time.Second
transport.ResponseHeaderTimeout = 10 * time.Minute
cloned.Transport = transport
}
return &cloned
}
func (w *DramaParse) Register(mux *asynq.ServeMux) {
mux.HandleFunc(queuepkg.TypeParseDramaEpisode, w.parseEpisode)
}
// Recover 恢复排队、等待重试或租约已过期的剧本解析任务。
func (w *DramaParse) Recover(ctx context.Context) error {
var tasks []model.DramaParseTask
if err := w.DB.WithContext(ctx).Where("status='queued' OR (status IN ('running','retry_wait') AND (lease_until IS NULL OR lease_until<?))", time.Now()).Find(&tasks).Error; err != nil {
return err
}
var firstErr error
for _, task := range tasks {
w.DB.Model(&model.DramaParseTask{}).Where("id=?", task.ID).Updates(map[string]any{"status": "queued", "lease_owner": nil, "lease_until": nil})
if err := queuepkg.EnqueueID(w.Queue, queuepkg.TypeParseDramaEpisode, task.ID, 0); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (w *DramaParse) parseEpisode(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, errDramaCancelled) {
return w.cancel(snapshot.Task)
}
if errors.Is(err, errDramaInactive) || snapshot.Task.ID == uuid.Nil {
return nil
}
return w.fail(snapshot.Task, "task_claim_failed", err.Error())
}
apiKey, err := w.Encryptor.Decrypt(snapshot.APIKeyCiphertext)
if err != nil {
return w.fail(snapshot.Task, "channel_secret_error", "渠道密钥不可用")
}
workCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
defer cancel()
var cancelled atomic.Bool
go w.watchCancellation(workCtx, cancel, taskID, &cancelled)
result, tokens, contextSnapshot, err := w.callModel(workCtx, snapshot, apiKey)
if err != nil {
if cancelled.Load() {
return w.cancel(snapshot.Task, tokens)
}
return w.fail(snapshot.Task, classifyDramaError(err), err.Error(), tokens)
}
if cancelled.Load() {
return w.cancel(snapshot.Task, tokens)
}
if err = w.persist(snapshot, result, tokens, contextSnapshot); err != nil {
if errors.Is(err, errDramaCancelled) {
return w.cancel(snapshot.Task, tokens)
}
return w.fail(snapshot.Task, "result_persist_failed", err.Error())
}
return nil
}
func (w *DramaParse) claim(ctx context.Context, taskID uuid.UUID) (dramaParseSnapshot, error) {
var snapshot dramaParseSnapshot
err := w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task model.DramaParseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).Take(&task).Error; err != nil {
return err
}
snapshot.Task = task
if task.Status == "cancel_requested" {
return errDramaCancelled
}
if task.Status != "queued" && task.Status != "retry_wait" {
return errDramaInactive
}
now := time.Now()
lease := now.Add(13 * time.Minute)
if err := tx.Model(&task).Updates(map[string]any{"status": "running", "attempt_count": gorm.Expr("attempt_count+1"), "started_at": now, "lease_owner": w.WorkerID, "lease_until": lease, "error_code": nil, "error_message": nil}).Error; err != nil {
return err
}
if err := tx.Model(&model.ProjectEpisode{}).Where("id=?", task.EpisodeID).Update("analysis_message", "剧本解析中").Error; err != nil {
return err
}
snapshot.Task.Status = "running"
if err := tx.Where("episode_id=? AND content_sha256=?", task.EpisodeID, task.SourceSHA256).Take(&snapshot.Source).Error; err != nil {
return err
}
var config dramaParseConfig
result := tx.Raw(`SELECT episode.episode_no,episode.name AS episode_name,project.name AS project_name,style.name AS style_name,
project.era_type,coalesce(project.custom_era,'') AS custom_era,model.name AS model_name,channel.base_url,channel.api_key_ciphertext
FROM project_episodes episode JOIN creative_projects project ON project.id=episode.project_id
JOIN project_styles style ON style.id=project.style_id
JOIN models model ON model.id=? AND model.enabled=true AND model.deleted_at IS NULL JOIN channels channel ON channel.id=model.channel_id AND channel.id=? AND channel.enabled=true AND channel.deleted_at IS NULL
WHERE episode.id=? AND project.id=? AND project.project_type='premium_drama'`, task.ModelID, task.ChannelID, task.EpisodeID, task.ProjectID).Scan(&config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("解析模型或渠道配置不可用")
}
snapshot.EpisodeNo = config.EpisodeNo
snapshot.EpisodeName = config.EpisodeName
snapshot.ProjectName = config.ProjectName
snapshot.StyleName = config.StyleName
snapshot.EraType = config.EraType
snapshot.CustomEra = config.CustomEra
snapshot.ModelName = config.ModelName
snapshot.BaseURL = config.BaseURL
snapshot.APIKeyCiphertext = config.APIKeyCiphertext
return nil
})
return snapshot, err
}
func (w *DramaParse) 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:
var status string
if w.DB.Model(&model.DramaParseTask{}).Where("id=?", taskID).Pluck("status", &status).Error == nil && status == "cancel_requested" {
flag.Store(true)
cancel()
return
}
}
}
}
func (w *DramaParse) callModel(ctx context.Context, snapshot dramaParseSnapshot, apiKey string) (dramaParsedResult, tokenTotals, json.RawMessage, error) {
if err := dramapkg.ValidateEpisodeContent(snapshot.Source.RawContent); err != nil {
return dramaParsedResult{}, tokenTotals{}, nil, err
}
assets, err := w.buildAssetContext(snapshot.Task.ProjectID)
if err != nil {
return dramaParsedResult{}, tokenTotals{}, nil, err
}
contextJSON, _ := json.Marshal(map[string]any{"assets": assets})
userContent := buildDramaUserPrompt(snapshot, snapshot.Source.RawContent, assets)
payload := map[string]any{
"model": snapshot.ModelName,
"messages": []any{map[string]any{"role": "system", "content": snapshot.Task.PromptSnapshot}, map[string]any{"role": "user", "content": userContent}},
"temperature": 0.2,
"response_format": map[string]any{"type": "json_object"},
}
pricing, err := billing.ParseTextPricingSnapshot(snapshot.Task.BillingSnapshot)
if err != nil {
return dramaParsedResult{}, tokenTotals{}, contextJSON, err
}
if pricing.Mode == billing.TextBillingPerToken {
if err := w.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_, reserveErr := billing.ReserveTextCall(tx, snapshot.Task.UserID, snapshot.Task.ID.String(), "parse", pricing, billing.EstimateTextTokens(snapshot.Task.PromptSnapshot+userContent), "剧本解析")
return reserveErr
}); err != nil {
return dramaParsedResult{}, tokenTotals{}, contextJSON, err
}
}
chat, callErr := w.chatWithRetry(ctx, snapshot.BaseURL, apiKey, payload)
if callErr != nil {
return dramaParsedResult{}, tokenTotals{}, contextJSON, callErr
}
input, output := chat.InputTokens, chat.OutputTokens
source := "upstream"
if input == 0 {
input = billing.EstimateTextTokens(snapshot.Task.PromptSnapshot + userContent)
source = "local"
}
if output == 0 {
output = billing.EstimateTextTokens(chat.Content)
if source == "upstream" {
source = "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(), "parse", pricing, billing.TextUsage{Input: chat.InputTokens, Output: chat.OutputTokens}, "剧本解析")
if settleErr != nil {
return settleErr
}
if err := tx.Model(&model.DramaParseTask{}).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),
"input_tokens": chat.InputTokens, "output_tokens": chat.OutputTokens, "total_tokens": chat.InputTokens + chat.OutputTokens,
"token_count_source": source, "usage_raw": gorm.Expr("?::jsonb", string(chat.UsageRaw)),
}).Error; err != nil {
return err
}
return nil
}); err != nil {
return dramaParsedResult{}, tokenTotals{}, contextJSON, err
}
}
usage := map[string]any{"index": 1, "input_tokens": input, "output_tokens": output, "source": source}
if len(chat.UsageRaw) > 0 {
var upstreamUsage any
if json.Unmarshal(chat.UsageRaw, &upstreamUsage) == nil {
usage["upstream_usage"] = upstreamUsage
}
}
totals := tokenTotals{Input: input, Output: output, Source: source, Segments: []map[string]any{usage}}
parsed, parseErr := parseDramaJSON(chat.Content)
if parseErr != nil {
finishReason := strings.TrimSpace(chat.FinishReason)
if finishReason == "" {
finishReason = "未知"
}
return dramaParsedResult{}, totals, contextJSON, fmt.Errorf("文本模型返回无法解析(结束原因:%s): %w", finishReason, parseErr)
}
if len(parsed.Storyboards) == 0 {
return parsed, totals, contextJSON, errors.New("文本模型未返回有效分镜")
}
return parsed, totals, contextJSON, nil
}
func (w *DramaParse) chatWithRetry(ctx context.Context, baseURL, apiKey string, payload map[string]any) (apimart.ChatResult, error) {
var result apimart.ChatResult
var err error
for attempt := 0; attempt < maxDramaModelAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return result, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * time.Second):
}
}
result, err = w.Provider.ChatWithUsage(ctx, baseURL, apiKey, payload)
if err == nil {
return result, nil
}
var httpErr *provider.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode < 500 && httpErr.StatusCode != http.StatusTooManyRequests {
return result, err
}
}
return result, err
}
func (w *DramaParse) buildAssetContext(projectID uuid.UUID) ([]map[string]any, error) {
assets := make([]map[string]any, 0)
if err := w.DB.Table("project_assets").Select("asset_type,name,aliases,description,attributes").Where("project_id=? AND deleted_at IS NULL", projectID).Order("asset_type,name").Find(&assets).Error; err != nil {
return nil, err
}
return assets, nil
}
func buildDramaUserPrompt(snapshot dramaParseSnapshot, source string, assets []map[string]any) string {
assetsJSON, _ := json.Marshal(assets)
return fmt.Sprintf("项目:%s\n视觉风格:%s\n故事时代:%s\n当前剧集:第%d集 %s\n项目资产:\n%s\n\n当前原文:\n%s\n\n解析要求:必须按上述视觉风格和故事时代解析角色、场景、道具及分镜,并在资产的description、image_prompt和分镜的prompt_content中体现与之一致的可视化特征。\n只返回合法JSON,顶层只能包含entities和storyboards。entities包含characters、scenes、props,每个资产包含name、description、image_promptstoryboards每项包含title、script_content、prompt_content、dialogue、asset_names、duration_seconds、source_excerpt。禁止返回摘要或其他顶层字段,不要返回Markdown或解释。", snapshot.ProjectName, snapshot.StyleName, dramaEraName(snapshot.EraType, snapshot.CustomEra), snapshot.EpisodeNo, snapshot.EpisodeName, string(assetsJSON), source)
}
func dramaEraName(eraType, customEra string) string {
if eraType == "other" && strings.TrimSpace(customEra) != "" {
return strings.TrimSpace(customEra)
}
if name := map[string]string{
"modern_city": "现代都市",
"ancient_history": "古代历史",
"ancient_fantasy": "古代玄幻",
"ancient_xianxia": "古代仙侠",
"future_scifi": "未来科幻",
}[eraType]; name != "" {
return name
}
return eraType
}
func parseDramaJSON(value string) (dramaParsedResult, error) {
value = strings.TrimSpace(value)
think := regexp.MustCompile(`(?s)<think>.*?</think>`)
value = think.ReplaceAllString(value, "")
value = strings.TrimPrefix(value, "```json")
value = strings.TrimPrefix(value, "```")
value = strings.TrimSuffix(value, "```")
value = strings.TrimSpace(value)
start := strings.Index(value, "{")
end := strings.LastIndex(value, "}")
if start < 0 || end <= start {
return dramaParsedResult{}, errors.New("文本模型返回内容不是JSON")
}
var result dramaParsedResult
if err := json.Unmarshal([]byte(value[start:end+1]), &result); err != nil {
return result, fmt.Errorf("文本模型返回JSON无效: %w", err)
}
result.Entities.Characters = append(result.Entities.Characters, result.Entities.Roles...)
valid := result.Storyboards[:0]
for _, item := range result.Storyboards {
item.ScriptContent = strings.TrimSpace(firstNonEmpty(item.ScriptContent, item.Content))
item.PromptContent = strings.TrimSpace(item.PromptContent)
if item.ScriptContent == "" && item.PromptContent == "" {
continue
}
if item.DurationSeconds < 5 || item.DurationSeconds > 15 {
item.DurationSeconds = 5
}
valid = append(valid, item)
}
result.Storyboards = valid
return result, nil
}
func (w *DramaParse) persist(snapshot dramaParseSnapshot, result dramaParsedResult, tokens tokenTotals, contextSnapshot json.RawMessage) error {
normalized, _ := json.Marshal(result)
usage, _ := json.Marshal(map[string]any{"segments": tokens.Segments})
return w.DB.Transaction(func(tx *gorm.DB) error {
var task model.DramaParseTask
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 errDramaCancelled
}
if task.Status != "running" {
return errors.New("任务状态已变化")
}
assetIDs, err := w.upsertEntities(tx, snapshot, result.Entities)
if err != nil {
return err
}
now := time.Now()
protected := make([]model.EpisodeStoryboard, 0)
if err := tx.Where("episode_id=? AND deleted_at IS NULL AND user_edited=true", snapshot.Task.EpisodeID).Find(&protected).Error; err != nil {
return err
}
protectedBySequence := make(map[int]model.EpisodeStoryboard, len(protected))
for _, storyboard := range protected {
protectedBySequence[storyboard.SequenceNo] = storyboard
}
if err := tx.Model(&model.EpisodeStoryboard{}).Where("episode_id=? AND deleted_at IS NULL AND user_edited=false", snapshot.Task.EpisodeID).Update("deleted_at", now).Error; err != nil {
return err
}
startMS := int64(0)
for index, item := range result.Storyboards {
sequence := index + 1
if existing, ok := protectedBySequence[sequence]; ok {
duration := existing.DurationSeconds
if duration < 5 || duration > 15 {
duration = 5
}
endMS := startMS + int64(duration*1000)
if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", existing.ID).Updates(map[string]any{"start_ms": startMS, "end_ms": endMS, "duration_seconds": duration}).Error; err != nil {
return err
}
startMS = endMS
continue
}
duration := item.DurationSeconds
if duration < 5 || duration > 15 {
duration = 5
}
refs := buildAssetRefs(item, assetIDs)
refsJSON, _ := json.Marshal(refs)
dialogueJSON, _ := json.Marshal(item.Dialogue)
endMS := startMS + int64(duration*1000)
storyboard := model.EpisodeStoryboard{ID: uuid.New(), EpisodeID: &snapshot.Task.EpisodeID, SequenceNo: sequence, StableKey: uuid.NewString(), StartMS: startMS, EndMS: endMS, DurationSeconds: duration, Title: truncateWorker(item.Title, 160), ScriptContent: item.ScriptContent, PromptContent: firstNonEmpty(item.PromptContent, item.ScriptContent), SourceExcerpt: item.SourceExcerpt, Dialogue: dialogueJSON, AssetRefs: refsJSON, Locked: false, Status: "idle", SourceParseTaskID: &task.ID}
if err := tx.Create(&storyboard).Error; err != nil {
return err
}
startMS = endMS
}
total := tokens.Input + tokens.Output
finished := time.Now()
if err := tx.Model(&task).Updates(map[string]any{"status": "succeeded", "normalized_result": gorm.Expr("?::jsonb", string(normalized)), "context_snapshot": gorm.Expr("?::jsonb", string(contextSnapshot)), "input_tokens": tokens.Input, "output_tokens": tokens.Output, "total_tokens": total, "token_count_source": tokens.Source, "usage_raw": gorm.Expr("?::jsonb", string(usage)), "actual_points": task.PrepaidPoints, "request_char_count": len([]rune(snapshot.Source.RawContent)), "response_char_count": len([]rune(string(normalized))), "finished_at": finished, "lease_owner": nil, "lease_until": nil}).Error; err != nil {
return err
}
return tx.Model(&model.ProjectEpisode{}).Where("id=?", snapshot.Task.EpisodeID).Updates(map[string]any{"status": "review", "analysis_message": "剧本解析完成"}).Error
})
}
func (w *DramaParse) upsertEntities(tx *gorm.DB, snapshot dramaParseSnapshot, entities parsedEntities) (map[string]uuid.UUID, error) {
result := map[string]uuid.UUID{}
groups := []struct {
Type string
Items []parsedEntity
}{{"character", entities.Characters}, {"scene", entities.Scenes}, {"prop", entities.Props}}
for _, group := range groups {
for _, item := range group.Items {
rawName := strings.TrimSpace(firstNonEmpty(item.CanonicalName, item.Name))
if rawName == "" {
continue
}
imagePrompt := strings.TrimSpace(firstNonEmpty(item.ImagePrompt, item.Description))
name := truncateWorker(rawName, 200)
aliasValues := append([]string{}, item.Aliases...)
if rawName != name {
aliasValues = append(aliasValues, rawName)
}
var existing model.ProjectAsset
err := tx.Where("project_id=? AND asset_type=? AND lower(name)=lower(?) AND deleted_at IS NULL", snapshot.Task.ProjectID, group.Type, name).Take(&existing).Error
aliases, _ := json.Marshal(uniqueStrings(aliasValues))
attributes, _ := json.Marshal(item.Attributes)
if errors.Is(err, gorm.ErrRecordNotFound) {
existing = model.ProjectAsset{ID: uuid.New(), ProjectID: snapshot.Task.ProjectID, SourceEpisodeID: &snapshot.Task.EpisodeID, AssetType: group.Type, Name: name, Description: item.Description, ImagePrompt: imagePrompt, AIDescription: item.Description, Aliases: aliases, Attributes: attributes, Appearances: json.RawMessage("[]"), SourceParseTaskID: &snapshot.Task.ID}
if err = tx.Create(&existing).Error; err != nil {
return nil, err
}
} else if err != nil {
return nil, err
} else {
updates := map[string]any{"ai_description": item.Description, "aliases": gorm.Expr("?::jsonb", string(mergeJSONArray(existing.Aliases, aliasValues))), "source_parse_task_id": snapshot.Task.ID}
if !existing.UserEdited {
updates["description"] = item.Description
updates["image_prompt"] = imagePrompt
updates["attributes"] = gorm.Expr("?::jsonb", string(attributes))
}
if err = tx.Model(&existing).Updates(updates).Error; err != nil {
return nil, err
}
}
result[group.Type+":"+strings.ToLower(rawName)] = existing.ID
result[group.Type+":"+strings.ToLower(name)] = existing.ID
for _, alias := range aliasValues {
result[group.Type+":"+strings.ToLower(strings.TrimSpace(alias))] = existing.ID
}
}
}
return result, nil
}
func buildAssetRefs(item parsedStoryboard, ids map[string]uuid.UUID) []map[string]any {
refs := make([]map[string]any, 0)
groups := []struct {
Type string
Names []string
}{{"character", item.AssetNames.Characters}, {"scene", item.AssetNames.Scenes}, {"prop", item.AssetNames.Props}}
seen := map[uuid.UUID]bool{}
for _, group := range groups {
for _, name := range group.Names {
if id, ok := ids[group.Type+":"+strings.ToLower(strings.TrimSpace(name))]; ok && !seen[id] {
refs = append(refs, map[string]any{"id": id, "name": name, "type": group.Type})
seen[id] = true
}
}
}
return refs
}
func (w *DramaParse) fail(task model.DramaParseTask, code, message string, tokenUsage ...tokenTotals) error {
now := time.Now()
return w.DB.Transaction(func(tx *gorm.DB) error {
refunded, err := billing.RefundTextCalls(tx, task.UserID, task.ID.String(), "剧本解析失败返还")
if err != nil {
return err
}
updates := map[string]any{"status": "failed", "actual_points": "0.00", "error_code": code, "error_message": truncateWorker(message, 4000), "finished_at": now, "lease_owner": nil, "lease_until": nil}
if refunded {
updates["cost_refunded"] = true
}
if len(tokenUsage) > 0 {
addTokenUpdates(updates, tokenUsage[0])
}
if err := tx.Model(&model.DramaParseTask{}).Where("id=? AND status<>'cancelled'", task.ID).Updates(updates).Error; err != nil {
return err
}
return tx.Model(&model.ProjectEpisode{}).Where("id=?", task.EpisodeID).Updates(map[string]any{"status": "failed", "analysis_message": truncateWorker(message, 1000)}).Error
})
}
func (w *DramaParse) cancel(task model.DramaParseTask, tokenUsage ...tokenTotals) error {
now := time.Now()
return w.DB.Transaction(func(tx *gorm.DB) error {
amount, err := billing.CommitTextReserves(tx, task.UserID, task.ID.String(), "剧本解析取消")
if err != nil {
return err
}
updates := map[string]any{"status": "cancelled", "actual_points": amount, "prepaid_points": amount, "finished_at": now, "lease_owner": nil, "lease_until": nil, "error_code": "user_cancelled", "error_message": "用户主动取消,费用不退"}
if len(tokenUsage) > 0 {
addTokenUpdates(updates, tokenUsage[0])
}
if err := tx.Model(&model.DramaParseTask{}).Where("id=?", task.ID).Updates(updates).Error; err != nil {
return err
}
return tx.Model(&model.ProjectEpisode{}).Where("id=?", task.EpisodeID).Updates(map[string]any{"status": "draft", "analysis_message": "解析已取消"}).Error
})
}
func addTokenUpdates(updates map[string]any, tokens tokenTotals) {
if tokens.Input == 0 && tokens.Output == 0 {
return
}
usage, _ := json.Marshal(map[string]any{"segments": tokens.Segments})
updates["input_tokens"] = tokens.Input
updates["output_tokens"] = tokens.Output
updates["total_tokens"] = tokens.Input + tokens.Output
updates["token_count_source"] = tokens.Source
updates["usage_raw"] = gorm.Expr("?::jsonb", string(usage))
}
func estimateTextTokens(value string) int64 {
return billing.EstimateTextTokens(value)
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
key := strings.ToLower(value)
if value != "" && !seen[key] {
seen[key] = true
out = append(out, value)
}
}
return out
}
func mergeJSONArray(raw json.RawMessage, values []string) json.RawMessage {
var existing []string
_ = json.Unmarshal(raw, &existing)
encoded, _ := json.Marshal(uniqueStrings(append(existing, values...)))
return encoded
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func normalizeCompare(value string) string {
return strings.Join(strings.Fields(strings.ToLower(value)), "")
}
func truncateWorker(value string, limit int) string {
r := []rune(value)
if len(r) > limit {
return string(r[:limit])
}
return value
}
func classifyDramaError(err error) string {
var httpErr *provider.HTTPError
if errors.As(err, &httpErr) {
return "upstream_failed"
}
if errors.Is(err, context.DeadlineExceeded) {
return "upstream_timeout"
}
return "parse_failed"
}