You've already forked agentic-coding-workflow
主流程
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+15
-12
@@ -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 — 发消息 + 流接收 + 取消 + 重试。
|
||||
|
||||
+29
-26
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+24
-18
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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。
|
||||
|
||||
+45
-42
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, spy := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
a1, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, a1.Version)
|
||||
|
||||
a2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v2", Note: "second"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, a2.Version)
|
||||
require.Equal(t, "second", a2.Note)
|
||||
|
||||
require.Contains(t, spy.actions(), "requirement_artifact.create")
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_VersionsIndependentPerPhase(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// planning 走到 v2,prototyping 的版本序列从 1 重新开始。
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p1"})
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "p2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
proto, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "x1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, proto.Version)
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} {
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok, "phase=%s", phase)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code, "phase=%s", phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_RetriesOnVersionConflict(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 首轮 CreateArtifact 撞 UNIQUE,service 应捕获并重试一次后成功。
|
||||
repo.createArtifactFailOnce = true
|
||||
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, a.Version)
|
||||
require.False(t, repo.createArtifactFailOnce, "失败开关应已被消费")
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_RequiresContent(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: ""})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_BlockedWhenRequirementClosed(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
|
||||
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_RecordsSourceMessageID(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
srcID := int64(42)
|
||||
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{
|
||||
Phase: PhaseAuditing, Content: "from chat", SourceMessageID: &srcID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, a.SourceMessageID)
|
||||
require.Equal(t, srcID, *a.SourceMessageID)
|
||||
}
|
||||
|
||||
func TestArtifactService_List_FilterByPhaseAndAll(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "x"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "y"})
|
||||
require.NoError(t, err)
|
||||
|
||||
planning, err := artSvc.List(context.Background(), caller, "demo", r.Number, PhasePlanning)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, planning, 2)
|
||||
require.Equal(t, 1, planning[0].Version)
|
||||
require.Equal(t, 2, planning[1].Version)
|
||||
|
||||
all, err := artSvc.List(context.Background(), caller, "demo", r.Number, "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 3)
|
||||
|
||||
_, err = artSvc.List(context.Background(), caller, "demo", r.Number, Phase("bogus"))
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_GetByVersion_NotFound(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePlanning, 99)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_GetByVersion_HappyPath(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhasePrototyping, Content: "hello", Note: "n"})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := artSvc.GetByVersion(context.Background(), caller, "demo", r.Number, PhasePrototyping, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", got.Content)
|
||||
require.Equal(t, "n", got.Note)
|
||||
require.Equal(t, owner, got.CreatedBy)
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_ForbiddenForOutsider(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
owns := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 私有项目下,非 owner / 非 admin 调用者写入被拒(统一返回 not_found 防探测)。
|
||||
outsider := Caller{UserID: uuid.New()}
|
||||
_, err = artSvc.Create(context.Background(), outsider, "demo", r.Number, CreateArtifactInput{Phase: PhasePlanning, Content: "v1"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
owns := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), owns, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
outsider := Caller{UserID: uuid.New()}
|
||||
_, err = artSvc.List(context.Background(), outsider, "demo", r.Number, "")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
+42
-20
@@ -40,6 +40,20 @@ func (p Phase) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase
|
||||
// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。
|
||||
var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing}
|
||||
|
||||
// IsArtifactPhase 报告 p 是否允许创建产物版本。
|
||||
func (p Phase) IsArtifactPhase() bool {
|
||||
for _, x := range ArtifactPhases {
|
||||
if p == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Status 是 Requirement / Issue 通用的开/关状态。
|
||||
type Status string
|
||||
|
||||
@@ -115,16 +129,20 @@ type Issue struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement
|
||||
// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
|
||||
type Prototype struct {
|
||||
ID uuid.UUID
|
||||
RequirementID uuid.UUID
|
||||
Version int
|
||||
Content string
|
||||
Note string
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
|
||||
// (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
|
||||
// SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用,
|
||||
// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。
|
||||
type Artifact struct {
|
||||
ID uuid.UUID
|
||||
RequirementID uuid.UUID
|
||||
Phase Phase
|
||||
Version int
|
||||
Content string
|
||||
Note string
|
||||
SourceMessageID *int64
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。
|
||||
@@ -169,11 +187,12 @@ type IssueService interface {
|
||||
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
|
||||
}
|
||||
|
||||
// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。
|
||||
type PrototypeService interface {
|
||||
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error)
|
||||
List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error)
|
||||
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error)
|
||||
// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
|
||||
type ArtifactService interface {
|
||||
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error)
|
||||
// List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
|
||||
List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error)
|
||||
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error)
|
||||
}
|
||||
|
||||
// ===== 输入 DTO =====
|
||||
@@ -240,11 +259,14 @@ type CreateIssueInput struct {
|
||||
WorkspaceID *uuid.UUID
|
||||
}
|
||||
|
||||
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1),
|
||||
// 不在此处提供。Note 可为空字符串。
|
||||
type CreatePrototypeInput struct {
|
||||
Content string
|
||||
Note string
|
||||
// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
|
||||
// ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases
|
||||
// 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。
|
||||
type CreateArtifactInput struct {
|
||||
Phase Phase
|
||||
Content string
|
||||
Note string
|
||||
SourceMessageID *int64
|
||||
}
|
||||
|
||||
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
|
||||
|
||||
+45
-37
@@ -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 =====
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,方便断言。
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user