From 01b337539555ceeeaf68fca20d6421835d0c022e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 9 Jun 2026 23:44:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=9F=E5=9E=8B=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/app.go | 4 +- internal/app/integration_test.go | 6 +- internal/app/mcp_integration_test.go | 2 +- internal/mcp/server.go | 1 + internal/mcp/token_service.go | 7 +- internal/mcp/tools_pm.go | 95 +++++++++ internal/project/domain.go | 33 +++- internal/project/handler.go | 127 +++++++++++- internal/project/handler_test.go | 3 +- internal/project/project_service_test.go | 70 ++++++- internal/project/prototype_service.go | 110 +++++++++++ internal/project/prototype_service_test.go | 181 ++++++++++++++++++ internal/project/queries/prototypes.sql | 19 ++ internal/project/repository.go | 72 +++++++ internal/project/sqlc/models.go | 10 + internal/project/sqlc/prototypes.sql.go | 122 ++++++++++++ .../0011_requirement_prototype.down.sql | 6 + migrations/0011_requirement_prototype.up.sql | 15 ++ web/src/api/projects.ts | 27 ++- web/src/stores/requirements.ts | 46 ++++- .../views/projects/RequirementDetailView.vue | 145 +++++++++++++- .../views/projects/RequirementListView.vue | 3 +- 22 files changed, 1077 insertions(+), 27 deletions(-) create mode 100644 internal/project/prototype_service.go create mode 100644 internal/project/prototype_service_test.go create mode 100644 internal/project/queries/prototypes.sql create mode 100644 internal/project/sqlc/prototypes.sql.go create mode 100644 migrations/0011_requirement_prototype.down.sql create mode 100644 migrations/0011_requirement_prototype.up.sql 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 { items: T[] } @@ -116,6 +126,21 @@ export const projectsApi = { reopenRequirement: (slug: string, number: number) => request(`/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(`/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 ===== listIssues: ( slug: string, diff --git a/web/src/stores/requirements.ts b/web/src/stores/requirements.ts index 6311190..046e544 100644 --- a/web/src/stores/requirements.ts +++ b/web/src/stores/requirements.ts @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' 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', () => { const items = ref([]) @@ -45,5 +51,41 @@ export const useRequirementsStore = defineStore('requirements', () => { await refresh(forSlug) } - return { items, slug, loading, filterPhase, filterStatus, refresh, create, changePhase, close, reopen } + async function listPrototypes(forSlug: string, number: number): Promise { + const out = await projectsApi.listPrototypes(forSlug, number) + return out.prototypes + } + + async function createPrototype( + forSlug: string, + number: number, + body: { content: string; note?: string }, + ): Promise { + return await projectsApi.createPrototype(forSlug, number, body) + } + + async function getPrototype( + forSlug: string, + number: number, + version: number, + ): Promise { + 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, + } }) diff --git a/web/src/views/projects/RequirementDetailView.vue b/web/src/views/projects/RequirementDetailView.vue index f213d04..947ffdd 100644 --- a/web/src/views/projects/RequirementDetailView.vue +++ b/web/src/views/projects/RequirementDetailView.vue @@ -85,6 +85,99 @@

+ + +
+ +
+ + + + + + {{ p.note }} + + + + +
+ + +
+

+ {{ selectedPrototype.content }} +

+

+ 选择左侧版本以查看内容 +

+
+
+ + + +
+ + + + 提交新版本 + +
+

+ 需求已关闭,无法提交新版本 +

+
+ = { planning: '规划中', + prototyping: '原型设计', auditing: '审核中', implementing: '实施中', reviewing: '评审中', @@ -183,10 +279,20 @@ const loading = ref(false) const toggling = ref(false) const data = ref(null) +const prototypes = ref([]) +const selectedVersion = ref(null) +const newContent = ref('') +const newNote = ref('') +const submitting = ref(false) + const workspaceOptions = computed(() => 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() { loading.value = true try { @@ -195,11 +301,46 @@ async function fetchData() { wsStore.fetchByProject(slug), ]) data.value = detail + await refreshPrototypes() } finally { 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) { if (!data.value) return await projectsApi.updateRequirement(slug, number, { workspace_id: v }) diff --git a/web/src/views/projects/RequirementListView.vue b/web/src/views/projects/RequirementListView.vue index 30da5f5..e1e421c 100644 --- a/web/src/views/projects/RequirementListView.vue +++ b/web/src/views/projects/RequirementListView.vue @@ -162,10 +162,11 @@ import AppShell from '@/layouts/AppShell.vue' import { useRequirementsStore } from '@/stores/requirements' 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 = { planning: '规划中', + prototyping: '原型设计', auditing: '审核中', implementing: '实施中', reviewing: '评审中',