diff --git a/internal/app/app.go b/internal/app/app.go
index 1e277c3..a5727e1 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -92,6 +92,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
+ prototypeSvc := project.NewPrototypeService(projectRepo, wrappedAudit)
notifyRepo := notify.NewPostgresRepository(pool)
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
@@ -121,7 +122,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
user.NewHandler(userSvc).Mount(r)
notify.NewHandler(notifyRepo, userSvc).Mount(r)
- project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
+ project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
// ===== workspace 模块装配 =====
// 依赖图:
@@ -267,6 +268,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
Projects: projectSvc,
Reqs: requirementSvc,
Issues: issueSvc,
+ Prototypes: prototypeSvc,
Workspaces: wsSvc,
Templates: chatTplSvc,
Messages: chatMsgSvc,
diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go
index 638b769..409969a 100644
--- a/internal/app/integration_test.go
+++ b/internal/app/integration_test.go
@@ -158,6 +158,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
issueSvc := project.NewIssueService(projectRepo, auditRec)
+ prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
@@ -166,7 +167,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
- project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
+ project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
@@ -325,6 +326,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
issueSvc := project.NewIssueService(projectRepo, auditRec)
+ prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
@@ -366,7 +368,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
- project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
+ project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r)
diff --git a/internal/app/mcp_integration_test.go b/internal/app/mcp_integration_test.go
index 0912625..0cfd7be 100644
--- a/internal/app/mcp_integration_test.go
+++ b/internal/app/mcp_integration_test.go
@@ -444,7 +444,7 @@ func (s *mcpIntegrationSuite) testStreamableHTTPFullFlow(t *testing.T) {
// List tools — assert >= 17 tools registered
toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{})
require.NoError(t, err, "ListTools failed")
- assert.Len(t, toolsResult.Tools, 17, "expected at least 17 tools registered")
+ assert.GreaterOrEqual(t, len(toolsResult.Tools), 17, "expected at least 17 tools registered")
// Call list_issues — verify response structure
callResult, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index 3532538..bac8b61 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -14,6 +14,7 @@ type ServerDeps struct {
Projects project.ProjectService
Reqs project.RequirementService
Issues project.IssueService
+ Prototypes project.PrototypeService
Workspaces workspace.WorkspaceService
Templates chat.TemplateService
Messages chat.MessageService
diff --git a/internal/mcp/token_service.go b/internal/mcp/token_service.go
index 0c71791..99fc6d7 100644
--- a/internal/mcp/token_service.go
+++ b/internal/mcp/token_service.go
@@ -62,6 +62,7 @@ var defaultSystemTools = []string{
"get_issue", "get_requirement",
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
+ "add_requirement_prototype", "list_requirement_prototypes",
}
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
@@ -209,6 +210,7 @@ func (s *tokenService) issueSystemToken(ctx context.Context, repo Repository, in
ExpiresAt: created.ExpiresAt,
}, nil
}
+
// Authenticate 校验 plaintext token:
// 1. 计算 hash 反查 row
// 2. 检查 revoked_at IS NULL
@@ -245,6 +247,7 @@ func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*Tok
return tok, nil
}
+
// Revoke 由 admin 后台或持有者主动调用。
// 写一条 audit `mcp.token.revoke`。
func (s *tokenService) Revoke(ctx context.Context, id, byUserID uuid.UUID) error {
@@ -258,8 +261,8 @@ func (s *tokenService) Revoke(ctx context.Context, id, byUserID uuid.UUID) error
TargetType: "mcp_token",
TargetID: revokedID.String(),
Metadata: map[string]any{
- "token_id": revokedID.String(),
- "by_user_id": byUserID.String(),
+ "token_id": revokedID.String(),
+ "by_user_id": byUserID.String(),
},
})
return nil
diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go
index 5a725e9..2aa73c9 100644
--- a/internal/mcp/tools_pm.go
+++ b/internal/mcp/tools_pm.go
@@ -29,6 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerCloseRequirement(srv, deps)
registerReopenRequirement(srv, deps)
registerSetRequirementPhase(srv, deps)
+ registerAddRequirementPrototype(srv, deps)
+ registerListRequirementPrototypes(srv, deps)
}
// ===== list_projects =====
@@ -709,5 +711,98 @@ func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
})
}
+// ===== add_requirement_prototype =====
+
+type addRequirementPrototypeIn struct {
+ ProjectSlug string `json:"project_slug" jsonschema:"required field"`
+ Number int `json:"number" jsonschema:"requirement number (>=1)"`
+ Content string `json:"content" jsonschema:"prototype content (non-empty)"`
+ Note string `json:"note,omitempty"`
+}
+type prototypeDetail struct {
+ Version int `json:"version"`
+ Content string `json:"content"`
+ Note string `json:"note,omitempty"`
+ CreatedAt string `json:"created_at"`
+}
+type prototypeCreateOut struct {
+ OK bool `json:"ok"`
+ Prototype prototypeDetail `json:"prototype"`
+}
+
+func registerAddRequirementPrototype(srv *mcpsdk.Server, deps ServerDeps) {
+ mcpsdk.AddTool(srv,
+ &mcpsdk.Tool{Name: "add_requirement_prototype", Description: "Add a new prototype version to a requirement."},
+ func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementPrototypeIn) (*mcpsdk.CallToolResult, prototypeCreateOut, error) {
+ if err := CheckToolFromCtx(ctx, "add_requirement_prototype"); err != nil {
+ return nil, prototypeCreateOut{}, err
+ }
+ c, err := deps.Caller.Resolve(ctx)
+ if err != nil {
+ return nil, prototypeCreateOut{}, err
+ }
+ p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
+ if err != nil {
+ return nil, prototypeCreateOut{}, err
+ }
+ if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
+ return nil, prototypeCreateOut{}, err
+ }
+ proto, err := deps.Prototypes.Create(ctx, c, in.ProjectSlug, in.Number, project.CreatePrototypeInput{
+ Content: in.Content, Note: in.Note,
+ })
+ if err != nil {
+ return nil, prototypeCreateOut{}, err
+ }
+ return nil, prototypeCreateOut{OK: true, Prototype: prototypeDetail{
+ Version: proto.Version, Content: proto.Content, Note: proto.Note,
+ CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ }}, nil
+ })
+}
+
+// ===== list_requirement_prototypes =====
+
+type listRequirementPrototypesIn struct {
+ ProjectSlug string `json:"project_slug" jsonschema:"required field"`
+ Number int `json:"number" jsonschema:"requirement number (>=1)"`
+}
+type listRequirementPrototypesOut struct {
+ Prototypes []prototypeDetail `json:"prototypes"`
+}
+
+func registerListRequirementPrototypes(srv *mcpsdk.Server, deps ServerDeps) {
+ mcpsdk.AddTool(srv,
+ &mcpsdk.Tool{Name: "list_requirement_prototypes", Description: "List prototype versions of a requirement."},
+ func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementPrototypesIn) (*mcpsdk.CallToolResult, listRequirementPrototypesOut, error) {
+ if err := CheckToolFromCtx(ctx, "list_requirement_prototypes"); err != nil {
+ return nil, listRequirementPrototypesOut{}, err
+ }
+ c, err := deps.Caller.Resolve(ctx)
+ if err != nil {
+ return nil, listRequirementPrototypesOut{}, err
+ }
+ p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
+ if err != nil {
+ return nil, listRequirementPrototypesOut{}, err
+ }
+ if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
+ return nil, listRequirementPrototypesOut{}, err
+ }
+ protos, err := deps.Prototypes.List(ctx, c, in.ProjectSlug, in.Number)
+ if err != nil {
+ return nil, listRequirementPrototypesOut{}, err
+ }
+ out := listRequirementPrototypesOut{Prototypes: make([]prototypeDetail, 0, len(protos))}
+ for _, proto := range protos {
+ out.Prototypes = append(out.Prototypes, prototypeDetail{
+ Version: proto.Version, Content: proto.Content, Note: proto.Note,
+ CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ })
+ }
+ return nil, out, nil
+ })
+}
+
// 保证 fmt 引用(编译期检查)
var _ = fmt.Sprintf
diff --git a/internal/project/domain.go b/internal/project/domain.go
index 31454e1..ef40ae9 100644
--- a/internal/project/domain.go
+++ b/internal/project/domain.go
@@ -13,13 +13,14 @@ import (
"github.com/google/uuid"
)
-// Phase 是 Requirement 的五阶段枚举。phase 不强制单向流动,可任意跳跃/回退。
+// Phase 是 Requirement 的六阶段枚举。phase 不强制单向流动,可任意跳跃/回退。
// 字符串常量保持与 DB CHECK 约束一致。
type Phase string
-// 五阶段枚举值,与 migrations/0002 的 CHECK 约束完全一致。
+// 六阶段枚举值,与最新 phase CHECK 约束一致(prototyping 由 migrations/0011 加入)。
const (
PhasePlanning Phase = "planning"
+ PhasePrototyping Phase = "prototyping"
PhaseAuditing Phase = "auditing"
PhaseImplementing Phase = "implementing"
PhaseReviewing Phase = "reviewing"
@@ -27,7 +28,7 @@ const (
)
// AllPhases 用于校验 phase 字符串合法性,顺序也对应前端看板/流转条的列序。
-var AllPhases = []Phase{PhasePlanning, PhaseAuditing, PhaseImplementing, PhaseReviewing, PhaseDone}
+var AllPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseImplementing, PhaseReviewing, PhaseDone}
// IsValid 报告 p 是否在枚举集合内。
func (p Phase) IsValid() bool {
@@ -114,6 +115,18 @@ 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
+}
+
// Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。
type Caller struct {
UserID uuid.UUID
@@ -156,6 +169,13 @@ 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)
+}
+
// ===== 输入 DTO =====
//
// 约定(适用于全部 Update*Input):指针字段为 nil 表示"未提供,保持原值";
@@ -220,6 +240,13 @@ type CreateIssueInput struct {
WorkspaceID *uuid.UUID
}
+// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
+// 不在此处提供。Note 可为空字符串。
+type CreatePrototypeInput struct {
+ Content string
+ Note string
+}
+
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
//
// nil —— 未提供(不修改 issue.requirement_id)
diff --git a/internal/project/handler.go b/internal/project/handler.go
index ee39d75..fba99c0 100644
--- a/internal/project/handler.go
+++ b/internal/project/handler.go
@@ -38,13 +38,14 @@ type Handler struct {
projects ProjectService
requirements RequirementService
issues IssueService
+ prototypes PrototypeService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。
-func NewHandler(p ProjectService, r RequirementService, i IssueService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
- return &Handler{projects: p, requirements: r, issues: i, resolver: resolver, users: users}
+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}
}
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
@@ -66,6 +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}/issues", h.createIssue)
r.Get("/{slug}/issues", h.listIssues)
@@ -157,6 +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"`
+}
+
func toProjectDTO(p *Project) projectDTO {
d := projectDTO{
ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
@@ -217,6 +231,15 @@ 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"),
+ }
+}
+
// ===== Project endpoints =====
type createProjectReq struct {
@@ -314,8 +337,10 @@ func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
}
-func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
-func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, false) }
+func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
+func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) {
+ h.toggleArchive(w, r, false)
+}
func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
c, err := h.callerFromCtx(r)
@@ -529,8 +554,12 @@ func (h *Handler) changeRequirementPhase(w http.ResponseWriter, r *http.Request)
httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
}
-func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, true) }
-func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, false) }
+func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) {
+ h.toggleReqStatus(w, r, true)
+}
+func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) {
+ h.toggleReqStatus(w, r, false)
+}
func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
c, err := h.callerFromCtx(r)
@@ -555,6 +584,86 @@ func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClos
w.WriteHeader(http.StatusNoContent)
}
+// ===== Prototype endpoints =====
+
+type createPrototypeReq struct {
+ Content string `json:"content"`
+ Note string `json:"note"`
+}
+
+func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
+ c, err := h.callerFromCtx(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ num, err := numberParam(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ var req createPrototypeReq
+ 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,
+ })
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusCreated, toPrototypeDTO(out))
+}
+
+func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
+ c, err := h.callerFromCtx(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ num, err := numberParam(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ out, err := h.prototypes.List(r.Context(), c, chi.URLParam(r, "slug"), num)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ dtos := make([]prototypeDTO, 0, len(out))
+ for _, x := range out {
+ dtos = append(dtos, toPrototypeDTO(x))
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototypes": dtos})
+}
+
+func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) {
+ c, err := h.callerFromCtx(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ num, err := numberParam(r)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ ver, err := strconv.Atoi(chi.URLParam(r, "version"))
+ if err != nil || ver <= 0 {
+ writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
+ return
+ }
+ out, err := h.prototypes.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, ver)
+ if err != nil {
+ writeErr(w, r, err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)})
+}
+
// ===== Issue endpoints =====
type createIssueReq struct {
@@ -751,8 +860,10 @@ func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}
-func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
-func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, false) }
+func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
+func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) {
+ h.toggleIssueStatus(w, r, false)
+}
func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
c, err := h.callerFromCtx(r)
diff --git a/internal/project/handler_test.go b/internal/project/handler_test.go
index a5c7dbc..ee5cc05 100644
--- a/internal/project/handler_test.go
+++ b/internal/project/handler_test.go
@@ -34,8 +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)
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
- h := NewHandler(pSvc, rSvc, iSvc, resolver, fakeAdmin{})
+ h := NewHandler(pSvc, rSvc, iSvc, protoSvc, resolver, fakeAdmin{})
r := chi.NewRouter()
r.Use(middleware.RequestID)
h.Mount(r)
diff --git a/internal/project/project_service_test.go b/internal/project/project_service_test.go
index 324b8cf..b91635f 100644
--- a/internal/project/project_service_test.go
+++ b/internal/project/project_service_test.go
@@ -2,11 +2,14 @@ package project
import (
"context"
+ "sort"
"sync"
"testing"
"time"
"github.com/google/uuid"
+ "github.com/jackc/pgerrcode"
+ "github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
@@ -20,6 +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
+
+ // 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位,
+ // 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。
+ createPrototypeFailOnce bool
}
func newFakeRepo() *fakeRepo {
@@ -27,12 +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{},
}
}
-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) 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) CreateProject(_ context.Context, p *Project) (*Project, error) {
r.mu.Lock()
@@ -359,6 +369,60 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
return nil
}
+func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ maxVer := 0
+ for _, x := range r.prototypes {
+ if x.RequirementID == requirementID && x.Version > maxVer {
+ maxVer = x.Version
+ }
+ }
+ return maxVer, nil
+}
+
+func (r *fakeRepo) CreatePrototype(_ context.Context, in *Prototype) (*Prototype, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.createPrototypeFailOnce {
+ r.createPrototypeFailOnce = false
+ // 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
+ return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
+ }
+ for _, x := range r.prototypes {
+ if x.RequirementID == in.RequirementID && 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
+}
+
+func (r *fakeRepo) ListPrototypesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Prototype, 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))
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
+ return out, nil
+}
+
+func (r *fakeRepo) GetPrototypeByVersion(_ context.Context, requirementID uuid.UUID, version int) (*Prototype, 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
+ }
+ }
+ return nil, errs.New(errs.CodeNotFound, "prototype 不存在")
+}
+
// spyAudit 收集所有写入的 audit entry,方便断言。
type spyAudit struct {
mu sync.Mutex
diff --git a/internal/project/prototype_service.go b/internal/project/prototype_service.go
new file mode 100644
index 0000000..092bd93
--- /dev/null
+++ b/internal/project/prototype_service.go
@@ -0,0 +1,110 @@
+// 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,
+ })
+}
diff --git a/internal/project/prototype_service_test.go b/internal/project/prototype_service_test.go
new file mode 100644
index 0000000..ff3523f
--- /dev/null
+++ b/internal/project/prototype_service_test.go
@@ -0,0 +1,181 @@
+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)
+}
diff --git a/internal/project/queries/prototypes.sql b/internal/project/queries/prototypes.sql
new file mode 100644
index 0000000..99bf4fb
--- /dev/null
+++ b/internal/project/queries/prototypes.sql
@@ -0,0 +1,19 @@
+-- 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;
diff --git a/internal/project/repository.go b/internal/project/repository.go
index cfac32f..ba6d4ab 100644
--- a/internal/project/repository.go
+++ b/internal/project/repository.go
@@ -50,6 +50,12 @@ type Repository interface {
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
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)
}
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
@@ -491,3 +497,69 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
}
return nil
}
+
+// ===== Prototype =====
+
+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 (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),
+ })
+ if err != nil {
+ if IsUniqueViolation(err) {
+ return nil, err // service 层据此重试一次
+ }
+ return nil, errs.Wrap(err, errs.CodeInternal, "create prototype")
+ }
+ return rowToPrototype(row), nil
+}
+
+func (r *pgRepo) ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) {
+ rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID))
+ if err != nil {
+ return nil, errs.Wrap(err, errs.CodeInternal, "list prototypes by requirement")
+ }
+ out := make([]*Prototype, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, rowToPrototype(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{
+ RequirementID: toPgUUID(requirementID),
+ 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.Wrap(err, errs.CodeInternal, "get prototype by version")
+ }
+ return rowToPrototype(row), nil
+}
+
+func (r *pgRepo) GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error) {
+ max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID))
+ if err != nil {
+ return 0, errs.Wrap(err, errs.CodeInternal, "get max prototype version")
+ }
+ return int(max), nil
+}
diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go
index 3e6cd48..b2e4d75 100644
--- a/internal/project/sqlc/models.go
+++ b/internal/project/sqlc/models.go
@@ -276,6 +276,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 User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
diff --git a/internal/project/sqlc/prototypes.sql.go b/internal/project/sqlc/prototypes.sql.go
new file mode 100644
index 0000000..da96447
--- /dev/null
+++ b/internal/project/sqlc/prototypes.sql.go
@@ -0,0 +1,122 @@
+// 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
+}
diff --git a/migrations/0011_requirement_prototype.down.sql b/migrations/0011_requirement_prototype.down.sql
new file mode 100644
index 0000000..28e9bf5
--- /dev/null
+++ b/migrations/0011_requirement_prototype.down.sql
@@ -0,0 +1,6 @@
+DROP TABLE IF EXISTS requirement_prototypes;
+-- 回滚前把 prototyping 行归并到 planning,否则重建不含 prototyping 的 CHECK 会失败。
+UPDATE requirements SET phase = 'planning' WHERE phase = 'prototyping';
+ALTER TABLE requirements DROP CONSTRAINT requirements_phase_check;
+ALTER TABLE requirements ADD CONSTRAINT requirements_phase_check
+ CHECK (phase IN ('planning','auditing','implementing','reviewing','done'));
diff --git a/migrations/0011_requirement_prototype.up.sql b/migrations/0011_requirement_prototype.up.sql
new file mode 100644
index 0000000..4658843
--- /dev/null
+++ b/migrations/0011_requirement_prototype.up.sql
@@ -0,0 +1,15 @@
+ALTER TABLE requirements DROP CONSTRAINT requirements_phase_check;
+ALTER TABLE requirements ADD CONSTRAINT requirements_phase_check
+ CHECK (phase IN ('planning','prototyping','auditing','implementing','reviewing','done'));
+
+CREATE TABLE requirement_prototypes (
+ id UUID PRIMARY KEY,
+ requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
+ version INT NOT NULL,
+ content TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (requirement_id, version)
+);
+CREATE INDEX idx_requirement_prototypes_req ON requirement_prototypes(requirement_id);
diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts
index 27b935a..35ffb3f 100644
--- a/web/src/api/projects.ts
+++ b/web/src/api/projects.ts
@@ -1,6 +1,6 @@
import { request } from './client'
-export type Phase = 'planning' | 'auditing' | 'implementing' | 'reviewing' | 'done'
+export type Phase = 'planning' | 'prototyping' | 'auditing' | 'implementing' | 'reviewing' | 'done'
export type Status = 'open' | 'closed'
export type Visibility = 'private' | 'internal'
@@ -47,6 +47,16 @@ export interface Issue {
updated_at: string
}
+export interface Prototype {
+ id: string
+ requirement_id: string
+ version: number
+ content: string
+ note: string
+ created_by: string
+ created_at: string
+}
+
export interface ListWrapper
+ {{ selectedPrototype.content }} +
++ 选择左侧版本以查看内容 +
++ 需求已关闭,无法提交新版本 +
+