diff --git a/internal/freedoc/create_document_pg_test.go b/internal/freedoc/create_document_pg_test.go new file mode 100644 index 00000000..d43329c2 --- /dev/null +++ b/internal/freedoc/create_document_pg_test.go @@ -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) + }) +} diff --git a/internal/freedoc/create_document_test.go b/internal/freedoc/create_document_test.go new file mode 100644 index 00000000..c1203163 --- /dev/null +++ b/internal/freedoc/create_document_test.go @@ -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") +} diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index 5afa94b6..b464e837 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -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 == "" { diff --git a/internal/freedoc/service_db_test.go b/internal/freedoc/service_db_test.go index 22eb1861..1d71f721 100644 --- a/internal/freedoc/service_db_test.go +++ b/internal/freedoc/service_db_test.go @@ -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) { diff --git a/internal/freedoc/service_test.go b/internal/freedoc/service_test.go index 0879b50a..006c8fae 100644 --- a/internal/freedoc/service_test.go +++ b/internal/freedoc/service_test.go @@ -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) { diff --git a/internal/freedoc/types.go b/internal/freedoc/types.go index e15e1c90..78b5cfcd 100644 --- a/internal/freedoc/types.go +++ b/internal/freedoc/types.go @@ -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 更新自由文档请求