package app import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "sync" "time" "unicode/utf8" "infinite-canvas/backend/internal/auth" "infinite-canvas/backend/internal/canvas" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/payment" "infinite-canvas/backend/internal/platform" "infinite-canvas/backend/internal/prompts" "infinite-canvas/backend/internal/repository" "infinite-canvas/backend/internal/skills" "infinite-canvas/backend/internal/sms" "infinite-canvas/backend/internal/tools" ) type Service struct { repo *repository.Repository dataDir string cancelMu sync.Mutex registrationMu sync.Mutex emailCodeMu sync.Mutex redeemBatchMu sync.Mutex storageMu sync.Mutex storageTestMu sync.Mutex workerRuntimeMu sync.Mutex agentSchedulerMu sync.Mutex agentSchedulerWake chan struct{} taskDispatcherWake chan struct{} geminiCacheLockMu sync.Mutex agentSchedulerCursor string agentConflictStreak map[string]int activeStorageTests map[string]bool characterTaskMu sync.Mutex activeCancels map[string]context.CancelFunc pendingStorage map[string]int64 coordinator *platform.Coordinator platform *platform.Service taskBillingCoordinator *taskBillingCoordinator taskTerminalCoordinator *taskTerminalCoordinator taskRouteExecutor *taskRouteExecutor taskWorkerCoordinator *taskWorkerCoordinator taskLifecycleCoordinator *taskLifecycleCoordinator runtimeErr error pluginRuntime *pluginRuntime pluginRuntimeErr error paymentRegistry *payment.Registry workerID string routeCatalogMu sync.RWMutex routeCatalogRefreshMu sync.Mutex routeCatalog *routeCatalogSnapshot routeCatalogTTL time.Duration routeCatalogMaxStale time.Duration routeCatalogVersion int64 routeHealthMu sync.Mutex routeHealthBlocked map[string]time.Time workers *platform.Worker updateManager UpdateManager readCachesOnce sync.Once concurrencyReadCache *platform.BoundedReadCache[string, platform.RuntimeTaskPolicy] textReplayReadCache *platform.BoundedReadCache[textReplayCacheKey, *TextReplayResult] routeVersionReadCache *platform.BoundedReadCache[string, int64] geminiCacheLocks map[string]*geminiCacheKeyLock routeCatalogRetryAt time.Time routeCatalogRefreshError error skills *skills.Service tools *tools.Service prompts *prompts.Service auth *auth.Service sms *sms.Service canvas *canvas.Service piRunnerMu sync.Mutex piRunnerWg sync.WaitGroup piRunners map[string]context.CancelFunc // piRunnerRestarts 记录审批恢复时旧会话仍在收尾的运行,旧会话退出后再启动一次。 piRunnerRestarts map[string]struct{} piRunnersClosed bool disablePiRuntime bool // legacyCloudAgentRootTask is enabled only by tests that exercise the pre-Pi // model-worker path. Runtime availability must not change root task semantics. legacyCloudAgentRootTask bool approvedMediaMu sync.Mutex approvedMediaWg sync.WaitGroup approvedMediaWaiters map[string]context.CancelFunc approvedMediaClosed bool } const taskWorkerConcurrency = 3 const taskLogPayloadLimit = 4000 type CreateTaskRequest struct { creationPrepare *creationTaskPreparation admission *taskAdmission ProjectID string `json:"projectId"` Type string `json:"type"` Operation string `json:"operation"` Prompt string `json:"prompt"` Provider string `json:"provider"` Model string `json:"model"` LogicalModelID string `json:"logicalModelId"` Input map[string]any `json:"input"` TraceID string `json:"-"` RequestID string `json:"-"` } type TaskListOptions struct { Limit int ProjectID string ActiveOnly bool } func New(repo *repository.Repository, dataDir string) *Service { return newService(repo, dataDir) } func newService(repo *repository.Repository, dataDir string) *Service { coordinator, err := platform.NewCoordinator(repo.Dialect()) pluginRuntime, pluginRuntimeErr := newPluginRuntime(dataDir) paymentRegistry, _ := payment.NewRegistry() if pluginRuntime != nil { if dynamic := pluginRuntime.paymentRegistrySnapshot(); dynamic != nil { paymentRegistry = dynamic } } service := &Service{repo: repo, dataDir: dataDir, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), agentConflictStreak: make(map[string]int), agentSchedulerWake: make(chan struct{}, 1), taskDispatcherWake: make(chan struct{}, 1), piRunners: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: paymentRegistry, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time), geminiCacheLocks: make(map[string]*geminiCacheKeyLock)} service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo) service.taskTerminalCoordinator = newTaskTerminalCoordinator(service) service.taskRouteExecutor = newTaskRouteExecutor(service) service.taskWorkerCoordinator = newTaskWorkerCoordinator(service) service.taskLifecycleCoordinator = newTaskLifecycleCoordinator(service) service.skills = skills.New(service.repo, service.dataDir, service.runWorkerLoop) service.tools = tools.New(service.repo) service.prompts = prompts.New(service.repo, promptAdminGate{svc: service}) service.auth = auth.New(service.repo, authHost{svc: service}, nil) service.sms = service.newSMSDomain() service.auth.SetSMSDelivery(service.sms) service.canvas = canvas.New(service.repo, canvasHost{svc: service}) service.platform = platform.New(service.repo, coordinator, platformHost{svc: service}) return service } func (s *Service) taskBilling() *taskBillingCoordinator { if s.taskBillingCoordinator != nil { return s.taskBillingCoordinator } // 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。 return newTaskBillingCoordinator(s.repo) } func (s *Service) StartWorker() { runtime := s.backgroundWorkers() ctx, started := runtime.Start() if !started { return } s.taskWorker().start(ctx) s.startResourceDeletionWorker(ctx) s.startSkillSyncWorker(ctx) s.startPaymentWorker(ctx) go s.syncAgentSessionLimit() s.runWorkerLoop(func(ctx context.Context) { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() for { s.recoverCloudAgentPiRunners() select { case <-ctx.Done(): return case <-ticker.C: } } }) } func (s *Service) syncAgentSessionLimit() { policy, err := s.RuntimePolicy() if err != nil { slog.Warn("agent session limit load failed", "error", err) return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := s.applyAgentSessionLimit(ctx, policy.Task.AgentMaxSessions); err != nil { slog.Warn("agent session limit apply failed", "error", err) } } func (s *Service) BeginDrain() { s.backgroundWorkers().BeginDrain() } func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().Stop(ctx) } func (s *Service) IsDraining() bool { return s.backgroundWorkers().IsDraining() } func (s *Service) ActiveWorkerTasks() int64 { return s.backgroundWorkers().ActiveTaskCount() } func (s *Service) backgroundWorkers() *platform.Worker { s.workerRuntimeMu.Lock() defer s.workerRuntimeMu.Unlock() if s.workers == nil { s.workers = &platform.Worker{} } return s.workers } func (s *Service) runWorkerLoop(fn func(context.Context)) bool { return s.backgroundWorkers().GoLoop(fn) } func (s *Service) runWorkerTask(fn func()) bool { return s.backgroundWorkers().GoTask(fn) } func channelModelNames(channel model.ModelChannel) []string { models := []string{} _ = json.Unmarshal([]byte(channel.ModelsJSON), &models) return uniqueNonEmpty(models) } func (s *Service) Tasks(userID string, limit int) ([]TaskSummary, error) { return s.TasksWithOptions(userID, TaskListOptions{Limit: limit}) } func (s *Service) TasksWithOptions(userID string, options TaskListOptions) ([]TaskSummary, error) { tasks, err := s.repo.Tasks(userID, options.Limit, options.ProjectID, options.ActiveOnly) if err != nil { return nil, err } orders, err := s.repo.BillingOrdersByTaskIDs(userID, taskBillingTaskIDs(tasks)) if err != nil { return nil, err } // Active task lists are the primary source for the canvas cancel affordance. // The worker may have already logged an upstream request while the task row // is waiting for its next lease update, so hydrate the list read model from // the API log in one query instead of briefly exposing a stale cancel button. taskIDs := make([]string, 0, len(tasks)) for _, task := range tasks { taskIDs = append(taskIDs, task.ID) } providerRequestIDs, err := s.repo.LatestProviderRequestIDsForTasks(taskIDs) if err != nil { return nil, err } for index := range tasks { if strings.TrimSpace(tasks[index].ProviderRequestID) != "" { continue } if order, ok := orders[tasks[index].ID]; ok && strings.TrimSpace(order.ProviderRequestID) != "" { tasks[index].ProviderRequestID = strings.TrimSpace(order.ProviderRequestID) continue } tasks[index].ProviderRequestID = providerRequestIDs[tasks[index].ID] } return taskSummariesForOutputWithBilling(tasks, orders), nil } func (s *Service) Task(userID string, id string) (*model.Task, error) { task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err } s.hydrateTaskProviderRequestID(task) return taskForOutput(*task), nil } func (s *Service) hydrateTaskProviderRequestID(task *model.Task) { if task == nil || task.ProviderRequestID != "" { return } if task.BillingOrderID != "" { if order, err := s.repo.BillingOrder(task.BillingOrderID); err == nil { task.ProviderRequestID = strings.TrimSpace(order.ProviderRequestID) } } if task.ProviderRequestID == "" { if providerRequestID, err := s.repo.LatestProviderRequestIDForTask(task.ID); err == nil { task.ProviderRequestID = providerRequestID } } } // 上游请求日志会在任务执行期间更新 provider 状态,终态保存前必须重新合并,避免旧任务对象覆盖可恢复 ID。 func (s *Service) refreshTaskProviderState(task *model.Task) error { if task == nil || task.ID == "" { return errors.New("任务状态无效") } latest, err := s.repo.Task(task.ID) if err != nil { return fmt.Errorf("刷新任务上游状态失败:%w", err) } if latest.ProviderRequestID != "" { task.ProviderRequestID = latest.ProviderRequestID } task.PollStage = latest.PollStage task.NextPollAt = latest.NextPollAt task.ProviderCancelStatus = latest.ProviderCancelStatus task.ProviderCancelError = latest.ProviderCancelError task.ProviderCancelAttempts = latest.ProviderCancelAttempts task.ProviderCancelRequestedAt = latest.ProviderCancelRequestedAt task.ProviderCancelledAt = latest.ProviderCancelledAt task.ProviderCancelNextCheckAt = latest.ProviderCancelNextCheckAt return nil } func (s *Service) RetryTask(userID string, id string) (*model.Task, error) { return s.taskLifecycle().retryTask(userID, id) } func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*model.Task, error) { return s.taskLifecycle().cancelTask(ctx, userID, id) } func (s *Service) TaskLogs(userID string, id string) ([]model.TaskLog, error) { return s.repo.TaskLogs(userID, id) } func (s *Service) ProcessNextTask() error { return s.taskWorker().processNextTask() } func shotIDs(prefix string, count int) []string { ids := make([]string, 0, count) for index := 0; index < count; index++ { ids = append(ids, fmt.Sprintf("%s-shot-%d", prefix, index+1)) } return ids } func stringSlice(value any) []string { items, ok := value.([]interface{}) if !ok { text := stringValue(value) if text == "" { return nil } return []string{text} } result := make([]string, 0, len(items)) for _, item := range items { if text := strings.TrimSpace(fmt.Sprint(item)); text != "" { result = append(result, text) } } return result } func stringValue(value any) string { text := strings.TrimSpace(fmt.Sprint(value)) if text == "" { return "" } return text } func (s *Service) log(userID string, taskID string, level string, message string, payload string) error { traceID := "" requestID := "" if taskID != "" { if task, err := s.repo.Task(taskID); err == nil { traceID = task.TraceID requestID = task.RequestID } } return s.repo.Create(&model.TaskLog{ID: newID(), UserID: userID, TaskID: taskID, TraceID: traceID, RequestID: requestID, Level: level, Message: message, Payload: truncateTaskLogPayload(payload)}) } func truncateTaskLogPayload(payload string) string { if len(payload) <= taskLogPayloadLimit { return payload } end := taskLogPayloadLimit for end > 0 && !utf8.ValidString(payload[:end]) { end-- } return payload[:end] + fmt.Sprintf("\n...(日志内容已截断,原始长度 %d 字符)", len(payload)) } func (s *Service) registerActiveTask(id string, cancel context.CancelFunc) { s.cancelMu.Lock() defer s.cancelMu.Unlock() s.activeCancels[id] = cancel } func (s *Service) unregisterActiveTask(id string) { s.cancelMu.Lock() defer s.cancelMu.Unlock() delete(s.activeCancels, id) } func (s *Service) cancelActiveTask(id string) { s.cancelMu.Lock() cancel := s.activeCancels[id] s.cancelMu.Unlock() if cancel != nil { cancel() } } func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any { return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"}) } func nodeOpWithMetadata(id string, nodeType string, title string, x int, y int, metadata map[string]any) map[string]any { return map[string]any{ "type": "add_node", "id": id, "nodeType": nodeType, "title": title, "position": map[string]int{"x": x, "y": y}, "metadata": metadata, } } func connectOp(from string, to string) map[string]any { return map[string]any{"type": "connect_nodes", "fromNodeId": from, "toNodeId": to} } func ptr[T any](value T) *T { return kernel.Ptr(value) } func shortTitle(value string, max int) string { title := strings.TrimSpace(value) if title == "" { title = "影视分镜" } if len([]rune(title)) > max { return string([]rune(title)[:max]) + "..." } return title } func defaultString(value string, fallback string) string { return kernel.DefaultString(value, fallback) } func newID() string { return kernel.NewID() }