diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go index a9248e3..6df33f8 100644 --- a/internal/acp/agentkind_service_test.go +++ b/internal/acp/agentkind_service_test.go @@ -89,10 +89,10 @@ func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.S func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { panic("n/a") } -func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID) ([]*acp.Session, error) { +func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } -func (f *fakeAgentKindRepo) ListAllSessions(context.Context) ([]*acp.Session, error) { +func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { diff --git a/internal/acp/domain.go b/internal/acp/domain.go index d294037..a2ef47d 100644 --- a/internal/acp/domain.go +++ b/internal/acp/domain.go @@ -120,10 +120,17 @@ type AgentKindService interface { type SessionService interface { Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) - List(ctx context.Context, c Caller, all bool) ([]*Session, error) + List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) Terminate(ctx context.Context, c Caller, id uuid.UUID) error } +// SessionListFilter 控制 sessions 列表的过滤维度。 +// All 仅 admin 可用(查看全部用户);RequirementID nil 表示不按需求过滤。 +type SessionListFilter struct { + All bool + RequirementID *uuid.UUID +} + // CreateSessionInput 是创建 session 的入参。 // branch / issue_number / requirement_number 三选一或全空(spec §8.1)。 type CreateSessionInput struct { diff --git a/internal/acp/handler.go b/internal/acp/handler.go index 70a9b34..8e564f7 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -300,8 +300,16 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { writeErr(w, r, err) return } - all := r.URL.Query().Get("all") == "true" - list, err := h.sessSvc.List(r.Context(), c, all) + f := SessionListFilter{All: r.URL.Query().Get("all") == "true"} + if v := r.URL.Query().Get("requirement_id"); v != "" { + id, err := uuid.Parse(v) + if err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_id 非法")) + return + } + f.RequirementID = &id + } + list, err := h.sessSvc.List(r.Context(), c, f) if err != nil { writeErr(w, r, err) return diff --git a/internal/acp/queries/sessions.sql b/internal/acp/queries/sessions.sql index 19c998e..1590eb3 100644 --- a/internal/acp/queries/sessions.sql +++ b/internal/acp/queries/sessions.sql @@ -23,6 +23,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id, pid, exit_code, last_error, started_at, ended_at FROM acp_sessions WHERE user_id = $1 + AND ($2::uuid IS NULL OR requirement_id = $2) ORDER BY started_at DESC; -- name: ListAllSessions :many @@ -31,6 +32,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id, branch, cwd_path, is_main_worktree, status, pid, exit_code, last_error, started_at, ended_at FROM acp_sessions +WHERE ($1::uuid IS NULL OR requirement_id = $1) ORDER BY started_at DESC; -- name: UpdateSessionRunning :exec diff --git a/internal/acp/relay_test.go b/internal/acp/relay_test.go index 9be248b..13619a7 100644 --- a/internal/acp/relay_test.go +++ b/internal/acp/relay_test.go @@ -81,10 +81,10 @@ func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Sessi func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { panic("n/a") } -func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID) ([]*acp.Session, error) { +func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } -func (f *fakeEventRepo) ListAllSessions(context.Context) ([]*acp.Session, error) { +func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { diff --git a/internal/acp/repository.go b/internal/acp/repository.go index 54ed314..59713de 100644 --- a/internal/acp/repository.go +++ b/internal/acp/repository.go @@ -31,8 +31,9 @@ type Repository interface { // Session InsertSession(ctx context.Context, s *Session) (*Session, error) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error) - ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) - ListAllSessions(ctx context.Context) ([]*Session, error) + // requirementID 非 nil 时仅返回关联该需求的 sessions。 + ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) + ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error CountActiveSessions(ctx context.Context) (int64, error) @@ -288,8 +289,11 @@ func (r *pgRepo) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, er return rowToSession(row), nil } -func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) { - rows, err := r.q.ListSessionsByUser(ctx, toPgUUID(userID)) +func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) { + rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{ + UserID: toPgUUID(userID), + Column2: toPgUUIDPtr(requirementID), + }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list sessions by user") } @@ -300,8 +304,8 @@ func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*S return out, nil } -func (r *pgRepo) ListAllSessions(ctx context.Context) ([]*Session, error) { - rows, err := r.q.ListAllSessions(ctx) +func (r *pgRepo) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) { + rows, err := r.q.ListAllSessions(ctx, toPgUUIDPtr(requirementID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions") } diff --git a/internal/acp/session_service.go b/internal/acp/session_service.go index 9e4263a..172c5e3 100644 --- a/internal/acp/session_service.go +++ b/internal/acp/session_service.go @@ -335,14 +335,14 @@ func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Sess return sess, nil } -func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) { - if all && !c.IsAdmin { +func (s *sessionService) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) { + if f.All && !c.IsAdmin { return nil, errs.New(errs.CodeForbidden, "admin only") } - if all { - return s.repo.ListAllSessions(ctx) + if f.All { + return s.repo.ListAllSessions(ctx, f.RequirementID) } - return s.repo.ListSessionsByUser(ctx, c.UserID) + return s.repo.ListSessionsByUser(ctx, c.UserID, f.RequirementID) } func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error { diff --git a/internal/acp/session_service_test.go b/internal/acp/session_service_test.go index 826866a..ce5628c 100644 --- a/internal/acp/session_service_test.go +++ b/internal/acp/session_service_test.go @@ -212,10 +212,33 @@ func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { re func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { return 0, nil } -func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) { - return nil, nil +func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, userID uuid.UUID, reqID *uuid.UUID) ([]*acp.Session, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []*acp.Session + for _, s := range r.sessions { + if s.UserID != userID { + continue + } + if reqID != nil && (s.RequirementID == nil || *s.RequirementID != *reqID) { + continue + } + out = append(out, s) + } + return out, nil +} +func (r *fakeAcpRepo) ListAllSessions(_ context.Context, reqID *uuid.UUID) ([]*acp.Session, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []*acp.Session + for _, s := range r.sessions { + if reqID != nil && (s.RequirementID == nil || *s.RequirementID != *reqID) { + continue + } + out = append(out, s) + } + return out, nil } -func (r *fakeAcpRepo) ListAllSessions(_ context.Context) ([]*acp.Session, error) { return nil, nil } func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error { return nil } @@ -440,3 +463,35 @@ func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context, func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { return 0, nil } + +func TestSessionService_List_RequirementFilter(t *testing.T) { + t.Parallel() + + uid := uuid.New() + reqID := uuid.New() + repo := &fakeAcpRepo{} + repo.sessions = []*acp.Session{ + {ID: uuid.New(), UserID: uid, RequirementID: &reqID}, + {ID: uuid.New(), UserID: uid}, + {ID: uuid.New(), UserID: uuid.New(), RequirementID: &reqID}, + } + + svc := acp.NewSessionService( + repo, nil, nil, nil, nil, nil, nil, + acp.SessionServiceConfig{}, nil, + ) + + // 普通用户:仅本人 + requirement 过滤 + list, err := svc.List(context.Background(), acp.Caller{UserID: uid}, acp.SessionListFilter{RequirementID: &reqID}) + require.NoError(t, err) + require.Len(t, list, 1) + + // 非 admin 请求 all → 403 + _, err = svc.List(context.Background(), acp.Caller{UserID: uid}, acp.SessionListFilter{All: true}) + require.Error(t, err) + + // admin all + requirement 过滤:跨用户 2 条 + list, err = svc.List(context.Background(), acp.Caller{UserID: uid, IsAdmin: true}, acp.SessionListFilter{All: true, RequirementID: &reqID}) + require.NoError(t, err) + require.Len(t, list, 2) +} diff --git a/internal/acp/sqlc/models.go b/internal/acp/sqlc/models.go index e8dcafb..1c7c7e7 100644 --- a/internal/acp/sqlc/models.go +++ b/internal/acp/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/acp/sqlc/sessions.sql.go b/internal/acp/sqlc/sessions.sql.go index 9322bec..2fa6625 100644 --- a/internal/acp/sqlc/sessions.sql.go +++ b/internal/acp/sqlc/sessions.sql.go @@ -154,11 +154,12 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id, branch, cwd_path, is_main_worktree, status, pid, exit_code, last_error, started_at, ended_at FROM acp_sessions +WHERE ($1::uuid IS NULL OR requirement_id = $1) ORDER BY started_at DESC ` -func (q *Queries) ListAllSessions(ctx context.Context) ([]AcpSession, error) { - rows, err := q.db.Query(ctx, listAllSessions) +func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([]AcpSession, error) { + rows, err := q.db.Query(ctx, listAllSessions, dollar_1) if err != nil { return nil, err } @@ -202,11 +203,17 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id, pid, exit_code, last_error, started_at, ended_at FROM acp_sessions WHERE user_id = $1 + AND ($2::uuid IS NULL OR requirement_id = $2) ORDER BY started_at DESC ` -func (q *Queries) ListSessionsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpSession, error) { - rows, err := q.db.Query(ctx, listSessionsByUser, userID) +type ListSessionsByUserParams struct { + UserID pgtype.UUID `json:"user_id"` + Column2 pgtype.UUID `json:"column_2"` +} + +func (q *Queries) ListSessionsByUser(ctx context.Context, arg ListSessionsByUserParams) ([]AcpSession, error) { + rows, err := q.db.Query(ctx, listSessionsByUser, arg.UserID, arg.Column2) if err != nil { return nil, err } diff --git a/internal/app/app.go b/internal/app/app.go index b86ca24..58af317 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -91,7 +91,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) + artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit) notifyRepo := notify.NewPostgresRepository(pool) notifyDispatcher := notify.NewDispatcher(notifyRepo, log) @@ -117,7 +117,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, prototypeSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== workspace 模块装配 ===== // 依赖图: @@ -263,7 +263,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { Projects: projectSvc, Reqs: requirementSvc, Issues: issueSvc, - Prototypes: prototypeSvc, + Artifacts: artifactSvc, Workspaces: wsSvc, Templates: chatTplSvc, Messages: chatMsgSvc, diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 409969a..b868d50 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -158,7 +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) + artifactSvc := project.NewArtifactService(projectRepo, auditRec) adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123") require.NoError(t, err) @@ -167,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, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) + project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) srv := httptest.NewServer(r) t.Cleanup(srv.Close) @@ -326,7 +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) + artifactSvc := project.NewArtifactService(projectRepo, auditRec) adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123") require.NoError(t, err) @@ -368,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, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) + project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, 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/audit/sqlc/models.go b/internal/audit/sqlc/models.go index 49e6200..ecc8a25 100644 --- a/internal/audit/sqlc/models.go +++ b/internal/audit/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/chat/domain.go b/internal/chat/domain.go index 98974ca..64e98d8 100644 --- a/internal/chat/domain.go +++ b/internal/chat/domain.go @@ -103,6 +103,7 @@ type Conversation struct { ProjectID *uuid.UUID WorkspaceID *uuid.UUID IssueID *uuid.UUID + RequirementID *uuid.UUID ModelID uuid.UUID SystemPrompt string Title *string @@ -181,22 +182,24 @@ type ConversationService interface { // CreateConversationInput 是创建会话的入参。 type CreateConversationInput struct { - ModelID uuid.UUID - TemplateID *uuid.UUID - SystemPrompt *string // 优先级:自定义 > 模板内容 > 空字符串 - ProjectID *uuid.UUID - WorkspaceID *uuid.UUID - IssueID *uuid.UUID + ModelID uuid.UUID + TemplateID *uuid.UUID + SystemPrompt *string // 优先级:自定义 > 模板内容 > 空字符串 + ProjectID *uuid.UUID + WorkspaceID *uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID } // ConversationFilter 列表过滤参数。 type ConversationFilter struct { - ProjectID *uuid.UUID - WorkspaceID *uuid.UUID - IssueID *uuid.UUID - TitleQuery string - BeforeID *uuid.UUID // cursor - Limit int + ProjectID *uuid.UUID + WorkspaceID *uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID + TitleQuery string + BeforeID *uuid.UUID // cursor + Limit int } // MessageService — 发消息 + 流接收 + 取消 + 重试。 diff --git a/internal/chat/dto.go b/internal/chat/dto.go index df67e05..bb8309a 100644 --- a/internal/chat/dto.go +++ b/internal/chat/dto.go @@ -10,12 +10,13 @@ import ( // CreateConversationReq is the HTTP request body for POST /conversations. type CreateConversationReq struct { - ModelID uuid.UUID `json:"model_id"` - TemplateID *uuid.UUID `json:"template_id,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - ProjectID *uuid.UUID `json:"project_id,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` - IssueID *uuid.UUID `json:"issue_id,omitempty"` + ModelID uuid.UUID `json:"model_id"` + TemplateID *uuid.UUID `json:"template_id,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` + IssueID *uuid.UUID `json:"issue_id,omitempty"` + RequirementID *uuid.UUID `json:"requirement_id,omitempty"` } // UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}. @@ -91,16 +92,17 @@ type UpdateModelReq struct { // ConversationDTO is the JSON representation of a Conversation. type ConversationDTO struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id"` - ModelID uuid.UUID `json:"model_id"` - SystemPrompt string `json:"system_prompt"` - Title *string `json:"title,omitempty"` - ProjectID *uuid.UUID `json:"project_id,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` - IssueID *uuid.UUID `json:"issue_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ModelID uuid.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` + IssueID *uuid.UUID `json:"issue_id,omitempty"` + RequirementID *uuid.UUID `json:"requirement_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MessageDTO is the JSON representation of a Message. @@ -224,16 +226,17 @@ type AdminUsageResp struct { func toConversationDTO(c *Conversation) ConversationDTO { return ConversationDTO{ - ID: c.ID, - UserID: c.UserID, - ModelID: c.ModelID, - SystemPrompt: c.SystemPrompt, - Title: c.Title, - ProjectID: c.ProjectID, - WorkspaceID: c.WorkspaceID, - IssueID: c.IssueID, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID, + UserID: c.UserID, + ModelID: c.ModelID, + SystemPrompt: c.SystemPrompt, + Title: c.Title, + ProjectID: c.ProjectID, + WorkspaceID: c.WorkspaceID, + IssueID: c.IssueID, + RequirementID: c.RequirementID, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, } } diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 3c84519..fa11af1 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -200,6 +200,11 @@ func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) { f.IssueID = &id } } + if v := q.Get("requirement_id"); v != "" { + if id, e := parseUUID(v); e == nil { + f.RequirementID = &id + } + } convs, err := h.convSvc.List(r.Context(), uid, f) if err != nil { writeErr(w, r, err) @@ -224,12 +229,13 @@ func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) { return } conv, err := h.convSvc.Create(r.Context(), uid, CreateConversationInput{ - ModelID: req.ModelID, - TemplateID: req.TemplateID, - SystemPrompt: req.SystemPrompt, - ProjectID: req.ProjectID, - WorkspaceID: req.WorkspaceID, - IssueID: req.IssueID, + ModelID: req.ModelID, + TemplateID: req.TemplateID, + SystemPrompt: req.SystemPrompt, + ProjectID: req.ProjectID, + WorkspaceID: req.WorkspaceID, + IssueID: req.IssueID, + RequirementID: req.RequirementID, }) if err != nil { writeErr(w, r, err) diff --git a/internal/chat/queries/conversations.sql b/internal/chat/queries/conversations.sql index 25b4f02..ea39875 100644 --- a/internal/chat/queries/conversations.sql +++ b/internal/chat/queries/conversations.sql @@ -1,6 +1,6 @@ -- name: InsertConversation :one -INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *; -- name: GetConversation :one @@ -12,9 +12,10 @@ WHERE user_id = $1 AND deleted_at IS NULL AND ($2::uuid IS NULL OR project_id = $2) AND ($3::uuid IS NULL OR workspace_id = $3) AND ($4::uuid IS NULL OR issue_id = $4) - AND ($5::text = '' OR title ILIKE '%' || $5 || '%') + AND ($5::uuid IS NULL OR requirement_id = $5) + AND ($6::text = '' OR title ILIKE '%' || $6 || '%') ORDER BY updated_at DESC -LIMIT $6; +LIMIT $7; -- name: UpdateConversationTitle :exec UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW() diff --git a/internal/chat/repository.go b/internal/chat/repository.go index 793158f..b88da79 100644 --- a/internal/chat/repository.go +++ b/internal/chat/repository.go @@ -20,8 +20,8 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - "github.com/yan1h/agent-coding-workflow/internal/infra/errs" chatsqlc "github.com/yan1h/agent-coding-workflow/internal/chat/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // Postgres epoch: 2000-01-01 00:00:00 UTC @@ -104,14 +104,15 @@ type Tx interface { // InsertConversationParams 是 InsertConversation 的参数。 type InsertConversationParams struct { - ID uuid.UUID - UserID uuid.UUID - ProjectID *uuid.UUID - WorkspaceID *uuid.UUID - IssueID *uuid.UUID - ModelID uuid.UUID - SystemPrompt string - Title *string + ID uuid.UUID + UserID uuid.UUID + ProjectID *uuid.UUID + WorkspaceID *uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID + ModelID uuid.UUID + SystemPrompt string + Title *string } // UpdateMessageOKParams 是 UpdateMessageOK 的参数。 @@ -313,6 +314,7 @@ func convFromRow(r chatsqlc.Conversation) *Conversation { ProjectID: fromPgUUIDPtr(r.ProjectID), WorkspaceID: fromPgUUIDPtr(r.WorkspaceID), IssueID: fromPgUUIDPtr(r.IssueID), + RequirementID: fromPgUUIDPtr(r.RequirementID), ModelID: fromPgUUID(r.ModelID), SystemPrompt: r.SystemPrompt, Title: r.Title, @@ -469,14 +471,15 @@ func dayUsageRowFromRow(r chatsqlc.SummarizeUsageByDayRow) DayUsageRow { func (r *pgRepo) InsertConversation(ctx context.Context, p InsertConversationParams) (*Conversation, error) { row, err := r.q.InsertConversation(ctx, chatsqlc.InsertConversationParams{ - ID: toPgUUID(p.ID), - UserID: toPgUUID(p.UserID), - ProjectID: toPgUUIDPtr(p.ProjectID), - WorkspaceID: toPgUUIDPtr(p.WorkspaceID), - IssueID: toPgUUIDPtr(p.IssueID), - ModelID: toPgUUID(p.ModelID), - SystemPrompt: p.SystemPrompt, - Title: p.Title, + ID: toPgUUID(p.ID), + UserID: toPgUUID(p.UserID), + ProjectID: toPgUUIDPtr(p.ProjectID), + WorkspaceID: toPgUUIDPtr(p.WorkspaceID), + IssueID: toPgUUIDPtr(p.IssueID), + ModelID: toPgUUID(p.ModelID), + SystemPrompt: p.SystemPrompt, + Title: p.Title, + RequirementID: toPgUUIDPtr(p.RequirementID), }) if err != nil { return nil, errs.Wrap(translatePgError(err), errs.CodeInternal, "insert conversation") @@ -500,12 +503,15 @@ func (r *pgRepo) ListConversationsByUser(ctx context.Context, userID uuid.UUID, if limit <= 0 { limit = 20 } + // Column2..Column6 与 SQL 位置参数一一对应:$2=project, $3=workspace, + // $4=issue, $5=requirement, $6=title(sqlc 未命名参数按序生成字段)。 rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{ UserID: toPgUUID(userID), Column2: toPgUUIDPtr(f.ProjectID), Column3: toPgUUIDPtr(f.WorkspaceID), Column4: toPgUUIDPtr(f.IssueID), - Column5: f.TitleQuery, + Column5: toPgUUIDPtr(f.RequirementID), + Column6: f.TitleQuery, Limit: int32(limit), }) if err != nil { diff --git a/internal/chat/service_conversation.go b/internal/chat/service_conversation.go index 1d5abec..87e21a1 100644 --- a/internal/chat/service_conversation.go +++ b/internal/chat/service_conversation.go @@ -61,14 +61,15 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C } c, err := s.repo.InsertConversation(ctx, InsertConversationParams{ - ID: id, - UserID: userID, - ProjectID: in.ProjectID, - WorkspaceID: in.WorkspaceID, - IssueID: in.IssueID, - ModelID: in.ModelID, - SystemPrompt: systemPrompt, - Title: nil, + ID: id, + UserID: userID, + ProjectID: in.ProjectID, + WorkspaceID: in.WorkspaceID, + IssueID: in.IssueID, + RequirementID: in.RequirementID, + ModelID: in.ModelID, + SystemPrompt: systemPrompt, + Title: nil, }) if err != nil { return nil, err diff --git a/internal/chat/service_conversation_test.go b/internal/chat/service_conversation_test.go index 4d0b4cf..8f57a7f 100644 --- a/internal/chat/service_conversation_test.go +++ b/internal/chat/service_conversation_test.go @@ -40,16 +40,17 @@ func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversatio r.mu.Lock() defer r.mu.Unlock() c := &Conversation{ - ID: p.ID, - UserID: p.UserID, - ProjectID: p.ProjectID, - WorkspaceID: p.WorkspaceID, - IssueID: p.IssueID, - ModelID: p.ModelID, - SystemPrompt: p.SystemPrompt, - Title: p.Title, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: p.ID, + UserID: p.UserID, + ProjectID: p.ProjectID, + WorkspaceID: p.WorkspaceID, + IssueID: p.IssueID, + RequirementID: p.RequirementID, + ModelID: p.ModelID, + SystemPrompt: p.SystemPrompt, + Title: p.Title, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } r.conversations[p.ID] = c return c, nil @@ -70,9 +71,13 @@ func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UU defer r.mu.Unlock() var out []Conversation for _, c := range r.conversations { - if c.UserID == userID && c.DeletedAt == nil { - out = append(out, *c) + if c.UserID != userID || c.DeletedAt != nil { + continue } + if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) { + continue + } + out = append(out, *c) } limit := f.Limit if limit > 0 && len(out) > limit { @@ -235,6 +240,33 @@ func TestConversationService_Create_HappyPath(t *testing.T) { assert.Equal(t, &userID, entry.UserID) } +func TestConversationService_Create_WithRequirementMount(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + + reqID := uuid.Must(uuid.NewV7()) + projID := uuid.Must(uuid.NewV7()) + c, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: m.ID, ProjectID: &projID, RequirementID: &reqID, + }) + require.NoError(t, err) + require.NotNil(t, c.RequirementID) + assert.Equal(t, reqID, *c.RequirementID) + + // List 按 requirement 过滤:命中 1 条;其他 requirement 不命中。 + _, err = svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID}) + require.NoError(t, err) + list, err := svc.List(context.Background(), userID, ConversationFilter{RequirementID: &reqID}) + require.NoError(t, err) + require.Len(t, list, 1) + other := uuid.Must(uuid.NewV7()) + list, err = svc.List(context.Background(), userID, ConversationFilter{RequirementID: &other}) + require.NoError(t, err) + require.Empty(t, list) +} + func TestConversationService_Create_WithSystemTemplate(t *testing.T) { repo := newConvFakeRepo() svc := newConvSvc(repo, noopAuditor{}) diff --git a/internal/chat/sqlc/conversations.sql.go b/internal/chat/sqlc/conversations.sql.go index dcc481c..8c6f42f 100644 --- a/internal/chat/sqlc/conversations.sql.go +++ b/internal/chat/sqlc/conversations.sql.go @@ -12,7 +12,7 @@ import ( ) const getConversation = `-- name: GetConversation :one -SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations WHERE id = $1 AND deleted_at IS NULL +SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations WHERE id = $1 AND deleted_at IS NULL ` func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversation, error) { @@ -31,25 +31,27 @@ func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversa &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.RequirementID, ) return i, err } const insertConversation = `-- name: InsertConversation :one -INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at +INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id ` type InsertConversationParams struct { - ID pgtype.UUID `json:"id"` - UserID pgtype.UUID `json:"user_id"` - ProjectID pgtype.UUID `json:"project_id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - IssueID pgtype.UUID `json:"issue_id"` - ModelID pgtype.UUID `json:"model_id"` - SystemPrompt string `json:"system_prompt"` - Title *string `json:"title"` + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + RequirementID pgtype.UUID `json:"requirement_id"` } func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) (Conversation, error) { @@ -62,6 +64,7 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation arg.ModelID, arg.SystemPrompt, arg.Title, + arg.RequirementID, ) var i Conversation err := row.Scan( @@ -77,19 +80,21 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.RequirementID, ) return i, err } const listConversationsByUser = `-- name: ListConversationsByUser :many -SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations +SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations WHERE user_id = $1 AND deleted_at IS NULL AND ($2::uuid IS NULL OR project_id = $2) AND ($3::uuid IS NULL OR workspace_id = $3) AND ($4::uuid IS NULL OR issue_id = $4) - AND ($5::text = '' OR title ILIKE '%' || $5 || '%') + AND ($5::uuid IS NULL OR requirement_id = $5) + AND ($6::text = '' OR title ILIKE '%' || $6 || '%') ORDER BY updated_at DESC -LIMIT $6 +LIMIT $7 ` type ListConversationsByUserParams struct { @@ -97,7 +102,8 @@ type ListConversationsByUserParams struct { Column2 pgtype.UUID `json:"column_2"` Column3 pgtype.UUID `json:"column_3"` Column4 pgtype.UUID `json:"column_4"` - Column5 string `json:"column_5"` + Column5 pgtype.UUID `json:"column_5"` + Column6 string `json:"column_6"` Limit int32 `json:"limit"` } @@ -108,6 +114,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat arg.Column3, arg.Column4, arg.Column5, + arg.Column6, arg.Limit, ) if err != nil { @@ -130,6 +137,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.RequirementID, ); err != nil { return nil, err } diff --git a/internal/chat/sqlc/models.go b/internal/chat/sqlc/models.go index 93e1a58..34557db 100644 --- a/internal/chat/sqlc/models.go +++ b/internal/chat/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/infra/notify/sqlc/models.go b/internal/infra/notify/sqlc/models.go index 471cef5..8dab7b4 100644 --- a/internal/infra/notify/sqlc/models.go +++ b/internal/infra/notify/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/jobs/sqlc/models.go b/internal/jobs/sqlc/models.go index 0985142..c609782 100644 --- a/internal/jobs/sqlc/models.go +++ b/internal/jobs/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/mcp/server.go b/internal/mcp/server.go index bac8b61..6360916 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -14,7 +14,7 @@ type ServerDeps struct { Projects project.ProjectService Reqs project.RequirementService Issues project.IssueService - Prototypes project.PrototypeService + Artifacts project.ArtifactService Workspaces workspace.WorkspaceService Templates chat.TemplateService Messages chat.MessageService diff --git a/internal/mcp/sqlc/models.go b/internal/mcp/sqlc/models.go index 6d58f23..a329b20 100644 --- a/internal/mcp/sqlc/models.go +++ b/internal/mcp/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/mcp/token_service.go b/internal/mcp/token_service.go index 99fc6d7..823c2ef 100644 --- a/internal/mcp/token_service.go +++ b/internal/mcp/token_service.go @@ -62,7 +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", + "add_requirement_artifact", "list_requirement_artifacts", } // NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。 diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go index 2aa73c9..22802f4 100644 --- a/internal/mcp/tools_pm.go +++ b/internal/mcp/tools_pm.go @@ -29,8 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) { registerCloseRequirement(srv, deps) registerReopenRequirement(srv, deps) registerSetRequirementPhase(srv, deps) - registerAddRequirementPrototype(srv, deps) - registerListRequirementPrototypes(srv, deps) + registerAddRequirementArtifact(srv, deps) + registerListRequirementArtifacts(srv, deps) } // ===== list_projects ===== @@ -711,93 +711,96 @@ func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) { }) } -// ===== add_requirement_prototype ===== +// ===== add_requirement_artifact ===== -type addRequirementPrototypeIn struct { +type addRequirementArtifactIn 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)"` + Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing"` + Content string `json:"content" jsonschema:"artifact content (non-empty)"` Note string `json:"note,omitempty"` } -type prototypeDetail struct { +type artifactDetail struct { + Phase string `json:"phase"` 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"` +type artifactCreateOut struct { + OK bool `json:"ok"` + Artifact artifactDetail `json:"artifact"` } -func registerAddRequirementPrototype(srv *mcpsdk.Server, deps ServerDeps) { +func registerAddRequirementArtifact(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 + &mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing) to a requirement."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) { + if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil { + return nil, artifactCreateOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { - return nil, prototypeCreateOut{}, err + return nil, artifactCreateOut{}, err } p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) if err != nil { - return nil, prototypeCreateOut{}, err + return nil, artifactCreateOut{}, err } if err := CheckProjectFromCtx(ctx, p.ID); err != nil { - return nil, prototypeCreateOut{}, err + return nil, artifactCreateOut{}, err } - proto, err := deps.Prototypes.Create(ctx, c, in.ProjectSlug, in.Number, project.CreatePrototypeInput{ - Content: in.Content, Note: in.Note, + art, err := deps.Artifacts.Create(ctx, c, in.ProjectSlug, in.Number, project.CreateArtifactInput{ + Phase: project.Phase(in.Phase), Content: in.Content, Note: in.Note, }) if err != nil { - return nil, prototypeCreateOut{}, err + return nil, artifactCreateOut{}, 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"), + return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{ + Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note, + CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), }}, nil }) } -// ===== list_requirement_prototypes ===== +// ===== list_requirement_artifacts ===== -type listRequirementPrototypesIn struct { +type listRequirementArtifactsIn struct { ProjectSlug string `json:"project_slug" jsonschema:"required field"` Number int `json:"number" jsonschema:"requirement number (>=1)"` + Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing; empty = all"` } -type listRequirementPrototypesOut struct { - Prototypes []prototypeDetail `json:"prototypes"` +type listRequirementArtifactsOut struct { + Artifacts []artifactDetail `json:"artifacts"` } -func registerListRequirementPrototypes(srv *mcpsdk.Server, deps ServerDeps) { +func registerListRequirementArtifacts(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 + &mcpsdk.Tool{Name: "list_requirement_artifacts", Description: "List phase artifact versions of a requirement."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementArtifactsIn) (*mcpsdk.CallToolResult, listRequirementArtifactsOut, error) { + if err := CheckToolFromCtx(ctx, "list_requirement_artifacts"); err != nil { + return nil, listRequirementArtifactsOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { - return nil, listRequirementPrototypesOut{}, err + return nil, listRequirementArtifactsOut{}, err } p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) if err != nil { - return nil, listRequirementPrototypesOut{}, err + return nil, listRequirementArtifactsOut{}, err } if err := CheckProjectFromCtx(ctx, p.ID); err != nil { - return nil, listRequirementPrototypesOut{}, err + return nil, listRequirementArtifactsOut{}, err } - protos, err := deps.Prototypes.List(ctx, c, in.ProjectSlug, in.Number) + arts, err := deps.Artifacts.List(ctx, c, in.ProjectSlug, in.Number, project.Phase(in.Phase)) if err != nil { - return nil, listRequirementPrototypesOut{}, err + return nil, listRequirementArtifactsOut{}, 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"), + out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))} + for _, art := range arts { + out.Artifacts = append(out.Artifacts, artifactDetail{ + Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note, + CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), }) } return nil, out, nil diff --git a/internal/project/artifact_service.go b/internal/project/artifact_service.go new file mode 100644 index 0000000..8076345 --- /dev/null +++ b/internal/project/artifact_service.go @@ -0,0 +1,121 @@ +// Package project 内的 artifact_service.go 实装 ArtifactService。 +// +// 设计取舍: +// - Version 在 (Requirement, Phase) 内自增(max+1),由 service 计算后落库; +// UNIQUE(requirement_id, phase, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。 +// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing),与 DB CHECK 一致。 +// - 已 closed requirement:禁止新建产物版本,返回 CodeRequirementClosed。 +// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。 +// - 读操作经 loadProjectForRead 校验可见性。 +package project + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +type artifactService struct { + repo Repository + audit audit.Recorder +} + +// NewArtifactService 构造 ArtifactService。 +func NewArtifactService(repo Repository, rec audit.Recorder) ArtifactService { + return &artifactService{repo: repo, audit: rec} +} + +func (s *artifactService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) { + if !in.Phase.IsArtifactPhase() { + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + } + if in.Content == "" { + return nil, errs.New(errs.CodeInvalidInput, "content 必填") + } + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) + if err != nil { + return nil, err + } + if r.Status == StatusClosed { + return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增产物版本") + } + out, err := s.createWithRetry(ctx, r.ID, c.UserID, in) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "requirement_artifact.create", out.ID.String(), map[string]any{ + "requirement_id": r.ID.String(), "phase": string(out.Phase), "version": out.Version, + }) + return out, nil +} + +// createWithRetry 取 (requirement, phase) 内 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。 +func (s *artifactService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreateArtifactInput) (*Artifact, error) { + for attempt := 0; attempt < 2; attempt++ { + maxVer, err := s.repo.GetMaxArtifactVersion(ctx, reqID, in.Phase) + if err != nil { + return nil, err + } + out, err := s.repo.CreateArtifact(ctx, &Artifact{ + ID: uuid.New(), RequirementID: reqID, Phase: in.Phase, + Version: maxVer + 1, Content: in.Content, Note: in.Note, + SourceMessageID: in.SourceMessageID, + CreatedBy: createdBy, + }) + if err == nil { + return out, nil + } + if !IsUniqueViolation(err) { + return nil, err + } + // version 撞了,下一轮重新取 max + } + return nil, errs.New(errs.CodeInternal, "artifact version 持续冲突") +} + +func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase) ([]*Artifact, error) { + if phase != "" && !phase.IsArtifactPhase() { + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + } + p, err := loadProjectForRead(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) + if err != nil { + return nil, err + } + return s.repo.ListArtifactsByRequirement(ctx, r.ID, phase) +} + +func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int) (*Artifact, error) { + if !phase.IsArtifactPhase() { + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + } + p, err := loadProjectForRead(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) + if err != nil { + return nil, err + } + return s.repo.GetArtifactByVersion(ctx, r.ID, phase, version) +} + +func (s *artifactService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { + if s.audit == nil { + return + } + uid := c.UserID + _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ + UserID: &uid, Action: action, TargetType: "requirement_artifact", TargetID: targetID, Metadata: meta, + }) +} diff --git a/internal/project/artifact_service_test.go b/internal/project/artifact_service_test.go new file mode 100644 index 0000000..8efbafc --- /dev/null +++ b/internal/project/artifact_service_test.go @@ -0,0 +1,249 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) { + spy := &spyAudit{} + return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy +} + +func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, spy := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + a1, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) + require.NoError(t, err) + require.Equal(t, 1, a1.Version) + + a2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v2", Note: "second"}) + require.NoError(t, err) + require.Equal(t, 2, a2.Version) + require.Equal(t, "second", a2.Note) + + require.Contains(t, spy.actions(), "requirement_artifact.create") +} + +func TestArtifactService_Create_VersionsIndependentPerPhase(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + // planning 走到 v2,prototyping 的版本序列从 1 重新开始。 + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p1"}) + require.NoError(t, err) + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p2"}) + require.NoError(t, err) + + proto, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "x1"}) + require.NoError(t, err) + require.Equal(t, 1, proto.Version) +} + +func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} { + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"}) + ae, ok := errs.As(err) + require.True(t, ok, "phase=%s", phase) + require.Equal(t, errs.CodeInvalidInput, ae.Code, "phase=%s", phase) + } +} + +func TestArtifactService_Create_RetriesOnVersionConflict(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + // 首轮 CreateArtifact 撞 UNIQUE,service 应捕获并重试一次后成功。 + repo.createArtifactFailOnce = true + a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) + require.NoError(t, err) + require.Equal(t, 1, a.Version) + require.False(t, repo.createArtifactFailOnce, "失败开关应已被消费") +} + +func TestArtifactService_Create_RequiresContent(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: ""}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestArtifactService_Create_BlockedWhenRequirementClosed(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) + + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeRequirementClosed, ae.Code) +} + +func TestArtifactService_Create_RecordsSourceMessageID(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + srcID := int64(42) + a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{ + Phase: PhaseAuditing, Content: "from chat", SourceMessageID: &srcID, + }) + require.NoError(t, err) + require.NotNil(t, a.SourceMessageID) + require.Equal(t, srcID, *a.SourceMessageID) +} + +func TestArtifactService_List_FilterByPhaseAndAll(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + for i := 0; i < 2; i++ { + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "x"}) + require.NoError(t, err) + } + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "y"}) + require.NoError(t, err) + + planning, err := artSvc.List(context.Background(), caller, "demo", r.Number, PhasePlanning) + require.NoError(t, err) + require.Len(t, planning, 2) + require.Equal(t, 1, planning[0].Version) + require.Equal(t, 2, planning[1].Version) + + all, err := artSvc.List(context.Background(), caller, "demo", r.Number, "") + require.NoError(t, err) + require.Len(t, all, 3) + + _, err = artSvc.List(context.Background(), caller, "demo", r.Number, Phase("bogus")) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestArtifactService_GetByVersion_NotFound(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + _, err = artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePlanning, 99) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestArtifactService_GetByVersion_HappyPath(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "hello", Note: "n"}) + require.NoError(t, err) + + got, err := artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePrototyping, 1) + require.NoError(t, err) + require.Equal(t, "hello", got.Content) + require.Equal(t, "n", got.Note) + require.Equal(t, owner, got.CreatedBy) +} + +func TestArtifactService_Create_ForbiddenForOutsider(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + owns := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + // 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。 + outsider := Caller{UserID: uuid.New()} + _, err = artSvc.Create(context.Background(), outsider, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + owns := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + outsider := Caller{UserID: uuid.New()} + _, err = artSvc.List(context.Background(), outsider, "demo", r.Number, "") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} diff --git a/internal/project/domain.go b/internal/project/domain.go index ef40ae9..5dc00a0 100644 --- a/internal/project/domain.go +++ b/internal/project/domain.go @@ -40,6 +40,20 @@ func (p Phase) IsValid() bool { return false } +// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase +// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。 +var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing} + +// IsArtifactPhase 报告 p 是否允许创建产物版本。 +func (p Phase) IsArtifactPhase() bool { + for _, x := range ArtifactPhases { + if p == x { + return true + } + } + return false +} + // Status 是 Requirement / Issue 通用的开/关状态。 type Status string @@ -115,16 +129,20 @@ type Issue struct { UpdatedAt time.Time } -// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement -// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。 -type Prototype struct { - ID uuid.UUID - RequirementID uuid.UUID - Version int - Content string - Note string - CreatedBy uuid.UUID - CreatedAt time.Time +// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在 +// (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。 +// SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用, +// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。 +type Artifact struct { + ID uuid.UUID + RequirementID uuid.UUID + Phase Phase + Version int + Content string + Note string + SourceMessageID *int64 + CreatedBy uuid.UUID + CreatedAt time.Time } // Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。 @@ -169,11 +187,12 @@ type IssueService interface { Reopen(ctx context.Context, c Caller, projectSlug string, number int) error } -// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。 -type PrototypeService interface { - Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error) - List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error) - GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error) +// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。 +type ArtifactService interface { + Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) + // List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。 + List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error) + GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error) } // ===== 输入 DTO ===== @@ -240,11 +259,14 @@ type CreateIssueInput struct { WorkspaceID *uuid.UUID } -// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1), -// 不在此处提供。Note 可为空字符串。 -type CreatePrototypeInput struct { - Content string - Note string +// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算 +// ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases +// 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。 +type CreateArtifactInput struct { + Phase Phase + Content string + Note string + SourceMessageID *int64 } // UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态: diff --git a/internal/project/handler.go b/internal/project/handler.go index fba99c0..e2508a2 100644 --- a/internal/project/handler.go +++ b/internal/project/handler.go @@ -38,14 +38,14 @@ type Handler struct { projects ProjectService requirements RequirementService issues IssueService - prototypes PrototypeService + artifacts ArtifactService resolver middleware.SessionResolver users AdminLookup } // NewHandler 构造 Handler。 -func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler { - return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users} +func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler { + return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users} } // Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。 @@ -67,9 +67,9 @@ func (h *Handler) Mount(r chi.Router) { r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase) r.Post("/{slug}/requirements/{number}/close", h.closeRequirement) r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement) - r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype) - r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes) - r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype) + r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact) + r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts) + r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact) r.Post("/{slug}/issues", h.createIssue) r.Get("/{slug}/issues", h.listIssues) @@ -161,14 +161,16 @@ type issueDTO struct { UpdatedAt string `json:"updated_at"` } -type prototypeDTO struct { - ID string `json:"id"` - RequirementID string `json:"requirement_id"` - Version int `json:"version"` - Content string `json:"content"` - Note string `json:"note"` - CreatedBy string `json:"created_by"` - CreatedAt string `json:"created_at"` +type artifactDTO struct { + ID string `json:"id"` + RequirementID string `json:"requirement_id"` + Phase string `json:"phase"` + Version int `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` } func toProjectDTO(p *Project) projectDTO { @@ -231,12 +233,13 @@ func toIssueDTO(i *Issue) issueDTO { return d } -func toPrototypeDTO(p *Prototype) prototypeDTO { - return prototypeDTO{ - ID: p.ID.String(), RequirementID: p.RequirementID.String(), - Version: p.Version, Content: p.Content, Note: p.Note, - CreatedBy: p.CreatedBy.String(), - CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), +func toArtifactDTO(a *Artifact) artifactDTO { + return artifactDTO{ + ID: a.ID.String(), RequirementID: a.RequirementID.String(), + Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note, + SourceMessageID: a.SourceMessageID, + CreatedBy: a.CreatedBy.String(), + CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), } } @@ -584,14 +587,16 @@ func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClos w.WriteHeader(http.StatusNoContent) } -// ===== Prototype endpoints ===== +// ===== Artifact endpoints ===== -type createPrototypeReq struct { - Content string `json:"content"` - Note string `json:"note"` +type createArtifactReq struct { + Phase string `json:"phase"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` } -func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) { +func (h *Handler) createArtifact(w http.ResponseWriter, r *http.Request) { c, err := h.callerFromCtx(r) if err != nil { writeErr(w, r, err) @@ -602,22 +607,23 @@ func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) { writeErr(w, r, err) return } - var req createPrototypeReq + var req createArtifactReq if err := decodeBody(r, &req); err != nil { writeErr(w, r, err) return } - out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{ - Content: req.Content, Note: req.Note, + out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{ + Phase: Phase(req.Phase), Content: req.Content, Note: req.Note, + SourceMessageID: req.SourceMessageID, }) if err != nil { writeErr(w, r, err) return } - httpx.WriteJSON(w, http.StatusCreated, toPrototypeDTO(out)) + httpx.WriteJSON(w, http.StatusCreated, toArtifactDTO(out)) } -func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) { +func (h *Handler) listArtifacts(w http.ResponseWriter, r *http.Request) { c, err := h.callerFromCtx(r) if err != nil { writeErr(w, r, err) @@ -628,19 +634,20 @@ func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) { writeErr(w, r, err) return } - out, err := h.prototypes.List(r.Context(), c, chi.URLParam(r, "slug"), num) + phase := Phase(r.URL.Query().Get("phase")) + out, err := h.artifacts.List(r.Context(), c, chi.URLParam(r, "slug"), num, phase) if err != nil { writeErr(w, r, err) return } - dtos := make([]prototypeDTO, 0, len(out)) + dtos := make([]artifactDTO, 0, len(out)) for _, x := range out { - dtos = append(dtos, toPrototypeDTO(x)) + dtos = append(dtos, toArtifactDTO(x)) } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototypes": dtos}) + httpx.WriteJSON(w, http.StatusOK, dtos) } -func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) { +func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) { c, err := h.callerFromCtx(r) if err != nil { writeErr(w, r, err) @@ -656,12 +663,13 @@ func (h *Handler) getPrototype(w http.ResponseWriter, r *http.Request) { writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法")) return } - out, err := h.prototypes.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, ver) + phase := Phase(chi.URLParam(r, "phase")) + out, err := h.artifacts.GetByVersion(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver) if err != nil { writeErr(w, r, err) return } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)}) + httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out)) } // ===== Issue endpoints ===== diff --git a/internal/project/handler_test.go b/internal/project/handler_test.go index ee5cc05..5b30221 100644 --- a/internal/project/handler_test.go +++ b/internal/project/handler_test.go @@ -34,9 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake pSvc := NewProjectService(repo, nil) rSvc := NewRequirementService(repo, nil) iSvc := NewIssueService(repo, nil) - protoSvc := NewPrototypeService(repo, nil) + artSvc := NewArtifactService(repo, nil) resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}} - h := NewHandler(pSvc, rSvc, iSvc, protoSvc, resolver, fakeAdmin{}) + h := NewHandler(pSvc, rSvc, iSvc, artSvc, resolver, fakeAdmin{}) r := chi.NewRouter() r.Use(middleware.RequestID) h.Mount(r) @@ -214,3 +214,54 @@ func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) { t.Cleanup(func() { _ = resp5.Body.Close() }) require.Equal(t, http.StatusBadRequest, resp5.StatusCode) } + +// TestHandler_Artifacts_Routes 验证 artifacts 三条路由:创建 201、list 裸数组 + +// ?phase= 过滤、{phase}/{version} 获取、非法 phase 400。 +func TestHandler_Artifacts_Routes(t *testing.T) { + owner := uuid.New() + tok := "tok" + _, h := mountFullHandler(t, tok, owner) + checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated) + + // 创建 planning v1(带 source_message_id)+ auditing v1 + resp := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, + map[string]any{"phase": "planning", "content": "# plan", "note": "n1", "source_message_id": 7}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + created := decodeBodyMap(t, resp) + require.Equal(t, "planning", created["phase"]) + require.Equal(t, float64(1), created["version"]) + require.Equal(t, float64(7), created["source_message_id"]) + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, + map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated) + + // 非法 phase → 400 + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, + map[string]any{"phase": "implementing", "content": "x"}, http.StatusBadRequest) + + // list 裸数组:全量 2 条;?phase=planning 过滤 1 条 + respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil) + t.Cleanup(func() { _ = respList.Body.Close() }) + require.Equal(t, http.StatusOK, respList.StatusCode) + var all []map[string]any + require.NoError(t, json.NewDecoder(respList.Body).Decode(&all)) + require.Len(t, all, 2) + + respPlanning := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts?phase=planning", tok, nil) + t.Cleanup(func() { _ = respPlanning.Body.Close() }) + require.Equal(t, http.StatusOK, respPlanning.StatusCode) + var planning []map[string]any + require.NoError(t, json.NewDecoder(respPlanning.Body).Decode(&planning)) + require.Len(t, planning, 1) + require.Equal(t, "# plan", planning[0]["content"]) + + // get by {phase}/{version} + respGet := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/1", tok, nil) + got := decodeBodyMap(t, respGet) + require.Equal(t, http.StatusOK, respGet.StatusCode) + require.Equal(t, "# plan", got["content"]) + + // get 非法 phase → 400;不存在版本 → 404 + checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest) + checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound) +} diff --git a/internal/project/project_service_test.go b/internal/project/project_service_test.go index b91635f..a9bb6a5 100644 --- a/internal/project/project_service_test.go +++ b/internal/project/project_service_test.go @@ -23,11 +23,11 @@ type fakeRepo struct { projects map[uuid.UUID]*Project requirements map[uuid.UUID]*Requirement issues map[uuid.UUID]*Issue - prototypes map[uuid.UUID]*Prototype + artifacts map[uuid.UUID]*Artifact - // 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位, - // 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。 - createPrototypeFailOnce bool + // 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位, + // 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。 + createArtifactFailOnce bool } func newFakeRepo() *fakeRepo { @@ -35,14 +35,14 @@ func newFakeRepo() *fakeRepo { projects: map[uuid.UUID]*Project{}, requirements: map[uuid.UUID]*Requirement{}, issues: map[uuid.UUID]*Issue{}, - prototypes: map[uuid.UUID]*Prototype{}, + artifacts: map[uuid.UUID]*Artifact{}, } } -func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v } -func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v } -func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v } -func (r *fakeRepo) clonePrototype(p *Prototype) *Prototype { v := *p; return &v } +func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v } +func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v } +func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v } +func (r *fakeRepo) cloneArtifact(a *Artifact) *Artifact { v := *a; return &v } func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) { r.mu.Lock() @@ -369,58 +369,63 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error { return nil } -func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) { +func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) { r.mu.Lock() defer r.mu.Unlock() maxVer := 0 - for _, x := range r.prototypes { - if x.RequirementID == requirementID && x.Version > maxVer { + for _, x := range r.artifacts { + if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer { maxVer = x.Version } } return maxVer, nil } -func (r *fakeRepo) CreatePrototype(_ context.Context, in *Prototype) (*Prototype, error) { +func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, error) { r.mu.Lock() defer r.mu.Unlock() - if r.createPrototypeFailOnce { - r.createPrototypeFailOnce = false + if r.createArtifactFailOnce { + r.createArtifactFailOnce = false // 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。 return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} } - for _, x := range r.prototypes { - if x.RequirementID == in.RequirementID && x.Version == in.Version { + for _, x := range r.artifacts { + if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version { return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} } } in.CreatedAt = time.Now() - r.prototypes[in.ID] = r.clonePrototype(in) - return r.clonePrototype(in), nil + r.artifacts[in.ID] = r.cloneArtifact(in) + return r.cloneArtifact(in), nil } -func (r *fakeRepo) ListPrototypesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Prototype, error) { +func (r *fakeRepo) ListArtifactsByRequirement(_ context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) { r.mu.Lock() defer r.mu.Unlock() - out := make([]*Prototype, 0) - for _, x := range r.prototypes { - if x.RequirementID == requirementID { - out = append(out, r.clonePrototype(x)) + out := make([]*Artifact, 0) + for _, x := range r.artifacts { + if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) { + out = append(out, r.cloneArtifact(x)) } } - sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) + sort.Slice(out, func(i, j int) bool { + if out[i].Phase != out[j].Phase { + return out[i].Phase < out[j].Phase + } + return out[i].Version < out[j].Version + }) return out, nil } -func (r *fakeRepo) GetPrototypeByVersion(_ context.Context, requirementID uuid.UUID, version int) (*Prototype, error) { +func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) { r.mu.Lock() defer r.mu.Unlock() - for _, x := range r.prototypes { - if x.RequirementID == requirementID && x.Version == version { - return r.clonePrototype(x), nil + for _, x := range r.artifacts { + if x.RequirementID == requirementID && x.Phase == phase && x.Version == version { + return r.cloneArtifact(x), nil } } - return nil, errs.New(errs.CodeNotFound, "prototype 不存在") + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } // spyAudit 收集所有写入的 audit entry,方便断言。 diff --git a/internal/project/prototype_service.go b/internal/project/prototype_service.go deleted file mode 100644 index 092bd93..0000000 --- a/internal/project/prototype_service.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package project 内的 prototype_service.go 实装 PrototypeService。 -// -// 设计取舍: -// - Version 在同一 Requirement 内自增(max+1),由 service 计算后落库; -// UNIQUE(requirement_id, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。 -// - 已 closed requirement:禁止新建原型版本,返回 CodeRequirementClosed。 -// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。 -// - 读操作经 loadProjectForRead 校验可见性。 -package project - -import ( - "context" - - "github.com/google/uuid" - - "github.com/yan1h/agent-coding-workflow/internal/audit" - "github.com/yan1h/agent-coding-workflow/internal/infra/errs" -) - -type prototypeService struct { - repo Repository - audit audit.Recorder -} - -// NewPrototypeService 构造 PrototypeService。 -func NewPrototypeService(repo Repository, rec audit.Recorder) PrototypeService { - return &prototypeService{repo: repo, audit: rec} -} - -func (s *prototypeService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error) { - if in.Content == "" { - return nil, errs.New(errs.CodeInvalidInput, "content 必填") - } - p, err := loadProjectForWrite(ctx, s.repo, c, slug) - if err != nil { - return nil, err - } - r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) - if err != nil { - return nil, err - } - if r.Status == StatusClosed { - return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止新增原型版本") - } - out, err := s.createWithRetry(ctx, r.ID, c.UserID, in) - if err != nil { - return nil, err - } - s.recordAudit(ctx, c, "requirement_prototype.create", out.ID.String(), map[string]any{ - "requirement_id": r.ID.String(), "version": out.Version, - }) - return out, nil -} - -// createWithRetry 取 max+1 作为新 version;version 撞 UNIQUE 时重试 1 次。 -func (s *prototypeService) createWithRetry(ctx context.Context, reqID, createdBy uuid.UUID, in CreatePrototypeInput) (*Prototype, error) { - for attempt := 0; attempt < 2; attempt++ { - maxVer, err := s.repo.GetMaxPrototypeVersion(ctx, reqID) - if err != nil { - return nil, err - } - out, err := s.repo.CreatePrototype(ctx, &Prototype{ - ID: uuid.New(), RequirementID: reqID, - Version: maxVer + 1, Content: in.Content, Note: in.Note, - CreatedBy: createdBy, - }) - if err == nil { - return out, nil - } - if !IsUniqueViolation(err) { - return nil, err - } - // version 撞了,下一轮重新取 max - } - return nil, errs.New(errs.CodeInternal, "prototype version 持续冲突") -} - -func (s *prototypeService) List(ctx context.Context, c Caller, slug string, reqNumber int) ([]*Prototype, error) { - p, err := loadProjectForRead(ctx, s.repo, c, slug) - if err != nil { - return nil, err - } - r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) - if err != nil { - return nil, err - } - return s.repo.ListPrototypesByRequirement(ctx, r.ID) -} - -func (s *prototypeService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, version int) (*Prototype, error) { - p, err := loadProjectForRead(ctx, s.repo, c, slug) - if err != nil { - return nil, err - } - r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) - if err != nil { - return nil, err - } - return s.repo.GetPrototypeByVersion(ctx, r.ID, version) -} - -func (s *prototypeService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { - if s.audit == nil { - return - } - uid := c.UserID - _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ - UserID: &uid, Action: action, TargetType: "requirement_prototype", TargetID: targetID, Metadata: meta, - }) -} diff --git a/internal/project/prototype_service_test.go b/internal/project/prototype_service_test.go deleted file mode 100644 index ff3523f..0000000 --- a/internal/project/prototype_service_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package project - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/yan1h/agent-coding-workflow/internal/infra/errs" -) - -func newProtoSvc(repo *fakeRepo) (PrototypeService, RequirementService, ProjectService, *spyAudit) { - spy := &spyAudit{} - return NewPrototypeService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy -} - -func TestPrototypeService_Create_VersionAutoIncrement(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, spy := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - p1, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"}) - require.NoError(t, err) - require.Equal(t, 1, p1.Version) - - p2, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v2", Note: "second"}) - require.NoError(t, err) - require.Equal(t, 2, p2.Version) - require.Equal(t, "second", p2.Note) - - require.Contains(t, spy.actions(), "requirement_prototype.create") -} - -func TestPrototypeService_Create_RetriesOnVersionConflict(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - // 首轮 CreatePrototype 撞 UNIQUE,service 应捕获并重试一次后成功。 - repo.createPrototypeFailOnce = true - p, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"}) - require.NoError(t, err) - require.Equal(t, 1, p.Version) - require.False(t, repo.createPrototypeFailOnce, "失败开关应已被消费") -} - -func TestPrototypeService_Create_RequiresContent(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - _, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: ""}) - ae, ok := errs.As(err) - require.True(t, ok) - require.Equal(t, errs.CodeInvalidInput, ae.Code) -} - -func TestPrototypeService_Create_BlockedWhenRequirementClosed(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) - - _, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "v1"}) - ae, ok := errs.As(err) - require.True(t, ok) - require.Equal(t, errs.CodeRequirementClosed, ae.Code) -} - -func TestPrototypeService_List_OrderedByVersion(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - for i := 0; i < 3; i++ { - _, err := protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "x"}) - require.NoError(t, err) - } - - list, err := protoSvc.List(context.Background(), caller, "demo", r.Number) - require.NoError(t, err) - require.Len(t, list, 3) - require.Equal(t, 1, list[0].Version) - require.Equal(t, 2, list[1].Version) - require.Equal(t, 3, list[2].Version) -} - -func TestPrototypeService_GetByVersion_NotFound(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - _, err = protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 99) - ae, ok := errs.As(err) - require.True(t, ok) - require.Equal(t, errs.CodeNotFound, ae.Code) -} - -func TestPrototypeService_GetByVersion_HappyPath(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - caller := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - _, err = protoSvc.Create(context.Background(), caller, "demo", r.Number, CreatePrototypeInput{Content: "hello", Note: "n"}) - require.NoError(t, err) - - got, err := protoSvc.GetByVersion(context.Background(), caller, "demo", r.Number, 1) - require.NoError(t, err) - require.Equal(t, "hello", got.Content) - require.Equal(t, "n", got.Note) - require.Equal(t, owner, got.CreatedBy) -} - -func TestPrototypeService_Create_ForbiddenForOutsider(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - owns := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - // 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。 - outsider := Caller{UserID: uuid.New()} - _, err = protoSvc.Create(context.Background(), outsider, "demo", r.Number, CreatePrototypeInput{Content: "v1"}) - ae, ok := errs.As(err) - require.True(t, ok) - require.Equal(t, errs.CodeNotFound, ae.Code) -} - -func TestPrototypeService_List_ForbiddenForOutsider(t *testing.T) { - repo := newFakeRepo() - owner := uuid.New() - seedProject(t, repo, owner) - protoSvc, reqSvc, _, _ := newProtoSvc(repo) - owns := Caller{UserID: owner} - - r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"}) - require.NoError(t, err) - - outsider := Caller{UserID: uuid.New()} - _, err = protoSvc.List(context.Background(), outsider, "demo", r.Number) - ae, ok := errs.As(err) - require.True(t, ok) - require.Equal(t, errs.CodeNotFound, ae.Code) -} diff --git a/internal/project/queries/artifacts.sql b/internal/project/queries/artifacts.sql new file mode 100644 index 0000000..15d9870 --- /dev/null +++ b/internal/project/queries/artifacts.sql @@ -0,0 +1,19 @@ +-- name: CreateRequirementArtifact :one +INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at; + +-- name: ListRequirementArtifactsByRequirement :many +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +FROM requirement_artifacts +WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2) +ORDER BY phase ASC, version ASC; + +-- name: GetRequirementArtifactByVersion :one +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +FROM requirement_artifacts +WHERE requirement_id = $1 AND phase = $2 AND version = $3; + +-- name: GetMaxRequirementArtifactVersion :one +SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts +WHERE requirement_id = $1 AND phase = $2; diff --git a/internal/project/queries/prototypes.sql b/internal/project/queries/prototypes.sql deleted file mode 100644 index 99bf4fb..0000000 --- a/internal/project/queries/prototypes.sql +++ /dev/null @@ -1,19 +0,0 @@ --- name: CreateRequirementPrototype :one -INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, requirement_id, version, content, note, created_by, created_at; - --- name: ListRequirementPrototypesByRequirement :many -SELECT id, requirement_id, version, content, note, created_by, created_at -FROM requirement_prototypes -WHERE requirement_id = $1 -ORDER BY version ASC; - --- name: GetRequirementPrototypeByVersion :one -SELECT id, requirement_id, version, content, note, created_by, created_at -FROM requirement_prototypes -WHERE requirement_id = $1 AND version = $2; - --- name: GetMaxRequirementPrototypeVersion :one -SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes -WHERE requirement_id = $1; diff --git a/internal/project/repository.go b/internal/project/repository.go index ba6d4ab..a43b867 100644 --- a/internal/project/repository.go +++ b/internal/project/repository.go @@ -51,11 +51,12 @@ type Repository interface { CloseIssue(ctx context.Context, id uuid.UUID) error ReopenIssue(ctx context.Context, id uuid.UUID) error - // Prototype - CreatePrototype(ctx context.Context, p *Prototype) (*Prototype, error) - ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) - GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) - GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error) + // Artifact + CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error) + // ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。 + ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) + GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) + GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) } // IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create* @@ -498,68 +499,79 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error { return nil } -// ===== Prototype ===== +// ===== Artifact ===== -func rowToPrototype(row projectsqlc.RequirementPrototype) *Prototype { - return &Prototype{ - ID: fromPgUUID(row.ID), - RequirementID: fromPgUUID(row.RequirementID), - Version: int(row.Version), - Content: row.Content, - Note: row.Note, - CreatedBy: fromPgUUID(row.CreatedBy), - CreatedAt: row.CreatedAt.Time, +func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact { + return &Artifact{ + ID: fromPgUUID(row.ID), + RequirementID: fromPgUUID(row.RequirementID), + Phase: Phase(row.Phase), + Version: int(row.Version), + Content: row.Content, + Note: row.Note, + SourceMessageID: row.SourceMessageID, + CreatedBy: fromPgUUID(row.CreatedBy), + CreatedAt: row.CreatedAt.Time, } } -func (r *pgRepo) CreatePrototype(ctx context.Context, in *Prototype) (*Prototype, error) { - row, err := r.q.CreateRequirementPrototype(ctx, projectsqlc.CreateRequirementPrototypeParams{ - ID: toPgUUID(in.ID), - RequirementID: toPgUUID(in.RequirementID), - Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出 - Content: in.Content, - Note: in.Note, - CreatedBy: toPgUUID(in.CreatedBy), +func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) { + row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{ + ID: toPgUUID(in.ID), + RequirementID: toPgUUID(in.RequirementID), + Phase: string(in.Phase), + Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出 + Content: in.Content, + Note: in.Note, + SourceMessageID: in.SourceMessageID, + CreatedBy: toPgUUID(in.CreatedBy), }) if err != nil { if IsUniqueViolation(err) { return nil, err // service 层据此重试一次 } - return nil, errs.Wrap(err, errs.CodeInternal, "create prototype") + return nil, errs.Wrap(err, errs.CodeInternal, "create artifact") } - return rowToPrototype(row), nil + return rowToArtifact(row), nil } -func (r *pgRepo) ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) { - rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID)) +func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) { + rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{ + RequirementID: toPgUUID(requirementID), + Column2: string(phase), + }) if err != nil { - return nil, errs.Wrap(err, errs.CodeInternal, "list prototypes by requirement") + return nil, errs.Wrap(err, errs.CodeInternal, "list artifacts by requirement") } - out := make([]*Prototype, 0, len(rows)) + out := make([]*Artifact, 0, len(rows)) for _, row := range rows { - out = append(out, rowToPrototype(row)) + out = append(out, rowToArtifact(row)) } return out, nil } -func (r *pgRepo) GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) { - row, err := r.q.GetRequirementPrototypeByVersion(ctx, projectsqlc.GetRequirementPrototypeByVersionParams{ +func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) { + row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{ RequirementID: toPgUUID(requirementID), + Phase: string(phase), Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32 }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, errs.New(errs.CodeNotFound, "prototype 不存在") + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } - return nil, errs.Wrap(err, errs.CodeInternal, "get prototype by version") + return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by version") } - return rowToPrototype(row), nil + return rowToArtifact(row), nil } -func (r *pgRepo) GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, error) { - max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID)) +func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) { + max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{ + RequirementID: toPgUUID(requirementID), + Phase: string(phase), + }) if err != nil { - return 0, errs.Wrap(err, errs.CodeInternal, "get max prototype version") + return 0, errs.Wrap(err, errs.CodeInternal, "get max artifact version") } return int(max), nil } diff --git a/internal/project/sqlc/artifacts.sql.go b/internal/project/sqlc/artifacts.sql.go new file mode 100644 index 0000000..1156715 --- /dev/null +++ b/internal/project/sqlc/artifacts.sql.go @@ -0,0 +1,143 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: artifacts.sql + +package projectsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createRequirementArtifact = `-- name: CreateRequirementArtifact :one +INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +` + +type CreateRequirementArtifactParams struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +func (q *Queries) CreateRequirementArtifact(ctx context.Context, arg CreateRequirementArtifactParams) (RequirementArtifact, error) { + row := q.db.QueryRow(ctx, createRequirementArtifact, + arg.ID, + arg.RequirementID, + arg.Phase, + arg.Version, + arg.Content, + arg.Note, + arg.SourceMessageID, + arg.CreatedBy, + ) + var i RequirementArtifact + err := row.Scan( + &i.ID, + &i.RequirementID, + &i.Phase, + &i.Version, + &i.Content, + &i.Note, + &i.SourceMessageID, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const getMaxRequirementArtifactVersion = `-- name: GetMaxRequirementArtifactVersion :one +SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts +WHERE requirement_id = $1 AND phase = $2 +` + +type GetMaxRequirementArtifactVersionParams struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` +} + +func (q *Queries) GetMaxRequirementArtifactVersion(ctx context.Context, arg GetMaxRequirementArtifactVersionParams) (int32, error) { + row := q.db.QueryRow(ctx, getMaxRequirementArtifactVersion, arg.RequirementID, arg.Phase) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const getRequirementArtifactByVersion = `-- name: GetRequirementArtifactByVersion :one +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +FROM requirement_artifacts +WHERE requirement_id = $1 AND phase = $2 AND version = $3 +` + +type GetRequirementArtifactByVersionParams struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` +} + +func (q *Queries) GetRequirementArtifactByVersion(ctx context.Context, arg GetRequirementArtifactByVersionParams) (RequirementArtifact, error) { + row := q.db.QueryRow(ctx, getRequirementArtifactByVersion, arg.RequirementID, arg.Phase, arg.Version) + var i RequirementArtifact + err := row.Scan( + &i.ID, + &i.RequirementID, + &i.Phase, + &i.Version, + &i.Content, + &i.Note, + &i.SourceMessageID, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const listRequirementArtifactsByRequirement = `-- name: ListRequirementArtifactsByRequirement :many +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +FROM requirement_artifacts +WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2) +ORDER BY phase ASC, version ASC +` + +type ListRequirementArtifactsByRequirementParams struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Column2 string `json:"column_2"` +} + +func (q *Queries) ListRequirementArtifactsByRequirement(ctx context.Context, arg ListRequirementArtifactsByRequirementParams) ([]RequirementArtifact, error) { + rows, err := q.db.Query(ctx, listRequirementArtifactsByRequirement, arg.RequirementID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RequirementArtifact + for rows.Next() { + var i RequirementArtifact + if err := rows.Scan( + &i.ID, + &i.RequirementID, + &i.Phase, + &i.Version, + &i.Content, + &i.Note, + &i.SourceMessageID, + &i.CreatedBy, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go index b2e4d75..91a19ce 100644 --- a/internal/project/sqlc/models.go +++ b/internal/project/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,14 +277,16 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } -type RequirementPrototype struct { - ID pgtype.UUID `json:"id"` - RequirementID pgtype.UUID `json:"requirement_id"` - Version int32 `json:"version"` - Content string `json:"content"` - Note string `json:"note"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type User struct { diff --git a/internal/project/sqlc/prototypes.sql.go b/internal/project/sqlc/prototypes.sql.go deleted file mode 100644 index da96447..0000000 --- a/internal/project/sqlc/prototypes.sql.go +++ /dev/null @@ -1,122 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: prototypes.sql - -package projectsqlc - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createRequirementPrototype = `-- name: CreateRequirementPrototype :one -INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, requirement_id, version, content, note, created_by, created_at -` - -type CreateRequirementPrototypeParams struct { - ID pgtype.UUID `json:"id"` - RequirementID pgtype.UUID `json:"requirement_id"` - Version int32 `json:"version"` - Content string `json:"content"` - Note string `json:"note"` - CreatedBy pgtype.UUID `json:"created_by"` -} - -func (q *Queries) CreateRequirementPrototype(ctx context.Context, arg CreateRequirementPrototypeParams) (RequirementPrototype, error) { - row := q.db.QueryRow(ctx, createRequirementPrototype, - arg.ID, - arg.RequirementID, - arg.Version, - arg.Content, - arg.Note, - arg.CreatedBy, - ) - var i RequirementPrototype - err := row.Scan( - &i.ID, - &i.RequirementID, - &i.Version, - &i.Content, - &i.Note, - &i.CreatedBy, - &i.CreatedAt, - ) - return i, err -} - -const getMaxRequirementPrototypeVersion = `-- name: GetMaxRequirementPrototypeVersion :one -SELECT COALESCE(MAX(version), 0)::int FROM requirement_prototypes -WHERE requirement_id = $1 -` - -func (q *Queries) GetMaxRequirementPrototypeVersion(ctx context.Context, requirementID pgtype.UUID) (int32, error) { - row := q.db.QueryRow(ctx, getMaxRequirementPrototypeVersion, requirementID) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - -const getRequirementPrototypeByVersion = `-- name: GetRequirementPrototypeByVersion :one -SELECT id, requirement_id, version, content, note, created_by, created_at -FROM requirement_prototypes -WHERE requirement_id = $1 AND version = $2 -` - -type GetRequirementPrototypeByVersionParams struct { - RequirementID pgtype.UUID `json:"requirement_id"` - Version int32 `json:"version"` -} - -func (q *Queries) GetRequirementPrototypeByVersion(ctx context.Context, arg GetRequirementPrototypeByVersionParams) (RequirementPrototype, error) { - row := q.db.QueryRow(ctx, getRequirementPrototypeByVersion, arg.RequirementID, arg.Version) - var i RequirementPrototype - err := row.Scan( - &i.ID, - &i.RequirementID, - &i.Version, - &i.Content, - &i.Note, - &i.CreatedBy, - &i.CreatedAt, - ) - return i, err -} - -const listRequirementPrototypesByRequirement = `-- name: ListRequirementPrototypesByRequirement :many -SELECT id, requirement_id, version, content, note, created_by, created_at -FROM requirement_prototypes -WHERE requirement_id = $1 -ORDER BY version ASC -` - -func (q *Queries) ListRequirementPrototypesByRequirement(ctx context.Context, requirementID pgtype.UUID) ([]RequirementPrototype, error) { - rows, err := q.db.Query(ctx, listRequirementPrototypesByRequirement, requirementID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []RequirementPrototype - for rows.Next() { - var i RequirementPrototype - if err := rows.Scan( - &i.ID, - &i.RequirementID, - &i.Version, - &i.Content, - &i.Note, - &i.CreatedBy, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/run/sqlc/models.go b/internal/run/sqlc/models.go index 5371a07..e860ba5 100644 --- a/internal/run/sqlc/models.go +++ b/internal/run/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/user/sqlc/models.go b/internal/user/sqlc/models.go index c5babc0..cbdf0d1 100644 --- a/internal/user/sqlc/models.go +++ b/internal/user/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/internal/workspace/sqlc/models.go b/internal/workspace/sqlc/models.go index c7c707e..4871025 100644 --- a/internal/workspace/sqlc/models.go +++ b/internal/workspace/sqlc/models.go @@ -95,6 +95,7 @@ type Conversation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` } type GitCredential struct { @@ -276,6 +277,18 @@ type Requirement struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type User struct { ID pgtype.UUID `json:"id"` Email string `json:"email"` diff --git a/migrations/0012_requirement_artifacts.down.sql b/migrations/0012_requirement_artifacts.down.sql new file mode 100644 index 0000000..9bf09cd --- /dev/null +++ b/migrations/0012_requirement_artifacts.down.sql @@ -0,0 +1,20 @@ +DROP INDEX IF EXISTS conversations_requirement_idx; +ALTER TABLE conversations DROP COLUMN IF EXISTS requirement_id; + +-- 还原 0011 的 requirement_prototypes;仅回迁 phase='prototyping' 行, +-- planning/auditing 产物在回滚时丢弃(旧 schema 无处安放)。 +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); +INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by, created_at) +SELECT id, requirement_id, version, content, note, created_by, created_at +FROM requirement_artifacts WHERE phase = 'prototyping'; +DROP TABLE requirement_artifacts; diff --git a/migrations/0012_requirement_artifacts.up.sql b/migrations/0012_requirement_artifacts.up.sql new file mode 100644 index 0000000..5ccf35c --- /dev/null +++ b/migrations/0012_requirement_artifacts.up.sql @@ -0,0 +1,26 @@ +-- 统一三阶段产物表:planning/prototyping/auditing 各自独立版本序列。 +-- source_message_id 仅作"产物派生自哪条 chat 消息"的追溯,手填产物为 NULL。 +CREATE TABLE requirement_artifacts ( + id UUID PRIMARY KEY, + requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, + phase TEXT NOT NULL CHECK (phase IN ('planning','prototyping','auditing')), + version INT NOT NULL, + content TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + source_message_id BIGINT REFERENCES messages(id) ON DELETE SET NULL, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (requirement_id, phase, version) +); +CREATE INDEX idx_requirement_artifacts_req_phase ON requirement_artifacts(requirement_id, phase); + +-- 旧 prototype 数据迁移(保留原 id/version/created_at)后下线旧表。 +INSERT INTO requirement_artifacts + (id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at) +SELECT id, requirement_id, 'prototyping', version, content, note, NULL, created_by, created_at +FROM requirement_prototypes; +DROP TABLE requirement_prototypes; + +-- 会话挂载 requirement(新列在表末尾,满足 sqlc 列序约定)。 +ALTER TABLE conversations ADD COLUMN requirement_id UUID REFERENCES requirements(id); +CREATE INDEX conversations_requirement_idx ON conversations(requirement_id) WHERE requirement_id IS NOT NULL; diff --git a/web/src/api/acp.ts b/web/src/api/acp.ts index b2e54b2..999eb8f 100644 --- a/web/src/api/acp.ts +++ b/web/src/api/acp.ts @@ -127,8 +127,13 @@ export const acpApi = { request(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }), }, sessions: { - list: (all?: boolean) => - request('/api/v1/acp/sessions' + (all ? '?all=true' : '')), + list: (opts: { all?: boolean; requirement_id?: string } = {}) => { + const q = new URLSearchParams() + if (opts.all) q.set('all', 'true') + if (opts.requirement_id) q.set('requirement_id', opts.requirement_id) + const qs = q.toString() + return request('/api/v1/acp/sessions' + (qs ? '?' + qs : '')) + }, get: (id: string) => request(`/api/v1/acp/sessions/${enc(id)}`), create: (req: CreateSessionReq) => diff --git a/web/src/api/chat.ts b/web/src/api/chat.ts index 48c5e09..2499c0e 100644 --- a/web/src/api/chat.ts +++ b/web/src/api/chat.ts @@ -10,6 +10,7 @@ export interface Conversation { project_id?: string | null workspace_id?: string | null issue_id?: string | null + requirement_id?: string | null model_id: string system_prompt: string title?: string | null @@ -72,6 +73,7 @@ export interface ListConversationsParams { project_id?: string workspace_id?: string issue_id?: string + requirement_id?: string q?: string limit?: number } @@ -83,6 +85,7 @@ export interface CreateConversationBody { project_id?: string workspace_id?: string issue_id?: string + requirement_id?: string } export interface UsageDailyItem { diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts index 4ef0fe0..6364ba8 100644 --- a/web/src/api/projects.ts +++ b/web/src/api/projects.ts @@ -47,12 +47,17 @@ export interface Issue { updated_at: string } -export interface Prototype { +// ArtifactPhase 是允许产出文档型产物的三个阶段(与后端 ArtifactPhases 一致)。 +export type ArtifactPhase = 'planning' | 'prototyping' | 'auditing' + +export interface Artifact { id: string requirement_id: string + phase: ArtifactPhase version: number content: string note: string + source_message_id?: number | null created_by: string created_at: string } @@ -126,19 +131,23 @@ 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`, + // ===== Artifact ===== + listArtifacts: (slug: string, number: number, phase?: ArtifactPhase) => + request( + `/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts${phase ? `?phase=${phase}` : ''}`, ), - createPrototype: (slug: string, number: number, body: { content: string; note?: string }) => - request(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, { + createArtifact: ( + slug: string, + number: number, + body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number }, + ) => + request(`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts`, { method: 'POST', body, }), - getPrototype: (slug: string, number: number, version: number) => - request<{ prototype: Prototype }>( - `/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`, + getArtifact: (slug: string, number: number, phase: ArtifactPhase, version: number) => + request( + `/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts/${phase}/${version}`, ), // ===== Issue ===== diff --git a/web/src/composables/useConversationSession.test.ts b/web/src/composables/useConversationSession.test.ts new file mode 100644 index 0000000..943fdf0 --- /dev/null +++ b/web/src/composables/useConversationSession.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import type { ChatStreamEvent } from './useChatStream' + +const chatApiMock = vi.hoisted(() => ({ + getConversation: vi.fn(), + listMessages: vi.fn(), + sendMessage: vi.fn(), + cancelMessage: vi.fn(), + retryMessage: vi.fn(), +})) + +vi.mock('@/api/chat', () => ({ chatApi: chatApiMock })) + +// startChatStream 的 mock:记录每次调用,暴露 emit 让测试驱动事件。 +interface StreamInstance { + url: string + since: number + emit: (ev: ChatStreamEvent) => void + stopped: boolean +} +const streamInstances = vi.hoisted(() => [] as StreamInstance[]) + +vi.mock('@/composables/useChatStream', () => ({ + startChatStream: vi.fn( + ( + url: string, + since: number, + handlers: { onEvent: (ev: ChatStreamEvent) => void; onError: (e: Error) => void }, + ) => { + const inst: StreamInstance = { + url, + since, + emit: (ev) => handlers.onEvent(ev), + stopped: false, + } + streamInstances.push(inst) + return { + stop: () => { + inst.stopped = true + }, + } + }, + ), +})) + +import { useConversationSession } from './useConversationSession' + +function conv(id: string) { + return { id, user_id: 'u1', model_id: 'm1', system_prompt: '', created_at: '', updated_at: '' } +} + +function msg(id: number, status = 'ok', role = 'assistant') { + return { + id, + conversation_id: 'c1', + role, + content: `msg-${id}`, + status, + created_at: '', + updated_at: '', + } +} + +beforeEach(() => { + streamInstances.length = 0 + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useConversationSession', () => { + it('openConversation 将 DESC 消息排为 id 升序', async () => { + chatApiMock.getConversation.mockResolvedValue({ + conversation: conv('c1'), + messages: [msg(3), msg(2), msg(1)], + }) + const s = useConversationSession() + await s.openConversation('c1') + expect(s.messages.value.map((m) => m.id)).toEqual([1, 2, 3]) + expect(s.isStreaming.value).toBe(false) + }) + + it('openConversation 发现 pending 消息时续接流', async () => { + chatApiMock.getConversation.mockResolvedValue({ + conversation: conv('c1'), + messages: [msg(2, 'pending'), msg(1)], + }) + const s = useConversationSession() + await s.openConversation('c1') + expect(s.streamingMessageId.value).toBe(2) + expect(streamInstances).toHaveLength(1) + expect(streamInstances[0].since).toBe(1) + }) + + it('sendMessage 追加两条占位消息并累积 text_delta 至 done', async () => { + chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] }) + chatApiMock.sendMessage.mockResolvedValue({ + user_message_id: 10, + assistant_message_id: 11, + stream_url: '/stream', + }) + const s = useConversationSession() + await s.openConversation('c1') + await s.sendMessage('hello') + + expect(s.messages.value.map((m) => [m.id, m.role, m.status])).toEqual([ + [10, 'user', 'ok'], + [11, 'assistant', 'pending'], + ]) + expect(s.isStreaming.value).toBe(true) + + const stream = streamInstances[0] + stream.emit({ id: 1, event: 'text_delta', data: { text: 'foo' } }) + stream.emit({ id: 2, event: 'text_delta', data: { text: 'bar' } }) + stream.emit({ id: 3, event: 'done', data: {} }) + + const assistant = s.messages.value[1] + expect(assistant.content).toBe('foobar') + expect(assistant.status).toBe('ok') + expect(s.isStreaming.value).toBe(false) + }) + + it('error 事件标记消息失败并结束流式', async () => { + chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] }) + chatApiMock.sendMessage.mockResolvedValue({ + user_message_id: 1, + assistant_message_id: 2, + stream_url: '/stream', + }) + const s = useConversationSession() + await s.openConversation('c1') + await s.sendMessage('x') + streamInstances[0].emit({ id: 1, event: 'error', data: { message: 'boom' } }) + expect(s.messages.value[1].status).toBe('error') + expect(s.messages.value[1].error_message).toBe('boom') + expect(s.isStreaming.value).toBe(false) + }) + + it('closeCurrent 终止流并清空状态', async () => { + chatApiMock.getConversation.mockResolvedValue({ + conversation: conv('c1'), + messages: [msg(2, 'pending')], + }) + const s = useConversationSession() + await s.openConversation('c1') + expect(streamInstances[0].stopped).toBe(false) + s.closeCurrent() + expect(streamInstances[0].stopped).toBe(true) + expect(s.currentConversation.value).toBeNull() + expect(s.messages.value).toEqual([]) + }) + + it('双实例互不干扰', async () => { + chatApiMock.getConversation + .mockResolvedValueOnce({ conversation: conv('c1'), messages: [msg(1)] }) + .mockResolvedValueOnce({ conversation: conv('c2'), messages: [msg(5), msg(4)] }) + const a = useConversationSession() + const b = useConversationSession() + await a.openConversation('c1') + await b.openConversation('c2') + expect(a.messages.value.map((m) => m.id)).toEqual([1]) + expect(b.messages.value.map((m) => m.id)).toEqual([4, 5]) + b.closeCurrent() + expect(a.currentConversation.value?.id).toBe('c1') + }) +}) diff --git a/web/src/composables/useConversationSession.ts b/web/src/composables/useConversationSession.ts new file mode 100644 index 0000000..8f41615 --- /dev/null +++ b/web/src/composables/useConversationSession.ts @@ -0,0 +1,223 @@ +import { computed, ref } from 'vue' + +import { chatApi, type ChatMessage, type Conversation } from '@/api/chat' +import { + startChatStream, + type ChatStreamEvent, + type ChatStreamHandle, +} from '@/composables/useChatStream' + +interface UsagePayload { + PromptTokens?: number + CompletionTokens?: number + ThinkingTokens?: number +} + +interface TextDeltaPayload { + text?: string +} + +interface ErrorPayload { + code?: string + message?: string +} + +interface MetaPayload { + id?: number + role?: string + status?: ChatMessage['status'] +} + +// useConversationSession 承载"单个会话的消息流"全部状态与操作(打开/分页/发送/ +// 取消/重试/SSE 流式接收)。每次调用返回一个独立实例,互不串扰——全局 /chat 页 +// 经 chat store 持有一份,需求详情页内嵌面板各自持有一份。 +// 调用方负责在组件卸载时调用 closeCurrent() 终止流。 +export function useConversationSession() { + const currentConversation = ref(null) + const messages = ref([]) + const streamingMessageId = ref(null) + const streamHandle = ref(null) + + const isStreaming = computed(() => streamingMessageId.value !== null) + + async function openConversation(id: string) { + detachStream() + const detail = await chatApi.getConversation(id) + currentConversation.value = detail.conversation + // 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。 + messages.value = [...detail.messages].sort((a, b) => a.id - b.id) + const pending = detail.messages.find((m) => m.status === 'pending') + if (pending) { + streamingMessageId.value = pending.id + attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1) + } + } + + async function loadOlderMessages() { + const conv = currentConversation.value + if (!conv) return + if (messages.value.length === 0) return + const beforeId = messages.value.reduce( + (min, m) => (m.id < min ? m.id : min), + messages.value[0].id, + ) + const older = await chatApi.listMessages(conv.id, { before_id: beforeId }) + if (older.length === 0) return + const existingIds = new Set(messages.value.map((m) => m.id)) + const fresh = older.filter((m) => !existingIds.has(m.id)) + if (fresh.length === 0) return + // 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。 + messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id) + } + + async function sendMessage(content: string, attachmentIds: string[] = []) { + if (!currentConversation.value) return + if (streamingMessageId.value !== null) return + const conv = currentConversation.value + const resp = await chatApi.sendMessage(conv.id, { + content, + attachment_ids: attachmentIds, + }) + const now = new Date().toISOString() + messages.value.push({ + id: resp.user_message_id, + conversation_id: conv.id, + role: 'user', + content, + status: 'ok', + created_at: now, + updated_at: now, + }) + messages.value.push({ + id: resp.assistant_message_id, + conversation_id: conv.id, + role: 'assistant', + content: '', + status: 'pending', + created_at: now, + updated_at: now, + }) + streamingMessageId.value = resp.assistant_message_id + attachStream(resp.stream_url, resp.user_message_id) + } + + async function cancelMessage() { + const id = streamingMessageId.value + if (id == null) return + await chatApi.cancelMessage(id) + detachStream() + } + + async function retryMessage(messageId: number) { + if (!currentConversation.value) return + const resp = await chatApi.retryMessage(messageId) + const idx = messages.value.findIndex((m) => m.id === messageId) + if (idx >= 0) { + const old = messages.value[idx] + messages.value[idx] = { + ...old, + id: resp.assistant_message_id, + content: '', + thinking: null, + status: 'pending', + error_message: null, + } + } + streamingMessageId.value = resp.assistant_message_id + attachStream(resp.stream_url, resp.assistant_message_id - 1) + } + + function closeCurrent() { + detachStream() + currentConversation.value = null + messages.value = [] + streamingMessageId.value = null + } + + function attachStream(streamUrl: string, sinceMessageID: number) { + detachStream() + streamHandle.value = startChatStream(streamUrl, sinceMessageID, { + onEvent: (ev) => applyStreamEvent(ev), + onClose: () => { + streamHandle.value = null + }, + onError: (err) => { + console.error('chat stream error', err) + const id = streamingMessageId.value + if (id != null) { + const msg = messages.value.find((m) => m.id === id) + if (msg && msg.status === 'pending') { + msg.status = 'error' + msg.error_message = err.message + } + } + streamingMessageId.value = null + streamHandle.value = null + }, + }) + } + + function detachStream() { + streamHandle.value?.stop() + streamHandle.value = null + } + + function applyStreamEvent(ev: ChatStreamEvent) { + const targetId = streamingMessageId.value + if (targetId == null) return + const msg = messages.value.find((m) => m.id === targetId) + if (!msg) return + switch (ev.event) { + case 'message_meta': { + const d = ev.data as MetaPayload + if (d.status) msg.status = d.status + break + } + case 'text_delta': { + const d = ev.data as TextDeltaPayload + if (d.text) msg.content += d.text + break + } + case 'thinking_delta': { + const d = ev.data as TextDeltaPayload + if (d.text) msg.thinking = (msg.thinking ?? '') + d.text + break + } + case 'usage': { + const u = ev.data as UsagePayload + msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens + msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens + msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens + break + } + case 'done': + // 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。 + // tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。 + msg.status = 'ok' + streamingMessageId.value = null + break + case 'error': { + const d = ev.data as ErrorPayload + msg.status = 'error' + msg.error_message = d.message ?? d.code ?? 'stream error' + streamingMessageId.value = null + break + } + } + } + + return { + currentConversation, + messages, + streamingMessageId, + isStreaming, + openConversation, + loadOlderMessages, + sendMessage, + cancelMessage, + retryMessage, + closeCurrent, + } +} + +export type ConversationSession = ReturnType diff --git a/web/src/constants/requirementPhasePrompts.test.ts b/web/src/constants/requirementPhasePrompts.test.ts new file mode 100644 index 0000000..0ba23ee --- /dev/null +++ b/web/src/constants/requirementPhasePrompts.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' + +import type { Artifact } from '@/api/projects' +import { + buildRequirementSystemPrompt, + PHASE_ROLE_PROMPTS, + PREV_ARTIFACT_PHASE, +} from './requirementPhasePrompts' + +const req = { number: 7, title: '导出报表', description: '支持导出 Excel' } + +function artifact(content: string): Artifact { + return { + id: 'a1', + requirement_id: 'r1', + phase: 'planning', + version: 2, + content, + note: '', + created_by: 'u1', + created_at: '', + } +} + +describe('buildRequirementSystemPrompt', () => { + it('planning:角色模板 + 需求信息,无上一阶段产物', () => { + const out = buildRequirementSystemPrompt('planning', req) + expect(out).toContain(PHASE_ROLE_PROMPTS.planning) + expect(out).toContain('需求 #7:导出报表') + expect(out).toContain('支持导出 Excel') + expect(out).not.toContain('上一阶段') + }) + + it('prototyping:注入上一阶段(planning)产物', () => { + const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容')) + expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping) + expect(out).toContain('上一阶段(planning)产物 v2') + expect(out).toContain('# 规划文档内容') + }) + + it('描述为空时不渲染需求描述段', () => { + const out = buildRequirementSystemPrompt('planning', { ...req, description: '' }) + expect(out).not.toContain('需求描述') + }) + + it('超长产物内容截断并标注', () => { + const long = 'x'.repeat(10000) + const out = buildRequirementSystemPrompt('auditing', req, artifact(long)) + expect(out).toContain('已截断') + expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length) + }) + + it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => { + expect(PREV_ARTIFACT_PHASE.planning).toBeNull() + expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning') + expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping') + }) +}) diff --git a/web/src/constants/requirementPhasePrompts.ts b/web/src/constants/requirementPhasePrompts.ts new file mode 100644 index 0000000..b9dde62 --- /dev/null +++ b/web/src/constants/requirementPhasePrompts.ts @@ -0,0 +1,44 @@ +import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects' + +// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。 +const MAX_PREV_ARTIFACT_CHARS = 8000 + +// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt, +// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。 +export const PHASE_ROLE_PROMPTS: Record = { + planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。 +讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`, + prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。 +内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`, + auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。 +评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`, +} + +// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。 +export const PREV_ARTIFACT_PHASE: Record = { + planning: null, + prototyping: 'planning', + auditing: 'prototyping', +} + +// buildRequirementSystemPrompt 组装阶段会话的 system prompt: +// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。 +export function buildRequirementSystemPrompt( + phase: ArtifactPhase, + requirement: Pick, + prevArtifact?: Artifact | null, +): string { + const parts: string[] = [PHASE_ROLE_PROMPTS[phase]] + parts.push(`## 当前需求\n需求 #${requirement.number}:${requirement.title}`) + if (requirement.description) { + parts.push(`### 需求描述\n${requirement.description}`) + } + if (prevArtifact) { + let content = prevArtifact.content + if (content.length > MAX_PREV_ARTIFACT_CHARS) { + content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)' + } + parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`) + } + return parts.join('\n\n') +} diff --git a/web/src/stores/acp.ts b/web/src/stores/acp.ts index f79a2e4..24afc34 100644 --- a/web/src/stores/acp.ts +++ b/web/src/stores/acp.ts @@ -60,11 +60,11 @@ export const useAcpStore = defineStore('acp', () => { return acpApi.agentKinds.get(id) } - async function listSessions(all = false) { + async function listSessions(all = false, requirementId?: string) { loading.value = true error.value = null try { - sessions.value = await acpApi.sessions.list(all) + sessions.value = await acpApi.sessions.list({ all, requirement_id: requirementId }) } catch (e) { error.value = e instanceof Error ? e.message : 'failed' } finally { diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts index 0b01ba1..df52e26 100644 --- a/web/src/stores/chat.ts +++ b/web/src/stores/chat.ts @@ -1,239 +1,41 @@ import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { ref } from 'vue' -import { - chatApi, - type ChatMessage, - type Conversation, - type ListConversationsParams, -} from '@/api/chat' -import { - startChatStream, - type ChatStreamEvent, - type ChatStreamHandle, -} from '@/composables/useChatStream' - -interface UsagePayload { - PromptTokens?: number - CompletionTokens?: number - ThinkingTokens?: number -} - -interface TextDeltaPayload { - text?: string -} - -interface ErrorPayload { - code?: string - message?: string -} - -interface MetaPayload { - id?: number - role?: string - status?: ChatMessage['status'] -} +import { chatApi, type Conversation, type ListConversationsParams } from '@/api/chat' +import { useConversationSession } from '@/composables/useConversationSession' +// chat store = 全局 /chat 页那一份会话实例 + 会话列表。 +// "单会话消息流"逻辑在 useConversationSession 中(可多实例,详情页内嵌面板自建), +// 这里持有一份并原样转发,保持对外 API 不变(ChatDetailView 零改动)。 export const useChatStore = defineStore('chat', () => { const conversations = ref([]) - const currentConversation = ref(null) - const messages = ref([]) - const streamingMessageId = ref(null) - const streamHandle = ref(null) - - const isStreaming = computed(() => streamingMessageId.value !== null) + const session = useConversationSession() async function loadConversations(filter: ListConversationsParams = {}) { conversations.value = await chatApi.listConversations(filter) } - async function openConversation(id: string) { - detachStream() - const detail = await chatApi.getConversation(id) - currentConversation.value = detail.conversation - // 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。 - messages.value = [...detail.messages].sort((a, b) => a.id - b.id) - const pending = detail.messages.find((m) => m.status === 'pending') - if (pending) { - streamingMessageId.value = pending.id - attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1) - } - } - - async function loadOlderMessages() { - const conv = currentConversation.value - if (!conv) return - if (messages.value.length === 0) return - const beforeId = messages.value.reduce( - (min, m) => (m.id < min ? m.id : min), - messages.value[0].id, - ) - const older = await chatApi.listMessages(conv.id, { before_id: beforeId }) - if (older.length === 0) return - const existingIds = new Set(messages.value.map((m) => m.id)) - const fresh = older.filter((m) => !existingIds.has(m.id)) - if (fresh.length === 0) return - // 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。 - messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id) - } - - async function sendMessage(content: string, attachmentIds: string[] = []) { - if (!currentConversation.value) return - if (streamingMessageId.value !== null) return - const conv = currentConversation.value - const resp = await chatApi.sendMessage(conv.id, { - content, - attachment_ids: attachmentIds, - }) - const now = new Date().toISOString() - messages.value.push({ - id: resp.user_message_id, - conversation_id: conv.id, - role: 'user', - content, - status: 'ok', - created_at: now, - updated_at: now, - }) - messages.value.push({ - id: resp.assistant_message_id, - conversation_id: conv.id, - role: 'assistant', - content: '', - status: 'pending', - created_at: now, - updated_at: now, - }) - streamingMessageId.value = resp.assistant_message_id - attachStream(resp.stream_url, resp.user_message_id) - } - - async function cancelMessage() { - const id = streamingMessageId.value - if (id == null) return - await chatApi.cancelMessage(id) - detachStream() - } - - async function retryMessage(messageId: number) { - if (!currentConversation.value) return - const resp = await chatApi.retryMessage(messageId) - const idx = messages.value.findIndex((m) => m.id === messageId) - if (idx >= 0) { - const old = messages.value[idx] - messages.value[idx] = { - ...old, - id: resp.assistant_message_id, - content: '', - thinking: null, - status: 'pending', - error_message: null, - } - } - streamingMessageId.value = resp.assistant_message_id - attachStream(resp.stream_url, resp.assistant_message_id - 1) - } - async function deleteConversation(id: string) { await chatApi.deleteConversation(id) conversations.value = conversations.value.filter((c) => c.id !== id) - if (currentConversation.value?.id === id) { - closeCurrent() - } - } - - function closeCurrent() { - detachStream() - currentConversation.value = null - messages.value = [] - streamingMessageId.value = null - } - - function attachStream(streamUrl: string, sinceMessageID: number) { - detachStream() - streamHandle.value = startChatStream(streamUrl, sinceMessageID, { - onEvent: (ev) => applyStreamEvent(ev), - onClose: () => { - streamHandle.value = null - }, - onError: (err) => { - console.error('chat stream error', err) - const id = streamingMessageId.value - if (id != null) { - const msg = messages.value.find((m) => m.id === id) - if (msg && msg.status === 'pending') { - msg.status = 'error' - msg.error_message = err.message - } - } - streamingMessageId.value = null - streamHandle.value = null - }, - }) - } - - function detachStream() { - streamHandle.value?.stop() - streamHandle.value = null - } - - function applyStreamEvent(ev: ChatStreamEvent) { - const targetId = streamingMessageId.value - if (targetId == null) return - const msg = messages.value.find((m) => m.id === targetId) - if (!msg) return - switch (ev.event) { - case 'message_meta': { - const d = ev.data as MetaPayload - if (d.status) msg.status = d.status - break - } - case 'text_delta': { - const d = ev.data as TextDeltaPayload - if (d.text) msg.content += d.text - break - } - case 'thinking_delta': { - const d = ev.data as TextDeltaPayload - if (d.text) msg.thinking = (msg.thinking ?? '') + d.text - break - } - case 'usage': { - const u = ev.data as UsagePayload - msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens - msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens - msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens - break - } - case 'done': - // 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。 - // tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。 - msg.status = 'ok' - streamingMessageId.value = null - break - case 'error': { - const d = ev.data as ErrorPayload - msg.status = 'error' - msg.error_message = d.message ?? d.code ?? 'stream error' - streamingMessageId.value = null - break - } + if (session.currentConversation.value?.id === id) { + session.closeCurrent() } } return { conversations, - currentConversation, - messages, - streamingMessageId, - isStreaming, + currentConversation: session.currentConversation, + messages: session.messages, + streamingMessageId: session.streamingMessageId, + isStreaming: session.isStreaming, loadConversations, - openConversation, - loadOlderMessages, - closeCurrent, - sendMessage, - cancelMessage, - retryMessage, + openConversation: session.openConversation, + loadOlderMessages: session.loadOlderMessages, + closeCurrent: session.closeCurrent, + sendMessage: session.sendMessage, + cancelMessage: session.cancelMessage, + retryMessage: session.retryMessage, deleteConversation, } }) diff --git a/web/src/stores/requirements.ts b/web/src/stores/requirements.ts index 046e544..d0e6fcb 100644 --- a/web/src/stores/requirements.ts +++ b/web/src/stores/requirements.ts @@ -2,8 +2,9 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { projectsApi, + type Artifact, + type ArtifactPhase, type Phase, - type Prototype, type Requirement, type Status, } from '@/api/projects' @@ -51,26 +52,29 @@ export const useRequirementsStore = defineStore('requirements', () => { await refresh(forSlug) } - async function listPrototypes(forSlug: string, number: number): Promise { - const out = await projectsApi.listPrototypes(forSlug, number) - return out.prototypes - } - - async function createPrototype( + async function listArtifacts( forSlug: string, number: number, - body: { content: string; note?: string }, - ): Promise { - return await projectsApi.createPrototype(forSlug, number, body) + phase?: ArtifactPhase, + ): Promise { + return await projectsApi.listArtifacts(forSlug, number, phase) } - async function getPrototype( + async function createArtifact( forSlug: string, number: number, + body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number }, + ): Promise { + return await projectsApi.createArtifact(forSlug, number, body) + } + + async function getArtifact( + forSlug: string, + number: number, + phase: ArtifactPhase, version: number, - ): Promise { - const out = await projectsApi.getPrototype(forSlug, number, version) - return out.prototype + ): Promise { + return await projectsApi.getArtifact(forSlug, number, phase, version) } return { @@ -84,8 +88,8 @@ export const useRequirementsStore = defineStore('requirements', () => { changePhase, close, reopen, - listPrototypes, - createPrototype, - getPrototype, + listArtifacts, + createArtifact, + getArtifact, } }) diff --git a/web/src/views/acp/AcpSessionCreateView.test.ts b/web/src/views/acp/AcpSessionCreateView.test.ts index 3900b19..6543495 100644 --- a/web/src/views/acp/AcpSessionCreateView.test.ts +++ b/web/src/views/acp/AcpSessionCreateView.test.ts @@ -46,8 +46,13 @@ vi.mock('@/api/workspaces', () => ({ }, })) +const routeMock = vi.hoisted(() => ({ + params: { slug: 'p', wsSlug: 'w' }, + query: {} as Record, +})) + vi.mock('vue-router', () => ({ - useRoute: () => ({ params: { slug: 'p', wsSlug: 'w' } }), + useRoute: () => routeMock, useRouter: () => ({ push: vi.fn() }), })) @@ -55,6 +60,7 @@ import AcpSessionCreateView from './AcpSessionCreateView.vue' beforeEach(() => { setActivePinia(createPinia()) + routeMock.query = {} }) describe('AcpSessionCreateView', () => { @@ -63,4 +69,13 @@ describe('AcpSessionCreateView', () => { await new Promise((r) => setTimeout(r, 20)) expect(wrapper.html()).toContain('New ACP Session') }) + + it('prefills requirement/issue number from query', async () => { + routeMock.query = { requirement_number: '7', issue_number: '42' } + const wrapper = mount(AcpSessionCreateView) + await new Promise((r) => setTimeout(r, 20)) + const inputs = wrapper.findAll('input[type="number"]') + expect((inputs[0].element as HTMLInputElement).value).toBe('42') + expect((inputs[1].element as HTMLInputElement).value).toBe('7') + }) }) diff --git a/web/src/views/acp/AcpSessionCreateView.vue b/web/src/views/acp/AcpSessionCreateView.vue index 7efe79b..f6a7355 100644 --- a/web/src/views/acp/AcpSessionCreateView.vue +++ b/web/src/views/acp/AcpSessionCreateView.vue @@ -20,6 +20,13 @@ const submitting = ref(false) const errorMsg = ref(null) onMounted(async () => { + // 来自需求详情页等入口的预填(?requirement_number= / ?issue_number=)。 + if (typeof route.query.requirement_number === 'string') { + form.value.requirement_number = route.query.requirement_number + } + if (typeof route.query.issue_number === 'string') { + form.value.issue_number = route.query.issue_number + } await acpStore.listAgentKinds(false) if (wsStore.list.length === 0) { await wsStore.fetchByProject(route.params.slug as string) diff --git a/web/src/views/chat/components/MessageBubble.vue b/web/src/views/chat/components/MessageBubble.vue index 7e4da0d..769c0de 100644 --- a/web/src/views/chat/components/MessageBubble.vue +++ b/web/src/views/chat/components/MessageBubble.vue @@ -62,6 +62,18 @@ in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }} · think {{ message.thinking_tokens }} +
+ + 保存为产物 + +
@@ -74,7 +86,15 @@ import MarkdownIt from 'markdown-it' import type { ChatMessage } from '@/api/chat' const md = new MarkdownIt({ html: false, breaks: true, linkify: true }) -const props = defineProps<{ message: ChatMessage }>() -const emit = defineEmits<{ retry: [id: number] }>() +// savable:仅需求阶段对话面板传 true,对成功的 assistant 回复显示「保存为产物」。 +const props = defineProps<{ message: ChatMessage; savable?: boolean }>() +const emit = defineEmits<{ retry: [id: number]; save: [id: number] }>() const rendered = computed(() => md.render(props.message.content)) +const canSave = computed( + () => + props.savable === true && + props.message.role === 'assistant' && + props.message.status === 'ok' && + props.message.content.trim() !== '', +) diff --git a/web/src/views/projects/RequirementDetailView.vue b/web/src/views/projects/RequirementDetailView.vue index 947ffdd..6e1a518 100644 --- a/web/src/views/projects/RequirementDetailView.vue +++ b/web/src/views/projects/RequirementDetailView.vue @@ -47,7 +47,7 @@ - +