主流程

This commit is contained in:
2026-06-10 17:53:09 +08:00
parent c6f239f424
commit b20435c027
66 changed files with 2779 additions and 1181 deletions
+2 -2
View File
@@ -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) { func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a") 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") 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") panic("n/a")
} }
func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
+8 -1
View File
@@ -120,10 +120,17 @@ type AgentKindService interface {
type SessionService interface { type SessionService interface {
Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error)
Get(ctx context.Context, c Caller, id uuid.UUID) (*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 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 的入参。 // CreateSessionInput 是创建 session 的入参。
// branch / issue_number / requirement_number 三选一或全空(spec §8.1)。 // branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
type CreateSessionInput struct { type CreateSessionInput struct {
+10 -2
View File
@@ -300,8 +300,16 @@ func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err) writeErr(w, r, err)
return return
} }
all := r.URL.Query().Get("all") == "true" f := SessionListFilter{All: r.URL.Query().Get("all") == "true"}
list, err := h.sessSvc.List(r.Context(), c, all) 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 { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
return return
+2
View File
@@ -23,6 +23,7 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions FROM acp_sessions
WHERE user_id = $1 WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2)
ORDER BY started_at DESC; ORDER BY started_at DESC;
-- name: ListAllSessions :many -- 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, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC; ORDER BY started_at DESC;
-- name: UpdateSessionRunning :exec -- name: UpdateSessionRunning :exec
+2 -2
View File
@@ -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) { func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a") 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") 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") panic("n/a")
} }
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
+10 -6
View File
@@ -31,8 +31,9 @@ type Repository interface {
// Session // Session
InsertSession(ctx context.Context, s *Session) (*Session, error) InsertSession(ctx context.Context, s *Session) (*Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) // requirementID 非 nil 时仅返回关联该需求的 sessions。
ListAllSessions(ctx context.Context) ([]*Session, error) 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 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 UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
CountActiveSessions(ctx context.Context) (int64, 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 return rowToSession(row), nil
} }
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]*Session, error) { func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListSessionsByUser(ctx, toPgUUID(userID)) rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{
UserID: toPgUUID(userID),
Column2: toPgUUIDPtr(requirementID),
})
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list sessions by user") 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 return out, nil
} }
func (r *pgRepo) ListAllSessions(ctx context.Context) ([]*Session, error) { func (r *pgRepo) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListAllSessions(ctx) rows, err := r.q.ListAllSessions(ctx, toPgUUIDPtr(requirementID))
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions") return nil, errs.Wrap(err, errs.CodeInternal, "list all sessions")
} }
+5 -5
View File
@@ -335,14 +335,14 @@ func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Sess
return sess, nil return sess, nil
} }
func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) { func (s *sessionService) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) {
if all && !c.IsAdmin { if f.All && !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only") return nil, errs.New(errs.CodeForbidden, "admin only")
} }
if all { if f.All {
return s.repo.ListAllSessions(ctx) 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 { func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error {
+58 -3
View File
@@ -212,10 +212,33 @@ func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { re
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
return 0, nil return 0, nil
} }
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) { func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, userID uuid.UUID, reqID *uuid.UUID) ([]*acp.Session, error) {
return nil, nil 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 { func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
return nil return nil
} }
@@ -440,3 +463,35 @@ func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context,
func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
return 0, nil 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)
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+11 -4
View File
@@ -154,11 +154,12 @@ SELECT id, workspace_id, project_id, agent_kind_id, user_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC ORDER BY started_at DESC
` `
func (q *Queries) ListAllSessions(ctx context.Context) ([]AcpSession, error) { func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([]AcpSession, error) {
rows, err := q.db.Query(ctx, listAllSessions) rows, err := q.db.Query(ctx, listAllSessions, dollar_1)
if err != nil { if err != nil {
return nil, err 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 pid, exit_code, last_error, started_at, ended_at
FROM acp_sessions FROM acp_sessions
WHERE user_id = $1 WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2)
ORDER BY started_at DESC ORDER BY started_at DESC
` `
func (q *Queries) ListSessionsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpSession, error) { type ListSessionsByUserParams struct {
rows, err := q.db.Query(ctx, listSessionsByUser, userID) 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 { if err != nil {
return nil, err return nil, err
} }
+3 -3
View File
@@ -91,7 +91,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
projectSvc := project.NewProjectService(projectRepo, wrappedAudit) projectSvc := project.NewProjectService(projectRepo, wrappedAudit)
requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit) requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit)
issueSvc := project.NewIssueService(projectRepo, wrappedAudit) issueSvc := project.NewIssueService(projectRepo, wrappedAudit)
prototypeSvc := project.NewPrototypeService(projectRepo, wrappedAudit) artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit)
notifyRepo := notify.NewPostgresRepository(pool) notifyRepo := notify.NewPostgresRepository(pool)
notifyDispatcher := notify.NewDispatcher(notifyRepo, log) 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 同名同签名)。 // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。
user.NewHandler(userSvc).Mount(r) user.NewHandler(userSvc).Mount(r)
notify.NewHandler(notifyRepo, userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
// ===== workspace 模块装配 ===== // ===== workspace 模块装配 =====
// 依赖图: // 依赖图:
@@ -263,7 +263,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
Projects: projectSvc, Projects: projectSvc,
Reqs: requirementSvc, Reqs: requirementSvc,
Issues: issueSvc, Issues: issueSvc,
Prototypes: prototypeSvc, Artifacts: artifactSvc,
Workspaces: wsSvc, Workspaces: wsSvc,
Templates: chatTplSvc, Templates: chatTplSvc,
Messages: chatMsgSvc, Messages: chatMsgSvc,
+4 -4
View File
@@ -158,7 +158,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec) projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec) requirementSvc := project.NewRequirementService(projectRepo, auditRec)
issueSvc := project.NewIssueService(projectRepo, auditRec) issueSvc := project.NewIssueService(projectRepo, auditRec)
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123") adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err) require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r) user.NewHandler(userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, prototypeSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r) srv := httptest.NewServer(r)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -326,7 +326,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
projectSvc := project.NewProjectService(projectRepo, auditRec) projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec) requirementSvc := project.NewRequirementService(projectRepo, auditRec)
issueSvc := project.NewIssueService(projectRepo, auditRec) issueSvc := project.NewIssueService(projectRepo, auditRec)
prototypeSvc := project.NewPrototypeService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123") adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err) require.NoError(t, err)
@@ -368,7 +368,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r) user.NewHandler(userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, 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) workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r) srv := httptest.NewServer(r)
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+3
View File
@@ -103,6 +103,7 @@ type Conversation struct {
ProjectID *uuid.UUID ProjectID *uuid.UUID
WorkspaceID *uuid.UUID WorkspaceID *uuid.UUID
IssueID *uuid.UUID IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID ModelID uuid.UUID
SystemPrompt string SystemPrompt string
Title *string Title *string
@@ -187,6 +188,7 @@ type CreateConversationInput struct {
ProjectID *uuid.UUID ProjectID *uuid.UUID
WorkspaceID *uuid.UUID WorkspaceID *uuid.UUID
IssueID *uuid.UUID IssueID *uuid.UUID
RequirementID *uuid.UUID
} }
// ConversationFilter 列表过滤参数。 // ConversationFilter 列表过滤参数。
@@ -194,6 +196,7 @@ type ConversationFilter struct {
ProjectID *uuid.UUID ProjectID *uuid.UUID
WorkspaceID *uuid.UUID WorkspaceID *uuid.UUID
IssueID *uuid.UUID IssueID *uuid.UUID
RequirementID *uuid.UUID
TitleQuery string TitleQuery string
BeforeID *uuid.UUID // cursor BeforeID *uuid.UUID // cursor
Limit int Limit int
+3
View File
@@ -16,6 +16,7 @@ type CreateConversationReq struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"` ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_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}. // UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}.
@@ -99,6 +100,7 @@ type ConversationDTO struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"` ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"` WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"` IssueID *uuid.UUID `json:"issue_id,omitempty"`
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -232,6 +234,7 @@ func toConversationDTO(c *Conversation) ConversationDTO {
ProjectID: c.ProjectID, ProjectID: c.ProjectID,
WorkspaceID: c.WorkspaceID, WorkspaceID: c.WorkspaceID,
IssueID: c.IssueID, IssueID: c.IssueID,
RequirementID: c.RequirementID,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt, UpdatedAt: c.UpdatedAt,
} }
+6
View File
@@ -200,6 +200,11 @@ func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) {
f.IssueID = &id 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) convs, err := h.convSvc.List(r.Context(), uid, f)
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
@@ -230,6 +235,7 @@ func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) {
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
WorkspaceID: req.WorkspaceID, WorkspaceID: req.WorkspaceID,
IssueID: req.IssueID, IssueID: req.IssueID,
RequirementID: req.RequirementID,
}) })
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
+5 -4
View File
@@ -1,6 +1,6 @@
-- name: InsertConversation :one -- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; RETURNING *;
-- name: GetConversation :one -- 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 ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR workspace_id = $3) AND ($3::uuid IS NULL OR workspace_id = $3)
AND ($4::uuid IS NULL OR issue_id = $4) 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 ORDER BY updated_at DESC
LIMIT $6; LIMIT $7;
-- name: UpdateConversationTitle :exec -- name: UpdateConversationTitle :exec
UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW() UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW()
+8 -2
View File
@@ -20,8 +20,8 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "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" 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 // Postgres epoch: 2000-01-01 00:00:00 UTC
@@ -109,6 +109,7 @@ type InsertConversationParams struct {
ProjectID *uuid.UUID ProjectID *uuid.UUID
WorkspaceID *uuid.UUID WorkspaceID *uuid.UUID
IssueID *uuid.UUID IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID ModelID uuid.UUID
SystemPrompt string SystemPrompt string
Title *string Title *string
@@ -313,6 +314,7 @@ func convFromRow(r chatsqlc.Conversation) *Conversation {
ProjectID: fromPgUUIDPtr(r.ProjectID), ProjectID: fromPgUUIDPtr(r.ProjectID),
WorkspaceID: fromPgUUIDPtr(r.WorkspaceID), WorkspaceID: fromPgUUIDPtr(r.WorkspaceID),
IssueID: fromPgUUIDPtr(r.IssueID), IssueID: fromPgUUIDPtr(r.IssueID),
RequirementID: fromPgUUIDPtr(r.RequirementID),
ModelID: fromPgUUID(r.ModelID), ModelID: fromPgUUID(r.ModelID),
SystemPrompt: r.SystemPrompt, SystemPrompt: r.SystemPrompt,
Title: r.Title, Title: r.Title,
@@ -477,6 +479,7 @@ func (r *pgRepo) InsertConversation(ctx context.Context, p InsertConversationPar
ModelID: toPgUUID(p.ModelID), ModelID: toPgUUID(p.ModelID),
SystemPrompt: p.SystemPrompt, SystemPrompt: p.SystemPrompt,
Title: p.Title, Title: p.Title,
RequirementID: toPgUUIDPtr(p.RequirementID),
}) })
if err != nil { if err != nil {
return nil, errs.Wrap(translatePgError(err), errs.CodeInternal, "insert conversation") 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 { if limit <= 0 {
limit = 20 limit = 20
} }
// Column2..Column6 与 SQL 位置参数一一对应:$2=project, $3=workspace,
// $4=issue, $5=requirement, $6=title(sqlc 未命名参数按序生成字段)。
rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{ rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{
UserID: toPgUUID(userID), UserID: toPgUUID(userID),
Column2: toPgUUIDPtr(f.ProjectID), Column2: toPgUUIDPtr(f.ProjectID),
Column3: toPgUUIDPtr(f.WorkspaceID), Column3: toPgUUIDPtr(f.WorkspaceID),
Column4: toPgUUIDPtr(f.IssueID), Column4: toPgUUIDPtr(f.IssueID),
Column5: f.TitleQuery, Column5: toPgUUIDPtr(f.RequirementID),
Column6: f.TitleQuery,
Limit: int32(limit), Limit: int32(limit),
}) })
if err != nil { if err != nil {
+1
View File
@@ -66,6 +66,7 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
ProjectID: in.ProjectID, ProjectID: in.ProjectID,
WorkspaceID: in.WorkspaceID, WorkspaceID: in.WorkspaceID,
IssueID: in.IssueID, IssueID: in.IssueID,
RequirementID: in.RequirementID,
ModelID: in.ModelID, ModelID: in.ModelID,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
Title: nil, Title: nil,
+34 -2
View File
@@ -45,6 +45,7 @@ func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversatio
ProjectID: p.ProjectID, ProjectID: p.ProjectID,
WorkspaceID: p.WorkspaceID, WorkspaceID: p.WorkspaceID,
IssueID: p.IssueID, IssueID: p.IssueID,
RequirementID: p.RequirementID,
ModelID: p.ModelID, ModelID: p.ModelID,
SystemPrompt: p.SystemPrompt, SystemPrompt: p.SystemPrompt,
Title: p.Title, Title: p.Title,
@@ -70,9 +71,13 @@ func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UU
defer r.mu.Unlock() defer r.mu.Unlock()
var out []Conversation var out []Conversation
for _, c := range r.conversations { for _, c := range r.conversations {
if c.UserID == userID && c.DeletedAt == nil { if c.UserID != userID || c.DeletedAt != nil {
out = append(out, *c) continue
} }
if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) {
continue
}
out = append(out, *c)
} }
limit := f.Limit limit := f.Limit
if limit > 0 && len(out) > limit { if limit > 0 && len(out) > limit {
@@ -235,6 +240,33 @@ func TestConversationService_Create_HappyPath(t *testing.T) {
assert.Equal(t, &userID, entry.UserID) 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) { func TestConversationService_Create_WithSystemTemplate(t *testing.T) {
repo := newConvFakeRepo() repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{}) svc := newConvSvc(repo, noopAuditor{})
+16 -8
View File
@@ -12,7 +12,7 @@ import (
) )
const getConversation = `-- name: GetConversation :one 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) { func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversation, error) {
@@ -31,14 +31,15 @@ func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversa
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.DeletedAt, &i.DeletedAt,
&i.RequirementID,
) )
return i, err return i, err
} }
const insertConversation = `-- name: InsertConversation :one const insertConversation = `-- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title) 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) 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 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 { type InsertConversationParams struct {
@@ -50,6 +51,7 @@ type InsertConversationParams struct {
ModelID pgtype.UUID `json:"model_id"` ModelID pgtype.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"` SystemPrompt string `json:"system_prompt"`
Title *string `json:"title"` Title *string `json:"title"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) (Conversation, error) { 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.ModelID,
arg.SystemPrompt, arg.SystemPrompt,
arg.Title, arg.Title,
arg.RequirementID,
) )
var i Conversation var i Conversation
err := row.Scan( err := row.Scan(
@@ -77,19 +80,21 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.DeletedAt, &i.DeletedAt,
&i.RequirementID,
) )
return i, err return i, err
} }
const listConversationsByUser = `-- name: ListConversationsByUser :many 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 WHERE user_id = $1 AND deleted_at IS NULL
AND ($2::uuid IS NULL OR project_id = $2) AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR workspace_id = $3) AND ($3::uuid IS NULL OR workspace_id = $3)
AND ($4::uuid IS NULL OR issue_id = $4) 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 ORDER BY updated_at DESC
LIMIT $6 LIMIT $7
` `
type ListConversationsByUserParams struct { type ListConversationsByUserParams struct {
@@ -97,7 +102,8 @@ type ListConversationsByUserParams struct {
Column2 pgtype.UUID `json:"column_2"` Column2 pgtype.UUID `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"` Column3 pgtype.UUID `json:"column_3"`
Column4 pgtype.UUID `json:"column_4"` 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"` Limit int32 `json:"limit"`
} }
@@ -108,6 +114,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
arg.Column3, arg.Column3,
arg.Column4, arg.Column4,
arg.Column5, arg.Column5,
arg.Column6,
arg.Limit, arg.Limit,
) )
if err != nil { if err != nil {
@@ -130,6 +137,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.DeletedAt, &i.DeletedAt,
&i.RequirementID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+1 -1
View File
@@ -14,7 +14,7 @@ type ServerDeps struct {
Projects project.ProjectService Projects project.ProjectService
Reqs project.RequirementService Reqs project.RequirementService
Issues project.IssueService Issues project.IssueService
Prototypes project.PrototypeService Artifacts project.ArtifactService
Workspaces workspace.WorkspaceService Workspaces workspace.WorkspaceService
Templates chat.TemplateService Templates chat.TemplateService
Messages chat.MessageService Messages chat.MessageService
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+1 -1
View File
@@ -62,7 +62,7 @@ var defaultSystemTools = []string{
"get_issue", "get_requirement", "get_issue", "get_requirement",
"create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue", "create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue",
"create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase", "create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase",
"add_requirement_prototype", "list_requirement_prototypes", "add_requirement_artifact", "list_requirement_artifacts",
} }
// NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。 // NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。
+44 -41
View File
@@ -29,8 +29,8 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
registerCloseRequirement(srv, deps) registerCloseRequirement(srv, deps)
registerReopenRequirement(srv, deps) registerReopenRequirement(srv, deps)
registerSetRequirementPhase(srv, deps) registerSetRequirementPhase(srv, deps)
registerAddRequirementPrototype(srv, deps) registerAddRequirementArtifact(srv, deps)
registerListRequirementPrototypes(srv, deps) registerListRequirementArtifacts(srv, deps)
} }
// ===== list_projects ===== // ===== 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"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"requirement number (>=1)"` 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"` Note string `json:"note,omitempty"`
} }
type prototypeDetail struct { type artifactDetail struct {
Phase string `json:"phase"`
Version int `json:"version"` Version int `json:"version"`
Content string `json:"content"` Content string `json:"content"`
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
type prototypeCreateOut struct { type artifactCreateOut struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Prototype prototypeDetail `json:"prototype"` Artifact artifactDetail `json:"artifact"`
} }
func registerAddRequirementPrototype(srv *mcpsdk.Server, deps ServerDeps) { func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv, mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "add_requirement_prototype", Description: "Add a new prototype version to a requirement."}, &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 addRequirementPrototypeIn) (*mcpsdk.CallToolResult, prototypeCreateOut, error) { func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) {
if err := CheckToolFromCtx(ctx, "add_requirement_prototype"); err != nil { if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil {
return nil, prototypeCreateOut{}, err return nil, artifactCreateOut{}, err
} }
c, err := deps.Caller.Resolve(ctx) c, err := deps.Caller.Resolve(ctx)
if err != nil { if err != nil {
return nil, prototypeCreateOut{}, err return nil, artifactCreateOut{}, err
} }
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil { if err != nil {
return nil, prototypeCreateOut{}, err return nil, artifactCreateOut{}, err
} }
if err := CheckProjectFromCtx(ctx, p.ID); err != nil { 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{ art, err := deps.Artifacts.Create(ctx, c, in.ProjectSlug, in.Number, project.CreateArtifactInput{
Content: in.Content, Note: in.Note, Phase: project.Phase(in.Phase), Content: in.Content, Note: in.Note,
}) })
if err != nil { if err != nil {
return nil, prototypeCreateOut{}, err return nil, artifactCreateOut{}, err
} }
return nil, prototypeCreateOut{OK: true, Prototype: prototypeDetail{ return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{
Version: proto.Version, Content: proto.Content, Note: proto.Note, Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}}, nil }}, nil
}) })
} }
// ===== list_requirement_prototypes ===== // ===== list_requirement_artifacts =====
type listRequirementPrototypesIn struct { type listRequirementArtifactsIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required field"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"requirement number (>=1)"` 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 { type listRequirementArtifactsOut struct {
Prototypes []prototypeDetail `json:"prototypes"` Artifacts []artifactDetail `json:"artifacts"`
} }
func registerListRequirementPrototypes(srv *mcpsdk.Server, deps ServerDeps) { func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) {
mcpsdk.AddTool(srv, mcpsdk.AddTool(srv,
&mcpsdk.Tool{Name: "list_requirement_prototypes", Description: "List prototype versions of a requirement."}, &mcpsdk.Tool{Name: "list_requirement_artifacts", Description: "List phase artifact versions of a requirement."},
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementPrototypesIn) (*mcpsdk.CallToolResult, listRequirementPrototypesOut, error) { func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementArtifactsIn) (*mcpsdk.CallToolResult, listRequirementArtifactsOut, error) {
if err := CheckToolFromCtx(ctx, "list_requirement_prototypes"); err != nil { if err := CheckToolFromCtx(ctx, "list_requirement_artifacts"); err != nil {
return nil, listRequirementPrototypesOut{}, err return nil, listRequirementArtifactsOut{}, err
} }
c, err := deps.Caller.Resolve(ctx) c, err := deps.Caller.Resolve(ctx)
if err != nil { if err != nil {
return nil, listRequirementPrototypesOut{}, err return nil, listRequirementArtifactsOut{}, err
} }
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
if err != nil { if err != nil {
return nil, listRequirementPrototypesOut{}, err return nil, listRequirementArtifactsOut{}, err
} }
if err := CheckProjectFromCtx(ctx, p.ID); err != nil { 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 { if err != nil {
return nil, listRequirementPrototypesOut{}, err return nil, listRequirementArtifactsOut{}, err
} }
out := listRequirementPrototypesOut{Prototypes: make([]prototypeDetail, 0, len(protos))} out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))}
for _, proto := range protos { for _, art := range arts {
out.Prototypes = append(out.Prototypes, prototypeDetail{ out.Artifacts = append(out.Artifacts, artifactDetail{
Version: proto.Version, Content: proto.Content, Note: proto.Note, Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note,
CreatedAt: proto.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}) })
} }
return nil, out, nil return nil, out, nil
+121
View File
@@ -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,
})
}
+249
View File
@@ -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)
}
+33 -11
View File
@@ -40,6 +40,20 @@ func (p Phase) IsValid() bool {
return false 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 通用的开/关状态。 // Status 是 Requirement / Issue 通用的开/关状态。
type Status string type Status string
@@ -115,14 +129,18 @@ type Issue struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
// Prototype 是某条 Requirement 的一个原型设计版本快照。Version 在同一 Requirement // Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
// 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。 // (RequirementID, Phase) 内自增(max+1),CreatedBy 永远存在(DELETE RESTRICT)。
type Prototype struct { // SourceMessageID 非 nil 时指向该产物派生自的 chat assistant 消息(仅追溯用,
// schema 层 FK ON DELETE SET NULL,代码层不依赖 chat 模块)。
type Artifact struct {
ID uuid.UUID ID uuid.UUID
RequirementID uuid.UUID RequirementID uuid.UUID
Phase Phase
Version int Version int
Content string Content string
Note string Note string
SourceMessageID *int64
CreatedBy uuid.UUID CreatedBy uuid.UUID
CreatedAt time.Time CreatedAt time.Time
} }
@@ -169,11 +187,12 @@ type IssueService interface {
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
} }
// PrototypeService 暴露 Requirement 原型设计版本的应用服务方法。 // ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
type PrototypeService interface { type ArtifactService interface {
Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreatePrototypeInput) (*Prototype, error) Create(ctx context.Context, c Caller, projectSlug string, reqNumber int, in CreateArtifactInput) (*Artifact, error)
List(ctx context.Context, c Caller, projectSlug string, reqNumber int) ([]*Prototype, error) // List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, version int) (*Prototype, error) 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 ===== // ===== 输入 DTO =====
@@ -240,11 +259,14 @@ type CreateIssueInput struct {
WorkspaceID *uuid.UUID WorkspaceID *uuid.UUID
} }
// CreatePrototypeInput 创建一个原型设计版本。Version 由 Service 自动计算(max+1), // CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
// 不在此处提供。Note 可为空字符串。 // ((requirement, phase) 内 max+1),不在此处提供。Phase 必须是 ArtifactPhases
type CreatePrototypeInput struct { // 之一;Note 可为空;SourceMessageID 仅在产物派生自 chat 消息时提供。
type CreateArtifactInput struct {
Phase Phase
Content string Content string
Note string Note string
SourceMessageID *int64
} }
// UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态: // UpdateIssueInput 是 Issue 的 patch 输入。RequirementNumber 为指针的指针,承载三种状态:
+36 -28
View File
@@ -38,14 +38,14 @@ type Handler struct {
projects ProjectService projects ProjectService
requirements RequirementService requirements RequirementService
issues IssueService issues IssueService
prototypes PrototypeService artifacts ArtifactService
resolver middleware.SessionResolver resolver middleware.SessionResolver
users AdminLookup users AdminLookup
} }
// NewHandler 构造 Handler。 // NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, proto PrototypeService, resolver middleware.SessionResolver, users AdminLookup) *Handler { func NewHandler(p ProjectService, r RequirementService, i IssueService, arts ArtifactService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, prototypes: proto, resolver: resolver, users: users} return &Handler{projects: p, requirements: r, issues: i, artifacts: arts, resolver: resolver, users: users}
} }
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。 // 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}/phase", h.changeRequirementPhase)
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement) r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement) r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
r.Post("/{slug}/requirements/{number}/prototypes", h.createPrototype) r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
r.Get("/{slug}/requirements/{number}/prototypes", h.listPrototypes) r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
r.Get("/{slug}/requirements/{number}/prototypes/{version}", h.getPrototype) r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
r.Post("/{slug}/issues", h.createIssue) r.Post("/{slug}/issues", h.createIssue)
r.Get("/{slug}/issues", h.listIssues) r.Get("/{slug}/issues", h.listIssues)
@@ -161,12 +161,14 @@ type issueDTO struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
type prototypeDTO struct { type artifactDTO struct {
ID string `json:"id"` ID string `json:"id"`
RequirementID string `json:"requirement_id"` RequirementID string `json:"requirement_id"`
Phase string `json:"phase"`
Version int `json:"version"` Version int `json:"version"`
Content string `json:"content"` Content string `json:"content"`
Note string `json:"note"` Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
CreatedBy string `json:"created_by"` CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -231,12 +233,13 @@ func toIssueDTO(i *Issue) issueDTO {
return d return d
} }
func toPrototypeDTO(p *Prototype) prototypeDTO { func toArtifactDTO(a *Artifact) artifactDTO {
return prototypeDTO{ return artifactDTO{
ID: p.ID.String(), RequirementID: p.RequirementID.String(), ID: a.ID.String(), RequirementID: a.RequirementID.String(),
Version: p.Version, Content: p.Content, Note: p.Note, Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note,
CreatedBy: p.CreatedBy.String(), SourceMessageID: a.SourceMessageID,
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), 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) w.WriteHeader(http.StatusNoContent)
} }
// ===== Prototype endpoints ===== // ===== Artifact endpoints =====
type createPrototypeReq struct { type createArtifactReq struct {
Phase string `json:"phase"`
Content string `json:"content"` Content string `json:"content"`
Note string `json:"note"` 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) c, err := h.callerFromCtx(r)
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
@@ -602,22 +607,23 @@ func (h *Handler) createPrototype(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err) writeErr(w, r, err)
return return
} }
var req createPrototypeReq var req createArtifactReq
if err := decodeBody(r, &req); err != nil { if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err) writeErr(w, r, err)
return return
} }
out, err := h.prototypes.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreatePrototypeInput{ out, err := h.artifacts.Create(r.Context(), c, chi.URLParam(r, "slug"), num, CreateArtifactInput{
Content: req.Content, Note: req.Note, Phase: Phase(req.Phase), Content: req.Content, Note: req.Note,
SourceMessageID: req.SourceMessageID,
}) })
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
return 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) c, err := h.callerFromCtx(r)
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
@@ -628,19 +634,20 @@ func (h *Handler) listPrototypes(w http.ResponseWriter, r *http.Request) {
writeErr(w, r, err) writeErr(w, r, err)
return 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 { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
return return
} }
dtos := make([]prototypeDTO, 0, len(out)) dtos := make([]artifactDTO, 0, len(out))
for _, x := range 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) c, err := h.callerFromCtx(r)
if err != nil { if err != nil {
writeErr(w, r, err) 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 非法")) writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
return 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 { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
return return
} }
httpx.WriteJSON(w, http.StatusOK, map[string]any{"prototype": toPrototypeDTO(out)}) httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
} }
// ===== Issue endpoints ===== // ===== Issue endpoints =====
+53 -2
View File
@@ -34,9 +34,9 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
pSvc := NewProjectService(repo, nil) pSvc := NewProjectService(repo, nil)
rSvc := NewRequirementService(repo, nil) rSvc := NewRequirementService(repo, nil)
iSvc := NewIssueService(repo, nil) iSvc := NewIssueService(repo, nil)
protoSvc := NewPrototypeService(repo, nil) artSvc := NewArtifactService(repo, nil)
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}} resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
h := NewHandler(pSvc, rSvc, iSvc, protoSvc, resolver, fakeAdmin{}) h := NewHandler(pSvc, rSvc, iSvc, artSvc, resolver, fakeAdmin{})
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
h.Mount(r) h.Mount(r)
@@ -214,3 +214,54 @@ func TestHandler_Requirement_PATCH_WorkspaceID_TriState(t *testing.T) {
t.Cleanup(func() { _ = resp5.Body.Close() }) t.Cleanup(func() { _ = resp5.Body.Close() })
require.Equal(t, http.StatusBadRequest, resp5.StatusCode) 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)
}
+32 -27
View File
@@ -23,11 +23,11 @@ type fakeRepo struct {
projects map[uuid.UUID]*Project projects map[uuid.UUID]*Project
requirements map[uuid.UUID]*Requirement requirements map[uuid.UUID]*Requirement
issues map[uuid.UUID]*Issue issues map[uuid.UUID]*Issue
prototypes map[uuid.UUID]*Prototype artifacts map[uuid.UUID]*Artifact
// 置 true 时,下一次 CreatePrototype 返回一次真实的 PG 唯一约束冲突后自动复位, // 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
// 用于覆盖 prototypeService.createWithRetry 的 version 竞争重试分支。 // 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
createPrototypeFailOnce bool createArtifactFailOnce bool
} }
func newFakeRepo() *fakeRepo { func newFakeRepo() *fakeRepo {
@@ -35,14 +35,14 @@ func newFakeRepo() *fakeRepo {
projects: map[uuid.UUID]*Project{}, projects: map[uuid.UUID]*Project{},
requirements: map[uuid.UUID]*Requirement{}, requirements: map[uuid.UUID]*Requirement{},
issues: map[uuid.UUID]*Issue{}, issues: map[uuid.UUID]*Issue{},
prototypes: map[uuid.UUID]*Prototype{}, artifacts: map[uuid.UUID]*Artifact{},
} }
} }
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v } func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v } func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v } func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
func (r *fakeRepo) clonePrototype(p *Prototype) *Prototype { v := *p; return &v } func (r *fakeRepo) cloneArtifact(a *Artifact) *Artifact { v := *a; return &v }
func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) { func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) {
r.mu.Lock() r.mu.Lock()
@@ -369,58 +369,63 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
return nil return nil
} }
func (r *fakeRepo) GetMaxPrototypeVersion(_ context.Context, requirementID uuid.UUID) (int, error) { func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
maxVer := 0 maxVer := 0
for _, x := range r.prototypes { for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Version > maxVer { if x.RequirementID == requirementID && x.Phase == phase && x.Version > maxVer {
maxVer = x.Version maxVer = x.Version
} }
} }
return maxVer, nil 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() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if r.createPrototypeFailOnce { if r.createArtifactFailOnce {
r.createPrototypeFailOnce = false r.createArtifactFailOnce = false
// 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。 // 模拟真实 PG 唯一约束冲突,触发 IsUniqueViolation 重试分支。
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
} }
for _, x := range r.prototypes { for _, x := range r.artifacts {
if x.RequirementID == in.RequirementID && x.Version == in.Version { if x.RequirementID == in.RequirementID && x.Phase == in.Phase && x.Version == in.Version {
return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation} return nil, &pgconn.PgError{Code: pgerrcode.UniqueViolation}
} }
} }
in.CreatedAt = time.Now() in.CreatedAt = time.Now()
r.prototypes[in.ID] = r.clonePrototype(in) r.artifacts[in.ID] = r.cloneArtifact(in)
return r.clonePrototype(in), nil 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() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
out := make([]*Prototype, 0) out := make([]*Artifact, 0)
for _, x := range r.prototypes { for _, x := range r.artifacts {
if x.RequirementID == requirementID { if x.RequirementID == requirementID && (phase == "" || x.Phase == phase) {
out = append(out, r.clonePrototype(x)) 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 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() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
for _, x := range r.prototypes { for _, x := range r.artifacts {
if x.RequirementID == requirementID && x.Version == version { if x.RequirementID == requirementID && x.Phase == phase && x.Version == version {
return r.clonePrototype(x), nil return r.cloneArtifact(x), nil
} }
} }
return nil, errs.New(errs.CodeNotFound, "prototype 不存在") return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
} }
// spyAudit 收集所有写入的 audit entry,方便断言。 // spyAudit 收集所有写入的 audit entry,方便断言。
-110
View File
@@ -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,
})
}
-181
View File
@@ -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)
}
+19
View File
@@ -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;
-19
View File
@@ -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;
+37 -25
View File
@@ -51,11 +51,12 @@ type Repository interface {
CloseIssue(ctx context.Context, id uuid.UUID) error CloseIssue(ctx context.Context, id uuid.UUID) error
ReopenIssue(ctx context.Context, id uuid.UUID) error ReopenIssue(ctx context.Context, id uuid.UUID) error
// Prototype // Artifact
CreatePrototype(ctx context.Context, p *Prototype) (*Prototype, error) CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
ListPrototypesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Prototype, error) // ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error)
GetMaxPrototypeVersion(ctx context.Context, requirementID uuid.UUID) (int, 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* // IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
@@ -498,68 +499,79 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
return nil return nil
} }
// ===== Prototype ===== // ===== Artifact =====
func rowToPrototype(row projectsqlc.RequirementPrototype) *Prototype { func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
return &Prototype{ return &Artifact{
ID: fromPgUUID(row.ID), ID: fromPgUUID(row.ID),
RequirementID: fromPgUUID(row.RequirementID), RequirementID: fromPgUUID(row.RequirementID),
Phase: Phase(row.Phase),
Version: int(row.Version), Version: int(row.Version),
Content: row.Content, Content: row.Content,
Note: row.Note, Note: row.Note,
SourceMessageID: row.SourceMessageID,
CreatedBy: fromPgUUID(row.CreatedBy), CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time, CreatedAt: row.CreatedAt.Time,
} }
} }
func (r *pgRepo) CreatePrototype(ctx context.Context, in *Prototype) (*Prototype, error) { func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) {
row, err := r.q.CreateRequirementPrototype(ctx, projectsqlc.CreateRequirementPrototypeParams{ row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{
ID: toPgUUID(in.ID), ID: toPgUUID(in.ID),
RequirementID: toPgUUID(in.RequirementID), RequirementID: toPgUUID(in.RequirementID),
Phase: string(in.Phase),
Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出 Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出
Content: in.Content, Content: in.Content,
Note: in.Note, Note: in.Note,
SourceMessageID: in.SourceMessageID,
CreatedBy: toPgUUID(in.CreatedBy), CreatedBy: toPgUUID(in.CreatedBy),
}) })
if err != nil { if err != nil {
if IsUniqueViolation(err) { if IsUniqueViolation(err) {
return nil, err // service 层据此重试一次 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) { func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) {
rows, err := r.q.ListRequirementPrototypesByRequirement(ctx, toPgUUID(requirementID)) rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{
RequirementID: toPgUUID(requirementID),
Column2: string(phase),
})
if err != nil { 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 { for _, row := range rows {
out = append(out, rowToPrototype(row)) out = append(out, rowToArtifact(row))
} }
return out, nil return out, nil
} }
func (r *pgRepo) GetPrototypeByVersion(ctx context.Context, requirementID uuid.UUID, version int) (*Prototype, error) { func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) {
row, err := r.q.GetRequirementPrototypeByVersion(ctx, projectsqlc.GetRequirementPrototypeByVersionParams{ row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{
RequirementID: toPgUUID(requirementID), RequirementID: toPgUUID(requirementID),
Phase: string(phase),
Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32 Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { 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) { func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
max, err := r.q.GetMaxRequirementPrototypeVersion(ctx, toPgUUID(requirementID)) max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{
RequirementID: toPgUUID(requirementID),
Phase: string(phase),
})
if err != nil { 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 return int(max), nil
} }
+143
View File
@@ -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
}
+4 -1
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,12 +277,14 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
} }
type RequirementPrototype struct { type RequirementArtifact struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"` RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"` Version int32 `json:"version"`
Content string `json:"content"` Content string `json:"content"`
Note string `json:"note"` Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
-122
View File
@@ -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
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
} }
type GitCredential struct { type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"` 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 { type User struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Email string `json:"email"` Email string `json:"email"`
@@ -0,0 +1,20 @@
DROP INDEX IF EXISTS conversations_requirement_idx;
ALTER TABLE conversations DROP COLUMN IF EXISTS requirement_id;
-- 还原 0011 的 requirement_prototypes;仅回迁 phase='prototyping' 行,
-- planning/auditing 产物在回滚时丢弃(旧 schema 无处安放)。
CREATE TABLE requirement_prototypes (
id UUID PRIMARY KEY,
requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
version INT NOT NULL,
content TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (requirement_id, version)
);
CREATE INDEX idx_requirement_prototypes_req ON requirement_prototypes(requirement_id);
INSERT INTO requirement_prototypes (id, requirement_id, version, content, note, created_by, created_at)
SELECT id, requirement_id, version, content, note, created_by, created_at
FROM requirement_artifacts WHERE phase = 'prototyping';
DROP TABLE requirement_artifacts;
@@ -0,0 +1,26 @@
-- 统一三阶段产物表:planning/prototyping/auditing 各自独立版本序列。
-- source_message_id 仅作"产物派生自哪条 chat 消息"的追溯,手填产物为 NULL。
CREATE TABLE requirement_artifacts (
id UUID PRIMARY KEY,
requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE,
phase TEXT NOT NULL CHECK (phase IN ('planning','prototyping','auditing')),
version INT NOT NULL,
content TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
source_message_id BIGINT REFERENCES messages(id) ON DELETE SET NULL,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (requirement_id, phase, version)
);
CREATE INDEX idx_requirement_artifacts_req_phase ON requirement_artifacts(requirement_id, phase);
-- 旧 prototype 数据迁移(保留原 id/version/created_at)后下线旧表。
INSERT INTO requirement_artifacts
(id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at)
SELECT id, requirement_id, 'prototyping', version, content, note, NULL, created_by, created_at
FROM requirement_prototypes;
DROP TABLE requirement_prototypes;
-- 会话挂载 requirement(新列在表末尾,满足 sqlc 列序约定)。
ALTER TABLE conversations ADD COLUMN requirement_id UUID REFERENCES requirements(id);
CREATE INDEX conversations_requirement_idx ON conversations(requirement_id) WHERE requirement_id IS NOT NULL;
+7 -2
View File
@@ -127,8 +127,13 @@ export const acpApi = {
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }), request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
}, },
sessions: { sessions: {
list: (all?: boolean) => list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
request<AcpSession[]>('/api/v1/acp/sessions' + (all ? '?all=true' : '')), const q = new URLSearchParams()
if (opts.all) q.set('all', 'true')
if (opts.requirement_id) q.set('requirement_id', opts.requirement_id)
const qs = q.toString()
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
},
get: (id: string) => get: (id: string) =>
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`), request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
create: (req: CreateSessionReq) => create: (req: CreateSessionReq) =>
+3
View File
@@ -10,6 +10,7 @@ export interface Conversation {
project_id?: string | null project_id?: string | null
workspace_id?: string | null workspace_id?: string | null
issue_id?: string | null issue_id?: string | null
requirement_id?: string | null
model_id: string model_id: string
system_prompt: string system_prompt: string
title?: string | null title?: string | null
@@ -72,6 +73,7 @@ export interface ListConversationsParams {
project_id?: string project_id?: string
workspace_id?: string workspace_id?: string
issue_id?: string issue_id?: string
requirement_id?: string
q?: string q?: string
limit?: number limit?: number
} }
@@ -83,6 +85,7 @@ export interface CreateConversationBody {
project_id?: string project_id?: string
workspace_id?: string workspace_id?: string
issue_id?: string issue_id?: string
requirement_id?: string
} }
export interface UsageDailyItem { export interface UsageDailyItem {
+19 -10
View File
@@ -47,12 +47,17 @@ export interface Issue {
updated_at: string updated_at: string
} }
export interface Prototype { // ArtifactPhase 是允许产出文档型产物的三个阶段(与后端 ArtifactPhases 一致)。
export type ArtifactPhase = 'planning' | 'prototyping' | 'auditing'
export interface Artifact {
id: string id: string
requirement_id: string requirement_id: string
phase: ArtifactPhase
version: number version: number
content: string content: string
note: string note: string
source_message_id?: number | null
created_by: string created_by: string
created_at: string created_at: string
} }
@@ -126,19 +131,23 @@ export const projectsApi = {
reopenRequirement: (slug: string, number: number) => reopenRequirement: (slug: string, number: number) =>
request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }), request<void>(`/api/v1/projects/${enc(slug)}/requirements/${number}/reopen`, { method: 'POST' }),
// ===== Prototype ===== // ===== Artifact =====
listPrototypes: (slug: string, number: number) => listArtifacts: (slug: string, number: number, phase?: ArtifactPhase) =>
request<{ prototypes: Prototype[] }>( request<Artifact[]>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, `/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts${phase ? `?phase=${phase}` : ''}`,
), ),
createPrototype: (slug: string, number: number, body: { content: string; note?: string }) => createArtifact: (
request<Prototype>(`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes`, { slug: string,
number: number,
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
) =>
request<Artifact>(`/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts`, {
method: 'POST', method: 'POST',
body, body,
}), }),
getPrototype: (slug: string, number: number, version: number) => getArtifact: (slug: string, number: number, phase: ArtifactPhase, version: number) =>
request<{ prototype: Prototype }>( request<Artifact>(
`/api/v1/projects/${enc(slug)}/requirements/${number}/prototypes/${version}`, `/api/v1/projects/${enc(slug)}/requirements/${number}/artifacts/${phase}/${version}`,
), ),
// ===== Issue ===== // ===== Issue =====
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChatStreamEvent } from './useChatStream'
const chatApiMock = vi.hoisted(() => ({
getConversation: vi.fn(),
listMessages: vi.fn(),
sendMessage: vi.fn(),
cancelMessage: vi.fn(),
retryMessage: vi.fn(),
}))
vi.mock('@/api/chat', () => ({ chatApi: chatApiMock }))
// startChatStream 的 mock:记录每次调用,暴露 emit 让测试驱动事件。
interface StreamInstance {
url: string
since: number
emit: (ev: ChatStreamEvent) => void
stopped: boolean
}
const streamInstances = vi.hoisted(() => [] as StreamInstance[])
vi.mock('@/composables/useChatStream', () => ({
startChatStream: vi.fn(
(
url: string,
since: number,
handlers: { onEvent: (ev: ChatStreamEvent) => void; onError: (e: Error) => void },
) => {
const inst: StreamInstance = {
url,
since,
emit: (ev) => handlers.onEvent(ev),
stopped: false,
}
streamInstances.push(inst)
return {
stop: () => {
inst.stopped = true
},
}
},
),
}))
import { useConversationSession } from './useConversationSession'
function conv(id: string) {
return { id, user_id: 'u1', model_id: 'm1', system_prompt: '', created_at: '', updated_at: '' }
}
function msg(id: number, status = 'ok', role = 'assistant') {
return {
id,
conversation_id: 'c1',
role,
content: `msg-${id}`,
status,
created_at: '',
updated_at: '',
}
}
beforeEach(() => {
streamInstances.length = 0
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useConversationSession', () => {
it('openConversation 将 DESC 消息排为 id 升序', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(3), msg(2), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.messages.value.map((m) => m.id)).toEqual([1, 2, 3])
expect(s.isStreaming.value).toBe(false)
})
it('openConversation 发现 pending 消息时续接流', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending'), msg(1)],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(s.streamingMessageId.value).toBe(2)
expect(streamInstances).toHaveLength(1)
expect(streamInstances[0].since).toBe(1)
})
it('sendMessage 追加两条占位消息并累积 text_delta 至 done', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 10,
assistant_message_id: 11,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('hello')
expect(s.messages.value.map((m) => [m.id, m.role, m.status])).toEqual([
[10, 'user', 'ok'],
[11, 'assistant', 'pending'],
])
expect(s.isStreaming.value).toBe(true)
const stream = streamInstances[0]
stream.emit({ id: 1, event: 'text_delta', data: { text: 'foo' } })
stream.emit({ id: 2, event: 'text_delta', data: { text: 'bar' } })
stream.emit({ id: 3, event: 'done', data: {} })
const assistant = s.messages.value[1]
expect(assistant.content).toBe('foobar')
expect(assistant.status).toBe('ok')
expect(s.isStreaming.value).toBe(false)
})
it('error 事件标记消息失败并结束流式', async () => {
chatApiMock.getConversation.mockResolvedValue({ conversation: conv('c1'), messages: [] })
chatApiMock.sendMessage.mockResolvedValue({
user_message_id: 1,
assistant_message_id: 2,
stream_url: '/stream',
})
const s = useConversationSession()
await s.openConversation('c1')
await s.sendMessage('x')
streamInstances[0].emit({ id: 1, event: 'error', data: { message: 'boom' } })
expect(s.messages.value[1].status).toBe('error')
expect(s.messages.value[1].error_message).toBe('boom')
expect(s.isStreaming.value).toBe(false)
})
it('closeCurrent 终止流并清空状态', async () => {
chatApiMock.getConversation.mockResolvedValue({
conversation: conv('c1'),
messages: [msg(2, 'pending')],
})
const s = useConversationSession()
await s.openConversation('c1')
expect(streamInstances[0].stopped).toBe(false)
s.closeCurrent()
expect(streamInstances[0].stopped).toBe(true)
expect(s.currentConversation.value).toBeNull()
expect(s.messages.value).toEqual([])
})
it('双实例互不干扰', async () => {
chatApiMock.getConversation
.mockResolvedValueOnce({ conversation: conv('c1'), messages: [msg(1)] })
.mockResolvedValueOnce({ conversation: conv('c2'), messages: [msg(5), msg(4)] })
const a = useConversationSession()
const b = useConversationSession()
await a.openConversation('c1')
await b.openConversation('c2')
expect(a.messages.value.map((m) => m.id)).toEqual([1])
expect(b.messages.value.map((m) => m.id)).toEqual([4, 5])
b.closeCurrent()
expect(a.currentConversation.value?.id).toBe('c1')
})
})
@@ -0,0 +1,223 @@
import { computed, ref } from 'vue'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import {
startChatStream,
type ChatStreamEvent,
type ChatStreamHandle,
} from '@/composables/useChatStream'
interface UsagePayload {
PromptTokens?: number
CompletionTokens?: number
ThinkingTokens?: number
}
interface TextDeltaPayload {
text?: string
}
interface ErrorPayload {
code?: string
message?: string
}
interface MetaPayload {
id?: number
role?: string
status?: ChatMessage['status']
}
// useConversationSession 承载"单个会话的消息流"全部状态与操作(打开/分页/发送/
// 取消/重试/SSE 流式接收)。每次调用返回一个独立实例,互不串扰——全局 /chat 页
// 经 chat store 持有一份,需求详情页内嵌面板各自持有一份。
// 调用方负责在组件卸载时调用 closeCurrent() 终止流。
export function useConversationSession() {
const currentConversation = ref<Conversation | null>(null)
const messages = ref<ChatMessage[]>([])
const streamingMessageId = ref<number | null>(null)
const streamHandle = ref<ChatStreamHandle | null>(null)
const isStreaming = computed(() => streamingMessageId.value !== null)
async function openConversation(id: string) {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
async function cancelMessage() {
const id = streamingMessageId.value
if (id == null) return
await chatApi.cancelMessage(id)
detachStream()
}
async function retryMessage(messageId: number) {
if (!currentConversation.value) return
const resp = await chatApi.retryMessage(messageId)
const idx = messages.value.findIndex((m) => m.id === messageId)
if (idx >= 0) {
const old = messages.value[idx]
messages.value[idx] = {
...old,
id: resp.assistant_message_id,
content: '',
thinking: null,
status: 'pending',
error_message: null,
}
}
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.assistant_message_id - 1)
}
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream()
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev),
onClose: () => {
streamHandle.value = null
},
onError: (err) => {
console.error('chat stream error', err)
const id = streamingMessageId.value
if (id != null) {
const msg = messages.value.find((m) => m.id === id)
if (msg && msg.status === 'pending') {
msg.status = 'error'
msg.error_message = err.message
}
}
streamingMessageId.value = null
streamHandle.value = null
},
})
}
function detachStream() {
streamHandle.value?.stop()
streamHandle.value = null
}
function applyStreamEvent(ev: ChatStreamEvent) {
const targetId = streamingMessageId.value
if (targetId == null) return
const msg = messages.value.find((m) => m.id === targetId)
if (!msg) return
switch (ev.event) {
case 'message_meta': {
const d = ev.data as MetaPayload
if (d.status) msg.status = d.status
break
}
case 'text_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.content += d.text
break
}
case 'thinking_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
break
}
case 'usage': {
const u = ev.data as UsagePayload
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
break
}
case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok'
streamingMessageId.value = null
break
case 'error': {
const d = ev.data as ErrorPayload
msg.status = 'error'
msg.error_message = d.message ?? d.code ?? 'stream error'
streamingMessageId.value = null
break
}
}
}
return {
currentConversation,
messages,
streamingMessageId,
isStreaming,
openConversation,
loadOlderMessages,
sendMessage,
cancelMessage,
retryMessage,
closeCurrent,
}
}
export type ConversationSession = ReturnType<typeof useConversationSession>
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import type { Artifact } from '@/api/projects'
import {
buildRequirementSystemPrompt,
PHASE_ROLE_PROMPTS,
PREV_ARTIFACT_PHASE,
} from './requirementPhasePrompts'
const req = { number: 7, title: '导出报表', description: '支持导出 Excel' }
function artifact(content: string): Artifact {
return {
id: 'a1',
requirement_id: 'r1',
phase: 'planning',
version: 2,
content,
note: '',
created_by: 'u1',
created_at: '',
}
}
describe('buildRequirementSystemPrompt', () => {
it('planning:角色模板 + 需求信息,无上一阶段产物', () => {
const out = buildRequirementSystemPrompt('planning', req)
expect(out).toContain(PHASE_ROLE_PROMPTS.planning)
expect(out).toContain('需求 #7:导出报表')
expect(out).toContain('支持导出 Excel')
expect(out).not.toContain('上一阶段')
})
it('prototyping:注入上一阶段(planning)产物', () => {
const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容'))
expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping)
expect(out).toContain('上一阶段(planning)产物 v2')
expect(out).toContain('# 规划文档内容')
})
it('描述为空时不渲染需求描述段', () => {
const out = buildRequirementSystemPrompt('planning', { ...req, description: '' })
expect(out).not.toContain('需求描述')
})
it('超长产物内容截断并标注', () => {
const long = 'x'.repeat(10000)
const out = buildRequirementSystemPrompt('auditing', req, artifact(long))
expect(out).toContain('已截断')
expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length)
})
it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => {
expect(PREV_ARTIFACT_PHASE.planning).toBeNull()
expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning')
expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping')
})
})
@@ -0,0 +1,44 @@
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。
const MAX_PREV_ARTIFACT_CHARS = 8000
// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt,
// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。
export const PHASE_ROLE_PROMPTS: Record<ArtifactPhase, string> = {
planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。
`,
prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。
/线`,
auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。
/ / `,
}
// 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。
export const PREV_ARTIFACT_PHASE: Record<ArtifactPhase, ArtifactPhase | null> = {
planning: null,
prototyping: 'planning',
auditing: 'prototyping',
}
// buildRequirementSystemPrompt 组装阶段会话的 system prompt:
// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。
export function buildRequirementSystemPrompt(
phase: ArtifactPhase,
requirement: Pick<Requirement, 'number' | 'title' | 'description'>,
prevArtifact?: Artifact | null,
): string {
const parts: string[] = [PHASE_ROLE_PROMPTS[phase]]
parts.push(`## 当前需求\n需求 #${requirement.number}${requirement.title}`)
if (requirement.description) {
parts.push(`### 需求描述\n${requirement.description}`)
}
if (prevArtifact) {
let content = prevArtifact.content
if (content.length > MAX_PREV_ARTIFACT_CHARS) {
content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)'
}
parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`)
}
return parts.join('\n\n')
}
+2 -2
View File
@@ -60,11 +60,11 @@ export const useAcpStore = defineStore('acp', () => {
return acpApi.agentKinds.get(id) return acpApi.agentKinds.get(id)
} }
async function listSessions(all = false) { async function listSessions(all = false, requirementId?: string) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
sessions.value = await acpApi.sessions.list(all) sessions.value = await acpApi.sessions.list({ all, requirement_id: requirementId })
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'failed' error.value = e instanceof Error ? e.message : 'failed'
} finally { } finally {
+19 -217
View File
@@ -1,239 +1,41 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { ref } from 'vue'
import { import { chatApi, type Conversation, type ListConversationsParams } from '@/api/chat'
chatApi, import { useConversationSession } from '@/composables/useConversationSession'
type ChatMessage,
type Conversation,
type ListConversationsParams,
} from '@/api/chat'
import {
startChatStream,
type ChatStreamEvent,
type ChatStreamHandle,
} from '@/composables/useChatStream'
interface UsagePayload {
PromptTokens?: number
CompletionTokens?: number
ThinkingTokens?: number
}
interface TextDeltaPayload {
text?: string
}
interface ErrorPayload {
code?: string
message?: string
}
interface MetaPayload {
id?: number
role?: string
status?: ChatMessage['status']
}
// chat store = 全局 /chat 页那一份会话实例 + 会话列表。
// "单会话消息流"逻辑在 useConversationSession 中(可多实例,详情页内嵌面板自建),
// 这里持有一份并原样转发,保持对外 API 不变(ChatDetailView 零改动)。
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
const currentConversation = ref<Conversation | null>(null) const session = useConversationSession()
const messages = ref<ChatMessage[]>([])
const streamingMessageId = ref<number | null>(null)
const streamHandle = ref<ChatStreamHandle | null>(null)
const isStreaming = computed(() => streamingMessageId.value !== null)
async function loadConversations(filter: ListConversationsParams = {}) { async function loadConversations(filter: ListConversationsParams = {}) {
conversations.value = await chatApi.listConversations(filter) conversations.value = await chatApi.listConversations(filter)
} }
async function openConversation(id: string) {
detachStream()
const detail = await chatApi.getConversation(id)
currentConversation.value = detail.conversation
// 后端按 id DESC 返回(最新在前);视图按升序渲染(最新在底部),故排为升序。
messages.value = [...detail.messages].sort((a, b) => a.id - b.id)
const pending = detail.messages.find((m) => m.status === 'pending')
if (pending) {
streamingMessageId.value = pending.id
attachStream(`/api/v1/conversations/${id}/stream`, pending.id - 1)
}
}
async function loadOlderMessages() {
const conv = currentConversation.value
if (!conv) return
if (messages.value.length === 0) return
const beforeId = messages.value.reduce(
(min, m) => (m.id < min ? m.id : min),
messages.value[0].id,
)
const older = await chatApi.listMessages(conv.id, { before_id: beforeId })
if (older.length === 0) return
const existingIds = new Set(messages.value.map((m) => m.id))
const fresh = older.filter((m) => !existingIds.has(m.id))
if (fresh.length === 0) return
// 合并后统一按 id 升序,避免后端 DESC 批次与现有升序列表错位。
messages.value = [...fresh, ...messages.value].sort((a, b) => a.id - b.id)
}
async function sendMessage(content: string, attachmentIds: string[] = []) {
if (!currentConversation.value) return
if (streamingMessageId.value !== null) return
const conv = currentConversation.value
const resp = await chatApi.sendMessage(conv.id, {
content,
attachment_ids: attachmentIds,
})
const now = new Date().toISOString()
messages.value.push({
id: resp.user_message_id,
conversation_id: conv.id,
role: 'user',
content,
status: 'ok',
created_at: now,
updated_at: now,
})
messages.value.push({
id: resp.assistant_message_id,
conversation_id: conv.id,
role: 'assistant',
content: '',
status: 'pending',
created_at: now,
updated_at: now,
})
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.user_message_id)
}
async function cancelMessage() {
const id = streamingMessageId.value
if (id == null) return
await chatApi.cancelMessage(id)
detachStream()
}
async function retryMessage(messageId: number) {
if (!currentConversation.value) return
const resp = await chatApi.retryMessage(messageId)
const idx = messages.value.findIndex((m) => m.id === messageId)
if (idx >= 0) {
const old = messages.value[idx]
messages.value[idx] = {
...old,
id: resp.assistant_message_id,
content: '',
thinking: null,
status: 'pending',
error_message: null,
}
}
streamingMessageId.value = resp.assistant_message_id
attachStream(resp.stream_url, resp.assistant_message_id - 1)
}
async function deleteConversation(id: string) { async function deleteConversation(id: string) {
await chatApi.deleteConversation(id) await chatApi.deleteConversation(id)
conversations.value = conversations.value.filter((c) => c.id !== id) conversations.value = conversations.value.filter((c) => c.id !== id)
if (currentConversation.value?.id === id) { if (session.currentConversation.value?.id === id) {
closeCurrent() session.closeCurrent()
}
}
function closeCurrent() {
detachStream()
currentConversation.value = null
messages.value = []
streamingMessageId.value = null
}
function attachStream(streamUrl: string, sinceMessageID: number) {
detachStream()
streamHandle.value = startChatStream(streamUrl, sinceMessageID, {
onEvent: (ev) => applyStreamEvent(ev),
onClose: () => {
streamHandle.value = null
},
onError: (err) => {
console.error('chat stream error', err)
const id = streamingMessageId.value
if (id != null) {
const msg = messages.value.find((m) => m.id === id)
if (msg && msg.status === 'pending') {
msg.status = 'error'
msg.error_message = err.message
}
}
streamingMessageId.value = null
streamHandle.value = null
},
})
}
function detachStream() {
streamHandle.value?.stop()
streamHandle.value = null
}
function applyStreamEvent(ev: ChatStreamEvent) {
const targetId = streamingMessageId.value
if (targetId == null) return
const msg = messages.value.find((m) => m.id === targetId)
if (!msg) return
switch (ev.event) {
case 'message_meta': {
const d = ev.data as MetaPayload
if (d.status) msg.status = d.status
break
}
case 'text_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.content += d.text
break
}
case 'thinking_delta': {
const d = ev.data as TextDeltaPayload
if (d.text) msg.thinking = (msg.thinking ?? '') + d.text
break
}
case 'usage': {
const u = ev.data as UsagePayload
msg.prompt_tokens = u.PromptTokens ?? msg.prompt_tokens
msg.completion_tokens = u.CompletionTokens ?? msg.completion_tokens
msg.thinking_tokens = u.ThinkingTokens ?? msg.thinking_tokens
break
}
case 'done':
// 后端事件顺序:EventUsage → EventDone(参见 internal/chat/streamer.go)。
// tokens 已在 'usage' 分支写入;某些 provider 不发 usage 时此处不会兜底。
msg.status = 'ok'
streamingMessageId.value = null
break
case 'error': {
const d = ev.data as ErrorPayload
msg.status = 'error'
msg.error_message = d.message ?? d.code ?? 'stream error'
streamingMessageId.value = null
break
}
} }
} }
return { return {
conversations, conversations,
currentConversation, currentConversation: session.currentConversation,
messages, messages: session.messages,
streamingMessageId, streamingMessageId: session.streamingMessageId,
isStreaming, isStreaming: session.isStreaming,
loadConversations, loadConversations,
openConversation, openConversation: session.openConversation,
loadOlderMessages, loadOlderMessages: session.loadOlderMessages,
closeCurrent, closeCurrent: session.closeCurrent,
sendMessage, sendMessage: session.sendMessage,
cancelMessage, cancelMessage: session.cancelMessage,
retryMessage, retryMessage: session.retryMessage,
deleteConversation, deleteConversation,
} }
}) })
+21 -17
View File
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { import {
projectsApi, projectsApi,
type Artifact,
type ArtifactPhase,
type Phase, type Phase,
type Prototype,
type Requirement, type Requirement,
type Status, type Status,
} from '@/api/projects' } from '@/api/projects'
@@ -51,26 +52,29 @@ export const useRequirementsStore = defineStore('requirements', () => {
await refresh(forSlug) await refresh(forSlug)
} }
async function listPrototypes(forSlug: string, number: number): Promise<Prototype[]> { async function listArtifacts(
const out = await projectsApi.listPrototypes(forSlug, number)
return out.prototypes
}
async function createPrototype(
forSlug: string, forSlug: string,
number: number, number: number,
body: { content: string; note?: string }, phase?: ArtifactPhase,
): Promise<Prototype> { ): Promise<Artifact[]> {
return await projectsApi.createPrototype(forSlug, number, body) return await projectsApi.listArtifacts(forSlug, number, phase)
} }
async function getPrototype( async function createArtifact(
forSlug: string, forSlug: string,
number: number, number: number,
body: { phase: ArtifactPhase; content: string; note?: string; source_message_id?: number },
): Promise<Artifact> {
return await projectsApi.createArtifact(forSlug, number, body)
}
async function getArtifact(
forSlug: string,
number: number,
phase: ArtifactPhase,
version: number, version: number,
): Promise<Prototype> { ): Promise<Artifact> {
const out = await projectsApi.getPrototype(forSlug, number, version) return await projectsApi.getArtifact(forSlug, number, phase, version)
return out.prototype
} }
return { return {
@@ -84,8 +88,8 @@ export const useRequirementsStore = defineStore('requirements', () => {
changePhase, changePhase,
close, close,
reopen, reopen,
listPrototypes, listArtifacts,
createPrototype, createArtifact,
getPrototype, getArtifact,
} }
}) })
+16 -1
View File
@@ -46,8 +46,13 @@ vi.mock('@/api/workspaces', () => ({
}, },
})) }))
const routeMock = vi.hoisted(() => ({
params: { slug: 'p', wsSlug: 'w' },
query: {} as Record<string, string>,
}))
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'p', wsSlug: 'w' } }), useRoute: () => routeMock,
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
})) }))
@@ -55,6 +60,7 @@ import AcpSessionCreateView from './AcpSessionCreateView.vue'
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
routeMock.query = {}
}) })
describe('AcpSessionCreateView', () => { describe('AcpSessionCreateView', () => {
@@ -63,4 +69,13 @@ describe('AcpSessionCreateView', () => {
await new Promise((r) => setTimeout(r, 20)) await new Promise((r) => setTimeout(r, 20))
expect(wrapper.html()).toContain('New ACP Session') expect(wrapper.html()).toContain('New ACP Session')
}) })
it('prefills requirement/issue number from query', async () => {
routeMock.query = { requirement_number: '7', issue_number: '42' }
const wrapper = mount(AcpSessionCreateView)
await new Promise((r) => setTimeout(r, 20))
const inputs = wrapper.findAll('input[type="number"]')
expect((inputs[0].element as HTMLInputElement).value).toBe('42')
expect((inputs[1].element as HTMLInputElement).value).toBe('7')
})
}) })
@@ -20,6 +20,13 @@ const submitting = ref(false)
const errorMsg = ref<string | null>(null) const errorMsg = ref<string | null>(null)
onMounted(async () => { onMounted(async () => {
// ?requirement_number= / ?issue_number=
if (typeof route.query.requirement_number === 'string') {
form.value.requirement_number = route.query.requirement_number
}
if (typeof route.query.issue_number === 'string') {
form.value.issue_number = route.query.issue_number
}
await acpStore.listAgentKinds(false) await acpStore.listAgentKinds(false)
if (wsStore.list.length === 0) { if (wsStore.list.length === 0) {
await wsStore.fetchByProject(route.params.slug as string) await wsStore.fetchByProject(route.params.slug as string)
@@ -62,6 +62,18 @@
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }} in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span> <span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
</div> </div>
<div
v-if="canSave"
class="mt-2"
>
<NButton
size="tiny"
secondary
@click="emit('save', message.id)"
>
保存为产物
</NButton>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -74,7 +86,15 @@ import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat' import type { ChatMessage } from '@/api/chat'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true }) const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const props = defineProps<{ message: ChatMessage }>() // savable true assistant
const emit = defineEmits<{ retry: [id: number] }>() const props = defineProps<{ message: ChatMessage; savable?: boolean }>()
const emit = defineEmits<{ retry: [id: number]; save: [id: number] }>()
const rendered = computed(() => md.render(props.message.content)) const rendered = computed(() => md.render(props.message.content))
const canSave = computed(
() =>
props.savable === true &&
props.message.role === 'assistant' &&
props.message.status === 'ok' &&
props.message.content.trim() !== '',
)
</script> </script>
+73 -138
View File
@@ -47,7 +47,7 @@
</div> </div>
</div> </div>
<!-- Phase flow bar --> <!-- Phase flow bar阶段跳转始终由用户确认触发 -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<template <template
v-for="(phase, idx) in PHASES" v-for="(phase, idx) in PHASES"
@@ -69,6 +69,9 @@
</template> </template>
</div> </div>
<!-- 左右布局=需求信息+当前阶段产物=按阶段切换的 AI 工作面板 -->
<div class="flex gap-6 items-start">
<div class="w-[45%] shrink-0 space-y-6">
<!-- Description --> <!-- Description -->
<NCard title="描述"> <NCard title="描述">
<p <p
@@ -85,98 +88,15 @@
</p> </p>
</NCard> </NCard>
<!-- Prototypes --> <!-- 当前阶段产物 AI 对话三阶段显示 -->
<NCard title="原型"> <ArtifactPanel
<div class="flex gap-4"> v-if="chatPhase"
<!-- Version list --> ref="artifactPanelRef"
<div class="w-56 shrink-0"> :slug="slug"
<NList :number="number"
v-if="prototypes.length > 0" :phase="chatPhase"
:show-divider="false" :readonly="data.requirement.status !== 'open'"
>
<NListItem
v-for="p in prototypes"
:key="p.id"
>
<NThing
class="cursor-pointer"
@click="onSelectPrototype(p.version)"
>
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === p.version ? 'text-blue-600' : ''"
>v{{ p.version }}</span>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(p.created_at).toLocaleString() }}
</span>
</template>
<span
v-if="p.note"
class="text-xs text-gray-500"
>{{ p.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无原型版本"
size="small"
/> />
</div>
<!-- Selected content -->
<div class="flex-1 min-w-0">
<p
v-if="selectedPrototype"
class="text-gray-700 whitespace-pre-wrap text-sm"
>
{{ selectedPrototype.content }}
</p>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
</p>
</div>
</div>
<!-- New version form -->
<NDivider />
<div
v-if="data.requirement.status === 'open'"
class="space-y-2"
>
<NInput
v-model:value="newContent"
type="textarea"
placeholder="原型内容"
:autosize="{ minRows: 4, maxRows: 12 }"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
/>
<NButton
type="primary"
size="small"
:loading="submitting"
:disabled="!newContent.trim()"
@click="onSubmitPrototype"
>
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
</NCard>
<!-- Workspace card --> <!-- Workspace card -->
<NCard title="工作区"> <NCard title="工作区">
@@ -222,6 +142,34 @@
</NCard> </NCard>
</div> </div>
<!-- 右栏v-if 切换保证阶段变化时旧面板卸载自动断流 -->
<div class="flex-1 min-w-0">
<RequirementChatPanel
v-if="chatPhase"
:slug="slug"
:requirement="data.requirement"
:phase="chatPhase"
:prev-artifact="prevArtifact"
:readonly="data.requirement.status !== 'open'"
class="min-h-[70vh]"
@artifact-saved="onArtifactSaved"
/>
<RequirementAcpPanel
v-else-if="
data.requirement.phase === 'implementing' || data.requirement.phase === 'reviewing'
"
:slug="slug"
:requirement="data.requirement"
/>
<RequirementSummaryPanel
v-else
:slug="slug"
:number="number"
/>
</div>
</div>
</div>
<div <div
v-else-if="!loading" v-else-if="!loading"
class="text-gray-400 text-sm py-8 text-center" class="text-gray-400 text-sm py-8 text-center"
@@ -233,7 +181,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useDialog } from 'naive-ui' import { useDialog } from 'naive-ui'
import { import {
@@ -246,26 +194,31 @@ import {
NListItem, NListItem,
NThing, NThing,
NEmpty, NEmpty,
NInput,
NDivider,
} from 'naive-ui' } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import { projectsApi } from '@/api/projects' import { projectsApi } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements' import { useRequirementsStore } from '@/stores/requirements'
import { useWorkspacesStore } from '@/stores/workspaces' import { useWorkspacesStore } from '@/stores/workspaces'
import type { Phase, Prototype, RequirementDetail } from '@/api/projects' import { PREV_ARTIFACT_PHASE } from '@/constants/requirementPhasePrompts'
import ArtifactPanel from './components/ArtifactPanel.vue'
import RequirementChatPanel from './components/RequirementChatPanel.vue'
import RequirementAcpPanel from './components/RequirementAcpPanel.vue'
import RequirementSummaryPanel from './components/RequirementSummaryPanel.vue'
import type { Artifact, ArtifactPhase, Phase, RequirementDetail } from '@/api/projects'
const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done'] const PHASES: Phase[] = ['planning', 'prototyping', 'auditing', 'implementing', 'reviewing', 'done']
const PHASE_LABELS: Record<Phase, string> = { const PHASE_LABELS: Record<Phase, string> = {
planning: '规划中', planning: '规划中',
prototyping: '原型设计', prototyping: '原型设计',
auditing: '审中', auditing: '审中',
implementing: '实施中', implementing: '实施中',
reviewing: '评审中', reviewing: '验收中',
done: '已完成', done: '已完成',
} }
const CHAT_PHASES: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const dialog = useDialog() const dialog = useDialog()
@@ -278,20 +231,18 @@ const number = Number(route.params.number)
const loading = ref(false) const loading = ref(false)
const toggling = ref(false) const toggling = ref(false)
const data = ref<RequirementDetail | null>(null) const data = ref<RequirementDetail | null>(null)
const artifactPanelRef = ref<InstanceType<typeof ArtifactPanel> | null>(null)
const prototypes = ref<Prototype[]>([]) const prevArtifact = ref<Artifact | null>(null)
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const workspaceOptions = computed(() => const workspaceOptions = computed(() =>
wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })), wsStore.list.map((w) => ({ label: `${w.name} (${w.slug})`, value: w.id })),
) )
const selectedPrototype = computed(() => // AI null
prototypes.value.find((p) => p.version === selectedVersion.value) ?? null, const chatPhase = computed<ArtifactPhase | null>(() => {
) const p = data.value?.requirement.phase
return p && (CHAT_PHASES as Phase[]).includes(p) ? (p as ArtifactPhase) : null
})
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
@@ -301,44 +252,25 @@ async function fetchData() {
wsStore.fetchByProject(slug), wsStore.fetchByProject(slug),
]) ])
data.value = detail data.value = detail
await refreshPrototypes()
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function refreshPrototypes() { // system promptplanning
prototypes.value = await store.listPrototypes(slug, number) async function loadPrevArtifact() {
// prevArtifact.value = null
if (prototypes.value.length > 0) { if (!chatPhase.value) return
const latest = prototypes.value[prototypes.value.length - 1] const prevPhase = PREV_ARTIFACT_PHASE[chatPhase.value]
if (selectedVersion.value === null || !selectedPrototype.value) { if (!prevPhase) return
selectedVersion.value = latest.version const list = await store.listArtifacts(slug, number, prevPhase)
} prevArtifact.value = list.length > 0 ? list[list.length - 1] : null
} else {
selectedVersion.value = null
}
} }
function onSelectPrototype(version: number) { watch(chatPhase, loadPrevArtifact)
selectedVersion.value = version
}
async function onSubmitPrototype() { function onArtifactSaved() {
if (!newContent.value.trim()) return artifactPanelRef.value?.refresh()
submitting.value = true
try {
const created = await store.createPrototype(slug, number, {
content: newContent.value,
note: newNote.value || undefined,
})
newContent.value = ''
newNote.value = ''
selectedVersion.value = created.version
await refreshPrototypes()
} finally {
submitting.value = false
}
} }
async function onWorkspaceChange(v: string | null) { async function onWorkspaceChange(v: string | null) {
@@ -384,5 +316,8 @@ async function onReopen() {
} }
} }
onMounted(fetchData) onMounted(async () => {
await fetchData()
await loadPrevArtifact()
})
</script> </script>
@@ -0,0 +1,195 @@
<template>
<NCard :title="`${PHASE_TITLE[phase]}产物`">
<div class="flex gap-4">
<!-- 版本列表 -->
<div class="w-44 shrink-0">
<NList
v-if="artifacts.length > 0"
:show-divider="false"
>
<NListItem
v-for="a in artifacts"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selectedVersion = a.version"
>
<template #header>
<span
class="text-sm font-medium"
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>
AI
</NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleDateString() }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无产物版本"
size="small"
/>
</div>
<!-- 选中版本内容markdown 渲染 -->
<div class="flex-1 min-w-0">
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="selectedArtifact"
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<p
v-else
class="text-gray-400 text-sm"
>
选择左侧版本以查看内容
</p>
</div>
</div>
<!-- 手动提交新版本 -->
<NDivider />
<div
v-if="!readonly"
class="space-y-2"
>
<NInput
v-model:value="newContent"
type="textarea"
placeholder="产物内容(Markdown)"
:autosize="{ minRows: 3, maxRows: 10 }"
/>
<NInput
v-model:value="newNote"
placeholder="备注(可选)"
/>
<NButton
type="primary"
size="small"
:loading="submitting"
:disabled="!newContent.trim()"
@click="onSubmit"
>
提交新版本
</NButton>
</div>
<p
v-else
class="text-gray-400 text-sm"
>
需求已关闭无法提交新版本
</p>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
NButton,
NCard,
NDivider,
NEmpty,
NInput,
NList,
NListItem,
NTag,
NThing,
} from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const props = defineProps<{
slug: string
number: number
phase: ArtifactPhase
readonly?: boolean
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const selectedVersion = ref<number | null>(null)
const newContent = ref('')
const newNote = ref('')
const submitting = ref(false)
const selectedArtifact = computed(
() => artifacts.value.find((a) => a.version === selectedVersion.value) ?? null,
)
const rendered = computed(() =>
selectedArtifact.value ? md.render(selectedArtifact.value.content) : '',
)
// latest system prompt
const latest = computed(() =>
artifacts.value.length > 0 ? artifacts.value[artifacts.value.length - 1] : null,
)
async function refresh() {
artifacts.value = await store.listArtifacts(props.slug, props.number, props.phase)
if (artifacts.value.length > 0) {
if (selectedVersion.value === null || !selectedArtifact.value) {
selectedVersion.value = artifacts.value[artifacts.value.length - 1].version
}
} else {
selectedVersion.value = null
}
}
async function onSubmit() {
if (!newContent.value.trim()) return
submitting.value = true
try {
const created = await store.createArtifact(props.slug, props.number, {
phase: props.phase,
content: newContent.value,
note: newNote.value || undefined,
})
newContent.value = ''
newNote.value = ''
selectedVersion.value = created.version
await refresh()
} finally {
submitting.value = false
}
}
//
watch(
() => props.phase,
() => {
selectedVersion.value = null
refresh()
},
)
onMounted(refresh)
defineExpose({ refresh, latest })
</script>
@@ -0,0 +1,123 @@
<template>
<NCard :title="`ACP 开发会话 · ${phaseLabel}`">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
<div class="flex items-center gap-2">
<NButton
size="small"
:loading="loading"
@click="refresh"
>
刷新
</NButton>
<NTooltip :disabled="!!workspaceSlug">
<template #trigger>
<NButton
size="small"
type="primary"
:disabled="!workspaceSlug"
@click="goCreate"
>
发起{{ phaseLabel }}会话
</NButton>
</template>
需先在左侧工作区卡片关联 workspace
</NTooltip>
</div>
</div>
<NList
v-if="sessions.length > 0"
:show-divider="false"
>
<NListItem
v-for="s in sessions"
:key="s.id"
>
<NThing
class="cursor-pointer"
@click="goDetail(s)"
>
<template #header>
<span class="font-mono text-sm">{{ s.branch }}</span>
</template>
<template #header-extra>
<SessionStatusBadge :status="s.status" />
</template>
<span class="text-xs text-gray-400">
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
<template v-if="s.last_error"> · {{ s.last_error }}</template>
</span>
</NThing>
</NListItem>
</NList>
<NEmpty
v-else
description="暂无关联会话"
size="small"
/>
</div>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naive-ui'
import { acpApi, type AcpSession } from '@/api/acp'
import type { Requirement } from '@/api/projects'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
import { useWorkspacesStore } from '@/stores/workspaces'
const props = defineProps<{
slug: string
requirement: Requirement
}>()
const router = useRouter()
const wsStore = useWorkspacesStore()
const sessions = ref<AcpSession[]>([])
const loading = ref(false)
const phaseLabel = computed(() =>
props.requirement.phase === 'reviewing' ? '验收' : '实施',
)
// requirement.workspace_id workspace slug wsSlug
const workspaceSlug = computed(() => {
const wsID = props.requirement.workspace_id
if (!wsID) return null
return wsStore.list.find((w) => w.id === wsID)?.slug ?? null
})
async function refresh() {
loading.value = true
try {
sessions.value = await acpApi.sessions.list({ requirement_id: props.requirement.id })
} finally {
loading.value = false
}
}
function goCreate() {
if (!workspaceSlug.value) return
router.push({
name: 'acp-sessions-new',
params: { slug: props.slug, wsSlug: workspaceSlug.value },
query: { requirement_number: String(props.requirement.number) },
})
}
function goDetail(s: AcpSession) {
if (!workspaceSlug.value) return
router.push({
name: 'acp-sessions-detail',
params: { slug: props.slug, wsSlug: workspaceSlug.value, sessionId: s.id },
})
}
onMounted(refresh)
</script>
@@ -0,0 +1,245 @@
<template>
<NCard
:title="`AI 对话 · ${PHASE_TITLE[phase]}`"
class="flex flex-col h-full"
content-class="flex flex-col flex-1 min-h-0"
>
<!-- 会话选择条 -->
<div class="flex items-center gap-2 mb-3">
<NSelect
:value="session.currentConversation.value?.id ?? null"
:options="conversationOptions"
placeholder="选择历史会话"
size="small"
class="flex-1"
:disabled="creating"
@update:value="onSelectConversation"
/>
<NButton
size="small"
type="primary"
secondary
@click="openCreateForm"
>
新建会话
</NButton>
</div>
<!-- 新建会话表单 -->
<div
v-if="showCreateForm"
class="space-y-2 mb-3 border rounded p-3"
>
<ModelSelector v-model:model-value="newModelId" />
<NInput
v-model:value="newSystemPrompt"
type="textarea"
placeholder="system prompt(已自动注入需求上下文,可修改)"
:autosize="{ minRows: 4, maxRows: 10 }"
/>
<div class="flex justify-end gap-2">
<NButton
size="small"
@click="showCreateForm = false"
>
取消
</NButton>
<NButton
size="small"
type="primary"
:loading="creating"
:disabled="!newModelId"
@click="onCreateConversation"
>
创建并开始
</NButton>
</div>
</div>
<!-- 消息区 -->
<div
v-if="session.currentConversation.value"
ref="scrollEl"
class="flex-1 min-h-0 overflow-y-auto space-y-3 py-2"
>
<MessageBubble
v-for="m in session.messages.value"
:key="m.id"
:message="m"
:savable="!readonly"
@retry="session.retryMessage"
@save="onSaveMessage"
/>
</div>
<div
v-else
class="flex-1 flex items-center justify-center text-neutral-400 text-sm"
>
选择历史会话或新建会话开始与 AI 讨论本阶段内容
</div>
<!-- 输入区 -->
<div
v-if="session.currentConversation.value"
class="border-t pt-3 flex gap-2"
>
<NInput
v-model:value="draft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="发送消息(Enter 发送,Shift+Enter 换行)"
:disabled="session.isStreaming.value"
@keydown.enter.exact.prevent="send"
/>
<NButton
v-if="!session.isStreaming.value"
type="primary"
:disabled="!draft.trim()"
@click="send"
>
发送
</NButton>
<NButton
v-else
type="warning"
@click="session.cancelMessage"
>
中断
</NButton>
</div>
<SaveArtifactModal
v-model:show="showSaveModal"
:slug="slug"
:number="requirement.number"
:phase="phase"
:source-message="saveSource"
@saved="emit('artifact-saved')"
/>
</NCard>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { NButton, NCard, NInput, NSelect } from 'naive-ui'
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
import { useConversationSession } from '@/composables/useConversationSession'
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
import MessageBubble from '@/views/chat/components/MessageBubble.vue'
import ModelSelector from '@/views/chat/components/ModelSelector.vue'
import SaveArtifactModal from './SaveArtifactModal.vue'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型设计',
auditing: '评审',
}
const props = defineProps<{
slug: string
requirement: Requirement
phase: ArtifactPhase
// planning system prompt
prevArtifact?: Artifact | null
readonly?: boolean
}>()
const emit = defineEmits<{ 'artifact-saved': [] }>()
// /chat
const session = useConversationSession()
const conversations = ref<Conversation[]>([])
const showCreateForm = ref(false)
const newModelId = ref<string | undefined>(undefined)
const newSystemPrompt = ref('')
const creating = ref(false)
const draft = ref('')
const scrollEl = ref<HTMLElement | null>(null)
const showSaveModal = ref(false)
const saveSource = ref<ChatMessage | null>(null)
const conversationOptions = computed(() =>
conversations.value.map((c) => ({
label: `${c.title || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
value: c.id,
})),
)
async function loadConversations() {
conversations.value = await chatApi.listConversations({
requirement_id: props.requirement.id,
})
}
function openCreateForm() {
newSystemPrompt.value = buildRequirementSystemPrompt(
props.phase,
props.requirement,
props.prevArtifact,
)
showCreateForm.value = true
}
async function onCreateConversation() {
if (!newModelId.value) return
creating.value = true
try {
const conv = await chatApi.createConversation({
model_id: newModelId.value,
system_prompt: newSystemPrompt.value,
project_id: props.requirement.project_id,
requirement_id: props.requirement.id,
})
showCreateForm.value = false
await loadConversations()
await session.openConversation(conv.id)
} finally {
creating.value = false
}
}
async function onSelectConversation(id: string | null) {
if (!id) return
await session.openConversation(id)
}
async function send() {
if (!draft.value.trim() || session.isStreaming.value) return
const body = draft.value
draft.value = ''
await session.sendMessage(body)
}
function onSaveMessage(id: number) {
const msg = session.messages.value.find((m) => m.id === id)
if (!msg) return
saveSource.value = msg
showSaveModal.value = true
}
// ChatDetailView
watch(
() => [session.messages.value.length, session.messages.value.at(-1)?.content?.length ?? 0],
() => {
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
},
)
//
watch(
() => props.phase,
() => {
session.closeCurrent()
showCreateForm.value = false
},
)
onMounted(loadConversations)
onUnmounted(() => session.closeCurrent())
</script>
@@ -0,0 +1,112 @@
<template>
<NCard title="产物汇总">
<NSpin :show="loading">
<div
v-if="grouped.length > 0"
class="space-y-4"
>
<div
v-for="g in grouped"
:key="g.phase"
>
<h3 class="text-sm font-semibold mb-2">
{{ PHASE_TITLE[g.phase] }}{{ g.items.length }} 个版本
</h3>
<NList :show-divider="false">
<NListItem
v-for="a in g.items"
:key="a.id"
>
<NThing
class="cursor-pointer"
@click="selected = selected?.id === a.id ? null : a"
>
<template #header>
<span
class="text-sm font-medium"
:class="selected?.id === a.id ? 'text-blue-600' : ''"
>v{{ a.version }}</span>
<NTag
v-if="a.source_message_id"
size="tiny"
class="ml-1"
>
AI
</NTag>
</template>
<template #header-extra>
<span class="text-xs text-gray-400">
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
</span>
</template>
<span
v-if="a.note"
class="text-xs text-gray-500"
>{{ a.note }}</span>
</NThing>
</NListItem>
</NList>
</div>
<template v-if="selected">
<NDivider />
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none"
v-html="rendered"
/>
</template>
</div>
<NEmpty
v-else-if="!loading"
description="暂无任何阶段产物"
/>
</NSpin>
</NCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { Artifact, ArtifactPhase } from '@/api/projects'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const PHASE_ORDER: ArtifactPhase[] = ['planning', 'prototyping', 'auditing']
const props = defineProps<{
slug: string
number: number
}>()
const store = useRequirementsStore()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const artifacts = ref<Artifact[]>([])
const loading = ref(false)
const selected = ref<Artifact | null>(null)
const grouped = computed(() =>
PHASE_ORDER.map((phase) => ({
phase,
items: artifacts.value.filter((a) => a.phase === phase),
})).filter((g) => g.items.length > 0),
)
const rendered = computed(() => (selected.value ? md.render(selected.value.content) : ''))
onMounted(async () => {
loading.value = true
try {
artifacts.value = await store.listArtifacts(props.slug, props.number)
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,119 @@
<template>
<NModal
:show="show"
preset="card"
:title="`保存为${PHASE_TITLE[phase]}产物`"
class="max-w-4xl"
@update:show="(v: boolean) => emit('update:show', v)"
>
<div class="space-y-3">
<NTabs type="segment">
<NTabPane
name="edit"
tab="编辑"
>
<NInput
v-model:value="content"
type="textarea"
placeholder="产物内容(Markdown,可在保存前修改)"
:autosize="{ minRows: 12, maxRows: 24 }"
/>
</NTabPane>
<NTabPane
name="preview"
tab="预览"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
v-html="rendered"
/>
</NTabPane>
</NTabs>
<NInput
v-model:value="note"
placeholder="版本备注(可选)"
/>
<div class="flex justify-end gap-2">
<NButton @click="emit('update:show', false)">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
:disabled="!content.trim()"
@click="onSave"
>
保存为新版本
</NButton>
</div>
</div>
</NModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NInput, NModal, NTabPane, NTabs, useMessage } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ArtifactPhase } from '@/api/projects'
import type { ChatMessage } from '@/api/chat'
import { useRequirementsStore } from '@/stores/requirements'
const PHASE_TITLE: Record<ArtifactPhase, string> = {
planning: '规划',
prototyping: '原型',
auditing: '评审',
}
const props = defineProps<{
show: boolean
slug: string
number: number
phase: ArtifactPhase
sourceMessage: ChatMessage | null
}>()
const emit = defineEmits<{ 'update:show': [v: boolean]; saved: [] }>()
const store = useRequirementsStore()
const message = useMessage()
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const content = ref('')
const note = ref('')
const saving = ref(false)
const rendered = computed(() => md.render(content.value))
//
watch(
() => props.show,
(v) => {
if (v) {
content.value = props.sourceMessage?.content ?? ''
note.value = ''
}
},
)
async function onSave() {
if (!content.value.trim()) return
saving.value = true
try {
await store.createArtifact(props.slug, props.number, {
phase: props.phase,
content: content.value,
note: note.value || undefined,
source_message_id: props.sourceMessage?.id,
})
message.success('已保存为新版本')
emit('update:show', false)
emit('saved')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
</script>