feat(freedoc): CreateDocument with client_key idempotency

Support direct freeform create for upload fact-source registration while
keeping source_content_sha256 as a content fingerprint only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 11:12:03 +00:00
co-authored by Cursor
parent 1e364f17ab
commit 9c7d6d49b2
6 changed files with 442 additions and 27 deletions
+124
View File
@@ -0,0 +1,124 @@
package freedoc_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
func TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService(t *testing.T) {
dsn := db.SkipIfNoTestDatabaseURL(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
require.NoError(t, err)
defer pool.Close()
var hasClientKey bool
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'freeform_documents' AND column_name = 'client_key'
)
`).Scan(&hasClientKey)
if err != nil || !hasClientKey {
t.Skip("freeform_documents.client_key missing; migrate 00029 required")
}
var hasVersions bool
_ = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables WHERE table_name = 'document_versions'
)
`).Scan(&hasVersions)
if !hasVersions {
t.Skip("document_versions missing; migrate 00028 required")
}
userID := 91001
clientKey := fmt.Sprintf("doc-upload-pg-%d", time.Now().UnixNano())
rev1 := docrevision.NewService(pool)
svc1 := freedoc.NewService(pool, nil)
svc1.SetDocRevision(rev1)
detail, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
ProjectID: 1,
Title: "PG上传规格",
ContentMarkdown: "# PG上传规格\n\n基线正文\n",
Space: "my_private",
ClientKey: clientKey,
})
require.NoError(t, err)
docID := detail.Document.DocID
require.NotEmpty(t, docID)
require.NotNil(t, detail.Document.ClientKey)
assert.Equal(t, clientKey, *detail.Document.ClientKey)
assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload")
assert.Len(t, detail.Document.SourceContentSHA256, 64)
// client_key 幂等:同一 owner+key 返回同一 doc
again, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
ProjectID: 1,
Title: "PG上传规格",
ContentMarkdown: "# PG上传规格\n\n基线正文\n",
ClientKey: clientKey,
})
require.NoError(t, err)
assert.Equal(t, docID, again.Document.DocID)
st1, err := rev1.GetRevisionState(ctx, docID)
require.NoError(t, err)
assert.GreaterOrEqual(t, st1.CurrentVersionNo, 1)
patched := "# PG上传规格\n\n基线正文\n\nPG人工修订\n"
_, err = rev1.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: docID, Markdown: patched,
ExpectedHeadSnapshot: &st1.HeadSnapshotID, ExpectedRevisionNo: &st1.HeadRevisionNo,
IdempotencyKey: fmt.Sprintf("pg-upload-patch-%d", time.Now().UnixNano()),
ActorUserID: int64(userID), LifecycleState: "drafting", ProjectID: 1,
})
require.NoError(t, err)
_, err = svc1.UpdateDocument(ctx, userID, docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &patched,
ChangeSummary: "pg patch",
})
require.NoError(t, err)
// 新 Service 实例:client_key 仍可解析,revision-state 与正文可恢复
rev2 := docrevision.NewService(pool)
svc2 := freedoc.NewService(pool, nil)
svc2.SetDocRevision(rev2)
byKey, err := svc2.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{
ProjectID: 1,
Title: "ignored",
ContentMarkdown: "# ignored\n",
ClientKey: clientKey,
})
require.NoError(t, err)
assert.Equal(t, docID, byKey.Document.DocID)
st2, err := rev2.GetRevisionState(ctx, docID)
require.NoError(t, err, "revision-state must hydrate after new Service")
assert.GreaterOrEqual(t, st2.HeadRevisionNo, 1)
got, err := svc2.GetDocument(ctx, userID, docID)
require.NoError(t, err)
assert.Contains(t, got.Content, "PG人工修订")
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM freeform_document_versions WHERE doc_id=$1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM freeform_documents WHERE doc_id=$1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id=$1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id=$1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id=$1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM document_access_audit WHERE doc_id=$1`, docID)
})
}
+83
View File
@@ -0,0 +1,83 @@
package freedoc_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
func TestCreateDocument_ClientKeyIdempotentAndBootstrapsLedger(t *testing.T) {
ctx := context.Background()
rev := docrevision.NewService(nil)
svc := freedoc.NewService(nil, nil)
svc.SetDocRevision(rev)
detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "超声探头规格.pdf",
ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n",
Space: "my_private",
ClientKey: "doc-upload-1789263145197-0",
})
require.NoError(t, err)
require.NotNil(t, detail)
assert.True(t, len(detail.Document.DocID) > 0)
assert.True(t, detail.Document.DocID[:8] == "doc-free" || detail.Document.DocID[:9] == "doc-free-")
require.NotNil(t, detail.Document.ClientKey)
assert.Equal(t, "doc-upload-1789263145197-0", *detail.Document.ClientKey)
assert.Equal(t, "freeform", detail.Document.GovernanceMode)
// source_content_sha256 must remain content fingerprint — never client_key prefix
assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload")
assert.NotContains(t, detail.Document.SourceContentSHA256, "client_key")
assert.Len(t, detail.Document.SourceContentSHA256, 64)
st, err := rev.GetRevisionState(ctx, detail.Document.DocID)
require.NoError(t, err)
assert.GreaterOrEqual(t, st.CurrentVersionNo, 1)
again, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "超声探头规格.pdf",
ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n",
ClientKey: "doc-upload-1789263145197-0",
})
require.NoError(t, err)
assert.Equal(t, detail.Document.DocID, again.Document.DocID)
otherUser, err := svc.CreateDocument(ctx, 99, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "他人同名键",
ContentMarkdown: "# other\n",
ClientKey: "doc-upload-1789263145197-0",
})
require.NoError(t, err)
assert.NotEqual(t, detail.Document.DocID, otherUser.Document.DocID)
}
func TestCreateDocument_RejectsEmptyContent(t *testing.T) {
svc := freedoc.NewService(nil, nil)
_, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
ProjectID: 1, Title: "x", ContentMarkdown: " ",
})
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
}
func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) {
svc := freedoc.NewService(nil, nil)
_, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
ProjectID: 0, Title: "x", ContentMarkdown: "# x\n",
})
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
assert.Contains(t, err.Error(), "project_id")
_, err = svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{
ProjectID: 1, Title: "x", ContentMarkdown: "# x\n", Space: "project_shared",
})
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
assert.Contains(t, err.Error(), "my_private")
}
+212 -7
View File
@@ -6,14 +6,15 @@ import (
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/replyexport"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Service struct {
@@ -25,15 +26,18 @@ type Service struct {
memDocs map[string]*FreeformDocument
memVersions map[string][]*FreeformDocumentVersion
memAudit []map[string]any
// memClientKeys mirrors PG uq_freeform_owner_client_key (owner|client_key → doc_id)
memClientKeys map[string]string
}
func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service {
return &Service{
pool: pool,
ctxMgr: ctxMgr,
memDocs: make(map[string]*FreeformDocument),
memVersions: make(map[string][]*FreeformDocumentVersion),
memAudit: make([]map[string]any, 0),
pool: pool,
ctxMgr: ctxMgr,
memDocs: make(map[string]*FreeformDocument),
memVersions: make(map[string][]*FreeformDocumentVersion),
memAudit: make([]map[string]any, 0),
memClientKeys: make(map[string]string),
}
}
@@ -232,6 +236,207 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
}, 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
}
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
if docID == "" {
+3 -3
View File
@@ -6,12 +6,12 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/freedoc"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFreeformDoc_DatabaseIntegration(t *testing.T) {
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/freedoc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFreeformDocService_LifecycleAndACL(t *testing.T) {
+18 -15
View File
@@ -15,20 +15,21 @@ var (
// FreeformDocument 自由私密/共享文档元数据
type FreeformDocument struct {
DocID string `json:"doc_id"`
ProjectID int `json:"project_id"`
OwnerUserID int `json:"owner_user_id"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
SourceContentSHA256 string `json:"source_content_sha256"`
Title string `json:"title"`
GovernanceMode string `json:"governance_mode"` // 恒为 'freeform'
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DocID string `json:"doc_id"`
ProjectID int `json:"project_id"`
OwnerUserID int `json:"owner_user_id"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
SourceContentSHA256 string `json:"source_content_sha256"`
ClientKey *string `json:"client_key,omitempty"`
Title string `json:"title"`
GovernanceMode string `json:"governance_mode"` // 恒为 'freeform'
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FreeformDocumentVersion 自由文档历史版本快照
@@ -70,12 +71,14 @@ type CreateFromMessageRequest struct {
ExpectedSHA256 string `json:"expected_sha256,omitempty"`
}
// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求
// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求(上传附件即时注册事实源)
type CreateFreeformDocRequest struct {
ProjectID int `json:"project_id"`
Title string `json:"title"`
ContentMarkdown string `json:"content_markdown"`
Space string `json:"space,omitempty"` // 默认 'my_private'
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
ClientKey string `json:"client_key,omitempty"`
}
// UpdateFreeformDocRequest 更新自由文档请求