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 全绿;
存量无元数据旧文档不可恢复(重新上传即可)。
This commit is contained in:
weichen
2026-09-27 07:45:15 +00:00
parent a0e40919a7
commit 88e33282db
19 changed files with 676 additions and 86 deletions
+61
View File
@@ -100,3 +100,64 @@ func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) {
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
assert.Contains(t, err.Error(), "my_private")
}
func TestCreateDocument_PersistsSourceFileMetadata(t *testing.T) {
ctx := context.Background()
svc := freedoc.NewService(nil, nil)
detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "upload-note",
ContentMarkdown: "# upload-note\n\n临床需求摘要占位\n",
Space: "my_private",
ClientKey: "doc-upload-meta-1",
SourceFilename: "upload-note.txt",
SourceMimeType: "text/plain",
SourceSizeBytes: 41,
SourceAssetID: 501,
})
require.NoError(t, err)
require.NotNil(t, detail)
assert.Equal(t, "upload-note.txt", detail.Document.SourceFilename)
assert.Equal(t, "text/plain", detail.Document.SourceMimeType)
assert.EqualValues(t, 41, detail.Document.SourceSizeBytes)
assert.EqualValues(t, 501, detail.Document.SourceAssetID)
// 列表概要同样带出上传元数据(前端跨端恢复预览路由的事实源)。
list, err := svc.ListDocuments(ctx, 42, 7, "my_private")
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, "upload-note.txt", list[0].SourceFilename)
assert.EqualValues(t, 41, list[0].SourceSizeBytes)
assert.EqualValues(t, 501, list[0].SourceAssetID)
// 非上传文档元数据恒为零值,不影响既有编辑器路径。
plain, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "plain-note",
ContentMarkdown: "# plain\n",
ClientKey: "doc-upload-meta-2",
})
require.NoError(t, err)
assert.Empty(t, plain.Document.SourceFilename)
assert.EqualValues(t, 0, plain.Document.SourceSizeBytes)
}
func TestCreateDocument_RejectsNegativeSourceMetadata(t *testing.T) {
ctx := context.Background()
svc := freedoc.NewService(nil, nil)
_, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "bad",
ContentMarkdown: "# bad\n",
SourceSizeBytes: -1,
})
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
_, err = svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
ProjectID: 7,
Title: "bad",
ContentMarkdown: "# bad\n",
SourceAssetID: -5,
})
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
}
+21 -3
View File
@@ -58,7 +58,8 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
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
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
@@ -68,6 +69,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
&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
@@ -255,6 +257,12 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
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"
@@ -307,9 +315,11 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
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, FALSE, $8, $8)
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
) 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 != "" {
@@ -350,6 +360,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
Space: space,
CurrentVersionID: 1,
CurrentSHA256: contentHash,
SourceFilename: req.SourceFilename,
SourceMimeType: req.SourceMimeType,
SourceSizeBytes: req.SourceSizeBytes,
SourceAssetID: req.SourceAssetID,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
@@ -386,6 +400,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
Space: space,
CurrentVersionID: 1,
CurrentSHA256: contentHash,
SourceFilename: req.SourceFilename,
SourceMimeType: req.SourceMimeType,
SourceSizeBytes: req.SourceSizeBytes,
SourceAssetID: req.SourceAssetID,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
+14 -27
View File
@@ -18,7 +18,8 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
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
is_deleted, deleted_at, created_at, updated_at,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
FROM freeform_documents
WHERE doc_id = $1
`, docID)
@@ -27,6 +28,7 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -108,14 +110,16 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
// 我的私密:属于当前用户
if projectID > 0 {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
ORDER BY updated_at DESC
`, userID, projectID)
} else {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
ORDER BY updated_at DESC
@@ -124,7 +128,8 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
} else if space == "project_shared" {
// 项目共享参考:属于当前项目的所有公开参考
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
FROM freeform_documents
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
ORDER BY updated_at DESC
@@ -132,7 +137,8 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
} else {
// 回收站:当前用户删除的
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
FROM freeform_documents
WHERE owner_user_id = $1 AND is_deleted = TRUE
ORDER BY deleted_at DESC
@@ -149,6 +155,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
if err := rows.Scan(
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
&sm.SourceFilename, &sm.SourceMimeType, &sm.SourceSizeBytes, &sm.SourceAssetID,
); err != nil {
return nil, err
}
@@ -166,32 +173,12 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
if space == "my_private" {
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
if projectID <= 0 || d.ProjectID == projectID {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
results = append(results, d.summary())
}
}
} else if space == "project_shared" {
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
results = append(results, d.summary())
}
}
}
+3 -1
View File
@@ -27,7 +27,8 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
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
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)
@@ -36,6 +37,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
&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
+35 -1
View File
@@ -26,7 +26,12 @@ type FreeformDocument struct {
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
IsDeleted bool `json:"is_deleted"`
// 上传附件注册时持久化的源文件元数据;非上传文档恒为零值。
SourceFilename string `json:"source_filename,omitempty"`
SourceMimeType string `json:"source_mime_type,omitempty"`
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
SourceAssetID int64 `json:"source_asset_id,omitempty"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -59,6 +64,11 @@ type FreeformDocSummary struct {
Space string `json:"space"`
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
// 上传附件注册时持久化的源文件元数据;非上传文档恒为零值。
SourceFilename string `json:"source_filename,omitempty"`
SourceMimeType string `json:"source_mime_type,omitempty"`
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
SourceAssetID int64 `json:"source_asset_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -79,6 +89,11 @@ type CreateFreeformDocRequest struct {
Space string `json:"space,omitempty"` // 默认 'my_private'
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
ClientKey string `json:"client_key,omitempty"`
// 可选上传源文件元数据:换设备/共享后恢复文件预览与路由的事实源。
SourceFilename string `json:"source_filename,omitempty"`
SourceMimeType string `json:"source_mime_type,omitempty"`
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
SourceAssetID int64 `json:"source_asset_id,omitempty"`
}
// UpdateFreeformDocRequest 更新自由文档请求
@@ -89,3 +104,22 @@ type UpdateFreeformDocRequest struct {
ExpectedVersion *int `json:"expected_version,omitempty"`
ExpectedSHA256 *string `json:"expected_sha256,omitempty"`
}
// summary 投影为列表概要(内存模式与 PG 行扫描共用同一字段集)。
func (d FreeformDocument) summary() FreeformDocSummary {
return FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
SourceFilename: d.SourceFilename,
SourceMimeType: d.SourceMimeType,
SourceSizeBytes: d.SourceSizeBytes,
SourceAssetID: d.SourceAssetID,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}