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

234 lines
6.7 KiB
Go

package freedoc
import (
"context"
"errors"
"fmt"
"time"
"chat-agent-system/internal/docrevision"
"github.com/jackc/pgx/v5"
)
// 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,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
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,
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
); 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),
})
}