Files
Agent-System/internal/freedoc/service.go
T
weichenandCursor 9c7d6d49b2 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>
2026-09-13 11:12:03 +00:00

1029 lines
30 KiB
Go

package freedoc
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"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 {
pool *pgxpool.Pool
ctxMgr *ctxmgr.ContextManager
docRev *docrevision.Service
mu sync.RWMutex
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),
memClientKeys: make(map[string]string),
}
}
// SetDocRevision attaches the shared revision ledger (Wave 6 freeform compatibility).
func (s *Service) SetDocRevision(rev *docrevision.Service) {
if s != nil {
s.docRev = rev
}
}
func computeSHA256(text string) string {
sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:])
}
// 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
}
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,
},
}
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
}
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc 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 doc_id = $1
`, docID)
if err := row.Scan(
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
// 私密隔离红线:my_private 只有 owner 可读
if doc.Space == "my_private" && doc.OwnerUserID != userID {
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success)
VALUES ($1, $2, 'read_denied', FALSE)
`, docID, userID)
return nil, ErrForbidden
}
var content string
err := s.pool.QueryRow(ctx, `
SELECT content_markdown FROM freeform_document_versions
WHERE doc_id = $1 AND version_no = $2
`, doc.DocID, doc.CurrentVersionID).Scan(&content)
if err != nil {
return nil, fmt.Errorf("read version: %w", err)
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success)
VALUES ($1, $2, 'read', TRUE)
`, docID, userID)
return &FreeformDocDetail{
Document: doc,
Content: content,
}, nil
}
// 内存模式
s.mu.RLock()
defer s.mu.RUnlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
versions := s.memVersions[docID]
content := ""
for _, v := range versions {
if v.VersionNo == doc.CurrentVersionID {
content = v.ContentMarkdown
break
}
}
return &FreeformDocDetail{
Document: *doc,
Content: content,
}, nil
}
// ListDocuments 按空间与权限列出自由文档概要
func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, space string) ([]FreeformDocSummary, error) {
if space != "my_private" && space != "project_shared" && space != "trash" {
space = "my_private"
}
if s.pool != nil {
var rows pgx.Rows
var err error
if space == "my_private" {
// 我的私密:属于当前用户
if projectID > 0 {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
ORDER BY updated_at DESC
`, userID, projectID)
} else {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
ORDER BY updated_at DESC
`, userID)
}
} else if space == "project_shared" {
// 项目共享参考:属于当前项目的所有公开参考
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
ORDER BY updated_at DESC
`, projectID)
} else {
// 回收站:当前用户删除的
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND is_deleted = TRUE
ORDER BY deleted_at DESC
`, userID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var results []FreeformDocSummary
for rows.Next() {
var sm FreeformDocSummary
if err := rows.Scan(
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
); err != nil {
return nil, err
}
results = append(results, sm)
}
return results, nil
}
// 内存模式
s.mu.RLock()
defer s.mu.RUnlock()
var results []FreeformDocSummary
for _, d := range s.memDocs {
if space == "my_private" {
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
if projectID <= 0 || d.ProjectID == projectID {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
}
} else if space == "project_shared" {
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
}
}
return results, nil
}
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var doc FreeformDocument
row := tx.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, created_at, updated_at
FROM freeform_documents
WHERE doc_id = $1 FOR UPDATE
`, docID)
if err := row.Scan(
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
// 私密文档非本人严禁修改
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
// 乐观并发冲突检验
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
return nil, ErrConflict
}
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
return nil, ErrConflict
}
now := time.Now()
newTitle := doc.Title
if req.Title != nil && *req.Title != "" {
newTitle = *req.Title
}
curContent := ""
_ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent)
newContent := curContent
if req.ContentMarkdown != nil {
newContent = *req.ContentMarkdown
}
newHash := computeSHA256(newContent)
if newHash != doc.CurrentSHA256 {
// 内容变更,生成新版本
nextVersion := doc.CurrentVersionID + 1
summary := req.ChangeSummary
if summary == "" {
summary = "编辑修改正文"
}
_, 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, $2, $3, $4, $5, $6, $7)
`, doc.DocID, nextVersion, newHash, newContent, userID, summary, now)
if err != nil {
return nil, fmt.Errorf("insert version: %w", err)
}
_, err = tx.Exec(ctx, `
UPDATE freeform_documents
SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4
WHERE doc_id = $5
`, newTitle, nextVersion, newHash, now, doc.DocID)
if err != nil {
return nil, fmt.Errorf("update doc: %w", err)
}
doc.CurrentVersionID = nextVersion
doc.CurrentSHA256 = newHash
} else if newTitle != doc.Title {
// 仅标题变更
_, err = tx.Exec(ctx, `
UPDATE freeform_documents
SET title = $1, updated_at = $2
WHERE doc_id = $3
`, newTitle, now, doc.DocID)
if err != nil {
return nil, fmt.Errorf("update doc title: %w", err)
}
}
doc.Title = newTitle
doc.UpdatedAt = now
_, _ = tx.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'update', TRUE, $3)
`, doc.DocID, userID, now)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &FreeformDocDetail{
Document: doc,
Content: newContent,
}, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
return nil, ErrConflict
}
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
return nil, ErrConflict
}
now := time.Now()
newTitle := doc.Title
if req.Title != nil && *req.Title != "" {
newTitle = *req.Title
}
doc.Title = newTitle
curContent := ""
versions := s.memVersions[docID]
if len(versions) > 0 {
curContent = versions[len(versions)-1].ContentMarkdown
}
newContent := curContent
if req.ContentMarkdown != nil {
newContent = *req.ContentMarkdown
}
newHash := computeSHA256(newContent)
if newHash != doc.CurrentSHA256 {
nextVersion := doc.CurrentVersionID + 1
doc.CurrentVersionID = nextVersion
doc.CurrentSHA256 = newHash
s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{
ID: int64(len(s.memVersions[docID]) + 1),
DocID: docID,
VersionNo: nextVersion,
ContentSHA256: newHash,
ContentMarkdown: newContent,
CreatedBy: userID,
ChangeSummary: req.ChangeSummary,
CreatedAt: now,
})
}
doc.UpdatedAt = now
detail := &FreeformDocDetail{
Document: *doc,
Content: newContent,
}
s.syncLedgerLocked(ctx, userID, detail)
return detail, nil
}
// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger.
func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) {
if s.docRev == nil || detail == nil {
return
}
doc := detail.Document
checkpoints := make([]struct {
VersionNo int
Markdown string
SHA string
}, 0, len(s.memVersions[doc.DocID]))
for _, v := range s.memVersions[doc.DocID] {
checkpoints = append(checkpoints, struct {
VersionNo int
Markdown string
SHA string
}{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256})
}
prior := checkpoints
if len(prior) > 0 {
prior = prior[:len(prior)-1]
}
st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content)
if err != nil || st == nil {
return
}
_, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
DocID: doc.DocID, Markdown: detail.Content,
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256),
ActorUserID: int64(userID),
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"},
LifecycleState: "drafting",
ProjectID: int64(doc.ProjectID),
})
}
// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, space, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET space = 'project_shared', updated_at = $1
WHERE doc_id = $2
`, now, docID)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'share', TRUE, $3)
`, docID, userID, now)
doc.Space = "project_shared"
doc.UpdatedAt = now
return &doc, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
doc.Space = "project_shared"
doc.UpdatedAt = time.Now()
return doc, nil
}
// UnshareDocument 撤回共享,恢复为个人私密
func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, space, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET space = 'my_private', updated_at = $1
WHERE doc_id = $2
`, now, docID)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'unshare', TRUE, $3)
`, docID, userID, now)
doc.Space = "my_private"
doc.UpdatedAt = now
return &doc, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
doc.Space = "my_private"
doc.UpdatedAt = time.Now()
return doc, nil
}
// DeleteDocument 软删除文档(仅 owner 可操作)
func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error {
if docID == "" {
return ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, owner_user_id, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if doc.IsDeleted {
return nil
}
if doc.OwnerUserID != userID {
return ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET is_deleted = TRUE, deleted_at = $1, space = 'trash'
WHERE doc_id = $2
`, now, docID)
if err != nil {
return err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'delete', TRUE, $3)
`, docID, userID, now)
return nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return ErrNotFound
}
if doc.OwnerUserID != userID {
return ErrForbidden
}
doc.IsDeleted = true
now := time.Now()
doc.DeletedAt = &now
doc.Space = "trash"
return nil
}