From 7418ddfa9cf6eb59f5b64473cf29ce2f375f4b4a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 21 Aug 2026 17:06:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(tasks):=20=E4=BB=BB=E5=8A=A1=E5=8F=96?= =?UTF-8?q?=E6=B6=88=20-=20=E7=A6=81=E6=AD=A2=E5=8F=96=E6=B6=88=E5=B7=B2?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E7=9A=84=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/service.go | 41 +++------------- .../service/task_cancellation_test.go | 47 +++++++++++++++++++ web/src/pages/tasks/index.tsx | 8 +++- web/src/pages/tasks/task-grid-card.tsx | 5 +- web/src/pages/tasks/task-list-row.tsx | 5 +- web/src/pages/tasks/task-shared.tsx | 4 ++ web/test/task-media-fallback.test.ts | 19 ++++++++ 7 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 backend/internal/service/task_cancellation_test.go diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index d9c76237f..e3737924f 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -691,16 +691,18 @@ func (s *Service) RetryTask(userID string, id string) (*model.Task, error) { return taskForOutput(*task), nil } -func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*model.Task, error) { +func (s *Service) CancelTask(_ context.Context, userID string, id string) (*model.Task, error) { task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err } + if task.Status == model.TaskStatusRunning { + return nil, errors.New("任务已开始生成,无法取消") + } if task.Status == model.TaskStatusSucceeded { return nil, errors.New("completed task cannot be cancelled") } now := time.Now() - cancelledRunningTask := false if task.Status == model.TaskStatusQueued { cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusQueued, now) if err != nil { @@ -721,31 +723,9 @@ func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*mo } } } + // 排队任务可能在条件更新前被 worker 抢占,重新读取后必须再次阻止取消。 if task.Status == model.TaskStatusRunning { - cancelled, err := s.repo.CancelTaskIfStatus(userID, task.ID, model.TaskStatusRunning, now) - 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.TaskStatusSucceeded { - return nil, errors.New("completed task cannot be cancelled") - } - task = latest - } else { - cancelledRunningTask = true - s.cancelActiveTask(task.ID) - if err := s.MarkBillingUncertain(task.BillingOrderID, "运行中的上游请求被用户取消,费用状态待核对"); err != nil { - return nil, err - } - task, err = s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - } + return nil, errors.New("任务已开始生成,无法取消") } if task.Status != model.TaskStatusCancelled { return nil, errors.New("task cannot be cancelled in its current state") @@ -753,15 +733,6 @@ func (s *Service) CancelTask(ctx context.Context, userID string, id string) (*mo if task.SessionID != "" { _ = s.markSessionFailed(*task, "会话任务已取消。") } - if cancelledRunningTask { - if err := s.requestProviderCancellation(ctx, task); err != nil { - return nil, err - } - task, err = s.repo.TaskForUser(userID, id) - if err != nil { - return nil, err - } - } if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil { _ = s.log(userID, task.ID, "error", "文本回放草稿归并失败", err.Error()) } diff --git a/backend/internal/service/task_cancellation_test.go b/backend/internal/service/task_cancellation_test.go new file mode 100644 index 000000000..4d919d9aa --- /dev/null +++ b/backend/internal/service/task_cancellation_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCancelTaskRejectsRunningTask(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + startedAt := time.Now() + task := model.Task{ + ID: "running-task", + UserID: "user-1", + Status: model.TaskStatusRunning, + Stage: "调用生成模型", + StartedAt: &startedAt, + } + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + + svc := &Service{repo: repository.New(db)} + if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已开始生成,无法取消" { + t.Fatalf("CancelTask() error = %v", err) + } + + stored, err := svc.repo.TaskForUser(task.UserID, task.ID) + if err != nil { + t.Fatal(err) + } + if stored.Status != model.TaskStatusRunning || stored.CompletedAt != nil { + t.Fatalf("running task changed after cancellation attempt: status=%s completedAt=%v", stored.Status, stored.CompletedAt) + } +} diff --git a/web/src/pages/tasks/index.tsx b/web/src/pages/tasks/index.tsx index 1a6ecfddb..efb870488 100644 --- a/web/src/pages/tasks/index.tsx +++ b/web/src/pages/tasks/index.tsx @@ -20,7 +20,7 @@ import { listProjects, type ProjectSummary } from "@/services/api/projects"; import { TaskGridCard } from "./task-grid-card"; import { TaskGroupHeader, type TaskGroup } from "./task-group-header"; import { TaskListRow } from "./task-list-row"; -import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; +import { formatModelName, getTaskCanvasContext, isTaskCancellable, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter"; type TaskKindFilter = "all" | "text" | "image" | "video"; @@ -292,6 +292,10 @@ export default function TasksPage() { const runAction = async (id: string, action: "retry" | "cancel") => { const currentTask = tasksRef.current.find((task) => task.id === id); + if (action === "cancel" && currentTask && !isTaskCancellable(currentTask)) { + message.warning("任务已开始生成,无法取消"); + return; + } if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask)) { message.warning("提交结果尚未确认,不能自动重试;请先核对官方状态,避免重复生成。"); return; @@ -551,7 +555,7 @@ export default function TasksPage() { 更新官方状态 ) : null} - {detailTask.provider === "dreamina-cli" && (detailTask.status === "queued" || detailTask.status === "running") ? ( + {detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask) ? ( diff --git a/web/src/pages/tasks/task-grid-card.tsx b/web/src/pages/tasks/task-grid-card.tsx index a10efae92..57844f0eb 100644 --- a/web/src/pages/tasks/task-grid-card.tsx +++ b/web/src/pages/tasks/task-grid-card.tsx @@ -5,10 +5,11 @@ import { MediaPreview } from "@/components/media-preview"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { statusLabel } from "@/lib/generation-task-display"; import type { GenerationTask } from "@/services/api/task-center"; -import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared"; +import { isTaskCancellable, isTaskFailed, statusDotClassName, TaskDate } from "./task-shared"; export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) { const isActive = task.status === "queued" || task.status === "running"; + const isCancellable = isTaskCancellable(task); const isFailed = isTaskFailed(task); const isVideo = task.previewKind === "video"; const fallbackVideo = task.type.includes("video"); @@ -38,7 +39,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { ta /> ) : null} - {isActive ? ( + {isCancellable ? (