diff --git a/backend/internal/app/error_codes.go b/backend/internal/app/error_codes.go index 9e8f0ed0f..35a0d0380 100644 --- a/backend/internal/app/error_codes.go +++ b/backend/internal/app/error_codes.go @@ -23,19 +23,20 @@ const ( type ErrorReason = kernel.ErrorReason const ( - ReasonInvalidArgument = kernel.ReasonInvalidArgument - ReasonUnauthorized = kernel.ReasonUnauthorized - ReasonForbidden = kernel.ReasonForbidden - ReasonNotFound = kernel.ReasonNotFound - ReasonConflict = kernel.ReasonConflict - ReasonFailedPrecondition = kernel.ReasonFailedPrecondition - ReasonQuotaExceeded = kernel.ReasonQuotaExceeded - ReasonRateLimited = kernel.ReasonRateLimited - ReasonUnavailable = kernel.ReasonUnavailable - ReasonTimeout = kernel.ReasonTimeout - ReasonInternal = kernel.ReasonInternal - ReasonBadGateway = kernel.ReasonBadGateway - ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed + ReasonInvalidArgument = kernel.ReasonInvalidArgument + ReasonUnauthorized = kernel.ReasonUnauthorized + ReasonForbidden = kernel.ReasonForbidden + ReasonNotFound = kernel.ReasonNotFound + ReasonConflict = kernel.ReasonConflict + ReasonProjectNameConflict = kernel.ReasonProjectNameConflict + ReasonFailedPrecondition = kernel.ReasonFailedPrecondition + ReasonQuotaExceeded = kernel.ReasonQuotaExceeded + ReasonRateLimited = kernel.ReasonRateLimited + ReasonUnavailable = kernel.ReasonUnavailable + ReasonTimeout = kernel.ReasonTimeout + ReasonInternal = kernel.ReasonInternal + ReasonBadGateway = kernel.ReasonBadGateway + ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed ) func ReasonForStatus(status int) ErrorReason { diff --git a/backend/internal/app/project.go b/backend/internal/app/project.go index 0b5de215c..332531110 100644 --- a/backend/internal/app/project.go +++ b/backend/internal/app/project.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "strings" "time" @@ -254,6 +255,12 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model. } project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, DefaultImageModel: defaultImageModel, DefaultVideoModel: defaultVideoModel, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now} if err := s.repo.CreateProject(&project); err != nil { + if errors.Is(err, repository.ErrProjectNameConflict) { + // 项目名称冲突需要明确返回 409,页面才能按已有逻辑换一个名称重试。 + conflict := NewAppError(http.StatusConflict, "项目名称已存在") + conflict.Reason = ReasonProjectNameConflict + return model.Project{}, conflict + } return model.Project{}, err } if _, err := s.createProjectWorkflow(project.ID, "", "project"); err != nil { diff --git a/backend/internal/app/project_name_conflict_test.go b/backend/internal/app/project_name_conflict_test.go new file mode 100644 index 000000000..9b246c401 --- /dev/null +++ b/backend/internal/app/project_name_conflict_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestCreateProjectReturnsStructuredConflictForDuplicateName(t *testing.T) { + t.Run("sqlite", func(t *testing.T) { + service, db := newProjectSettingsTestService(t) + testStructuredProjectNameConflict(t, service, db) + }) + t.Run("postgres", func(t *testing.T) { + service, db := newProjectNameConflictPostgresService(t) + testStructuredProjectNameConflict(t, service, db) + }) +} + +// testStructuredProjectNameConflict 验证重名的结构化错误,以及原项目和工作流不会被重复创建请求覆盖。 +func testStructuredProjectNameConflict(t *testing.T, service *Service, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close test database: %v", err) + } + }) + if err := db.AutoMigrate(&model.Project{}, &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}); err != nil { + t.Fatal(err) + } + + request := CreateProjectRequest{Name: "同名短剧", Description: "原项目内容"} + first, err := service.CreateProject("user-1", request) + if err != nil { + t.Fatalf("create initial project: %v", err) + } + + request.Description = "重复请求的内容" + _, err = service.CreateProject("user-1", request) + var conflict *AppError + if !errors.As(err, &conflict) { + t.Fatalf("duplicate project error = %v, want AppError", err) + } + if conflict.Status != http.StatusConflict || conflict.Reason != ReasonProjectNameConflict || conflict.Message != "项目名称已存在" { + t.Fatalf("duplicate project conflict = %#v", conflict) + } + var stored model.Project + if err := db.First(&stored, "id = ?", first.ID).Error; err != nil { + t.Fatal(err) + } + if stored.Description != first.Description || stored.Revision != first.Revision { + t.Fatal("duplicate creation changed the existing project") + } + var workflowCount int64 + if err := db.Model(&model.WorkflowInstance{}).Where("project_id = ?", first.ID).Count(&workflowCount).Error; err != nil || workflowCount != 1 { + t.Fatalf("workflow count = %d, error = %v", workflowCount, err) + } + if _, err := service.CreateProject("user-2", request); err != nil { + t.Fatalf("same name for another user: %v", err) + } + request.Name = "同名短剧(2)" + if _, err := service.CreateProject("user-1", request); err != nil { + t.Fatalf("retry with a new name: %v", err) + } +} + +// newProjectNameConflictPostgresService 只在独立测试 schema 中创建项目与工作流表,测试结束后删除该 schema。 +func newProjectNameConflictPostgresService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured") + } + options := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} + admin, err := gorm.Open(postgres.Open(dsn), options) + if err != nil { + t.Fatal(err) + } + adminSQL, err := admin.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = adminSQL.Close() }) + schema := fmt.Sprintf("project_name_conflict_app_%d", time.Now().UnixNano()) + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { + t.Error(err) + } + }) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + db, err := gorm.Open(postgres.Open(parsed.String()), options) + if err != nil { + t.Fatal(err) + } + return &Service{repo: repository.New(db)}, db +} diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index bfb163cdd..11d3517f0 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -1564,6 +1564,7 @@ components: - forbidden - not_found - conflict + - project_name_conflict - failed_precondition - quota_exceeded - rate_limited diff --git a/backend/internal/handler/response_test.go b/backend/internal/handler/response_test.go index 0a0891cf7..fc3985911 100644 --- a/backend/internal/handler/response_test.go +++ b/backend/internal/handler/response_test.go @@ -41,6 +41,19 @@ func TestFailServiceProjectsAppError(t *testing.T) { } } +func TestFailServiceProjectsProjectNameConflictReason(t *testing.T) { + recorder, context := responseTestContext() + err := service.NewAppError(http.StatusConflict, "项目名称已存在") + err.Reason = service.ReasonProjectNameConflict + + failService(context, err) + + response := decodeFailureEnvelope(t, recorder) + if recorder.Code != http.StatusConflict || response.Code != service.CodeConflict || response.Reason != string(service.ReasonProjectNameConflict) || response.Msg != err.Message { + t.Fatalf("project name conflict response: status=%d body=%#v", recorder.Code, response) + } +} + func TestFailServiceProjectsDNSFailureWithoutTransportDetails(t *testing.T) { recorder, context := responseTestContext() err := service.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", errors.New("private-sentinel resolver failure")) diff --git a/backend/internal/kernel/error_codes.go b/backend/internal/kernel/error_codes.go index 543a20626..5d97b8445 100644 --- a/backend/internal/kernel/error_codes.go +++ b/backend/internal/kernel/error_codes.go @@ -31,6 +31,7 @@ const ( ReasonForbidden ErrorReason = "forbidden" ReasonNotFound ErrorReason = "not_found" ReasonConflict ErrorReason = "conflict" + ReasonProjectNameConflict ErrorReason = "project_name_conflict" ReasonCanvasResourcesMissing ErrorReason = "canvas_history_resources_missing" ReasonFailedPrecondition ErrorReason = "failed_precondition" ReasonQuotaExceeded ErrorReason = "quota_exceeded" diff --git a/backend/internal/repository/project_name_conflict_test.go b/backend/internal/repository/project_name_conflict_test.go new file mode 100644 index 000000000..dcba0b050 --- /dev/null +++ b/backend/internal/repository/project_name_conflict_test.go @@ -0,0 +1,114 @@ +package repository + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestCreateProjectReturnsConflictForDuplicateUserName(t *testing.T) { + t.Run("sqlite", func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "projects.db")), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + closeTestDatabase(t, db) + testCreateProjectNameConflict(t, db) + }) + + t.Run("postgres", func(t *testing.T) { + dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set CANVAS_TEST_POSTGRES_DSN to an isolated test database") + } + config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} + admin, err := gorm.Open(postgres.Open(dsn), config) + if err != nil { + t.Fatal(err) + } + closeTestDatabase(t, admin) + + schema := fmt.Sprintf("project_name_conflict_%d", time.Now().UnixNano()) + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { + t.Errorf("drop test schema: %v", err) + } + }) + + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + db, err := gorm.Open(postgres.Open(parsed.String()), config) + if err != nil { + t.Fatal(err) + } + closeTestDatabase(t, db) + testCreateProjectNameConflict(t, db) + }) +} + +// testCreateProjectNameConflict 验证名称冲突按用户区分,且不误判项目 ID 冲突。 +func testCreateProjectNameConflict(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.AutoMigrate(&model.Project{}); err != nil { + t.Fatal(err) + } + repo := New(db) + + first := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Description: "原项目内容"} + if err := repo.CreateProject(&first); err != nil { + t.Fatal(err) + } + + duplicate := model.Project{ID: "project-2", UserID: "user-1", Name: "短剧", Description: "重复请求的内容"} + if err := repo.CreateProject(&duplicate); !errors.Is(err, ErrProjectNameConflict) { + t.Fatalf("duplicate name error = %v, want ErrProjectNameConflict", err) + } + stored, err := repo.ProjectForUser(first.UserID, first.ID) + if err != nil || stored.Description != first.Description { + t.Fatalf("duplicate creation changed the original project: project=%#v, error=%v", stored, err) + } + + sameNameForAnotherUser := model.Project{ID: "project-3", UserID: "user-2", Name: "短剧"} + if err := repo.CreateProject(&sameNameForAnotherUser); err != nil { + t.Fatalf("same name for another user: %v", err) + } + + primaryKeyConflict := model.Project{ID: "project-1", UserID: "user-3", Name: "其他项目"} + if err := repo.CreateProject(&primaryKeyConflict); err == nil || errors.Is(err, ErrProjectNameConflict) { + t.Fatalf("primary key conflict error = %v, want a different database error", err) + } +} + +func closeTestDatabase(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close test database: %v", err) + } + }) +} diff --git a/backend/internal/repository/repository_projects.go b/backend/internal/repository/repository_projects.go index 7f93b4310..380b9458a 100644 --- a/backend/internal/repository/repository_projects.go +++ b/backend/internal/repository/repository_projects.go @@ -3,6 +3,7 @@ package repository import ( + "errors" "fmt" "sort" "time" @@ -12,6 +13,9 @@ import ( "infinite-canvas/backend/internal/model" ) +// ErrProjectNameConflict 表示同一用户下的项目名称已被占用。 +var ErrProjectNameConflict = errors.New("project name conflict") + func (r *Repository) Projects(userID string) ([]model.Project, error) { var projects []model.Project err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error @@ -40,7 +44,18 @@ func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, e } func (r *Repository) CreateProject(project *model.Project) error { - return r.db.Create(project).Error + // 只忽略用户与名称这一业务唯一索引;主键等其他冲突仍按数据库错误返回。 + result := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "name"}}, + DoNothing: true, + }).Create(project) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrProjectNameConflict + } + return nil } func (r *Repository) UpdateProject(project *model.Project) error { diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index a0cc88463..aacb2dcb7 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -68,6 +68,7 @@ const ( ReasonBadGateway = app.ReasonBadGateway ReasonUpstreamDNSFailed = app.ReasonUpstreamDNSFailed ReasonConflict = app.ReasonConflict + ReasonProjectNameConflict = app.ReasonProjectNameConflict ReasonFailedPrecondition = app.ReasonFailedPrecondition ReasonForbidden = app.ReasonForbidden ReasonInternal = app.ReasonInternal diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 10df56896..6df1bca72 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -347,6 +347,7 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两 | 403 | 403 | `forbidden` | 无权限 | | 404 | 404 | `not_found` | 资源不存在 | | 409 | 409 | `conflict` | 冲突 | +| 409 | 409 | `project_name_conflict` | `POST /projects` 同一用户下已有同名项目;客户端可有限更换名称重试 | | 429 | 429 | `rate_limited` | 限流(未细分) | | 500 | 500 | `internal` | 内部错误 | | 502 | 502 | `bad_gateway` | 上游失败 | @@ -358,7 +359,7 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两 模型错误把已有字符串码放进 `reason`(如 `model_capability_not_supported`),`code` 仍为 400。 -常量:后端 `service/error_codes.go`,前端 `web/src/services/api/error-codes.ts`。 +常量:后端标准原因定义在 `backend/internal/kernel/error_codes.go`,由 `app` 和 `service` 兼容导出;前端业务模块按 `ApiError.reason` 分支。 ## 技能库分类 API diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 2183e1210..110851138 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -1276,6 +1276,13 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 - [ ] 登录后用 DeepSeek 等 chat-completion 渠道跑「开始一部新短剧 → AI 生成章节」,确认章节正常导入。 - [ ] Claude、Gemini、OpenAI Responses 渠道各跑一次文本生成,确认系统指令生效且没有重复出现在正文里。 +## AI 生成章节的同名项目处理 + +- 已实现:创建项目遇到同一用户下的重名时返回 HTTP/code 409、`reason=project_name_conflict`;前端只对该原因有限更换名称重试,其他冲突和服务错误直接显示,不调用模型。 +- [x] 隔离 PostgreSQL 的仓储和服务测试、HTTP 响应及前端重试边界测试通过:同用户重名返回结构化冲突、不同用户可同名、项目 ID 冲突不被误判、原项目和工作流不被覆盖;更换名称后可正常创建。 +- [ ] SQLite 同组用例待在启用 CGO 的环境运行。 +- [ ] 登录真实页面,用已存在项目的同一故事再次执行「AI 生成章节」,确认自动创建带后缀项目、章节正常导入,且只有一次模型生成请求;确认其他 HTTP 409 不触发重试。 + ## 画布智能引用 - [ ] 在简洁、专业模式及展开提示词面板中打开智能引用,检查明暗主题、弹层宽度和开关状态。 diff --git a/web/src/lib/project-name-conflict.ts b/web/src/lib/project-name-conflict.ts new file mode 100644 index 000000000..7c97ba0fc --- /dev/null +++ b/web/src/lib/project-name-conflict.ts @@ -0,0 +1,11 @@ +import { ApiError } from "@/services/api/request"; + +const maximumProjectNameRetries = 5; + +/** 只重试后端明确识别的项目重名,不依赖可能被统一错误处理改写的数据库文案。 */ +export function shouldRetryProjectNameConflict(error: unknown, attempt: number) { + return error instanceof ApiError && + error.status === 409 && + error.reason === "project_name_conflict" && + attempt < maximumProjectNameRetries; +} diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index a137ea913..a0fa18897 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -15,6 +15,7 @@ import { resourceFileUrl } from "@/services/api/resources"; import { ModelPicker } from "@/components/model-picker"; import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile"; import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; +import { shouldRetryProjectNameConflict } from "@/lib/project-name-conflict"; import { settingsPath } from "@/lib/settings-navigation"; import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts"; import { runBackendGenerationTask } from "@/services/api/generation-task"; @@ -339,9 +340,7 @@ async function createUniqueProjectName(story: string, selectedStyle: CanvasStyle try { return await createProject(buildInput(attempt === 0 ? base : `${base}(${attempt + 1})`)); } catch (error) { - const message = error instanceof Error ? error.message : ""; - const uniqueConflict = message.includes("UNIQUE") || message.includes("projects.user_id") || message.includes("projects.name"); - if (!uniqueConflict || attempt >= 5) throw error; + if (!shouldRetryProjectNameConflict(error, attempt)) throw error; attempt += 1; } } diff --git a/web/test/project-name-conflict.test.ts b/web/test/project-name-conflict.test.ts new file mode 100644 index 000000000..ccf42b491 --- /dev/null +++ b/web/test/project-name-conflict.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +import { ApiError } from "../src/services/api/request"; +import { shouldRetryProjectNameConflict } from "../src/lib/project-name-conflict"; + +describe("project name conflict retries", () => { + test("retries only the structured project-name conflict", () => { + const projectNameConflict = new ApiError("文案可能变化", { + status: 409, + reason: "project_name_conflict", + }); + + expect(shouldRetryProjectNameConflict(projectNameConflict, 0)).toBe(true); + expect(shouldRetryProjectNameConflict(new ApiError("项目名称已存在", { + status: 409, + reason: "conflict", + }), 0)).toBe(false); + expect(shouldRetryProjectNameConflict(new Error("项目名称已存在"), 0)).toBe(false); + }); + + test("stops after the configured number of name retries", () => { + const projectNameConflict = new ApiError("项目名称已存在", { + status: 409, + reason: "project_name_conflict", + }); + + expect(shouldRetryProjectNameConflict(projectNameConflict, 4)).toBe(true); + expect(shouldRetryProjectNameConflict(projectNameConflict, 5)).toBe(false); + }); + + test("错误文案、非 409 状态和缺少专用原因的错误不能触发重试", () => { + for (const error of [ + new ApiError("项目名称已存在", { status: 500, reason: "project_name_conflict" }), + new ApiError("UNIQUE constraint failed: projects.user_id, projects.name", { status: 409 }), + new Error("UNIQUE constraint failed: projects.user_id, projects.name"), + { status: 409, reason: "project_name_conflict" }, + ]) { + expect(shouldRetryProjectNameConflict(error, 0)).toBe(false); + } + }); +});