Files
Agent-System/internal/freedoc/service_create.go
T
weichen 3e01f18756 merge: integrate final-artifact-vault into local_dev (authorized exception)
User authorized lifting CURRENT FAV whole-branch merge protection on
2026-09-23. Keep FAV_DEGRADED_READ/FAV_PUBLISHER_ENABLED default false.
Drop duplicate goose 00034 (local_dev already has 00036). Preserve
agentx workspace admin routes and pin-export wiring.
2026-09-23 13:57:08 +00:00

405 lines
12 KiB
Go

package freedoc
import (
"context"
"errors"
"fmt"
"strings"
"time"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/replyexport"
"github.com/jackc/pgx/v5"
)
// CreateFromMessage 从单条智能体回复保存为私密文档(幂等)
func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) {
if req.MessageID <= 0 {
return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam)
}
// 1. 获取源消息
if s.ctxMgr == nil {
return nil, false, errors.New("ctxMgr is not configured")
}
msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID)
if err != nil {
return nil, false, fmt.Errorf("lookup message: %w", err)
}
if msg == nil {
return nil, false, ErrNotFound
}
// 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none'
if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 {
return nil, false, ErrMessageNotExportable
}
content := msg.Content
contentHash := computeSHA256(content)
if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash {
return nil, false, ErrConflict
}
title := req.Title
if title == "" {
title = replyexport.DeriveTitle(content, time.Now())
}
projectID := req.ProjectID
if projectID <= 0 {
projectID = 1 // 兜底默认项目
}
// 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回
if s.pool != nil {
var existingDoc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, legacy_version_id, current_sha256,
is_deleted, deleted_at, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
LIMIT 1
`, userID, req.MessageID)
if err := row.Scan(
&existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID,
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
); err == nil {
// 获取当前版本的 markdown
var curContent string
_ = s.pool.QueryRow(ctx, `
SELECT content_markdown FROM freeform_document_versions
WHERE doc_id = $1 AND version_no = $2
`, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent)
return &FreeformDocDetail{
Document: existingDoc,
Content: curContent,
}, true, nil
}
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer tx.Rollback(ctx)
now := time.Now()
_, err = tx.Exec(ctx, `
INSERT INTO freeform_documents (
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, legacy_version_id, current_sha256,
is_deleted, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now)
if err != nil {
return nil, false, fmt.Errorf("insert freeform_doc: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO freeform_document_versions (
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5)
`, docID, contentHash, content, userID, now)
if err != nil {
return nil, false, fmt.Errorf("insert version: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'create_from_message', TRUE, $3)
`, docID, userID, now)
if err != nil {
return nil, false, fmt.Errorf("insert audit: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, false, err
}
if s.OnFinalArtifact != nil {
s.OnFinalArtifact(ctx, int64(projectID), docID, fmt.Sprintf("freeform:%s", docID), 0, []byte(content))
}
createdDoc := FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceMessageID: &req.MessageID,
SourceContentSHA256: contentHash,
Title: title,
GovernanceMode: "freeform",
Space: "my_private",
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
}
return &FreeformDocDetail{
Document: createdDoc,
Content: content,
}, false, nil
}
// 内存备用实现
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.memDocs {
if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted {
versions := s.memVersions[d.DocID]
content := ""
if len(versions) > 0 {
content = versions[len(versions)-1].ContentMarkdown
}
return &FreeformDocDetail{
Document: *d,
Content: content,
}, true, nil
}
}
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
now := time.Now()
newDoc := &FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceMessageID: &req.MessageID,
SourceContentSHA256: contentHash,
Title: title,
GovernanceMode: "freeform",
Space: "my_private",
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
}
s.memDocs[docID] = newDoc
s.memVersions[docID] = []*FreeformDocumentVersion{
{
ID: 1,
DocID: docID,
VersionNo: 1,
ContentSHA256: contentHash,
ContentMarkdown: content,
CreatedBy: userID,
ChangeSummary: "从智能体回复保存",
CreatedAt: now,
},
}
if s.OnFinalArtifact != nil {
s.OnFinalArtifact(ctx, int64(projectID), docID, fmt.Sprintf("freeform:%s", docID), 0, []byte(content))
}
return &FreeformDocDetail{
Document: *newDoc,
Content: content,
}, false, nil
}
func clientKeyLookup(userID int, clientKey string) string {
return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey))
}
func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) {
if s == nil || s.docRev == nil || detail == nil {
return
}
md := detail.Content
if strings.TrimSpace(md) == "" {
return
}
_, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{
DocID: detail.Document.DocID,
Kind: "freeform",
Markdown: md,
ProjectID: int64(detail.Document.ProjectID),
LifecycleState: "drafting",
Actor: docrevision.ActorSnapshot{
DisplayName: "freeform-owner",
Role: "owner",
},
})
}
func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) {
var docID string
err := s.pool.QueryRow(ctx, `
SELECT doc_id FROM freeform_documents
WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE
LIMIT 1
`, userID, clientKey).Scan(&docID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return s.GetDocument(ctx, userID, docID)
}
// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。
// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。
// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。
func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) {
title := strings.TrimSpace(req.Title)
content := req.ContentMarkdown
if title == "" {
return nil, fmt.Errorf("%w: title required", ErrInvalidParam)
}
if strings.TrimSpace(content) == "" {
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
}
space := strings.TrimSpace(req.Space)
if space == "" {
space = "my_private"
}
// Direct create is upload/private registration only; project_shared must go through ShareDocument.
if space != "my_private" {
return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam)
}
projectID := req.ProjectID
if projectID <= 0 {
return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam)
}
clientKey := strings.TrimSpace(req.ClientKey)
var clientKeyPtr *string
if clientKey != "" {
clientKeyPtr = &clientKey
}
if clientKey != "" {
if s.pool != nil {
existing, err := s.findByClientKeyPG(ctx, userID, clientKey)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
} else {
s.mu.RLock()
existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]
s.mu.RUnlock()
if existingID != "" {
return s.GetDocument(ctx, userID, existingID)
}
}
}
contentHash := computeSHA256(content)
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
now := time.Now()
if s.pool != nil {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO freeform_documents (
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
client_key, title, governance_mode, space, legacy_version_id, current_sha256,
is_deleted, created_at, updated_at
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
if err != nil {
// Unique race: another request inserted the same owner+client_key
if clientKey != "" {
if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil {
return existing, nil
}
}
return nil, fmt.Errorf("insert freeform_doc: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO freeform_document_versions (
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5)
`, docID, contentHash, content, userID, now)
if err != nil {
return nil, fmt.Errorf("insert version: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'create_upload', TRUE, $3)
`, docID, userID, now)
if err != nil {
return nil, fmt.Errorf("insert audit: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
detail := &FreeformDocDetail{
Document: FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceContentSHA256: contentHash,
ClientKey: clientKeyPtr,
Title: title,
GovernanceMode: "freeform",
Space: space,
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
},
Content: content,
}
s.bootstrapLedger(ctx, userID, detail)
return detail, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if clientKey != "" {
if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" {
doc := s.memDocs[existingID]
versions := s.memVersions[existingID]
cur := ""
if len(versions) > 0 {
cur = versions[len(versions)-1].ContentMarkdown
}
if doc != nil {
return &FreeformDocDetail{Document: *doc, Content: cur}, nil
}
}
}
newDoc := &FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceContentSHA256: contentHash,
ClientKey: clientKeyPtr,
Title: title,
GovernanceMode: "freeform",
Space: space,
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
}
s.memDocs[docID] = newDoc
s.memVersions[docID] = []*FreeformDocumentVersion{{
ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash,
ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now,
}}
if clientKey != "" {
s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID
}
detail := &FreeformDocDetail{Document: *newDoc, Content: content}
s.bootstrapLedger(ctx, userID, detail)
return detail, nil
}