Files
weichen 88e33282db fix(freedoc): persist uploaded file metadata and text body server-side
上传文档跨端不可访问修复(ISSUE-20260927-upload-doc-cross-device):
- 迁移 00076:freeform_documents 增加 source_filename/mime/size_bytes/asset_id
- freedoc:Create/Get/List 读写源文件元数据,负值拒绝
- 前端:注册前全部附件先登记 CAS 并预读文本正文;文本正文嵌入注册
  markdown;跨端恢复 sourceType/fileMeta 路由回文件预览器
- 大小显示 formatFileSize(<512B 不再显示 0 KB)
- E2E 新增 FS-UPLOAD-02 跨浏览器上下文可访问性用例

回归:go vet/arch-check/go test -short(隔离 PG)+ web npm test/build 全绿;
存量无元数据旧文档不可恢复(重新上传即可)。
2026-09-27 07:45:15 +00:00

423 lines
13 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,
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
}