fix(api): enforce project identifier uniqueness

This commit is contained in:
2026-07-21 14:41:30 +08:00
parent 5341b44cc5
commit 14a140985c
6 changed files with 89 additions and 31 deletions

View File

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

View File

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

View File

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