// Package project 内的 requirement_service.go 实装 RequirementService。 // // 设计取舍: // - phase 不强制单向,但必须在 AllPhases 枚举内。 // - 已 closed requirement:禁止 phase 切换 + 禁止挂新 issue(后者由 // IssueService 实施);现有挂载 issue 仍可正常修改。 // - 已归档 project 下任何写操作直接 CodeProjectArchived。 // - number 自增由 SQL 子查询 + UNIQUE 兜底;service 在 Create 上捕获 // unique_violation 重试 1 次,再次失败转 CodeInternal。 package project import ( "context" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) type requirementService struct { repo Repository audit audit.Recorder } // NewRequirementService 构造 RequirementService。 func NewRequirementService(repo Repository, rec audit.Recorder) RequirementService { return &requirementService{repo: repo, audit: rec} } // loadProjectForWrite 解析 slug → 校验 caller 写权限 → 校验未归档。 func loadProjectForWrite(ctx context.Context, repo Repository, c Caller, slug string) (*Project, error) { p, err := repo.GetProjectBySlug(ctx, slug) if err != nil { return nil, err } if err := assertWritable(p, c); err != nil { return nil, err } return p, nil } func loadProjectForRead(ctx context.Context, repo Repository, c Caller, slug string) (*Project, error) { p, err := repo.GetProjectBySlug(ctx, slug) if err != nil { return nil, err } if err := assertReadable(p, c); err != nil { return nil, err } return p, nil } func (s *requirementService) Create(ctx context.Context, c Caller, slug string, in CreateRequirementInput) (*Requirement, error) { if in.Title == "" { return nil, errs.New(errs.CodeInvalidInput, "title 必填") } p, err := loadProjectForWrite(ctx, s.repo, c, slug) if err != nil { return nil, err } owner := c.UserID if in.OwnerID != nil { owner = *in.OwnerID } in1 := &Requirement{ ID: uuid.New(), ProjectID: p.ID, WorkspaceID: in.WorkspaceID, Title: in.Title, Description: in.Description, OwnerID: owner, } out, err := s.createWithRetry(ctx, in1) if err != nil { return nil, err } meta := map[string]any{ "project_id": p.ID.String(), "number": out.Number, "title": out.Title, } if in.WorkspaceID != nil { meta["workspace_id"] = in.WorkspaceID.String() } s.recordAudit(ctx, c, "requirement.create", out.ID.String(), meta) return out, nil } // createWithRetry 把 number 竞争吞下并重试 1 次;再次失败转 CodeInternal。 func (s *requirementService) createWithRetry(ctx context.Context, in *Requirement) (*Requirement, error) { for attempt := 0; attempt < 2; attempt++ { out, err := s.repo.CreateRequirement(ctx, in) if err == nil { return out, nil } if !IsUniqueViolation(err) { return nil, err } // number 撞了,下一轮 SQL 子查询会拿到新 max } return nil, errs.New(errs.CodeInternal, "requirement number 持续冲突") } func (s *requirementService) Get(ctx context.Context, c Caller, slug string, number int) (*Requirement, error) { p, err := loadProjectForRead(ctx, s.repo, c, slug) if err != nil { return nil, err } return s.repo.GetRequirementByNumber(ctx, p.ID, number) } func (s *requirementService) List(ctx context.Context, c Caller, slug string, f RequirementFilter) ([]*Requirement, error) { p, err := loadProjectForRead(ctx, s.repo, c, slug) if err != nil { return nil, err } if f.Phase != "" && !f.Phase.IsValid() { return nil, errs.New(errs.CodeInvalidInput, "phase 非法") } if f.Status != "" && f.Status != StatusOpen && f.Status != StatusClosed { return nil, errs.New(errs.CodeInvalidInput, "status 非法") } return s.repo.ListRequirements(ctx, p.ID, f.Phase, f.Status) } func (s *requirementService) Update(ctx context.Context, c Caller, slug string, number int, in UpdateRequirementInput) (*Requirement, error) { p, err := loadProjectForWrite(ctx, s.repo, c, slug) if err != nil { return nil, err } r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number) if err != nil { return nil, err } changed := map[string]any{} if in.Title != nil { r.Title = *in.Title changed["title"] = "" } if in.Description != nil { r.Description = *in.Description changed["description"] = "" } if in.OwnerID != nil { r.OwnerID = *in.OwnerID changed["owner_id"] = in.OwnerID.String() } // workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联) if in.WorkspaceID != nil { auditFrom := "" if r.WorkspaceID != nil { auditFrom = r.WorkspaceID.String() } if *in.WorkspaceID == uuid.Nil { r.WorkspaceID = nil } else { v := *in.WorkspaceID r.WorkspaceID = &v } auditTo := "" if r.WorkspaceID != nil { auditTo = r.WorkspaceID.String() } if auditFrom != auditTo { changed["from_workspace"] = auditFrom changed["to_workspace"] = auditTo } } if len(changed) == 0 { return r, nil } out, err := s.repo.UpdateRequirement(ctx, r) if err != nil { return nil, err } s.recordAudit(ctx, c, "requirement.update", out.ID.String(), map[string]any{"changed_fields": changed}) return out, nil } func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug string, number int, to Phase) (*Requirement, error) { if !to.IsValid() { return nil, errs.New(errs.CodeInvalidInput, "phase 非法") } p, err := loadProjectForWrite(ctx, s.repo, c, slug) if err != nil { return nil, err } r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number) if err != nil { return nil, err } if r.Status == StatusClosed { return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止 phase 切换") } if r.Phase == to { return r, nil } from := r.Phase out, err := s.repo.UpdateRequirementPhase(ctx, r.ID, to) if err != nil { return nil, err } s.recordAudit(ctx, c, "requirement.phase_change", out.ID.String(), map[string]any{ "from": string(from), "to": string(to), }) return out, nil } func (s *requirementService) Close(ctx context.Context, c Caller, slug string, number int) error { p, err := loadProjectForWrite(ctx, s.repo, c, slug) if err != nil { return err } r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number) if err != nil { return err } if r.Status == StatusClosed { return nil } if err := s.repo.CloseRequirement(ctx, r.ID); err != nil { return err } s.recordAudit(ctx, c, "requirement.close", r.ID.String(), nil) return nil } func (s *requirementService) Reopen(ctx context.Context, c Caller, slug string, number int) error { p, err := loadProjectForWrite(ctx, s.repo, c, slug) if err != nil { return err } r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number) if err != nil { return err } if r.Status == StatusOpen { return nil } if err := s.repo.ReopenRequirement(ctx, r.ID); err != nil { return err } s.recordAudit(ctx, c, "requirement.reopen", r.ID.String(), nil) return nil } func (s *requirementService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { if s.audit == nil { return } uid := c.UserID _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ UserID: &uid, Action: action, TargetType: "requirement", TargetID: targetID, Metadata: meta, }) }