fix(projects): 短剧项目 - 修复同名创建重试 (#691)

Co-authored-by: tanzui <305375675@.com>
This commit is contained in:
tanzui
2026-10-06 09:24:11 +08:00
committed by GitHub
parent 544daa4b9f
commit 53ee5b0cbd
14 changed files with 350 additions and 18 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -1564,6 +1564,7 @@ components:
- forbidden
- not_found
- conflict
- project_name_conflict
- failed_precondition
- quota_exceeded
- rate_limited

View File

@@ -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"))

View File

@@ -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"

View File

@@ -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)
}
})
}

View File

@@ -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 {

View File

@@ -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