主流程

This commit is contained in:
2026-06-10 17:53:09 +08:00
parent c6f239f424
commit b20435c027
66 changed files with 2779 additions and 1181 deletions
+121
View File
@@ -0,0 +1,121 @@
// Package project 内的 artifact_service.go 实装 ArtifactService。
//
// 设计取舍:
// - Version 在 (Requirement, Phase) 内自增(max+1),由 service 计算后落库;
// UNIQUE(requirement_id, phase, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。
// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing),与 DB CHECK 一致。
// - 已 closed requirement:禁止新建产物版本,返回 CodeRequirementClosed。
// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。
// - 读操作经 loadProjectForRead 校验可见性。
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 artifactService struct {
repo Repository
audit audit.Recorder
}
// NewArtifactService 构造 ArtifactService。
func NewArtifactService(repo Repository, rec audit.Recorder) ArtifactService {
return &artifactService{repo: repo, audit: rec}
}
func (s *artifactService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) {
if !in.Phase.IsArtifactPhase() {
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
}
if in.Content == "" {
return nil, errs.New(errs.CodeInvalidInput, "content 必填")
}
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
if r.Status == StatusClosed {
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增产物版本")
}
out, err := s.createWithRetry(ctx, r.ID, c.UserID, in)
if err != nil {
return nil, err
}
s.recordAudit(ctx, c, "requirement_artifact.create", out.ID.String(), map[string]any{
"requirement_id": r.ID.String(), "phase": string(out.Phase), "version": out.Version,
})
return out, nil
}
// createWithRetry 取 (requirement, phase) 内 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。
func (s *artifactService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreateArtifactInput) (*Artifact, error) {
for attempt := 0; attempt < 2; attempt++ {
maxVer, err := s.repo.GetMaxArtifactVersion(ctx, reqID, in.Phase)
if err != nil {
return nil, err
}
out, err := s.repo.CreateArtifact(ctx, &Artifact{
ID: uuid.New(), RequirementID: reqID, Phase: in.Phase,
Version: maxVer + 1, Content: in.Content, Note: in.Note,
SourceMessageID: in.SourceMessageID,
CreatedBy: createdBy,
})
if err == nil {
return out, nil
}
if !IsUniqueViolation(err) {
return nil, err
}
// version 撞了,下一轮重新取 max
}
return nil, errs.New(errs.CodeInternal, "artifact version 持续冲突")
}
func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase) ([]*Artifact, error) {
if phase != "" && !phase.IsArtifactPhase() {
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
}
p, err := loadProjectForRead(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
return s.repo.ListArtifactsByRequirement(ctx, r.ID, phase)
}
func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int) (*Artifact, error) {
if !phase.IsArtifactPhase() {
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
}
p, err := loadProjectForRead(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
return s.repo.GetArtifactByVersion(ctx, r.ID, phase, version)
}
func (s *artifactService) 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_artifact", TargetID: targetID, Metadata: meta,
})
}
+249
View File
@@ -0,0 +1,249 @@
package project
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) {
spy := &spyAudit{}
return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
}
func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, spy := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
a1, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
require.NoError(t, err)
require.Equal(t, 1, a1.Version)
a2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v2", Note: "second"})
require.NoError(t, err)
require.Equal(t, 2, a2.Version)
require.Equal(t, "second", a2.Note)
require.Contains(t, spy.actions(), "requirement_artifact.create")
}
func TestArtifactService_Create_VersionsIndependentPerPhase(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
// planning 走到 v2,prototyping 的版本序列从 1 重新开始。
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p1"})
require.NoError(t, err)
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p2"})
require.NoError(t, err)
proto, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "x1"})
require.NoError(t, err)
require.Equal(t, 1, proto.Version)
}
func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} {
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"})
ae, ok := errs.As(err)
require.True(t, ok, "phase=%s", phase)
require.Equal(t, errs.CodeInvalidInput, ae.Code, "phase=%s", phase)
}
}
func TestArtifactService_Create_RetriesOnVersionConflict(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
// 首轮 CreateArtifact 撞 UNIQUE,service 应捕获并重试一次后成功。
repo.createArtifactFailOnce = true
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
require.NoError(t, err)
require.Equal(t, 1, a.Version)
require.False(t, repo.createArtifactFailOnce, "失败开关应已被消费")
}
func TestArtifactService_Create_RequiresContent(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: ""})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeInvalidInput, ae.Code)
}
func TestArtifactService_Create_BlockedWhenRequirementClosed(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
}
func TestArtifactService_Create_RecordsSourceMessageID(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
srcID := int64(42)
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{
Phase: PhaseAuditing, Content: "from chat", SourceMessageID: &srcID,
})
require.NoError(t, err)
require.NotNil(t, a.SourceMessageID)
require.Equal(t, srcID, *a.SourceMessageID)
}
func TestArtifactService_List_FilterByPhaseAndAll(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
for i := 0; i < 2; i++ {
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "x"})
require.NoError(t, err)
}
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "y"})
require.NoError(t, err)
planning, err := artSvc.List(context.Background(), caller, "demo", r.Number, PhasePlanning)
require.NoError(t, err)
require.Len(t, planning, 2)
require.Equal(t, 1, planning[0].Version)
require.Equal(t, 2, planning[1].Version)
all, err := artSvc.List(context.Background(), caller, "demo", r.Number, "")
require.NoError(t, err)
require.Len(t, all, 3)
_, err = artSvc.List(context.Background(), caller, "demo", r.Number, Phase("bogus"))
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeInvalidInput, ae.Code)
}
func TestArtifactService_GetByVersion_NotFound(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePlanning, 99)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestArtifactService_GetByVersion_HappyPath(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "hello", Note: "n"})
require.NoError(t, err)
got, err := artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePrototyping, 1)
require.NoError(t, err)
require.Equal(t, "hello", got.Content)
require.Equal(t, "n", got.Note)
require.Equal(t, owner, got.CreatedBy)
}
func TestArtifactService_Create_ForbiddenForOutsider(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
owns := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
// 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。
outsider := Caller{UserID: uuid.New()}
_, err = artSvc.Create(context.Background(), outsider, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
owns := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
outsider := Caller{UserID: uuid.New()}
_, err = artSvc.List(context.Background(), outsider, "demo", r.Number, "")
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
+42 -20
View File
@@ -40,6 +40,20 @@ func (p Phase) IsValid() bool {
return false
}
// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase
// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。
var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing}
// IsArtifactPhase 报告 p 是否允许创建产物版本。
func (p Phase) IsArtifactPhase() bool {
for _, x := range ArtifactPhases {
if p == x {
return true
}
}
return false
}
// Status 是 Requirement / Issue 通用的开/关状态。
type Status string
@@ -115,16 +129,20 @@ type Issue struct {
UpdatedAt time.Time
}
// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement
// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
type Prototype struct {
ID uuid.UUID
RequirementID uuid.UUID
Version int
Content string
Note string
CreatedBy uuid.UUID
CreatedAt time.Time
// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
// (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
// SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用,
// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。
type Artifact struct {
ID uuid.UUID
RequirementID uuid.UUID
Phase Phase
Version int
Content string
Note string
SourceMessageID *int64
CreatedBy uuid.UUID
CreatedAt time.Time
}
// Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。
@@ -169,11 +187,12 @@ type IssueService interface {
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
}
// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。
type PrototypeService interface {
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error)
List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error)
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error)
// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
type ArtifactService interface {
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error)
// List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error)
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error)
}
// ===== 输入 DTO =====
@@ -240,11 +259,14 @@ type CreateIssueInput struct {
WorkspaceID *uuid.UUID
}
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
// 不在此处提供。Note 可为空字符串。
type CreatePrototypeInput struct {
Content string
Note string
// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
// ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases
// 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。
type CreateArtifactInput struct {
Phase Phase
Content string
Note string
SourceMessageID *int64
}
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
+45 -37
View File
@@ -38,14 +38,14 @@ type Handler struct {
projects ProjectService
requirements RequirementService
issues IssueService
prototypes PrototypeService
artifacts ArtifactService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users}
func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users}
}
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
@@ -67,9 +67,9 @@ func (h *Handler) Mount(r chi.Router) {
r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype)
r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes)
r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype)
r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
r.Post("/{slug}/issues", h.createIssue)
r.Get("/{slug}/issues", h.listIssues)
@@ -161,14 +161,16 @@ type issueDTO struct {
UpdatedAt string `json:"updated_at"`
}
type prototypeDTO struct {
ID string `json:"id"`
RequirementID string `json:"requirement_id"`
Version int `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
type artifactDTO struct {
ID string `json:"id"`
RequirementID string `json:"requirement_id"`
Phase string `json:"phase"`
Version int `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
func toProjectDTO(p *Project) projectDTO {
@@ -231,12 +233,13 @@ func toIssueDTO(i *Issue) issueDTO {
return d
}
func toPrototypeDTO(p *Prototype) prototypeDTO {
return prototypeDTO{
ID: p.ID.String(), RequirementID: p.RequirementID.String(),
Version: p.Version, Content: p.Content, Note: p.Note,
CreatedBy: p.CreatedBy.String(),
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
func toArtifactDTO(a *Artifact) artifactDTO {
return artifactDTO{
ID: a.ID.String(), RequirementID: a.RequirementID.String(),
Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
SourceMessageID: a.SourceMessageID,
CreatedBy: a.CreatedBy.String(),
CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
@@ -584,14 +587,16 @@ func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClos
w.WriteHeader(http.StatusNoContent)
}
// ===== Prototype endpoints =====
// ===== Artifact endpoints =====
type createPrototypeReq struct {
Content string `json:"content"`
Note string `json:"note"`
type createArtifactReq struct {
Phase string `json:"phase"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
}
func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
func (h *Handler) createArtifact(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
@@ -602,22 +607,23 @@ func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
var req createPrototypeReq
var req createArtifactReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{
Content: req.Content, Note: req.Note,
out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
SourceMessageID: req.SourceMessageID,
})
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, toPrototypeDTO(out))
httpx.WriteJSON(w, http.StatusCreated, toArtifactDTO(out))
}
func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
func (h *Handler) listArtifacts(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
@@ -628,19 +634,20 @@ func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err)
return
}
out, err := h.prototypes.List(r.Context(), c, chi.URLParam(r, "slug"), num)
phase := Phase(r.URL.Query().Get("phase"))
out, err := h.artifacts.List(r.Context(), c, chi.URLParam(r, "slug"), num, phase)
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]prototypeDTO, 0, len(out))
dtos := make([]artifactDTO, 0, len(out))
for _, x := range out {
dtos = append(dtos, toPrototypeDTO(x))
dtos = append(dtos, toArtifactDTO(x))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototypes": dtos})
httpx.WriteJSON(w, http.StatusOK, dtos)
}
func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
@@ -656,12 +663,13 @@ func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
return
}
out, err := h.prototypes.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, ver)
phase := Phase(chi.URLParam(r, "phase"))
out, err := h.artifacts.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)})
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
}
// ===== Issue endpoints =====
+53 -2
View File
@@ -34,9 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
pSvc := NewProjectService(repo, nil)
rSvc := NewRequirementService(repo, nil)
iSvc := NewIssueService(repo, nil)
protoSvc := NewPrototypeService(repo, nil)
artSvc := NewArtifactService(repo, nil)
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
h := NewHandler(pSvc, rSvc, iSvc, protoSvc, resolver, fakeAdmin{})
h := NewHandler(pSvc, rSvc, iSvc, artSvc, resolver, fakeAdmin{})
r := chi.NewRouter()
r.Use(middleware.RequestID)
h.Mount(r)
@@ -214,3 +214,54 @@ func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) {
t.Cleanup(func() { _ = resp5.Body.Close() })
require.Equal(t, http.StatusBadRequest, resp5.StatusCode)
}
// TestHandler_Artifacts_Routes 验证 artifacts 三条路由:创建 201、list 裸数组 +
// ?phase= 过滤、{phase}/{version} 获取、非法 phase 400。
func TestHandler_Artifacts_Routes(t *testing.T) {
owner := uuid.New()
tok := "tok"
_, h := mountFullHandler(t, tok, owner)
checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated)
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated)
// 创建 planning v1(带 source_message_id)+ auditing v1
resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
map[string]any{"phase": "planning", "content": "# plan", "note": "n1", "source_message_id": 7})
require.Equal(t, http.StatusCreated, resp.StatusCode)
created := decodeBodyMap(t, resp)
require.Equal(t, "planning", created["phase"])
require.Equal(t, float64(1), created["version"])
require.Equal(t, float64(7), created["source_message_id"])
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated)
// 非法 phase → 400
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
map[string]any{"phase": "implementing", "content": "x"}, http.StatusBadRequest)
// list 裸数组:全量 2 条;?phase=planning 过滤 1 条
respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil)
t.Cleanup(func() { _ = respList.Body.Close() })
require.Equal(t, http.StatusOK, respList.StatusCode)
var all []map[string]any
require.NoError(t, json.NewDecoder(respList.Body).Decode(&all))
require.Len(t, all, 2)
respPlanning := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts?phase=planning", tok, nil)
t.Cleanup(func() { _ = respPlanning.Body.Close() })
require.Equal(t, http.StatusOK, respPlanning.StatusCode)
var planning []map[string]any
require.NoError(t, json.NewDecoder(respPlanning.Body).Decode(&planning))
require.Len(t, planning, 1)
require.Equal(t, "# plan", planning[0]["content"])
// get by {phase}/{version}
respGet := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/1", tok, nil)
got := decodeBodyMap(t, respGet)
require.Equal(t, http.StatusOK, respGet.StatusCode)
require.Equal(t, "# plan", got["content"])
// get 非法 phase → 400;不存在版本 → 404
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest)
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound)
}
+35 -30
View File
@@ -23,11 +23,11 @@ type fakeRepo struct {
projects map[uuid.UUID]*Project
requirements map[uuid.UUID]*Requirement
issues map[uuid.UUID]*Issue
prototypes map[uuid.UUID]*Prototype
artifacts map[uuid.UUID]*Artifact
// 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。
createPrototypeFailOnce bool
// 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
createArtifactFailOnce bool
}
func newFakeRepo() *fakeRepo {
@@ -35,14 +35,14 @@ func newFakeRepo() *fakeRepo {
projects: map[uuid.UUID]*Project{},
requirements: map[uuid.UUID]*Requirement{},
issues: map[uuid.UUID]*Issue{},
prototypes: map[uuid.UUID]*Prototype{},
artifacts: map[uuid.UUID]*Artifact{},
}
}
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
func (r *fakeRepo) clonePrototype(p *Prototype) *Prototype { v := *p; return &v }
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
func (r *fakeRepo) cloneArtifact(a *Artifact) *Artifact { v := *a; return &v }
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
r.mu.Lock()
@@ -369,58 +369,63 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
return nil
}
func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) {
func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
maxVer := 0
for _, x := range r.prototypes {
if x.RequirementID == requirementID && x.Version > maxVer {
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer {
maxVer = x.Version
}
}
return maxVer, nil
}
func (r *fakeRepo) CreatePrototype(_ context.Context, in *Prototype) (*Prototype, error) {
func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.createPrototypeFailOnce {
r.createPrototypeFailOnce = false
if r.createArtifactFailOnce {
r.createArtifactFailOnce = false
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
for _, x := range r.prototypes {
if x.RequirementID == in.RequirementID && x.Version == in.Version {
for _, x := range r.artifacts {
if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version {
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
}
}
in.CreatedAt = time.Now()
r.prototypes[in.ID] = r.clonePrototype(in)
return r.clonePrototype(in), nil
r.artifacts[in.ID] = r.cloneArtifact(in)
return r.cloneArtifact(in), nil
}
func (r *fakeRepo) ListPrototypesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Prototype, error) {
func (r *fakeRepo) ListArtifactsByRequirement(_ context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*Prototype, 0)
for _, x := range r.prototypes {
if x.RequirementID == requirementID {
out = append(out, r.clonePrototype(x))
out := make([]*Artifact, 0)
for _, x := range r.artifacts {
if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) {
out = append(out, r.cloneArtifact(x))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
sort.Slice(out, func(i, j int) bool {
if out[i].Phase != out[j].Phase {
return out[i].Phase < out[j].Phase
}
return out[i].Version < out[j].Version
})
return out, nil
}
func (r *fakeRepo) GetPrototypeByVersion(_ context.Context, requirementID uuid.UUID, version int) (*Prototype, error) {
func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, x := range r.prototypes {
if x.RequirementID == requirementID && x.Version == version {
return r.clonePrototype(x), nil
for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Phase == phase && x.Version == version {
return r.cloneArtifact(x), nil
}
}
return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
// spyAudit 收集所有写入的 audit entry,方便断言。
-110
View File
@@ -1,110 +0,0 @@
// Package project 内的 prototype_service.go 实装 PrototypeService。
//
// 设计取舍:
// - Version 在同一 Requirement 内自增(max+1),由 service 计算后落库;
// UNIQUE(requirement_id, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。
// - 已 closed requirement:禁止新建原型版本,返回 CodeRequirementClosed。
// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。
// - 读操作经 loadProjectForRead 校验可见性。
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 prototypeService struct {
repo Repository
audit audit.Recorder
}
// NewPrototypeService 构造 PrototypeService。
func NewPrototypeService(repo Repository, rec audit.Recorder) PrototypeService {
return &prototypeService{repo: repo, audit: rec}
}
func (s *prototypeService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error) {
if in.Content == "" {
return nil, errs.New(errs.CodeInvalidInput, "content 必填")
}
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
if r.Status == StatusClosed {
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增原型版本")
}
out, err := s.createWithRetry(ctx, r.ID, c.UserID, in)
if err != nil {
return nil, err
}
s.recordAudit(ctx, c, "requirement_prototype.create", out.ID.String(), map[string]any{
"requirement_id": r.ID.String(), "version": out.Version,
})
return out, nil
}
// createWithRetry 取 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。
func (s *prototypeService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreatePrototypeInput) (*Prototype, error) {
for attempt := 0; attempt < 2; attempt++ {
maxVer, err := s.repo.GetMaxPrototypeVersion(ctx, reqID)
if err != nil {
return nil, err
}
out, err := s.repo.CreatePrototype(ctx, &Prototype{
ID: uuid.New(), RequirementID: reqID,
Version: maxVer + 1, Content: in.Content, Note: in.Note,
CreatedBy: createdBy,
})
if err == nil {
return out, nil
}
if !IsUniqueViolation(err) {
return nil, err
}
// version 撞了,下一轮重新取 max
}
return nil, errs.New(errs.CodeInternal, "prototype version 持续冲突")
}
func (s *prototypeService) List(ctx context.Context, c Caller, slug string, reqNumber int) ([]*Prototype, error) {
p, err := loadProjectForRead(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
return s.repo.ListPrototypesByRequirement(ctx, r.ID)
}
func (s *prototypeService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, version int) (*Prototype, error) {
p, err := loadProjectForRead(ctx, s.repo, c, slug)
if err != nil {
return nil, err
}
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
if err != nil {
return nil, err
}
return s.repo.GetPrototypeByVersion(ctx, r.ID, version)
}
func (s *prototypeService) 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_prototype", TargetID: targetID, Metadata: meta,
})
}
-181
View File
@@ -1,181 +0,0 @@
package project
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newProtoSvc(repo *fakeRepo) (PrototypeService, RequirementService, ProjectService, *spyAudit) {
spy := &spyAudit{}
return NewPrototypeService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
}
func TestPrototypeService_Create_VersionAutoIncrement(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, spy := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
p1, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
require.NoError(t, err)
require.Equal(t, 1, p1.Version)
p2, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v2", Note: "second"})
require.NoError(t, err)
require.Equal(t, 2, p2.Version)
require.Equal(t, "second", p2.Note)
require.Contains(t, spy.actions(), "requirement_prototype.create")
}
func TestPrototypeService_Create_RetriesOnVersionConflict(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
// 首轮 CreatePrototype 撞 UNIQUE,service 应捕获并重试一次后成功。
repo.createPrototypeFailOnce = true
p, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
require.NoError(t, err)
require.Equal(t, 1, p.Version)
require.False(t, repo.createPrototypeFailOnce, "失败开关应已被消费")
}
func TestPrototypeService_Create_RequiresContent(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: ""})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeInvalidInput, ae.Code)
}
func TestPrototypeService_Create_BlockedWhenRequirementClosed(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
}
func TestPrototypeService_List_OrderedByVersion(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
for i := 0; i < 3; i++ {
_, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "x"})
require.NoError(t, err)
}
list, err := protoSvc.List(context.Background(), caller, "demo", r.Number)
require.NoError(t, err)
require.Len(t, list, 3)
require.Equal(t, 1, list[0].Version)
require.Equal(t, 2, list[1].Version)
require.Equal(t, 3, list[2].Version)
}
func TestPrototypeService_GetByVersion_NotFound(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 99)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestPrototypeService_GetByVersion_HappyPath(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
caller := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
_, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "hello", Note: "n"})
require.NoError(t, err)
got, err := protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 1)
require.NoError(t, err)
require.Equal(t, "hello", got.Content)
require.Equal(t, "n", got.Note)
require.Equal(t, owner, got.CreatedBy)
}
func TestPrototypeService_Create_ForbiddenForOutsider(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
owns := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
// 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。
outsider := Caller{UserID: uuid.New()}
_, err = protoSvc.Create(context.Background(), outsider, "demo", r.Number, CreatePrototypeInput{Content: "v1"})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestPrototypeService_List_ForbiddenForOutsider(t *testing.T) {
repo := newFakeRepo()
owner := uuid.New()
seedProject(t, repo, owner)
protoSvc, reqSvc, _, _ := newProtoSvc(repo)
owns := Caller{UserID: owner}
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
require.NoError(t, err)
outsider := Caller{UserID: uuid.New()}
_, err = protoSvc.List(context.Background(), outsider, "demo", r.Number)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeNotFound, ae.Code)
}
+19
View File
@@ -0,0 +1,19 @@
-- name: CreateRequirementArtifact :one
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at;
-- name: ListRequirementArtifactsByRequirement :many
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
FROM requirement_artifacts
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
ORDER BY phase ASC, version ASC;
-- name: GetRequirementArtifactByVersion :one
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
FROM requirement_artifacts
WHERE requirement_id = $1 AND phase = $2 AND version = $3;
-- name: GetMaxRequirementArtifactVersion :one
SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts
WHERE requirement_id = $1 AND phase = $2;
-19
View File
@@ -1,19 +0,0 @@
-- name: CreateRequirementPrototype :one
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, requirement_id, version, content, note, created_by, created_at;
-- name: ListRequirementPrototypesByRequirement :many
SELECT id, requirement_id, version, content, note, created_by, created_at
FROM requirement_prototypes
WHERE requirement_id = $1
ORDER BY version ASC;
-- name: GetRequirementPrototypeByVersion :one
SELECT id, requirement_id, version, content, note, created_by, created_at
FROM requirement_prototypes
WHERE requirement_id = $1 AND version = $2;
-- name: GetMaxRequirementPrototypeVersion :one
SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes
WHERE requirement_id = $1;
+50 -38
View File
@@ -51,11 +51,12 @@ type Repository interface {
CloseIssue(ctx context.Context, id uuid.UUID) error
ReopenIssue(ctx context.Context, id uuid.UUID) error
// Prototype
CreatePrototype(ctx context.Context, p *Prototype) (*Prototype, error)
ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error)
GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error)
GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error)
// Artifact
CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
// ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error)
GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error)
GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error)
}
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
@@ -498,68 +499,79 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
return nil
}
// ===== Prototype =====
// ===== Artifact =====
func rowToPrototype(row projectsqlc.RequirementPrototype) *Prototype {
return &Prototype{
ID: fromPgUUID(row.ID),
RequirementID: fromPgUUID(row.RequirementID),
Version: int(row.Version),
Content: row.Content,
Note: row.Note,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
return &Artifact{
ID: fromPgUUID(row.ID),
RequirementID: fromPgUUID(row.RequirementID),
Phase: Phase(row.Phase),
Version: int(row.Version),
Content: row.Content,
Note: row.Note,
SourceMessageID: row.SourceMessageID,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
}
}
func (r *pgRepo) CreatePrototype(ctx context.Context, in *Prototype) (*Prototype, error) {
row, err := r.q.CreateRequirementPrototype(ctx, projectsqlc.CreateRequirementPrototypeParams{
ID: toPgUUID(in.ID),
RequirementID: toPgUUID(in.RequirementID),
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
Content: in.Content,
Note: in.Note,
CreatedBy: toPgUUID(in.CreatedBy),
func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) {
row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{
ID: toPgUUID(in.ID),
RequirementID: toPgUUID(in.RequirementID),
Phase: string(in.Phase),
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
Content: in.Content,
Note: in.Note,
SourceMessageID: in.SourceMessageID,
CreatedBy: toPgUUID(in.CreatedBy),
})
if err != nil {
if IsUniqueViolation(err) {
return nil, err // service 层据此重试一次
}
return nil, errs.Wrap(err, errs.CodeInternal, "create prototype")
return nil, errs.Wrap(err, errs.CodeInternal, "create artifact")
}
return rowToPrototype(row), nil
return rowToArtifact(row), nil
}
func (r *pgRepo) ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) {
rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID))
func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{
RequirementID: toPgUUID(requirementID),
Column2: string(phase),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list prototypes by requirement")
return nil, errs.Wrap(err, errs.CodeInternal, "list artifacts by requirement")
}
out := make([]*Prototype, 0, len(rows))
out := make([]*Artifact, 0, len(rows))
for _, row := range rows {
out = append(out, rowToPrototype(row))
out = append(out, rowToArtifact(row))
}
return out, nil
}
func (r *pgRepo) GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) {
row, err := r.q.GetRequirementPrototypeByVersion(ctx, projectsqlc.GetRequirementPrototypeByVersionParams{
func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "get prototype by version")
return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by version")
}
return rowToPrototype(row), nil
return rowToArtifact(row), nil
}
func (r *pgRepo) GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error) {
max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID))
func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
})
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "get max prototype version")
return 0, errs.Wrap(err, errs.CodeInternal, "get max artifact version")
}
return int(max), nil
}
+143
View File
@@ -0,0 +1,143 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: artifacts.sql
package projectsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createRequirementArtifact = `-- name: CreateRequirementArtifact :one
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
`
type CreateRequirementArtifactParams struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
}
func (q *Queries) CreateRequirementArtifact(ctx context.Context, arg CreateRequirementArtifactParams) (RequirementArtifact, error) {
row := q.db.QueryRow(ctx, createRequirementArtifact,
arg.ID,
arg.RequirementID,
arg.Phase,
arg.Version,
arg.Content,
arg.Note,
arg.SourceMessageID,
arg.CreatedBy,
)
var i RequirementArtifact
err := row.Scan(
&i.ID,
&i.RequirementID,
&i.Phase,
&i.Version,
&i.Content,
&i.Note,
&i.SourceMessageID,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const getMaxRequirementArtifactVersion = `-- name: GetMaxRequirementArtifactVersion :one
SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts
WHERE requirement_id = $1 AND phase = $2
`
type GetMaxRequirementArtifactVersionParams struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
}
func (q *Queries) GetMaxRequirementArtifactVersion(ctx context.Context, arg GetMaxRequirementArtifactVersionParams) (int32, error) {
row := q.db.QueryRow(ctx, getMaxRequirementArtifactVersion, arg.RequirementID, arg.Phase)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const getRequirementArtifactByVersion = `-- name: GetRequirementArtifactByVersion :one
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
FROM requirement_artifacts
WHERE requirement_id = $1 AND phase = $2 AND version = $3
`
type GetRequirementArtifactByVersionParams struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"`
}
func (q *Queries) GetRequirementArtifactByVersion(ctx context.Context, arg GetRequirementArtifactByVersionParams) (RequirementArtifact, error) {
row := q.db.QueryRow(ctx, getRequirementArtifactByVersion, arg.RequirementID, arg.Phase, arg.Version)
var i RequirementArtifact
err := row.Scan(
&i.ID,
&i.RequirementID,
&i.Phase,
&i.Version,
&i.Content,
&i.Note,
&i.SourceMessageID,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const listRequirementArtifactsByRequirement = `-- name: ListRequirementArtifactsByRequirement :many
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
FROM requirement_artifacts
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
ORDER BY phase ASC, version ASC
`
type ListRequirementArtifactsByRequirementParams struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Column2 string `json:"column_2"`
}
func (q *Queries) ListRequirementArtifactsByRequirement(ctx context.Context, arg ListRequirementArtifactsByRequirementParams) ([]RequirementArtifact, error) {
rows, err := q.db.Query(ctx, listRequirementArtifactsByRequirement, arg.RequirementID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RequirementArtifact
for rows.Next() {
var i RequirementArtifact
if err := rows.Scan(
&i.ID,
&i.RequirementID,
&i.Phase,
&i.Version,
&i.Content,
&i.Note,
&i.SourceMessageID,
&i.CreatedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+11 -8
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,14 +277,16 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type RequirementPrototype struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
type RequirementArtifact struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
-122
View File
@@ -1,122 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: prototypes.sql
package projectsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createRequirementPrototype = `-- name: CreateRequirementPrototype :one
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, requirement_id, version, content, note, created_by, created_at
`
type CreateRequirementPrototypeParams struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
CreatedBy pgtype.UUID `json:"created_by"`
}
func (q *Queries) CreateRequirementPrototype(ctx context.Context, arg CreateRequirementPrototypeParams) (RequirementPrototype, error) {
row := q.db.QueryRow(ctx, createRequirementPrototype,
arg.ID,
arg.RequirementID,
arg.Version,
arg.Content,
arg.Note,
arg.CreatedBy,
)
var i RequirementPrototype
err := row.Scan(
&i.ID,
&i.RequirementID,
&i.Version,
&i.Content,
&i.Note,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const getMaxRequirementPrototypeVersion = `-- name: GetMaxRequirementPrototypeVersion :one
SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes
WHERE requirement_id = $1
`
func (q *Queries) GetMaxRequirementPrototypeVersion(ctx context.Context, requirementID pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, getMaxRequirementPrototypeVersion, requirementID)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const getRequirementPrototypeByVersion = `-- name: GetRequirementPrototypeByVersion :one
SELECT id, requirement_id, version, content, note, created_by, created_at
FROM requirement_prototypes
WHERE requirement_id = $1 AND version = $2
`
type GetRequirementPrototypeByVersionParams struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Version int32 `json:"version"`
}
func (q *Queries) GetRequirementPrototypeByVersion(ctx context.Context, arg GetRequirementPrototypeByVersionParams) (RequirementPrototype, error) {
row := q.db.QueryRow(ctx, getRequirementPrototypeByVersion, arg.RequirementID, arg.Version)
var i RequirementPrototype
err := row.Scan(
&i.ID,
&i.RequirementID,
&i.Version,
&i.Content,
&i.Note,
&i.CreatedBy,
&i.CreatedAt,
)
return i, err
}
const listRequirementPrototypesByRequirement = `-- name: ListRequirementPrototypesByRequirement :many
SELECT id, requirement_id, version, content, note, created_by, created_at
FROM requirement_prototypes
WHERE requirement_id = $1
ORDER BY version ASC
`
func (q *Queries) ListRequirementPrototypesByRequirement(ctx context.Context, requirementID pgtype.UUID) ([]RequirementPrototype, error) {
rows, err := q.db.Query(ctx, listRequirementPrototypesByRequirement, requirementID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RequirementPrototype
for rows.Next() {
var i RequirementPrototype
if err := rows.Scan(
&i.ID,
&i.RequirementID,
&i.Version,
&i.Content,
&i.Note,
&i.CreatedBy,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}