From 1c3fb186e9fa69d6f256771f606e560406d11642 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 10:13:19 +0800 Subject: [PATCH] feat(project): RequirementService with phase state machine + close/reopen --- internal/project/requirement_service.go | 226 +++++++++++++++++++ internal/project/requirement_service_test.go | 101 +++++++++ 2 files changed, 327 insertions(+) create mode 100644 internal/project/requirement_service.go create mode 100644 internal/project/requirement_service_test.go diff --git a/internal/project/requirement_service.go b/internal/project/requirement_service.go new file mode 100644 index 0000000..26be8b3 --- /dev/null +++ b/internal/project/requirement_service.go @@ -0,0 +1,226 @@ +// 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, + Title: in.Title, Description: in.Description, + OwnerID: owner, + } + out, err := s.createWithRetry(ctx, in1) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "requirement.create", out.ID.String(), map[string]any{ + "project_id": p.ID.String(), "number": out.Number, "title": out.Title, + }) + 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() + } + 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, + }) +} diff --git a/internal/project/requirement_service_test.go b/internal/project/requirement_service_test.go new file mode 100644 index 0000000..184aaec --- /dev/null +++ b/internal/project/requirement_service_test.go @@ -0,0 +1,101 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func newReqSvc(repo *fakeRepo) (RequirementService, ProjectService, *spyAudit) { + spy := &spyAudit{} + return NewRequirementService(repo, spy), NewProjectService(repo, spy), spy +} + +func TestRequirementService_Create_HappyAndAutoIncrement(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, spy := newReqSvc(repo) + caller := Caller{UserID: owner} + + r1, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "First"}) + require.NoError(t, err) + require.Equal(t, 1, r1.Number) + + r2, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "Second"}) + require.NoError(t, err) + require.Equal(t, 2, r2.Number) + + require.Contains(t, spy.actions(), "requirement.create") +} + +func TestRequirementService_PhaseChange_FollowsEnum(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newReqSvc(repo) + caller := Caller{UserID: owner} + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + + // 任意跳跃合法:planning → reviewing + out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseReviewing) + require.NoError(t, err) + require.Equal(t, PhaseReviewing, out.Phase) + + // 非枚举字符串 → invalid + _, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, "bogus") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestRequirementService_PhaseChange_BlockedWhenClosed(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newReqSvc(repo) + caller := Caller{UserID: owner} + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number)) + + _, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeRequirementClosed, ae.Code) +} + +func TestRequirementService_Write_BlockedWhenProjectArchived(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + reqSvc, projSvc, _ := newReqSvc(repo) + caller := Caller{UserID: owner} + require.NoError(t, projSvc.Archive(context.Background(), caller, "demo")) + + _, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "X"}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeProjectArchived, ae.Code) +} + +func TestRequirementService_Reopen_AllowsPhaseAgain(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newReqSvc(repo) + caller := Caller{UserID: owner} + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number)) + require.NoError(t, svc.Reopen(context.Background(), caller, "demo", r.Number)) + + out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseDone) + require.NoError(t, err) + require.Equal(t, PhaseDone, out.Phase) +}