fix(db): enforce global unique project names across creators
Replace per-creator name uniqueness with a global index among non-retired projects, and reject cross-user duplicate creates with 409. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- 全局项目名唯一:任意活跃项目不得重名(跨创建人)。
|
||||
-- 替换 00056 的 per-creator 唯一索引。
|
||||
|
||||
-- Deduplicate globally by lower(trim(name)) among non-retired rows (keep lowest id).
|
||||
WITH dups AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY lower(trim(name))
|
||||
ORDER BY id ASC
|
||||
) AS rn
|
||||
FROM projects
|
||||
WHERE length(trim(name)) > 0
|
||||
AND COALESCE(status, 'active') <> 'demo_retired'
|
||||
)
|
||||
UPDATE projects p
|
||||
SET name = p.name || ' (dup-' || p.id || ')',
|
||||
updated_at = now()
|
||||
FROM dups d
|
||||
WHERE p.id = d.id AND d.rn > 1;
|
||||
|
||||
DROP INDEX IF EXISTS uq_projects_creator_name;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_name_global
|
||||
ON projects (lower(trim(name)))
|
||||
WHERE length(trim(name)) > 0
|
||||
AND COALESCE(status, 'active') <> 'demo_retired';
|
||||
|
||||
COMMENT ON INDEX uq_projects_name_global IS
|
||||
'Global unique project name among non-retired projects (case-insensitive, trimmed).';
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS uq_projects_name_global;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_creator_name
|
||||
ON projects (creator_user_id, lower(trim(name)))
|
||||
WHERE creator_user_id IS NOT NULL AND creator_user_id > 0
|
||||
AND length(trim(name)) > 0;
|
||||
@@ -24,8 +24,8 @@ var (
|
||||
// ErrInvalidInput 输入参数不合法
|
||||
ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed")
|
||||
|
||||
// ErrDuplicateProjectName 同一创建人下已存在同名项目
|
||||
ErrDuplicateProjectName = errors.New("duplicate project name for creator")
|
||||
// ErrDuplicateProjectName 全局已存在同名活跃项目
|
||||
ErrDuplicateProjectName = errors.New("duplicate project name")
|
||||
|
||||
// ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow")
|
||||
|
||||
@@ -60,8 +60,8 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
}
|
||||
}
|
||||
|
||||
if existing, err := s.store.FindProjectByCreatorAndName(ctx, user.ID, name); err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("%w: 您已创建同名项目「%s」", ErrDuplicateProjectName, name)
|
||||
if existing, err := s.store.FindProjectByName(ctx, name); err == nil && existing != nil {
|
||||
return nil, fmt.Errorf("%w: 项目名「%s」已被占用,请更换名称后重试", ErrDuplicateProjectName, name)
|
||||
} else if err != nil && err != ErrProjectNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -339,12 +339,17 @@ func TestCreateProject_ClientKeyIdempotentAndDuplicateName(t *testing.T) {
|
||||
}
|
||||
|
||||
member := &auth.UserAccount{ID: 99, Username: "other"}
|
||||
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
|
||||
_, err = svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目"})
|
||||
if err == nil || !errors.Is(err, ErrDuplicateProjectName) {
|
||||
t.Fatalf("different creator same name must fail globally, got %v", err)
|
||||
}
|
||||
|
||||
other, err := svc.CreateProject(ctx, member, CreateProjectInput{Name: "幂等项目-其他"})
|
||||
if err != nil {
|
||||
t.Fatalf("different creator same name should succeed: %v", err)
|
||||
t.Fatalf("different name should succeed: %v", err)
|
||||
}
|
||||
if other.ID == first.ID {
|
||||
t.Fatalf("different creators must get distinct projects")
|
||||
t.Fatalf("different names must get distinct projects")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type Store interface {
|
||||
GetProjectByCode(ctx context.Context, code string) (*Project, error)
|
||||
GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error)
|
||||
FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error)
|
||||
FindProjectByName(ctx context.Context, name string) (*Project, error)
|
||||
ListAllProjects(ctx context.Context) ([]*Project, error)
|
||||
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
|
||||
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
|
||||
@@ -201,6 +202,50 @@ func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) FindProjectByName(ctx context.Context, name string) (*Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||
COALESCE(product_shape, ''),
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||
COALESCE(client_key, '')
|
||||
FROM projects
|
||||
WHERE lower(trim(name)) = lower(trim($1))
|
||||
AND COALESCE(status, 'active') <> 'demo_retired'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, name).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
&p.ClientKey,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find project by name failed: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
@@ -838,6 +883,26 @@ func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserI
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
want := strings.ToLower(name)
|
||||
for _, p := range m.projects {
|
||||
if strings.EqualFold(p.Status, "demo_retired") {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
||||
cpy := *p
|
||||
return &cpy, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.Is(err, projectworkflow.ErrDuplicateProjectName) {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
||||
"error_code": "ERR_DUPLICATE_PROJECT_NAME",
|
||||
"message": "您已创建同名项目,请更换名称后重试",
|
||||
"message": "项目名已被占用,请更换名称后重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user