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
+2 -1
View File
@@ -2,10 +2,11 @@
维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。
更新:2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
更新:2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
| issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 |
|---|---|---|---|---|---|---|---|---|
| ISSUE-20260927-upload-doc-cross-device | freedoc / upload / freeform / fileMeta / 占位模板 / 跨端 | 上传文档源文件元数据与正文不落服务端:换浏览器/清存储/共享他人查看时退化为注册占位模板,文本类连二进制都未存(伴生 <512B 显示 "0 KB") | high | fixing | pi | 2026-09-27;根因链源码证实;修复已实现(迁移 00076+freedoc+前端),隔离库/单测/build PASS,dev 集成与浏览器验收待执行 | [主记录](../ops/short-term/2026-09-27-upload-doc-access.md) | 待集成验收后补 |
| ISSUE-20260923-nullable-duty | docgov / nullable / 23503 / reviewer_id | 可选职责空值写 0 导致 FK 失败 | high | resolved | Codex(历史补录) | 2026-09-17;历史 PG / API 冒烟;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#nullable-duty) | [LESSON-20260923-nullable-duty](../ops/lessons/2026-09-23-nullable-duty.md) |
| ISSUE-20260923-content-hash | docrevision / canonicalize / hard-break / 404 | 规范化破坏 Markdown 语义并造成正文哈希漂移 | critical | resolved | Codex(历史补录) | 2026-09-14;历史真实 UI / 重启;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#content-hash) | [LESSON-20260923-content-hash](../ops/lessons/2026-09-23-content-hash.md) |
| ISSUE-20260923-coordination-authority | agentctl / claim / SQLite / CAS / CURRENT / FEATURE-MATRIX | 本地副本 CAS 与任务许可不能表达跨主机全局权威认领 | high | investigating | Codex(研究) | 2026-09-23;源码/只读状态,未复现具体丢写 | [主记录](../ops/short-term/2026-09-23-coordination-db-daemon-research.md#issue-20260923-coordination-authority) | 沿用 content-hash 投影经验;跨机结论待实施验证 |
@@ -0,0 +1,96 @@
---
title: "上传文档跨端不可访问:源文件元数据与正文服务端持久化修复"
task_id: "upload-doc-access-2026-09-27"
status: in_progress
created: 2026-09-27
updated: 2026-09-27
owner: pi
task_type: implementation
task_tier: small
policy_revision: "agent-governance-2026-09-21-r1;dev a0e40919a7ef7b97d612d006aa879cf829e5b796"
branch: fix/upload-doc-access-2026-09-27
worktree: .worktrees/upload-doc-access-2026-09-27
base_dev_sha: "a0e40919a7ef7b97d612d006aa879cf829e5b796"
write_scope: ["internal/freedoc", "internal/server", "deploy/postgres/goose", "web/src", "web/package.json", "web/e2e", ".doc"]
allowed_environments: []
head_sha: "<准入时冻结的提交>"
mutex: "freedoc+web-upload"
review_state: pending
integration_state: pending
deployment: pending
cleanup: pending
cleanup_owner: "独立集成/维护执行者"
cleanup_trigger: "owner 退出且 dev 集成验收完成后复查"
remote_sync: not_requested
---
# 上传文档跨端不可访问:源文件元数据与正文服务端持久化修复
## 用户目标与授权
- 用户目标:登录测试用户账户后发现上传的私密文档无法访问,查看时显示注册占位模板(`upload-mui2xpg2` / 上传人 E2E编制-mui2xpg2 / 文件大小 0 KB / 文档概述与背景…),此前人工测试亦大量出现;要求定位并修复。
- 授权来源:用户在本会话中对「开任务 worktree 实施修复(方案 1+2+3 一起做)」回复「授权」。
- 交付终点:代码与迁移在任务 worktree 完成并通过验证;dev 集成与环境验证按门禁另行执行(本任务记录集成状态)。
- 禁止项:默认不 push;不操作 test/main/共享 KB;分析阶段不含业务实施(已进入实施)。
## 根因(已证实)
1. **上传注册正文是占位模板**:`web/src/lib/uploadFreeformRegister.ts` 的 `buildUploadRegistrationMarkdown` 生成的模板是服务端唯一持久化的"文档内容"。
2. **文件二进制不落服务端(文本类)**:`useSendMessage` 仅对非 TEXT_EXTS 扩展名调用 `uploadProjectAsset` 登记 CAS;txt/md/csv/json 等只存浏览器 IndexedDB + 内存 fileRaw。非文本文件虽入 CAS,但 MinerU 解析结果只进知识库,不回写文档正文。
3. **元数据不落服务端**:`freeform_documents` 表无文件名/大小/mime/asset_id 列;`useServerDocumentSync` 恢复的 deliverable 无 `sourceType/fileMeta`,`DocumentPane` 的 `isUploadedFile` 启发式五条件全部不命中 → 走普通 AST 编辑器 → `fetchFreeformDoc` 渲染占位模板。触发条件:换浏览器/换设备/清存储/其他成员查看共享文档。
4. **"0 KB"**:`InputArea` 用 `(file.size/1024).toFixed(0)`,<512B 文件四舍五入为 0,且被烘焙进持久化占位 markdown(E2E txt 仅约 41 字节)。
5. **E2E 盲区**:`FS-UPLOAD-01` 只断言注册出 `doc-free-*` pane,不校验跨会话内容可见性。
## 方案
- 迁移 `00076`:`freeform_documents` 增加 `source_filename/source_mime_type/source_size_bytes/source_asset_id`(软关联 CAS `file_references`,不加 FK)。
- freedoc:Create/Get/List 读写上述列;负值参数拒绝(ErrInvalidParam);Summary 带出元数据。
- 前端上传编排(`useSendMessage`):注册前先 CAS 登记全部附件(去掉 TEXT_EXTS 排除)并预读文本正文;注册时携带元数据与 asset_id;文本正文嵌入注册 markdown(`## 1. 文档正文(上传原文)`)。
- 跨端恢复(`useServerDocumentSync` → 新 `lib/freeformDocSync.ts`):按服务端 summary 恢复 `sourceType: 'uploaded_file'` 与 `fileMeta`(含 assetId/sizeBytes),DocumentPane 路由回 FilePreviewPanel,经 CAS raw 端点恢复原件预览。
- 大小显示:新 `lib/fileSize.ts` `formatFileSize`(B/KB/MB,任何正数不显示 0)。
- E2E:新增 `FS-UPLOAD-02`(全新浏览器上下文登录同账户,断言真实文件正文可见且占位句消失)。
## 范围与非目标
- 允许写入:internal/freedoc、internal/server(无需改动,请求透传)、deploy/postgres/goose、web/src、web/e2e、.doc。
- 非目标:MinerU 解析结果回写文档正文(二进制文件正文仍为占位+原件预览,属后续增强);历史已损坏数据(既无元数据也无 CAS 原件的旧上传文档)不可恢复,仅保持现状。
## 相关问题与经验
- 开工检索:ISSUES.md 检索「上传/upload」,命中 ISSUE-20260924-non-author-400-duty(权限问题,机制无关,未采纳);lessons INDEX 无上传链路相关命中。
- 待验证候选(本次发现、非本任务因果链):`TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService` 在全新隔离库(goose 76 + 全部 init SQL)上于基线 dev HEAD 同样失败(rev2 跨服务水合 HeadRevisionNo=0),疑似 docrevision 水合依赖未满足或用例自身问题;需独立排查后按闭环规范登记。
## 步骤与验收
1. [x] 迁移 00076 + freedoc 元数据读写 — 隔离库迁移到 76 通过;单测/PG 测试通过。
2. [x] 前端注册编排与跨端恢复 — tsc、npm test(525+34 PASS)、npm run build 通过。
3. [x] E2E 用例 FS-UPLOAD-02 编写并入选例清单 — `playwright --list` 含该用例;真实执行待 dev 集成后。
4. [ ] dev 集成(候选构建、迁移应用、环境验证)— 待独立执行。
## 验证与证据
| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 |
|---|---|---|---|---|
| Go 迁移链(worktree HEAD) | 隔离 PG(docker pgvector:pg16,127.0.0.1:5599,一次性容器)`go run ./.tmp/migrate <dsn>` + system/docgov/context_init.sql | 迁移至 76 无错 | PASS(schema_version: 76) | 容器用后即删;DSN 仅本机随机端口 |
| freedoc 单元+PG | `TEST_DATABASE_URL=<隔离库> go test ./internal/freedoc/ -count=1 -v` | 全 PASS | 除下方预存在失败外全 PASS | `TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService` 失败:基线 dev HEAD 在同一隔离库同样失败,判定预存在、与本任务无因果(见"相关问题") |
| Go 全量门禁 | `go vet ./...`;`./scripts/arch-check.sh --mode block`;`go test -short ./...`(TEST_DATABASE_URL/CONTEXT_DB_* 指向隔离库) | 全绿 | vet 无输出;arch-check "production gate passed";short 69 包 ok,仅上述预存在失败 | — |
| 前端 | `npm test`;`npm run build`(web/) | 全 PASS | 525(node:test)+34(vitest)PASS;build 成功 | — |
| E2E | `npx playwright test --list` | 含 FS-UPLOAD-02 | PASS(枚举) | 真实浏览器执行需 dev 环境运行新代码后补跑 |
环境说明:worktree 无 `.env`,`config.Load` 默认 DSN 指向 dev 库(127.0.0.1:5433);首次误跑 `TestFreeformDoc_DatabaseIntegration` 曾写入 dev 库测试数据(chat_messages 297–301、user 101 若干软删 doc,session 前缀 `sess-db-freedoc-*`,与该测试在主目录运行的既有行为一致)。后续全部测试改用隔离库环境变量。
## 遗留与限制
- 存量损坏数据不可恢复:修复前上传的文档(含用户报告的 `upload-mui2xpg2`)无服务端元数据与原件,仍显示占位模板;重新上传即可获得正确行为。
- 二进制(docx/pdf 等)文档正文仍为占位+原件预览;解析正文回写为后续增强。
- `readAttachmentsAsMaterials` 行为变化:文本附件现在优先以 asset_id 引用(此前 inline Base64);CAS 失败时回退旧行为。
## 收口与交接
- 剩余工作:review、候选集成到 dev(含 dev 库迁移 00076 与制品验证)、FS-UPLOAD-01/02 真实浏览器补跑。
- 本地提交 / dev 集成 / 部署制品 / 远端状态:见 frontmatter 各状态字段。
- cleanup:worktree 保留至集成验收后由独立维护者复查。
## 相关链接
- 问题分析见本文件"根因"节(用户会话内交付)。
@@ -0,0 +1,18 @@
-- +goose Up
-- 上传附件注册 freeform 文档时持久化源文件元数据(原仅存于浏览器 localStorage/IndexedDB,
-- 换设备/清存储/共享给其他成员后无法恢复文件预览,正文退化为注册占位模板)。
-- source_asset_id 软关联 CAS 项目资产(storage_blobs/file_references,见 internal/storage/cas.go),
-- 不加 FK:资产去重/引用计数生命周期独立于文档,删资产不级联删文档。
ALTER TABLE freeform_documents
ADD COLUMN IF NOT EXISTS source_filename TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS source_mime_type TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS source_size_bytes BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_asset_id BIGINT;
-- +goose Down
ALTER TABLE freeform_documents
DROP COLUMN IF EXISTS source_asset_id,
DROP COLUMN IF EXISTS source_size_bytes,
DROP COLUMN IF EXISTS source_mime_type,
DROP COLUMN IF EXISTS source_filename;
+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,
}
}
+56
View File
@@ -1630,6 +1630,62 @@ test.describe.serial('full-system browser multi-user E2E', () => {
}
});
test('FS-UPLOAD-02 uploaded doc readable from fresh browser context', async ({ browser }) => {
const t0 = Date.now();
const label = 'upload-xdev';
const { ctx, page } = await newRolePage(browser, label);
try {
if (!shared.uploadedDocId) fail('FS-UPLOAD-01 must run first to register uploadedDocId');
// 新浏览器上下文 = 独立 localStorage/IndexedDB,模拟换设备/换浏览器登录同一账户。
await login(page, USERS.author.username, FINAL_PASS.author);
await openProject(page);
await selectDeliverableCategory(page, 'private');
const trigger = page.getByTestId('doc-selector-trigger');
await trigger.click();
const item = page.locator(
`.dropdown-menu button[data-doc-id="${shared.uploadedDocId}"]`,
).first();
await expect(item).toBeVisible({ timeout: 30_000 });
await item.click();
const pane = page.getByTestId('active-document-pane');
await expect(pane).toHaveAttribute('data-doc-id', shared.uploadedDocId, { timeout: 30_000 });
// 真实文件内容可见(服务端持久化正文/CAS raw 恢复),不再退化为注册占位模板。
await expect(
page.getByText(`E2E upload body ${suffix}`, { exact: false }).first(),
).toBeVisible({ timeout: 45_000 });
await expect(
pane.getByText('本文件由用户上传并归档至', { exact: false }),
).toHaveCount(0);
await shot(page, 'FS-UPLOAD-02');
record({
id: 'FS-UPLOAD-02',
name: 'upload + cross-context access',
priority: 'P1',
input: shared.uploadedDocId,
expected: 'real file body visible in fresh browser',
actual: `docId=${shared.uploadedDocId} content=visible`,
verdict: 'PASS',
durationMs: Date.now() - t0,
console_errors: consoleErrorsFor(label),
});
} catch (e) {
await shot(page, 'FS-UPLOAD-02-FAIL');
record({
id: 'FS-UPLOAD-02',
name: 'upload + cross-context access',
priority: 'P1',
input: shared.uploadedDocId || 'missing',
expected: 'real file body visible in fresh browser',
actual: String(e),
verdict: 'FAIL',
durationMs: Date.now() - t0,
console_errors: consoleErrorsFor(label),
});
} finally {
await ctx.close();
}
});
test('FS-KB-01 admin KB console readonly check', async ({ browser }) => {
const t0 = Date.now();
const label = 'kb-admin';
+18
View File
@@ -15,6 +15,11 @@ export interface FreeformDocSummary {
space: 'my_private' | 'project_shared' | 'trash';
current_version_id: number;
current_sha256: string;
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
source_filename?: string;
source_mime_type?: string;
source_size_bytes?: number;
source_asset_id?: number;
created_at: string;
updated_at: string;
}
@@ -31,6 +36,11 @@ export interface FreeformDocDetail {
space: 'my_private' | 'project_shared' | 'trash';
current_version_id: number;
current_sha256: string;
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
source_filename?: string;
source_mime_type?: string;
source_size_bytes?: number;
source_asset_id?: number;
is_deleted: boolean;
created_at: string;
updated_at: string;
@@ -54,6 +64,10 @@ export async function createFreeformDoc(payload: {
contentMarkdown: string;
space?: 'my_private' | 'project_shared';
clientKey?: string;
sourceFilename?: string;
sourceMimeType?: string;
sourceSizeBytes?: number;
sourceAssetId?: number;
}): Promise<FreeformDocDetail> {
const res = await apiFetch('/api/v1/freeform-documents', {
method: 'POST',
@@ -64,6 +78,10 @@ export async function createFreeformDoc(payload: {
content_markdown: payload.contentMarkdown,
space: payload.space || 'my_private',
client_key: payload.clientKey || undefined,
source_filename: payload.sourceFilename || undefined,
source_mime_type: payload.sourceMimeType || undefined,
source_size_bytes: payload.sourceSizeBytes || undefined,
source_asset_id: payload.sourceAssetId || undefined,
}),
});
if (!res.ok) {
+5 -1
View File
@@ -4,11 +4,14 @@ import { QuotedMessage } from '../../types/workbench';
import { SendMessageResult } from '../../types/sendMessage';
import { applySendResult, resolveThinkingBlock } from './inputAreaSendLogic';
import { QuoteChip } from './QuoteChip';
import { formatFileSize } from '../../lib/fileSize';
export interface InputAttachmentItem {
name: string;
size: string;
type: string;
/** 原始字节数;上传注册时作为服务端事实源持久化,避免字符串二次解析。 */
sizeBytes?: number;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
@@ -146,7 +149,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
return {
name: file.name,
size: (file.size / 1024).toFixed(0) + ' KB',
size: formatFileSize(file.size),
sizeBytes: file.size,
type: file.type || 'document',
fileRaw: file,
fileUrl,
+50 -28
View File
@@ -31,6 +31,28 @@ import type {
TaskRunMaterial,
} from '../types/workbench';
/** 文本类扩展名:注册时正文直接写入 freeform markdown,跨端可读真实内容。 */
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
function attachmentExt(name: string): string {
const extMatch = name.match(/\.([^.]+)$/);
return extMatch ? extMatch[1].toLowerCase() : '';
}
function isTextAttachment(name: string): boolean {
return TEXT_EXTS.has(attachmentExt(name));
}
/** 读取文本附件正文(UTF-8),供注册 markdown 与材料 inline 使用。 */
function readAttachmentText(file: File | Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file, 'utf-8');
});
}
/**
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
*/
@@ -38,7 +60,6 @@ async function readAttachmentsAsMaterials(
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
projectId?: number,
): Promise<TaskRunMaterial[]> {
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
const results: TaskRunMaterial[] = [];
for (const att of attachments) {
if (att.assetId && att.assetId > 0) {
@@ -46,9 +67,7 @@ async function readAttachmentsAsMaterials(
continue;
}
if (!att.fileRaw) continue;
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
if (!TEXT_EXTS.has(ext)) {
if (!isTextAttachment(att.name)) {
if (!projectId || projectId <= 0) {
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
}
@@ -157,7 +176,7 @@ export function useSendMessage(input: SendMessageInput) {
return async (
text: string,
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
attachments?: { name: string; size: string; type: string; sizeBytes?: number; docId?: string; fileRaw?: File | Blob; fileUrl?: string; assetId?: number; deduplicated?: boolean; parseState?: string; textContent?: string }[],
quotes?: QuotedMessage[]
): Promise<SendMessageResult> => {
const trimmed = text.trim();
@@ -172,12 +191,36 @@ export function useSendMessage(input: SendMessageInput) {
}
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
// 注册前先完成 CAS 登记与文本正文预读:源文件元数据(文件名/大小/asset_id)与正文
// 随注册持久化为服务端事实源,换设备/清存储/共享给其他成员后仍可恢复预览与内容。
let processedAttachments = attachments;
if (attachments && attachments.length > 0) {
const prepared: NonNullable<typeof attachments> = [];
for (const att of attachments) {
const next = { ...att };
if (next.fileRaw && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(currentProjectId, next.fileRaw as Blob, next.name);
next.assetId = uploaded.asset_id;
next.deduplicated = uploaded.deduplicated === true;
next.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(跨端预览回退可能不可用):', e);
}
}
if (isTextAttachment(next.name) && next.fileRaw) {
try {
next.textContent = await readAttachmentText(next.fileRaw);
} catch (e) {
console.warn('[upload] 读取文本附件正文失败(注册退化为占位模板):', e);
}
}
prepared.push(next);
}
let registered;
try {
registered = await registerUploadAttachments({
attachments,
attachments: prepared,
projectId: currentProjectId,
userId: currentUser.id,
userName: currentUser.name,
@@ -218,31 +261,10 @@ export function useSendMessage(input: SendMessageInput) {
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
// 本地 IndexedDB 兜底(同浏览器刷新快速预览);服务端事实源已在注册前登记 CAS。
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(
currentProjectId,
r.attachment.fileRaw as Blob,
r.attachment.name,
);
if (r.deliverable.fileMeta) {
r.deliverable.fileMeta.assetId = uploaded.asset_id;
}
// 回填去重与解析状态,供消息附件卡片展示提示。
r.attachment.assetId = uploaded.asset_id;
r.attachment.deduplicated = uploaded.deduplicated === true;
r.attachment.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
}
}
}
}
+2 -21
View File
@@ -11,6 +11,7 @@ import {
import {
createEditorHydrationGate,
} from '../lib/editorHydrationGate';
import { deliverableFromServerSummary } from '../lib/freeformDocSync';
import type { LoginResult } from '../types/auth';
import type { DocumentAST } from '../types/block';
import type { UserProfile } from '../types/docgov';
@@ -68,28 +69,8 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
let updated = [...prev];
for (const s of allSummaries) {
const exists = updated.find((d) => d.docId === s.doc_id);
const category = s.space === 'my_private' ? 'private' : 'shared';
if (!exists) {
updated.unshift({
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
});
updated.unshift(deliverableFromServerSummary(s, currentProjectId, currentUser));
}
}
return updated;
+25
View File
@@ -0,0 +1,25 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatFileSize } from './fileSize';
describe('formatFileSize', () => {
it('renders bytes below 1KB without rounding to 0', () => {
// 回归:旧实现 对 <512B 文件输出 "0 KB" 并被烘焙进持久化注册 markdown。
assert.equal(formatFileSize(0), '0 B');
assert.equal(formatFileSize(40), '40 B');
assert.equal(formatFileSize(511), '511 B');
});
it('renders KB/MB with sensible precision', () => {
assert.equal(formatFileSize(512), '512 B');
assert.equal(formatFileSize(4096), '4 KB');
assert.equal(formatFileSize(120 * 1024), '120 KB');
assert.equal(formatFileSize(1024 * 1024), '1 MB');
assert.equal(formatFileSize(3.5 * 1024 * 1024), '3.5 MB');
});
it('degrades gracefully for invalid input', () => {
assert.equal(formatFileSize(-1), '未知大小');
assert.equal(formatFileSize(Number.NaN), '未知大小');
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* 文件大小显示格式化。
*
* 旧实现 `(file.size / 1024).toFixed(0) + ' KB'` 对 <512B 的文件四舍五入为 "0 KB",
* 且该字符串会被烘焙进上传注册 markdown 永久持久化。统一改为按量级选择单位,
* 任何正数大小不再显示为 0。
*/
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '未知大小';
if (bytes < 1024) return `${Math.round(bytes)} B`;
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024;
unit += 1;
}
// 一位小数,整数值去掉尾随 .0(如 2 KB 而非 2.0 KB)
const text = value.toFixed(1).replace(/\.0$/, '');
return `${text} ${UNITS[unit]}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { deliverableFromServerSummary } from './freeformDocSync';
import type { FreeformDocSummary } from '../api/freeformDoc';
function summary(overrides: Partial<FreeformDocSummary> = {}): FreeformDocSummary {
return {
doc_id: 'doc-free-1-2',
project_id: 7,
owner_user_id: 2,
title: 'upload-note',
space: 'my_private',
current_version_id: 1,
current_sha256: 'x',
created_at: '2026-09-27T07:46:45Z',
updated_at: '2026-09-27T07:46:45Z',
...overrides,
};
}
const user = { id: 2, name: 'E2E编制-mui2xpg2' };
describe('deliverableFromServerSummary', () => {
it('restores uploaded file routing metadata from server facts', () => {
const d = deliverableFromServerSummary(
summary({
source_filename: 'upload-mui2xpg2.txt',
source_mime_type: 'text/plain',
source_size_bytes: 41,
source_asset_id: 501,
}),
7,
user,
);
assert.equal(d.sourceType, 'uploaded_file');
assert.equal(d.fileMeta?.originalName, 'upload-mui2xpg2.txt');
// 回归:<512B 文件不得再显示 0 KB;字节数直接来自服务端事实源。
assert.equal(d.fileMeta?.size, '41 B');
assert.equal(d.fileMeta?.sizeBytes, 41);
assert.equal(d.fileMeta?.mimeType, 'text/plain');
assert.equal(d.fileMeta?.extension, 'txt');
assert.equal(d.fileMeta?.assetId, 501);
assert.equal(d.governanceMode, 'freeform');
assert.equal(d.space, 'my_private');
});
it('keeps plain freeform docs without upload metadata on the editor path', () => {
const d = deliverableFromServerSummary(summary(), 7, user);
assert.equal(d.sourceType, undefined);
assert.equal(d.fileMeta, undefined);
assert.equal(d.taskKey, 'freeform_doc');
});
it('handles legacy rows with filename but zero size gracefully', () => {
const d = deliverableFromServerSummary(
summary({ source_filename: 'old.bin', source_size_bytes: 0 }),
7,
user,
);
assert.equal(d.sourceType, 'uploaded_file');
assert.equal(d.fileMeta?.size, '未知大小');
assert.equal(d.fileMeta?.assetId, undefined);
});
it('maps shared space to shared category', () => {
const d = deliverableFromServerSummary(
summary({ space: 'project_shared', source_filename: 'shared.txt', source_size_bytes: 2048 }),
7,
user,
);
assert.equal(d.category, 'shared');
assert.equal(d.fileMeta?.size, '2 KB');
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* 服务端 freeform 文档概要 → 工作台 deliverable 的恢复映射。
*
* 上传附件注册的文档在服务端持久化了源文件元数据(source_filename 等);
* 恢复 sourceType/fileMeta 使 DocumentPane 路由到文件预览器,
* 并允许 FilePreviewPanel 经 CAS raw 端点跨端恢复原件。
* 该映射历史上内联在 useServerDocumentSync 中且丢失上传元数据,
* 导致换设备/清存储后上传文档退化为注册占位模板(2026-09-27 修复)。
*/
import type { FreeformDocSummary } from '../api/freeformDoc';
import { formatFileSize } from './fileSize';
import type { UserProfile } from '../types/docgov';
import type { DeliverableItem } from '../types/workbench';
export function deliverableFromServerSummary(
s: FreeformDocSummary,
currentProjectId: number,
currentUser: Pick<UserProfile, 'id' | 'name'>,
): DeliverableItem {
const category = s.space === 'my_private' ? 'private' : 'shared';
const isUpload = Boolean(s.source_filename);
const sourceFilename = s.source_filename || '';
const extMatch = sourceFilename.match(/\.([^.]+)$/);
return {
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
...(isUpload
? {
sourceType: 'uploaded_file' as const,
fileMeta: {
originalName: sourceFilename || s.title,
size:
typeof s.source_size_bytes === 'number' && s.source_size_bytes > 0
? formatFileSize(s.source_size_bytes)
: '未知大小',
sizeBytes: s.source_size_bytes,
mimeType: s.source_mime_type || 'document',
extension: extMatch ? extMatch[1].toLowerCase() : '',
assetId:
typeof s.source_asset_id === 'number' && s.source_asset_id > 0 ? s.source_asset_id : undefined,
uploadedAt: (s.created_at || '').replace('T', ' ').substring(0, 19),
},
}
: {}),
};
}
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildUploadClientKey,
buildUploadRegistrationMarkdown,
deliverableFromFreeformUpload,
registerUploadAttachments,
} from './uploadFreeformRegister';
@@ -125,4 +126,80 @@ describe('uploadFreeformRegister', () => {
assert.equal(d.sourceType, 'uploaded_file');
assert.equal(d.governanceMode, 'freeform');
});
it('buildUploadRegistrationMarkdown embeds real text content when provided', () => {
const md = buildUploadRegistrationMarkdown({
title: 'upload-note',
userName: '张工',
fileName: 'upload-note.txt',
fileSize: '41 B',
mimeType: 'text/plain',
textContent: '临床需求摘要占位\n第二行',
});
assert.ok(md.includes('## 1. 文档正文(上传原文)'));
assert.ok(md.includes('临床需求摘要占位'));
assert.ok(md.includes('第二行'));
assert.ok(!md.includes('本文件由用户上传并归档至'));
});
it('buildUploadRegistrationMarkdown keeps placeholder for binary files without textContent', () => {
const md = buildUploadRegistrationMarkdown({
title: 'spec',
userName: '张工',
fileName: 'spec.pdf',
fileSize: '2 MB',
mimeType: 'application/pdf',
});
assert.ok(md.includes('## 1. 文档概述与背景'));
assert.ok(md.includes('解析完成后可在线预览原件'));
assert.ok(md.includes('文件大小:2 MB'));
});
it('registerUploadAttachments persists source file metadata as server facts', async () => {
const seen: Array<Record<string, unknown>> = [];
const registered = await registerUploadAttachments({
attachments: [
{
name: 'upload-note.txt',
size: '41 B',
sizeBytes: 41,
type: 'text/plain',
assetId: 501,
textContent: '临床需求摘要占位',
},
],
projectId: 7,
userId: 2,
userName: '张工',
now: 1789263145197,
createFreeformDoc: async (payload) => {
seen.push({ ...payload });
return {
document: {
doc_id: 'doc-free-1789263145197-2',
project_id: 7,
owner_user_id: 2,
source_content_sha256: 'abc',
title: payload.title,
governance_mode: 'freeform',
space: 'my_private',
current_version_id: 1,
current_sha256: 'abc',
is_deleted: false,
created_at: '',
updated_at: '',
},
content_markdown: payload.contentMarkdown,
} as FreeformDocDetail;
},
});
assert.equal(seen.length, 1);
assert.equal(seen[0].sourceFilename, 'upload-note.txt');
assert.equal(seen[0].sourceMimeType, 'text/plain');
assert.equal(seen[0].sourceSizeBytes, 41);
assert.equal(seen[0].sourceAssetId, 501);
assert.ok(String(seen[0].contentMarkdown).includes('临床需求摘要占位'));
assert.equal(registered[0].deliverable.fileMeta?.assetId, 501);
assert.equal(registered[0].deliverable.fileMeta?.sizeBytes, 41);
});
});
+33 -3
View File
@@ -10,15 +10,19 @@ export type UploadAttachmentInput = {
name: string;
size: string;
type: string;
/** 原始字节数;作为服务端事实源持久化,恢复显示时不再解析 size 字符串。 */
sizeBytes?: number;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
/** CAS 资产 ID(二进制上传登记后回填) */
/** CAS 资产 ID(所有扩展名上传登记后回填;文本类此前跳过 CAS 导致跨端无原件) */
assetId?: number;
/** 上传命中内容去重:物理仅存一份 */
deduplicated?: boolean;
/** MinerU 解析状态初始值 */
parseState?: string;
/** 文本类文件正文(UTF-8)。注册时写入 content_markdown,跨端可读真实内容。 */
textContent?: string;
};
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
@@ -32,9 +36,24 @@ export function buildUploadRegistrationMarkdown(params: {
fileName: string;
fileSize: string;
mimeType: string;
/** 文本类文件正文:提供时作为文档正文嵌入,注册后即为可读内容而非占位模板。 */
textContent?: string;
}): string {
const { title, userName, fileName, fileSize, mimeType } = params;
return `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
const { title, userName, fileName, fileSize, mimeType, textContent } = params;
const header = `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n> 原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)\n\n`;
const body = normalizeTextContent(textContent);
if (body) {
return `${header}## 1. 文档正文(上传原文)\n\n${body}\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
}
return `${header}## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。解析完成后可在线预览原件。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
}
/** 文本正文规整:去 BOM、截断超长内容,保证 markdown 段落可读。 */
function normalizeTextContent(text?: string, maxChars = 200_000): string {
if (!text) return '';
const stripped = text.replace(/^\uFEFF/, '').trimEnd();
if (!stripped.trim()) return '';
return stripped.length > maxChars ? `${stripped.slice(0, maxChars)}\n\n> (原文过长,已截断至 ${maxChars} 字符;完整内容请下载原件查看。)` : stripped;
}
export type RegisteredUpload = {
@@ -80,8 +99,10 @@ export function deliverableFromFreeformUpload(params: {
fileMeta: {
originalName: att.name,
size: att.size,
sizeBytes: att.sizeBytes,
mimeType: att.type,
extension: ext,
assetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
fileRaw: att.fileRaw,
fileUrl: att.fileUrl,
uploadedAt: new Date().toISOString(),
@@ -101,6 +122,10 @@ export async function registerUploadAttachments(params: {
contentMarkdown: string;
space?: 'my_private' | 'project_shared';
clientKey?: string;
sourceFilename?: string;
sourceMimeType?: string;
sourceSizeBytes?: number;
sourceAssetId?: number;
}) => Promise<FreeformDocDetail>;
now?: number;
}): Promise<RegisteredUpload[]> {
@@ -120,6 +145,7 @@ export async function registerUploadAttachments(params: {
fileName: att.name,
fileSize: att.size,
mimeType: att.type,
textContent: att.textContent,
});
let detail: FreeformDocDetail;
try {
@@ -129,6 +155,10 @@ export async function registerUploadAttachments(params: {
contentMarkdown: markdown,
space: 'my_private',
clientKey,
sourceFilename: att.name,
sourceMimeType: att.type,
sourceSizeBytes: att.sizeBytes ?? 0,
sourceAssetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);