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 ? (
} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
diff --git a/web/src/pages/tasks/task-list-row.tsx b/web/src/pages/tasks/task-list-row.tsx
index ded078002..e16f926c3 100644
--- a/web/src/pages/tasks/task-list-row.tsx
+++ b/web/src/pages/tasks/task-list-row.tsx
@@ -7,7 +7,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
-import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
+import { formatModelName, getTaskCanvasContext, isTaskCancellable, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
export function TaskListRow({
task,
@@ -34,6 +34,7 @@ export function TaskListRow({
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
+ const isCancellable = isTaskCancellable(task);
const isFailed = isTaskFailed(task);
return (
@@ -94,7 +95,7 @@ export function TaskListRow({
/>
) : null}
- {isActive ? (
+ {isCancellable ? (
} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
diff --git a/web/src/pages/tasks/task-shared.tsx b/web/src/pages/tasks/task-shared.tsx
index de5a75553..513a36fe6 100644
--- a/web/src/pages/tasks/task-shared.tsx
+++ b/web/src/pages/tasks/task-shared.tsx
@@ -17,6 +17,10 @@ export function isTaskFailed(task: GenerationTask) {
return task.status === "failed" || task.status === "cancelled";
}
+export function isTaskCancellable(task: GenerationTask) {
+ return task.status === "queued";
+}
+
export function taskAttentionReason(task: GenerationTask) {
if (task.status === "cancelled") return providerCancelStatusLabel(task);
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
diff --git a/web/test/task-media-fallback.test.ts b/web/test/task-media-fallback.test.ts
index 26d3b85bc..ec07af311 100644
--- a/web/test/task-media-fallback.test.ts
+++ b/web/test/task-media-fallback.test.ts
@@ -35,3 +35,22 @@ describe("media fallback", () => {
expect(page).toContain("onUnavailable={() => setUnavailableUrl(url)}");
});
});
+
+describe("task cancellation policy", () => {
+ test("offers cancellation only while a task is queued", () => {
+ const shared = source("../src/pages/tasks/task-shared.tsx");
+ const list = source("../src/pages/tasks/task-list-row.tsx");
+ const grid = source("../src/pages/tasks/task-grid-card.tsx");
+ const page = source("../src/pages/tasks/index.tsx");
+
+ expect(shared).toContain('return task.status === "queued";');
+ expect(list).toContain("const isCancellable = isTaskCancellable(task);");
+ expect(list).toContain("{isCancellable ? (");
+ expect(grid).toContain("const isCancellable = isTaskCancellable(task);");
+ expect(grid).toContain("{isCancellable ? (");
+ expect(page).toContain('if (action === "cancel" && currentTask && !isTaskCancellable(currentTask))');
+ expect(page).toContain('message.warning("任务已开始生成,无法取消")');
+ expect(page).toContain('detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask)');
+ expect(page).not.toContain('detailTask.status === "queued" || detailTask.status === "running"');
+ });
+});