Files
ttstd-infinite-canvas/backend/internal/app/task_lifecycle.go

225 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
// taskLifecycleCoordinator 负责任务重试与取消这类会改变任务状态的写命令。
// 读模型和 worker 执行细节留在各自边界,避免写命令跨层拼接状态更新。
type taskLifecycleCoordinator struct {
service *Service
}
func newTaskLifecycleCoordinator(service *Service) *taskLifecycleCoordinator {
return &taskLifecycleCoordinator{service: service}
}
func (s *Service) taskLifecycle() *taskLifecycleCoordinator {
if s.taskLifecycleCoordinator != nil {
return s.taskLifecycleCoordinator
}
// 部分单元测试直接构造 Service 字面量;延迟创建保持这些测试和内部工具兼容。
return newTaskLifecycleCoordinator(s)
}
func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.Task, error) {
s := w.service
if s.IsDraining() {
return nil, &AppError{Status: 503, Code: 503, Message: "服务正在维护,暂不接受任务重试", Retryable: true}
}
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
if task.MediaRecoveryJSON != "" {
return nil, BadAuthRequest("作品已生成,请使用重试保存,不要重复生成")
}
if task.CreationSubmissionID != nil {
return nil, creationConflict("智能创作重做需要新的报价批准,请回到创作会话继续")
}
if task.AgentRunID != "" || task.GenerationID != "" || strings.HasPrefix(task.Operation, "cloud_agent") || taskHasAgentOrigin(task.InputJSON) {
return nil, BadAuthRequest("Agent 重试需要新的幂等键和预算校验,请回到 Agent 对话重新发送")
}
if task.Status != model.TaskStatusFailed && task.Status != model.TaskStatusCancelled {
return nil, errors.New("only failed or cancelled tasks can be retried")
}
if task.ProviderCancelStatus == model.ProviderCancelStatusRequested {
return nil, BadAuthRequest("上游取消状态仍在确认中,请确认费用结果后再重试")
}
if err := s.taskBilling().CheckRetryEligibility(task.BillingOrderID); err != nil {
if errors.Is(err, errTaskBillingReview) {
return nil, BadAuthRequest("上一次调用费用仍在核对中,处理完成前不能重复提交")
}
return nil, err
}
if isContentModerationFailure(task.Error) {
return nil, BadAuthRequest(contentModerationRetryMessage)
}
decryptedInput, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return nil, err
}
var billingInput map[string]any
if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil {
return nil, err
}
if err := s.prepareLogicalTaskRetry(task, billingInput); err != nil {
return nil, err
}
if err := s.requireCustomChannelsForTaskInput(billingInput); err != nil {
return nil, err
}
billingOrder, err := s.taskBillingOrder(userID, task, billingInput)
if err != nil {
return nil, err
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
if err := s.ensureTaskProjectActive(userID, task.ProjectID); err != nil {
return nil, err
}
task, err = s.repo.RetryTaskWithBilling(userID, task, billingOrder, policy.Task.ActiveTaskLimit)
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
if errors.Is(err, repository.ErrActiveTaskLimit) {
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
}
if errors.Is(err, repository.ErrTaskNotRetryable) {
return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试")
}
if err != nil {
return nil, err
}
_ = s.log(userID, task.ID, "info", "任务已重新入队", "")
return taskForOutput(*task), nil
}
func (w *taskLifecycleCoordinator) cancelTask(ctx context.Context, userID string, id string) (*model.Task, error) {
return w.cancelTaskWithIntent(ctx, userID, id, model.TaskCancellationIntent{Source: model.TaskCancellationUserRequest, ActorID: userID})
}
func (w *taskLifecycleCoordinator) cancelTaskWithIntent(_ context.Context, userID string, id string, intent model.TaskCancellationIntent) (*model.Task, error) {
s := w.service
switch intent.Source {
case model.TaskCancellationUserRequest, model.TaskCancellationParentCancelled, model.TaskCancellationParentFailed, model.TaskCancellationWorkerContext:
default:
return nil, BadAuthRequest("取消请求缺少有效来源")
}
task, err := s.repo.TaskForUser(userID, id)
if err != nil {
return nil, err
}
if task.Status != model.TaskStatusQueued && task.Status != model.TaskStatusRunning {
if task.Status == model.TaskStatusCancelled {
return taskForOutput(*task), nil
}
return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status)
}
// 先从账单和请求日志补齐上游 ID,再做条件更新。取消与 worker 完成之间
// 以数据库终态为准,避免“用户已取消但迟到结果又把任务写成成功”。
s.hydrateTaskProviderRequestID(task)
// 用户一旦看到任务进入上游提交/生成阶段,即使 request ID 尚未来得及写回,
// 也必须按“可能已经产生上游费用”处理。父任务失败和 worker context 属于
// 内部协调,仍需继续走下面的上游取消/对账流程,不能被这条 UI 规则截断。
if intent.Source == model.TaskCancellationUserRequest && (strings.TrimSpace(task.ProviderRequestID) != "" || task.MediaStage != "" || !taskStageAllowsUserCancellation(task.Status, task.Stage)) {
return nil, BadAuthRequest("第三方请求已提交,任务不可取消")
}
originalStatus := task.Status
now := time.Now()
if intent.RequestedAt.IsZero() {
intent.RequestedAt = now
}
task.Error = "任务已取消"
recordTaskDiagnostic(task, "cancellation", "task_cancelled", taskSubmissionOutcome(task, originalStatus == model.TaskStatusQueued), "reconcile_cancellation", nil)
intent.Diagnostic = *task.Diagnostic
cancelled, err := s.repo.CancelTaskIfStatus(userID, id, task.Status, now, intent)
if err != nil {
return nil, err
}
if !cancelled {
latest, latestErr := s.repo.TaskForUser(userID, id)
if latestErr != nil {
return nil, latestErr
}
if latest.Status == model.TaskStatusCancelled {
return taskForOutput(*latest), nil
}
return nil, errors.New("任务状态已变化,请刷新后重试")
}
task.Status = model.TaskStatusCancelled
task.Stage = "任务已取消"
task.Error = "任务已取消"
task.CompletedAt = &now
task.CancellationSource, task.CancellationActorID, task.CancellationRequestedAt = intent.Source, intent.ActorID, &intent.RequestedAt
s.cancelActiveTask(task.ID)
// 这些收尾操作必须幂等;任何单项失败都记录日志,但不能让已经落库的
// cancelled 状态重新对用户表现为“取消失败”。
if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil {
_ = s.log(task.UserID, task.ID, "error", "取消任务后归并文本回放失败", err.Error())
}
_ = s.log(task.UserID, task.ID, "warn", "任务取消请求已保存", task.CancellationSource)
if task.ProviderRequestID != "" {
// 上游取消可能需要轮询确认,不能阻塞取消接口;请求上下文也不能因
// 浏览器关闭而中断。后台对账会继续负责退款或费用核对。
cancelTask := *task
started := s.runWorkerTask(func() {
requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil {
_ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error())
}
})
if !started {
// drain 后不能启动未登记 goroutine;当前 HTTP 请求同步完成首次取消,
// http.Server.Shutdown 会等待该请求,后续确认仍由持久化对账恢复。
requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil {
_ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error())
}
cancel()
}
} else {
var billingErr error
if originalStatus == model.TaskStatusQueued {
billingErr = s.taskBilling().RefundBilling(task.BillingOrderID, "任务取消且尚未开始执行;来源:"+intent.Source)
} else {
// running 任务可能已经发起上游调用但尚未把 request ID 写回,不能
// 直接退款后放任上游继续生成,先冻结为待核对更安全。
billingErr = s.taskBilling().MarkBillingUncertain(task.BillingOrderID, "取消时上游请求 ID 尚未确认,费用待核对;来源:"+intent.Source)
}
if billingErr != nil {
_ = s.log(task.UserID, task.ID, "error", "取消任务后处理积分失败,已保留人工核对线索", billingErr.Error())
}
}
return taskForOutput(*task), nil
}
func taskStageAllowsUserCancellation(status model.TaskStatus, stage string) bool {
switch strings.ToLower(strings.TrimSpace(stage)) {
case "":
return status == model.TaskStatusQueued
case "queued", "等待队列调度":
return true
case "后端接管任务", "正在准备创作":
return status == model.TaskStatusRunning || status == model.TaskStatusQueued
default:
return false
}
}