You've already forked agentic-coding-workflow
原型阶段
This commit is contained in:
+3
-1
@@ -92,6 +92,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
|
projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
|
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
|
||||||
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
|
issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
|
||||||
|
prototypeSvc := project.NewPrototypeService(projectRepo, wrappedAudit)
|
||||||
|
|
||||||
notifyRepo := notify.NewPostgresRepository(pool)
|
notifyRepo := notify.NewPostgresRepository(pool)
|
||||||
notifyDispatcher := notify.NewDispatcher(notifyRepo, log)
|
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 同名同签名)。
|
// userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
notify.NewHandler(notifyRepo, 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 模块装配 =====
|
// ===== workspace 模块装配 =====
|
||||||
// 依赖图:
|
// 依赖图:
|
||||||
@@ -267,6 +268,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
Projects: projectSvc,
|
Projects: projectSvc,
|
||||||
Reqs: requirementSvc,
|
Reqs: requirementSvc,
|
||||||
Issues: issueSvc,
|
Issues: issueSvc,
|
||||||
|
Prototypes: prototypeSvc,
|
||||||
Workspaces: wsSvc,
|
Workspaces: wsSvc,
|
||||||
Templates: chatTplSvc,
|
Templates: chatTplSvc,
|
||||||
Messages: chatMsgSvc,
|
Messages: chatMsgSvc,
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||||
|
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
|
||||||
|
|
||||||
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -166,7 +167,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
user.NewHandler(userSvc).Mount(r)
|
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)
|
srv := httptest.NewServer(r)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -325,6 +326,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
|||||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||||
|
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec)
|
||||||
|
|
||||||
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -366,7 +368,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
user.NewHandler(userSvc).Mount(r)
|
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)
|
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
srv := httptest.NewServer(r)
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ func (s *mcpIntegrationSuite) testStreamableHTTPFullFlow(t *testing.T) {
|
|||||||
// List tools — assert >= 17 tools registered
|
// List tools — assert >= 17 tools registered
|
||||||
toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{})
|
toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{})
|
||||||
require.NoError(t, err, "ListTools failed")
|
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
|
// Call list_issues — verify response structure
|
||||||
callResult, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
|
callResult, err := session.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ServerDeps struct {
|
|||||||
Projects project.ProjectService
|
Projects project.ProjectService
|
||||||
Reqs project.RequirementService
|
Reqs project.RequirementService
|
||||||
Issues project.IssueService
|
Issues project.IssueService
|
||||||
|
Prototypes project.PrototypeService
|
||||||
Workspaces workspace.WorkspaceService
|
Workspaces workspace.WorkspaceService
|
||||||
Templates chat.TemplateService
|
Templates chat.TemplateService
|
||||||
Messages chat.MessageService
|
Messages chat.MessageService
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ var defaultSystemTools = []string{
|
|||||||
"get_issue", "get_requirement",
|
"get_issue", "get_requirement",
|
||||||
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
|
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
|
||||||
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
|
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
|
||||||
|
"add_requirement_prototype", "list_requirement_prototypes",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
|
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
|
||||||
@@ -209,6 +210,7 @@ func (s *tokenService) issueSystemToken(ctx context.Context, repo Repository, in
|
|||||||
ExpiresAt: created.ExpiresAt,
|
ExpiresAt: created.ExpiresAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate 校验 plaintext token:
|
// Authenticate 校验 plaintext token:
|
||||||
// 1. 计算 hash 反查 row
|
// 1. 计算 hash 反查 row
|
||||||
// 2. 检查 revoked_at IS NULL
|
// 2. 检查 revoked_at IS NULL
|
||||||
@@ -245,6 +247,7 @@ func (s *tokenService) Authenticate(ctx context.Context, plaintext string) (*Tok
|
|||||||
|
|
||||||
return tok, nil
|
return tok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke 由 admin 后台或持有者主动调用。
|
// Revoke 由 admin 后台或持有者主动调用。
|
||||||
// 写一条 audit `mcp.token.revoke`。
|
// 写一条 audit `mcp.token.revoke`。
|
||||||
func (s *tokenService) Revoke(ctx context.Context, id, byUserID uuid.UUID) error {
|
func (s *tokenService) Revoke(ctx context.Context, id, byUserID uuid.UUID) error {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
registerCloseRequirement(srv, deps)
|
registerCloseRequirement(srv, deps)
|
||||||
registerReopenRequirement(srv, deps)
|
registerReopenRequirement(srv, deps)
|
||||||
registerSetRequirementPhase(srv, deps)
|
registerSetRequirementPhase(srv, deps)
|
||||||
|
registerAddRequirementPrototype(srv, deps)
|
||||||
|
registerListRequirementPrototypes(srv, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== list_projects =====
|
// ===== 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 引用(编译期检查)
|
// 保证 fmt 引用(编译期检查)
|
||||||
var _ = fmt.Sprintf
|
var _ = fmt.Sprintf
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Phase 是 Requirement 的五阶段枚举。phase 不强制单向流动,可任意跳跃/回退。
|
// Phase 是 Requirement 的六阶段枚举。phase 不强制单向流动,可任意跳跃/回退。
|
||||||
// 字符串常量保持与 DB CHECK 约束一致。
|
// 字符串常量保持与 DB CHECK 约束一致。
|
||||||
type Phase string
|
type Phase string
|
||||||
|
|
||||||
// 五阶段枚举值,与 migrations/0002 的 CHECK 约束完全一致。
|
// 六阶段枚举值,与最新 phase CHECK 约束一致(prototyping 由 migrations/0011 加入)。
|
||||||
const (
|
const (
|
||||||
PhasePlanning Phase = "planning"
|
PhasePlanning Phase = "planning"
|
||||||
|
PhasePrototyping Phase = "prototyping"
|
||||||
PhaseAuditing Phase = "auditing"
|
PhaseAuditing Phase = "auditing"
|
||||||
PhaseImplementing Phase = "implementing"
|
PhaseImplementing Phase = "implementing"
|
||||||
PhaseReviewing Phase = "reviewing"
|
PhaseReviewing Phase = "reviewing"
|
||||||
@@ -27,7 +28,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AllPhases 用于校验 phase 字符串合法性,顺序也对应前端看板/流转条的列序。
|
// AllPhases 用于校验 phase 字符串合法性,顺序也对应前端看板/流转条的列序。
|
||||||
var AllPhases = []Phase{PhasePlanning, PhaseAuditing, PhaseImplementing, PhaseReviewing, PhaseDone}
|
var AllPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseImplementing, PhaseReviewing, PhaseDone}
|
||||||
|
|
||||||
// IsValid 报告 p 是否在枚举集合内。
|
// IsValid 报告 p 是否在枚举集合内。
|
||||||
func (p Phase) IsValid() bool {
|
func (p Phase) IsValid() bool {
|
||||||
@@ -114,6 +115,18 @@ type Issue struct {
|
|||||||
UpdatedAt time.Time
|
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 层据此判定鉴权。
|
// Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。
|
||||||
type Caller struct {
|
type Caller struct {
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
@@ -156,6 +169,13 @@ type IssueService interface {
|
|||||||
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
|
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 =====
|
// ===== 输入 DTO =====
|
||||||
//
|
//
|
||||||
// 约定(适用于全部 Update*Input):指针字段为 nil 表示"未提供,保持原值";
|
// 约定(适用于全部 Update*Input):指针字段为 nil 表示"未提供,保持原值";
|
||||||
@@ -220,6 +240,13 @@ type CreateIssueInput struct {
|
|||||||
WorkspaceID *uuid.UUID
|
WorkspaceID *uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
|
||||||
|
// 不在此处提供。Note 可为空字符串。
|
||||||
|
type CreatePrototypeInput struct {
|
||||||
|
Content string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
||||||
//
|
//
|
||||||
// nil —— 未提供(不修改 issue.requirement_id)
|
// nil —— 未提供(不修改 issue.requirement_id)
|
||||||
|
|||||||
+117
-6
@@ -38,13 +38,14 @@ type Handler struct {
|
|||||||
projects ProjectService
|
projects ProjectService
|
||||||
requirements RequirementService
|
requirements RequirementService
|
||||||
issues IssueService
|
issues IssueService
|
||||||
|
prototypes PrototypeService
|
||||||
resolver middleware.SessionResolver
|
resolver middleware.SessionResolver
|
||||||
users AdminLookup
|
users AdminLookup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler 构造 Handler。
|
// NewHandler 构造 Handler。
|
||||||
func NewHandler(p ProjectService, r RequirementService, i IssueService, resolver middleware.SessionResolver, users AdminLookup) *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, resolver: resolver, users: users}
|
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
|
// 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}/phase", h.changeRequirementPhase)
|
||||||
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
|
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
|
||||||
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
|
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.Post("/{slug}/issues", h.createIssue)
|
||||||
r.Get("/{slug}/issues", h.listIssues)
|
r.Get("/{slug}/issues", h.listIssues)
|
||||||
@@ -157,6 +161,16 @@ type issueDTO struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
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 {
|
func toProjectDTO(p *Project) projectDTO {
|
||||||
d := projectDTO{
|
d := projectDTO{
|
||||||
ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
|
ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
|
||||||
@@ -217,6 +231,15 @@ func toIssueDTO(i *Issue) issueDTO {
|
|||||||
return d
|
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 =====
|
// ===== Project endpoints =====
|
||||||
|
|
||||||
type createProjectReq struct {
|
type createProjectReq struct {
|
||||||
@@ -315,7 +338,9 @@ func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
|
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) unarchiveProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.toggleArchive(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
|
func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
|
||||||
c, err := h.callerFromCtx(r)
|
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))
|
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) closeRequirement(w http.ResponseWriter, r *http.Request) {
|
||||||
func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, false) }
|
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) {
|
func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
|
||||||
c, err := h.callerFromCtx(r)
|
c, err := h.callerFromCtx(r)
|
||||||
@@ -555,6 +584,86 @@ func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClos
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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 =====
|
// ===== Issue endpoints =====
|
||||||
|
|
||||||
type createIssueReq struct {
|
type createIssueReq struct {
|
||||||
@@ -752,7 +861,9 @@ func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
|
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) reopenIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.toggleIssueStatus(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
|
func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
|
||||||
c, err := h.callerFromCtx(r)
|
c, err := h.callerFromCtx(r)
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
|
|||||||
pSvc := NewProjectService(repo, nil)
|
pSvc := NewProjectService(repo, nil)
|
||||||
rSvc := NewRequirementService(repo, nil)
|
rSvc := NewRequirementService(repo, nil)
|
||||||
iSvc := NewIssueService(repo, nil)
|
iSvc := NewIssueService(repo, nil)
|
||||||
|
protoSvc := NewPrototypeService(repo, nil)
|
||||||
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
|
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 := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
h.Mount(r)
|
h.Mount(r)
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package project
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
@@ -20,6 +23,11 @@ type fakeRepo struct {
|
|||||||
projects map[uuid.UUID]*Project
|
projects map[uuid.UUID]*Project
|
||||||
requirements map[uuid.UUID]*Requirement
|
requirements map[uuid.UUID]*Requirement
|
||||||
issues map[uuid.UUID]*Issue
|
issues map[uuid.UUID]*Issue
|
||||||
|
prototypes map[uuid.UUID]*Prototype
|
||||||
|
|
||||||
|
// 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位,
|
||||||
|
// 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。
|
||||||
|
createPrototypeFailOnce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRepo() *fakeRepo {
|
func newFakeRepo() *fakeRepo {
|
||||||
@@ -27,12 +35,14 @@ func newFakeRepo() *fakeRepo {
|
|||||||
projects: map[uuid.UUID]*Project{},
|
projects: map[uuid.UUID]*Project{},
|
||||||
requirements: map[uuid.UUID]*Requirement{},
|
requirements: map[uuid.UUID]*Requirement{},
|
||||||
issues: map[uuid.UUID]*Issue{},
|
issues: map[uuid.UUID]*Issue{},
|
||||||
|
prototypes: map[uuid.UUID]*Prototype{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) cloneProject(p *Project) *Project { 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) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
|
||||||
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { 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) {
|
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -359,6 +369,60 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
|
|||||||
return nil
|
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,方便断言。
|
// spyAudit 收集所有写入的 audit entry,方便断言。
|
||||||
type spyAudit struct {
|
type spyAudit struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -50,6 +50,12 @@ type Repository interface {
|
|||||||
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
|
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
|
||||||
CloseIssue(ctx context.Context, id uuid.UUID) error
|
CloseIssue(ctx context.Context, id uuid.UUID) error
|
||||||
ReopenIssue(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*
|
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
|
||||||
@@ -491,3 +497,69 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,6 +276,16 @@ type Requirement struct {
|
|||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
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 {
|
type User struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
@@ -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);
|
||||||
+26
-1
@@ -1,6 +1,6 @@
|
|||||||
import { request } from './client'
|
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 Status = 'open' | 'closed'
|
||||||
export type Visibility = 'private' | 'internal'
|
export type Visibility = 'private' | 'internal'
|
||||||
|
|
||||||
@@ -47,6 +47,16 @@ export interface Issue {
|
|||||||
updated_at: string
|
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<T> {
|
export interface ListWrapper<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
}
|
}
|
||||||
@@ -116,6 +126,21 @@ export const projectsApi = {
|
|||||||
reopenRequirement: (slug: string, number: number) =>
|
reopenRequirement: (slug: string, number: number) =>
|
||||||
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
|
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// ===== Prototype =====
|
||||||
|
listPrototypes: (slug: string, number: number) =>
|
||||||
|
request<{ prototypes: Prototype[] }>(
|
||||||
|
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`,
|
||||||
|
),
|
||||||
|
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) =>
|
||||||
|
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
getPrototype: (slug: string, number: number, version: number) =>
|
||||||
|
request<{ prototype: Prototype }>(
|
||||||
|
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`,
|
||||||
|
),
|
||||||
|
|
||||||
// ===== Issue =====
|
// ===== Issue =====
|
||||||
listIssues: (
|
listIssues: (
|
||||||
slug: string,
|
slug: string,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { projectsApi, type Phase, type Requirement, type Status } from '@/api/projects'
|
import {
|
||||||
|
projectsApi,
|
||||||
|
type Phase,
|
||||||
|
type Prototype,
|
||||||
|
type Requirement,
|
||||||
|
type Status,
|
||||||
|
} from '@/api/projects'
|
||||||
|
|
||||||
export const useRequirementsStore = defineStore('requirements', () => {
|
export const useRequirementsStore = defineStore('requirements', () => {
|
||||||
const items = ref<Requirement[]>([])
|
const items = ref<Requirement[]>([])
|
||||||
@@ -45,5 +51,41 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
await refresh(forSlug)
|
await refresh(forSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, slug, loading, filterPhase, filterStatus, refresh, create, changePhase, close, reopen }
|
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> {
|
||||||
|
const out = await projectsApi.listPrototypes(forSlug, number)
|
||||||
|
return out.prototypes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPrototype(
|
||||||
|
forSlug: string,
|
||||||
|
number: number,
|
||||||
|
body: { content: string; note?: string },
|
||||||
|
): Promise<Prototype> {
|
||||||
|
return await projectsApi.createPrototype(forSlug, number, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPrototype(
|
||||||
|
forSlug: string,
|
||||||
|
number: number,
|
||||||
|
version: number,
|
||||||
|
): Promise<Prototype> {
|
||||||
|
const out = await projectsApi.getPrototype(forSlug, number, version)
|
||||||
|
return out.prototype
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
slug,
|
||||||
|
loading,
|
||||||
|
filterPhase,
|
||||||
|
filterStatus,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
changePhase,
|
||||||
|
close,
|
||||||
|
reopen,
|
||||||
|
listPrototypes,
|
||||||
|
createPrototype,
|
||||||
|
getPrototype,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,6 +85,99 @@
|
|||||||
</p>
|
</p>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
|
<!-- Prototypes -->
|
||||||
|
<NCard title="原型">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- Version list -->
|
||||||
|
<div class="w-56 shrink-0">
|
||||||
|
<NList
|
||||||
|
v-if="prototypes.length > 0"
|
||||||
|
:show-divider="false"
|
||||||
|
>
|
||||||
|
<NListItem
|
||||||
|
v-for="p in prototypes"
|
||||||
|
:key="p.id"
|
||||||
|
>
|
||||||
|
<NThing
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="onSelectPrototype(p.version)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="selectedVersion === p.version ? 'text-blue-600' : ''"
|
||||||
|
>v{{ p.version }}</span>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
{{ new Date(p.created_at).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="p.note"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>{{ p.note }}</span>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
<NEmpty
|
||||||
|
v-else
|
||||||
|
description="暂无原型版本"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
v-if="selectedPrototype"
|
||||||
|
class="text-gray-700 whitespace-pre-wrap text-sm"
|
||||||
|
>
|
||||||
|
{{ selectedPrototype.content }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
选择左侧版本以查看内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New version form -->
|
||||||
|
<NDivider />
|
||||||
|
<div
|
||||||
|
v-if="data.requirement.status === 'open'"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newContent"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="原型内容"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newNote"
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!newContent.trim()"
|
||||||
|
@click="onSubmitPrototype"
|
||||||
|
>
|
||||||
|
提交新版本
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-gray-400 text-sm"
|
||||||
|
>
|
||||||
|
需求已关闭,无法提交新版本
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
<!-- Workspace card -->
|
<!-- Workspace card -->
|
||||||
<NCard title="工作区">
|
<NCard title="工作区">
|
||||||
<NSelect
|
<NSelect
|
||||||
@@ -153,17 +246,20 @@ import {
|
|||||||
NListItem,
|
NListItem,
|
||||||
NThing,
|
NThing,
|
||||||
NEmpty,
|
NEmpty,
|
||||||
|
NInput,
|
||||||
|
NDivider,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import { projectsApi } from '@/api/projects'
|
import { projectsApi } from '@/api/projects'
|
||||||
import { useRequirementsStore } from '@/stores/requirements'
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
import type { Phase, RequirementDetail } from '@/api/projects'
|
import type { Phase, Prototype, RequirementDetail } from '@/api/projects'
|
||||||
|
|
||||||
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
|
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
|
||||||
|
|
||||||
const PHASE_LABELS: Record<Phase, string> = {
|
const PHASE_LABELS: Record<Phase, string> = {
|
||||||
planning: '规划中',
|
planning: '规划中',
|
||||||
|
prototyping: '原型设计',
|
||||||
auditing: '审核中',
|
auditing: '审核中',
|
||||||
implementing: '实施中',
|
implementing: '实施中',
|
||||||
reviewing: '评审中',
|
reviewing: '评审中',
|
||||||
@@ -183,10 +279,20 @@ const loading = ref(false)
|
|||||||
const toggling = ref(false)
|
const toggling = ref(false)
|
||||||
const data = ref<RequirementDetail | null>(null)
|
const data = ref<RequirementDetail | null>(null)
|
||||||
|
|
||||||
|
const prototypes = ref<Prototype[]>([])
|
||||||
|
const selectedVersion = ref<number | null>(null)
|
||||||
|
const newContent = ref('')
|
||||||
|
const newNote = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
const workspaceOptions = computed(() =>
|
const workspaceOptions = computed(() =>
|
||||||
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
|
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedPrototype = computed(() =>
|
||||||
|
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -195,11 +301,46 @@ async function fetchData() {
|
|||||||
wsStore.fetchByProject(slug),
|
wsStore.fetchByProject(slug),
|
||||||
])
|
])
|
||||||
data.value = detail
|
data.value = detail
|
||||||
|
await refreshPrototypes()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPrototypes() {
|
||||||
|
prototypes.value = await store.listPrototypes(slug, number)
|
||||||
|
// 默认选中最新版本
|
||||||
|
if (prototypes.value.length > 0) {
|
||||||
|
const latest = prototypes.value[prototypes.value.length - 1]
|
||||||
|
if (selectedVersion.value === null || !selectedPrototype.value) {
|
||||||
|
selectedVersion.value = latest.version
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedVersion.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectPrototype(version: number) {
|
||||||
|
selectedVersion.value = version
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitPrototype() {
|
||||||
|
if (!newContent.value.trim()) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const created = await store.createPrototype(slug, number, {
|
||||||
|
content: newContent.value,
|
||||||
|
note: newNote.value || undefined,
|
||||||
|
})
|
||||||
|
newContent.value = ''
|
||||||
|
newNote.value = ''
|
||||||
|
selectedVersion.value = created.version
|
||||||
|
await refreshPrototypes()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onWorkspaceChange(v: string | null) {
|
async function onWorkspaceChange(v: string | null) {
|
||||||
if (!data.value) return
|
if (!data.value) return
|
||||||
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
|
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
|
||||||
|
|||||||
@@ -162,10 +162,11 @@ import AppShell from '@/layouts/AppShell.vue'
|
|||||||
import { useRequirementsStore } from '@/stores/requirements'
|
import { useRequirementsStore } from '@/stores/requirements'
|
||||||
import type { Phase, Requirement } from '@/api/projects'
|
import type { Phase, Requirement } from '@/api/projects'
|
||||||
|
|
||||||
const PHASES: Phase[] = ['planning', 'auditing', 'implementing', 'reviewing', 'done']
|
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
|
||||||
|
|
||||||
const PHASE_LABELS: Record<Phase, string> = {
|
const PHASE_LABELS: Record<Phase, string> = {
|
||||||
planning: '规划中',
|
planning: '规划中',
|
||||||
|
prototyping: '原型设计',
|
||||||
auditing: '审核中',
|
auditing: '审核中',
|
||||||
implementing: '实施中',
|
implementing: '实施中',
|
||||||
reviewing: '评审中',
|
reviewing: '评审中',
|
||||||
|
|||||||
Reference in New Issue
Block a user