fix(api): enforce project identifier uniqueness
This commit is contained in:
@@ -142,6 +142,38 @@ func TestUpdateProject(t *testing.T) {
|
|||||||
require.Equal(t, "更新后的项目说明", updated.Description)
|
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) {
|
t.Run("returns not found when the identity is not owned by the current user", func(t *testing.T) {
|
||||||
router, _, _ := newProjectsHandlerTestRouter(t)
|
router, _, _ := newProjectsHandlerTestRouter(t)
|
||||||
other, err := NewService().CreateProject(2, "Other", "")
|
other, err := NewService().CreateProject(2, "Other", "")
|
||||||
|
|||||||
@@ -151,15 +151,6 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectReques
|
|||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
identifier = projectInitials(name)
|
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{
|
project := &models.SenlinAgentProject{
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -168,7 +159,10 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectReques
|
|||||||
Background: strings.TrimSpace(input.Background),
|
Background: strings.TrimSpace(input.Background),
|
||||||
Description: strings.TrimSpace(input.Description),
|
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 接触或序列化数据库模型。
|
// ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。
|
||||||
@@ -199,6 +193,14 @@ var (
|
|||||||
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
|
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 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。
|
// UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。
|
||||||
func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error {
|
func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error {
|
||||||
project, err := FindOwnedProject(ownerID, identity)
|
project, err := FindOwnedProject(ownerID, identity)
|
||||||
@@ -219,15 +221,6 @@ func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProje
|
|||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
return ErrProjectIdentifierRequired
|
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
|
updates["identifier"] = identifier
|
||||||
}
|
}
|
||||||
if input.Icon != nil {
|
if input.Icon != nil {
|
||||||
@@ -242,7 +235,7 @@ func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProje
|
|||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return nil
|
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) {
|
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,35 @@ func TestFindOwnedProjectScopesIdentityByOwner(t *testing.T) {
|
|||||||
require.True(t, errors.Is(err, gorm.ErrRecordNotFound))
|
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) {
|
func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
@@ -318,7 +349,7 @@ func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
|
|||||||
|
|
||||||
func newTestDB(t *testing.T) *gorm.DB {
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
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, err)
|
||||||
require.NoError(t, models.AutoMigrate(database))
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
models.DBService = database
|
models.DBService = database
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
var DBService *gorm.DB
|
var DBService *gorm.DB
|
||||||
|
|
||||||
func New(dsn string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import "time"
|
|||||||
type SenlinAgentProject struct {
|
type SenlinAgentProject struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
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"`
|
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||||
Name string `gorm:"not null"`
|
Name string `gorm:"not null"`
|
||||||
Identifier string `gorm:"index"`
|
Identifier string `gorm:"index;uniqueIndex:uidx_senlin_agent_projects_owner_identifier,priority:2"`
|
||||||
Icon string
|
Icon string
|
||||||
Background string
|
Background string
|
||||||
Description string
|
Description string
|
||||||
|
|||||||
@@ -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) {
|
func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) {
|
||||||
definitions := []struct {
|
definitions := []struct {
|
||||||
Name string
|
Name string
|
||||||
|
Identifier string
|
||||||
Description string
|
Description string
|
||||||
}{
|
}{
|
||||||
{Name: "项目 A1", Description: "企业智能协作平台的核心工作空间"},
|
{Name: "项目 A1", Identifier: "A1", Description: "企业智能协作平台的核心工作空间"},
|
||||||
{Name: "项目 A2", Description: "客户成功与交付跟踪"},
|
{Name: "项目 A2", Identifier: "A2", Description: "客户成功与交付跟踪"},
|
||||||
{Name: "数据中台", Description: "内部数据治理与指标分析"},
|
{Name: "数据中台", Identifier: "DATA", Description: "内部数据治理与指标分析"},
|
||||||
{Name: "运营自动化", Description: "定时任务和业务流程自动化"},
|
{Name: "运营自动化", Identifier: "AUTO", Description: "定时任务和业务流程自动化"},
|
||||||
}
|
}
|
||||||
|
|
||||||
projects := make([]models.SenlinAgentProject, 0, len(definitions))
|
projects := make([]models.SenlinAgentProject, 0, len(definitions))
|
||||||
for _, definition := range 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error
|
|||||||
return projects, nil
|
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
|
var project models.SenlinAgentProject
|
||||||
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
|
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -116,7 +117,7 @@ func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description st
|
|||||||
if err != gorm.ErrRecordNotFound {
|
if err != gorm.ErrRecordNotFound {
|
||||||
return project, err
|
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
|
return project, tx.Create(&project).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user