package app import ( "encoding/json" "strings" "time" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" ) // TaskSummary 是任务列表/会话详情的读模型,不直接复用数据库 Task,避免把 // 渠道模型、供应线路和受保护输入泄露到普通用户接口。 type TaskSummary struct { ID string `json:"id"` ProjectID string `json:"projectId,omitempty"` Type string `json:"type"` Status model.TaskStatus `json:"status"` Stage string `json:"stage"` Progress int `json:"progress"` Prompt string `json:"prompt"` Operation string `json:"operation,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` ProviderRequestID string `json:"providerRequestId,omitempty"` ProviderCancelStatus model.ProviderCancelStatus `json:"providerCancelStatus,omitempty"` ProviderCancelError string `json:"providerCancelError,omitempty"` ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` ErrorCode string `json:"errorCode,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` PreviewKind string `json:"previewKind,omitempty"` PreviewPosterURL string `json:"previewPosterUrl,omitempty"` Attempts int `json:"attempts"` StartedAt *time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Billing *TaskBillingSummary `json:"billing,omitempty"` ClientContext *TaskClientContext `json:"clientContext,omitempty"` } type TaskClientContext struct { NodeID string `json:"nodeId,omitempty"` ConversationID string `json:"conversationId,omitempty"` MessageID string `json:"messageId,omitempty"` BatchIndex int `json:"batchIndex,omitempty"` BatchCount int `json:"batchCount,omitempty"` DomainProjectID string `json:"domainProjectId,omitempty"` ChapterID string `json:"chapterId,omitempty"` ChapterOperation string `json:"chapterOperation,omitempty"` ShotID string `json:"shotId,omitempty"` WorkflowStepID string `json:"workflowStepId,omitempty"` ArtifactType string `json:"artifactType,omitempty"` } type TaskBillingSummary struct { AmountMicrocredits int64 `json:"amountMicrocredits"` Status model.BillingStatus `json:"status"` } func taskSummariesForOutput(tasks []model.Task) []TaskSummary { return taskSummariesForOutputWithBilling(tasks, nil) } func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary { result := make([]TaskSummary, 0, len(tasks)) for _, task := range tasks { summary := taskSummaryForOutput(task) if order, ok := orders[task.ID]; ok { summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status} if summary.ProviderRequestID == "" { summary.ProviderRequestID = order.ProviderRequestID } } result = append(result, summary) } return result } func taskBillingTaskIDs(tasks []model.Task) []string { ids := make([]string, 0, len(tasks)) seen := map[string]struct{}{} for _, task := range tasks { if task.BillingOrderID == "" { continue } if _, ok := seen[task.ID]; ok { continue } seen[task.ID] = struct{}{} ids = append(ids, task.ID) } return ids } func taskSummaryForOutput(task model.Task) TaskSummary { errorCode := "" if isContentModerationFailure(task.Error) { errorCode = contentModerationErrorCode } previewURL, previewKind, previewPosterURL := taskMediaPreviewWithPoster(task.ResultJSON, task.Type) return TaskSummary{ ID: task.ID, ProjectID: task.ProjectID, Type: task.Type, Status: task.Status, Stage: task.Stage, Progress: task.Progress, Prompt: truncateRunes(task.Prompt, 500), Operation: task.Operation, Provider: task.Provider, Model: task.Model, ProviderRequestID: task.ProviderRequestID, ProviderCancelStatus: task.ProviderCancelStatus, ProviderCancelError: task.ProviderCancelError, ProviderCancelAttempts: task.ProviderCancelAttempts, ProviderCancelRequestedAt: task.ProviderCancelRequestedAt, ProviderCancelledAt: task.ProviderCancelledAt, ErrorCode: errorCode, PreviewURL: previewURL, PreviewKind: previewKind, PreviewPosterURL: previewPosterURL, Attempts: task.Attempts, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, ClientContext: taskClientContext(task.InputJSON), } } // 列表只暴露页面恢复所需的非敏感关联 ID,不下发完整任务输入或其他 metadata。 func taskClientContext(raw string) *TaskClientContext { var input struct { Metadata struct { Source string `json:"source"` NodeID string `json:"nodeId"` ConversationID string `json:"conversationId"` MessageID string `json:"messageId"` BatchIndex int `json:"batchIndex"` BatchCount int `json:"batchCount"` DomainProjectID string `json:"domainProjectId"` ChapterID string `json:"chapterId"` Operation string `json:"operation"` ShotID string `json:"shotId"` WorkflowStepID string `json:"workflowStepId"` ArtifactType string `json:"artifactType"` } `json:"metadata"` } if json.Unmarshal([]byte(raw), &input) != nil { return nil } metadata := input.Metadata context := &TaskClientContext{NodeID: metadata.NodeID} if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" { context.ConversationID = metadata.ConversationID context.MessageID = metadata.MessageID context.BatchIndex = metadata.BatchIndex context.BatchCount = metadata.BatchCount return context } if metadata.ShotID != "" && metadata.WorkflowStepID != "" { context.DomainProjectID = metadata.DomainProjectID context.ShotID = metadata.ShotID context.WorkflowStepID = metadata.WorkflowStepID context.ArtifactType = metadata.ArtifactType return context } chapterOperation := "" if metadata.Operation == "chapter_character_breakdown" { chapterOperation = "characters" } else if metadata.Source == "short-drama-chapter-storyboard" { chapterOperation = "storyboard" } if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" { if context.NodeID == "" { return nil } return context } context.DomainProjectID = metadata.DomainProjectID context.ChapterID = metadata.ChapterID context.ChapterOperation = chapterOperation return context } // 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。 func taskMediaPreview(raw string, taskType string) (string, string) { previewURL, previewKind, _ := taskMediaPreviewWithPoster(raw, taskType) return previewURL, previewKind } func taskMediaPreviewWithPoster(raw string, taskType string) (string, string, string) { if strings.TrimSpace(raw) == "" { return "", "", "" } var payload any if json.Unmarshal([]byte(raw), &payload) != nil { return "", "", "" } defaultKind := "image" if strings.Contains(strings.ToLower(taskType), "video") { defaultKind = "video" } previewURL, previewKind := findTaskMediaPreview(payload, defaultKind) posterURL := findTaskMediaPoster(payload) if previewKind == "image" && posterURL == "" { posterURL = previewURL } return previewURL, previewKind, posterURL } func findTaskMediaPoster(value any) string { switch item := value.(type) { case []any: for _, child := range item { if posterURL := findTaskMediaPoster(child); posterURL != "" { return posterURL } } case map[string]any: for _, key := range []string{"posterUrl", "posterURL", "thumbnailUrl", "thumbnailURL", "coverUrl", "coverURL", "poster", "thumbnail", "cover"} { child, exists := item[key] if !exists { continue } if previewURL, previewKind := findTaskMediaPreview(child, "image"); previewURL != "" && previewKind == "image" { return previewURL } } for _, child := range item { if posterURL := findTaskMediaPoster(child); posterURL != "" { return posterURL } } } return "" } func findTaskMediaPreview(value any, hint string) (string, string) { switch item := value.(type) { case string: text := strings.TrimSpace(item) if !isResourceFileURL(text) && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") { return "", "" } kind := hint lower := strings.ToLower(text) if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") { kind = "video" } else if kind != "video" { kind = "image" } return text, kind case []any: for _, child := range item { if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" { return previewURL, previewKind } } case map[string]any: for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} { child, exists := item[key] if !exists { continue } childHint := hint if key == "video" { childHint = "video" } else if key == "images" || key == "image" { childHint = "image" } if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" { return previewURL, previewKind } } } return "", "" } func truncateRunes(value string, limit int) string { return kernel.TruncateRunes(value, limit) } func taskForOutput(task model.Task) *model.Task { task.InputJSON = publicTaskInputJSON(task.InputJSON) // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 task.LogicalModelRevisionID = "" task.RouteID = "" task.ChannelModelID = "" return &task } func publicTaskInputJSON(raw string) string { if strings.TrimSpace(raw) == "" { return "" } var input map[string]any if err := json.Unmarshal([]byte(raw), &input); err != nil { return "" } public := map[string]any{} // 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。 for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} { if value, ok := input[key]; ok { public[key] = value } } if len(public) == 0 { return "" } data, _ := json.Marshal(public) return string(data) }