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, COALESCE(source_asset_id, 0) 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, &existingDoc.SourceAssetID, ); 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) } if req.SourceSizeBytes < 0 { return nil, fmt.Errorf("%w: source_size_bytes must be >= 0", ErrInvalidParam) } if req.SourceAssetID < 0 { return nil, fmt.Errorf("%w: source_asset_id must be >= 0", 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, source_filename, source_mime_type, source_size_bytes, source_asset_id, is_deleted, created_at, updated_at ) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, $8, $9, $10, NULLIF($11, 0), FALSE, $12, $12) `, docID, projectID, userID, contentHash, clientKeyPtr, title, space, req.SourceFilename, req.SourceMimeType, req.SourceSizeBytes, req.SourceAssetID, 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, SourceFilename: req.SourceFilename, SourceMimeType: req.SourceMimeType, SourceSizeBytes: req.SourceSizeBytes, SourceAssetID: req.SourceAssetID, 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, SourceFilename: req.SourceFilename, SourceMimeType: req.SourceMimeType, SourceSizeBytes: req.SourceSizeBytes, SourceAssetID: req.SourceAssetID, 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 }