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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user