diff --git a/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql b/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql new file mode 100644 index 00000000..660ff277 --- /dev/null +++ b/deploy/postgres/goose/00057_2026-09-20-global-unique-project-name.sql @@ -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; diff --git a/internal/projectworkflow/errors.go b/internal/projectworkflow/errors.go index 2c431665..e18aaaff 100644 --- a/internal/projectworkflow/errors.go +++ b/internal/projectworkflow/errors.go @@ -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") diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index baee578f..87a7a792 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -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 } diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 441f5775..cee8df9e 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -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") } } diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index a8bf5239..1352ffc1 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -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() diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index ecd8e53e..80a6deac 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -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 }