Files
agentic-coding-workflow/internal/project/requirement_service.go
T

227 lines
6.6 KiB
Go

// 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"] = "<changed>"
}
if in.Description != nil {
r.Description = *in.Description
changed["description"] = "<changed>"
}
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,
})
}