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