diff --git a/backend/internal/logic/projects/handlers_test.go b/backend/internal/logic/projects/handlers_test.go index 0120da4..2dc6785 100644 --- a/backend/internal/logic/projects/handlers_test.go +++ b/backend/internal/logic/projects/handlers_test.go @@ -142,6 +142,38 @@ func TestUpdateProject(t *testing.T) { require.Equal(t, "更新后的项目说明", updated.Description) }) + t.Run("clears optional metadata while preserving omitted name and identifier", func(t *testing.T) { + router, project, _ := newProjectsHandlerTestRouter(t) + require.NoError(t, models.DBService.Model(project).Updates(map[string]any{ + "icon": "tree", + "background": "#165DFF", + "description": "原项目说明", + }).Error) + originalName := project.Name + originalIdentifier := project.Identifier + body, err := json.Marshal(gin.H{ + "icon": "", + "background": "", + "description": "", + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+project.Identity, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + var updated models.SenlinAgentProject + require.NoError(t, models.DBService.First(&updated, project.ID).Error) + require.Equal(t, originalName, updated.Name) + require.Equal(t, originalIdentifier, updated.Identifier) + require.Empty(t, updated.Icon) + require.Empty(t, updated.Background) + require.Empty(t, updated.Description) + }) + t.Run("returns not found when the identity is not owned by the current user", func(t *testing.T) { router, _, _ := newProjectsHandlerTestRouter(t) other, err := NewService().CreateProject(2, "Other", "") diff --git a/backend/internal/logic/projects/service.go b/backend/internal/logic/projects/service.go index 60f2182..170c1e1 100644 --- a/backend/internal/logic/projects/service.go +++ b/backend/internal/logic/projects/service.go @@ -151,15 +151,6 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectReques if identifier == "" { identifier = projectInitials(name) } - var count int64 - if err := models.DBService.Model(&models.SenlinAgentProject{}). - Where("owner_id = ? AND identifier = ?", ownerID, identifier). - Count(&count).Error; err != nil { - return nil, err - } - if count > 0 { - return nil, ErrProjectIdentifierConflict - } project := &models.SenlinAgentProject{ OwnerID: ownerID, Name: name, @@ -168,7 +159,10 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectReques Background: strings.TrimSpace(input.Background), Description: strings.TrimSpace(input.Description), } - return project, models.DBService.Create(project).Error + if err := projectWriteError(models.DBService.Create(project).Error); err != nil { + return nil, err + } + return project, nil } // ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。 @@ -199,6 +193,14 @@ var ( ErrProjectIdentifierConflict = errors.New("project identifier already exists") ) +// projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。 +func projectWriteError(err error) error { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return ErrProjectIdentifierConflict + } + return err +} + // UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。 func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error { project, err := FindOwnedProject(ownerID, identity) @@ -219,15 +221,6 @@ func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProje if identifier == "" { return ErrProjectIdentifierRequired } - var count int64 - if err := models.DBService.Model(&models.SenlinAgentProject{}). - Where("owner_id = ? AND identifier = ? AND id <> ?", ownerID, identifier, project.ID). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - return ErrProjectIdentifierConflict - } updates["identifier"] = identifier } if input.Icon != nil { @@ -242,7 +235,7 @@ func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProje if len(updates) == 0 { return nil } - return models.DBService.Model(project).Updates(updates).Error + return projectWriteError(models.DBService.Model(project).Updates(updates).Error) } func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) { diff --git a/backend/internal/logic/projects/service_test.go b/backend/internal/logic/projects/service_test.go index 891c55f..1c322de 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -7,7 +7,9 @@ import ( "time" "github.com/glebarez/sqlite" + "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" "gorm.io/gorm" "senlinai-agent/backend/internal/models" ) @@ -29,6 +31,35 @@ func TestFindOwnedProjectScopesIdentityByOwner(t *testing.T) { require.True(t, errors.Is(err, gorm.ErrRecordNotFound)) } +func TestProjectIdentifierDatabaseConstraintIsScopedByOwner(t *testing.T) { + database := newTestDB(t) + first := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"} + require.NoError(t, database.Create(&first).Error) + + duplicate := models.SenlinAgentProject{OwnerID: 1, Name: "Duplicate", Identifier: "SHARED"} + require.ErrorIs(t, database.Create(&duplicate).Error, gorm.ErrDuplicatedKey) + + otherOwner := models.SenlinAgentProject{OwnerID: 2, Name: "Allowed", Identifier: "SHARED"} + require.NoError(t, database.Create(&otherOwner).Error) +} + +func TestProjectIdentifierDatabaseErrorMapsToConflict(t *testing.T) { + database := newTestDB(t) + first := models.SenlinAgentProject{OwnerID: 1, Name: "Alpha", Identifier: "SHARED"} + require.NoError(t, database.Create(&first).Error) + duplicate := models.SenlinAgentProject{OwnerID: 1, Name: "Duplicate", Identifier: "SHARED"} + databaseErr := database.Create(&duplicate).Error + require.ErrorIs(t, databaseErr, gorm.ErrDuplicatedKey) + + require.ErrorIs(t, projectWriteError(databaseErr), ErrProjectIdentifierConflict) +} + +func TestPostgresDuplicateKeyErrorMapsToProjectConflict(t *testing.T) { + databaseErr := postgres.Dialector{}.Translate(&pgconn.PgError{Code: "23505"}) + require.ErrorIs(t, databaseErr, gorm.ErrDuplicatedKey) + require.ErrorIs(t, projectWriteError(databaseErr), ErrProjectIdentifierConflict) +} + func TestProjectTagsAreScopedToProject(t *testing.T) { newTestDB(t) service := NewService() @@ -318,7 +349,7 @@ func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) { func newTestDB(t *testing.T) *gorm.DB { t.Helper() - database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 74b6ba4..4d9752d 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -8,7 +8,8 @@ import ( var DBService *gorm.DB func New(dsn string) error { - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + // 启用 Gorm 跨驱动错误翻译,业务层无需解析 Postgres 错误文本或 SQLSTATE。 + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) if err != nil { return err } diff --git a/backend/internal/models/project.go b/backend/internal/models/project.go index 80cffea..f76e169 100644 --- a/backend/internal/models/project.go +++ b/backend/internal/models/project.go @@ -5,10 +5,10 @@ import "time" type SenlinAgentProject struct { ID uint `gorm:"primaryKey"` Identity string `gorm:"type:char(36);uniqueIndex"` - OwnerID uint `gorm:"index;not null"` + OwnerID uint `gorm:"index;not null;uniqueIndex:uidx_senlin_agent_projects_owner_identifier,priority:1"` OwnerIdentity string `gorm:"type:char(36);index"` Name string `gorm:"not null"` - Identifier string `gorm:"index"` + Identifier string `gorm:"index;uniqueIndex:uidx_senlin_agent_projects_owner_identifier,priority:2"` Icon string Background string Description string diff --git a/backend/internal/seed/demo.go b/backend/internal/seed/demo.go index 784f1cc..fecfc2e 100644 --- a/backend/internal/seed/demo.go +++ b/backend/internal/seed/demo.go @@ -77,17 +77,18 @@ func upsertUser(tx *gorm.DB, email string, displayName string, password string) func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) { definitions := []struct { Name string + Identifier string Description string }{ - {Name: "项目 A1", Description: "企业智能协作平台的核心工作空间"}, - {Name: "项目 A2", Description: "客户成功与交付跟踪"}, - {Name: "数据中台", Description: "内部数据治理与指标分析"}, - {Name: "运营自动化", Description: "定时任务和业务流程自动化"}, + {Name: "项目 A1", Identifier: "A1", Description: "企业智能协作平台的核心工作空间"}, + {Name: "项目 A2", Identifier: "A2", Description: "客户成功与交付跟踪"}, + {Name: "数据中台", Identifier: "DATA", Description: "内部数据治理与指标分析"}, + {Name: "运营自动化", Identifier: "AUTO", Description: "定时任务和业务流程自动化"}, } projects := make([]models.SenlinAgentProject, 0, len(definitions)) for _, definition := range definitions { - project, err := firstOrCreateProject(tx, ownerID, definition.Name, definition.Description) + project, err := firstOrCreateProject(tx, ownerID, definition.Name, definition.Identifier, definition.Description) if err != nil { return nil, err } @@ -107,7 +108,7 @@ func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error return projects, nil } -func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description string) (models.SenlinAgentProject, error) { +func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, identifier string, description string) (models.SenlinAgentProject, error) { var project models.SenlinAgentProject err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error if err == nil { @@ -116,7 +117,7 @@ func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description st if err != gorm.ErrRecordNotFound { return project, err } - project = models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description} + project = models.SenlinAgentProject{OwnerID: ownerID, Name: name, Identifier: identifier, Description: description} return project, tx.Create(&project).Error }