主流程

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
+15 -12
View File
@@ -103,6 +103,7 @@ type Conversation struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID
SystemPrompt string
Title *string
@@ -181,22 +182,24 @@ type ConversationService interface {
// CreateConversationInput 是创建会话的入参。
type CreateConversationInput struct {
ModelID uuid.UUID
TemplateID *uuid.UUID
SystemPrompt *string // 优先级:自定义 > 模板内容 > 空字符串
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
ModelID uuid.UUID
TemplateID *uuid.UUID
SystemPrompt *string // 优先级:自定义 > 模板内容 > 空字符串
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
}
// ConversationFilter 列表过滤参数。
type ConversationFilter struct {
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
TitleQuery string
BeforeID *uuid.UUID // cursor
Limit int
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
TitleQuery string
BeforeID *uuid.UUID // cursor
Limit int
}
// MessageService — 发消息 + 流接收 + 取消 + 重试。
+29 -26
View File
@@ -10,12 +10,13 @@ import (
// CreateConversationReq is the HTTP request body for POST /conversations.
type CreateConversationReq struct {
ModelID uuid.UUID `json:"model_id"`
TemplateID *uuid.UUID `json:"template_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"`
ModelID uuid.UUID `json:"model_id"`
TemplateID *uuid.UUID `json:"template_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"`
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
}
// UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}.
@@ -91,16 +92,17 @@ type UpdateModelReq struct {
// ConversationDTO is the JSON representation of a Conversation.
type ConversationDTO struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ModelID uuid.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ModelID uuid.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
IssueID *uuid.UUID `json:"issue_id,omitempty"`
RequirementID *uuid.UUID `json:"requirement_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MessageDTO is the JSON representation of a Message.
@@ -224,16 +226,17 @@ type AdminUsageResp struct {
func toConversationDTO(c *Conversation) ConversationDTO {
return ConversationDTO{
ID: c.ID,
UserID: c.UserID,
ModelID: c.ModelID,
SystemPrompt: c.SystemPrompt,
Title: c.Title,
ProjectID: c.ProjectID,
WorkspaceID: c.WorkspaceID,
IssueID: c.IssueID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
ID: c.ID,
UserID: c.UserID,
ModelID: c.ModelID,
SystemPrompt: c.SystemPrompt,
Title: c.Title,
ProjectID: c.ProjectID,
WorkspaceID: c.WorkspaceID,
IssueID: c.IssueID,
RequirementID: c.RequirementID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+12 -6
View File
@@ -200,6 +200,11 @@ func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) {
f.IssueID = &id
}
}
if v := q.Get("requirement_id"); v != "" {
if id, e := parseUUID(v); e == nil {
f.RequirementID = &id
}
}
convs, err := h.convSvc.List(r.Context(), uid, f)
if err != nil {
writeErr(w, r, err)
@@ -224,12 +229,13 @@ func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) {
return
}
conv, err := h.convSvc.Create(r.Context(), uid, CreateConversationInput{
ModelID: req.ModelID,
TemplateID: req.TemplateID,
SystemPrompt: req.SystemPrompt,
ProjectID: req.ProjectID,
WorkspaceID: req.WorkspaceID,
IssueID: req.IssueID,
ModelID: req.ModelID,
TemplateID: req.TemplateID,
SystemPrompt: req.SystemPrompt,
ProjectID: req.ProjectID,
WorkspaceID: req.WorkspaceID,
IssueID: req.IssueID,
RequirementID: req.RequirementID,
})
if err != nil {
writeErr(w, r, err)
+5 -4
View File
@@ -1,6 +1,6 @@
-- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: GetConversation :one
@@ -12,9 +12,10 @@ WHERE user_id = $1 AND deleted_at IS NULL
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR workspace_id = $3)
AND ($4::uuid IS NULL OR issue_id = $4)
AND ($5::text = '' OR title ILIKE '%' || $5 || '%')
AND ($5::uuid IS NULL OR requirement_id = $5)
AND ($6::text = '' OR title ILIKE '%' || $6 || '%')
ORDER BY updated_at DESC
LIMIT $6;
LIMIT $7;
-- name: UpdateConversationTitle :exec
UPDATE conversations SET title = $2, title_generated_at = NOW(), updated_at = NOW()
+24 -18
View File
@@ -20,8 +20,8 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
chatsqlc "github.com/yan1h/agent-coding-workflow/internal/chat/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// Postgres epoch: 2000-01-01 00:00:00 UTC
@@ -104,14 +104,15 @@ type Tx interface {
// InsertConversationParams 是 InsertConversation 的参数。
type InsertConversationParams struct {
ID uuid.UUID
UserID uuid.UUID
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
ModelID uuid.UUID
SystemPrompt string
Title *string
ID uuid.UUID
UserID uuid.UUID
ProjectID *uuid.UUID
WorkspaceID *uuid.UUID
IssueID *uuid.UUID
RequirementID *uuid.UUID
ModelID uuid.UUID
SystemPrompt string
Title *string
}
// UpdateMessageOKParams 是 UpdateMessageOK 的参数。
@@ -313,6 +314,7 @@ func convFromRow(r chatsqlc.Conversation) *Conversation {
ProjectID: fromPgUUIDPtr(r.ProjectID),
WorkspaceID: fromPgUUIDPtr(r.WorkspaceID),
IssueID: fromPgUUIDPtr(r.IssueID),
RequirementID: fromPgUUIDPtr(r.RequirementID),
ModelID: fromPgUUID(r.ModelID),
SystemPrompt: r.SystemPrompt,
Title: r.Title,
@@ -469,14 +471,15 @@ func dayUsageRowFromRow(r chatsqlc.SummarizeUsageByDayRow) DayUsageRow {
func (r *pgRepo) InsertConversation(ctx context.Context, p InsertConversationParams) (*Conversation, error) {
row, err := r.q.InsertConversation(ctx, chatsqlc.InsertConversationParams{
ID: toPgUUID(p.ID),
UserID: toPgUUID(p.UserID),
ProjectID: toPgUUIDPtr(p.ProjectID),
WorkspaceID: toPgUUIDPtr(p.WorkspaceID),
IssueID: toPgUUIDPtr(p.IssueID),
ModelID: toPgUUID(p.ModelID),
SystemPrompt: p.SystemPrompt,
Title: p.Title,
ID: toPgUUID(p.ID),
UserID: toPgUUID(p.UserID),
ProjectID: toPgUUIDPtr(p.ProjectID),
WorkspaceID: toPgUUIDPtr(p.WorkspaceID),
IssueID: toPgUUIDPtr(p.IssueID),
ModelID: toPgUUID(p.ModelID),
SystemPrompt: p.SystemPrompt,
Title: p.Title,
RequirementID: toPgUUIDPtr(p.RequirementID),
})
if err != nil {
return nil, errs.Wrap(translatePgError(err), errs.CodeInternal, "insert conversation")
@@ -500,12 +503,15 @@ func (r *pgRepo) ListConversationsByUser(ctx context.Context, userID uuid.UUID,
if limit <= 0 {
limit = 20
}
// Column2..Column6 与 SQL 位置参数一一对应:$2=project, $3=workspace,
// $4=issue, $5=requirement, $6=title(sqlc 未命名参数按序生成字段)。
rows, err := r.q.ListConversationsByUser(ctx, chatsqlc.ListConversationsByUserParams{
UserID: toPgUUID(userID),
Column2: toPgUUIDPtr(f.ProjectID),
Column3: toPgUUIDPtr(f.WorkspaceID),
Column4: toPgUUIDPtr(f.IssueID),
Column5: f.TitleQuery,
Column5: toPgUUIDPtr(f.RequirementID),
Column6: f.TitleQuery,
Limit: int32(limit),
})
if err != nil {
+9 -8
View File
@@ -61,14 +61,15 @@ func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in C
}
c, err := s.repo.InsertConversation(ctx, InsertConversationParams{
ID: id,
UserID: userID,
ProjectID: in.ProjectID,
WorkspaceID: in.WorkspaceID,
IssueID: in.IssueID,
ModelID: in.ModelID,
SystemPrompt: systemPrompt,
Title: nil,
ID: id,
UserID: userID,
ProjectID: in.ProjectID,
WorkspaceID: in.WorkspaceID,
IssueID: in.IssueID,
RequirementID: in.RequirementID,
ModelID: in.ModelID,
SystemPrompt: systemPrompt,
Title: nil,
})
if err != nil {
return nil, err
+44 -12
View File
@@ -40,16 +40,17 @@ func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversatio
r.mu.Lock()
defer r.mu.Unlock()
c := &Conversation{
ID: p.ID,
UserID: p.UserID,
ProjectID: p.ProjectID,
WorkspaceID: p.WorkspaceID,
IssueID: p.IssueID,
ModelID: p.ModelID,
SystemPrompt: p.SystemPrompt,
Title: p.Title,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ID: p.ID,
UserID: p.UserID,
ProjectID: p.ProjectID,
WorkspaceID: p.WorkspaceID,
IssueID: p.IssueID,
RequirementID: p.RequirementID,
ModelID: p.ModelID,
SystemPrompt: p.SystemPrompt,
Title: p.Title,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
r.conversations[p.ID] = c
return c, nil
@@ -70,9 +71,13 @@ func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UU
defer r.mu.Unlock()
var out []Conversation
for _, c := range r.conversations {
if c.UserID == userID && c.DeletedAt == nil {
out = append(out, *c)
if c.UserID != userID || c.DeletedAt != nil {
continue
}
if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) {
continue
}
out = append(out, *c)
}
limit := f.Limit
if limit > 0 && len(out) > limit {
@@ -235,6 +240,33 @@ func TestConversationService_Create_HappyPath(t *testing.T) {
assert.Equal(t, &userID, entry.UserID)
}
func TestConversationService_Create_WithRequirementMount(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
reqID := uuid.Must(uuid.NewV7())
projID := uuid.Must(uuid.NewV7())
c, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID, ProjectID: &projID, RequirementID: &reqID,
})
require.NoError(t, err)
require.NotNil(t, c.RequirementID)
assert.Equal(t, reqID, *c.RequirementID)
// List 按 requirement 过滤:命中 1 条;其他 requirement 不命中。
_, err = svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID})
require.NoError(t, err)
list, err := svc.List(context.Background(), userID, ConversationFilter{RequirementID: &reqID})
require.NoError(t, err)
require.Len(t, list, 1)
other := uuid.Must(uuid.NewV7())
list, err = svc.List(context.Background(), userID, ConversationFilter{RequirementID: &other})
require.NoError(t, err)
require.Empty(t, list)
}
func TestConversationService_Create_WithSystemTemplate(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
+24 -16
View File
@@ -12,7 +12,7 @@ import (
)
const getConversation = `-- name: GetConversation :one
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations WHERE id = $1 AND deleted_at IS NULL
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations WHERE id = $1 AND deleted_at IS NULL
`
func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversation, error) {
@@ -31,25 +31,27 @@ func (q *Queries) GetConversation(ctx context.Context, id pgtype.UUID) (Conversa
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
)
return i, err
}
const insertConversation = `-- name: InsertConversation :one
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at
INSERT INTO conversations (id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, requirement_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id
`
type InsertConversationParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ModelID pgtype.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title"`
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ModelID pgtype.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) (Conversation, error) {
@@ -62,6 +64,7 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
arg.ModelID,
arg.SystemPrompt,
arg.Title,
arg.RequirementID,
)
var i Conversation
err := row.Scan(
@@ -77,19 +80,21 @@ func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversation
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
)
return i, err
}
const listConversationsByUser = `-- name: ListConversationsByUser :many
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at FROM conversations
SELECT id, user_id, project_id, workspace_id, issue_id, model_id, system_prompt, title, title_generated_at, created_at, updated_at, deleted_at, requirement_id FROM conversations
WHERE user_id = $1 AND deleted_at IS NULL
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR workspace_id = $3)
AND ($4::uuid IS NULL OR issue_id = $4)
AND ($5::text = '' OR title ILIKE '%' || $5 || '%')
AND ($5::uuid IS NULL OR requirement_id = $5)
AND ($6::text = '' OR title ILIKE '%' || $6 || '%')
ORDER BY updated_at DESC
LIMIT $6
LIMIT $7
`
type ListConversationsByUserParams struct {
@@ -97,7 +102,8 @@ type ListConversationsByUserParams struct {
Column2 pgtype.UUID `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
Column4 pgtype.UUID `json:"column_4"`
Column5 string `json:"column_5"`
Column5 pgtype.UUID `json:"column_5"`
Column6 string `json:"column_6"`
Limit int32 `json:"limit"`
}
@@ -108,6 +114,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
arg.Column3,
arg.Column4,
arg.Column5,
arg.Column6,
arg.Limit,
)
if err != nil {
@@ -130,6 +137,7 @@ func (q *Queries) ListConversationsByUser(ctx context.Context, arg ListConversat
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.RequirementID,
); err != nil {
return nil, err
}
+13
View File
@@ -95,6 +95,7 @@ type Conversation struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type GitCredential struct {
@@ -276,6 +277,18 @@ type Requirement struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type RequirementArtifact struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`