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:
weichen
2026-09-20 09:10:55 +00:00
co-authored by Cursor
parent 638083cc74
commit 28478bdca5
6 changed files with 116 additions and 8 deletions
@@ -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;
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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
}
+8 -3
View File
@@ -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")
}
}
+65
View File
@@ -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()
+1 -1
View File
@@ -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
}