上传文档跨端不可访问修复(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 全绿; 存量无元数据旧文档不可恢复(重新上传即可)。
234 lines
6.7 KiB
Go
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),
|
|
})
|
|
}
|