From 88e33282db28ee1acbdfc4f37e1dcee2b7e64081 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 27 Sep 2026 07:45:15 +0000 Subject: [PATCH 1/2] fix(freedoc): persist uploaded file metadata and text body server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上传文档跨端不可访问修复(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 全绿; 存量无元数据旧文档不可恢复(重新上传即可)。 --- .doc/audit/ISSUES.md | 3 +- .../2026-09-27-upload-doc-access.md | 96 +++++++++++++++++++ ...6_2026-09-27-freeform-source-file-meta.sql | 18 ++++ internal/freedoc/create_document_test.go | 61 ++++++++++++ internal/freedoc/service_create.go | 24 ++++- internal/freedoc/service_read.go | 41 +++----- internal/freedoc/service_update.go | 4 +- internal/freedoc/types.go | 36 ++++++- web/e2e/full-system-browser-e2e.spec.ts | 56 +++++++++++ web/src/api/freeformDoc.ts | 18 ++++ web/src/components/chat/InputArea.tsx | 6 +- web/src/hooks/useSendMessage.ts | 78 +++++++++------ web/src/hooks/useServerDocumentSync.ts | 23 +---- web/src/lib/fileSize.test.ts | 25 +++++ web/src/lib/fileSize.ts | 23 +++++ web/src/lib/freeformDocSync.test.ts | 74 ++++++++++++++ web/src/lib/freeformDocSync.ts | 63 ++++++++++++ web/src/lib/uploadFreeformRegister.test.ts | 77 +++++++++++++++ web/src/lib/uploadFreeformRegister.ts | 36 ++++++- 19 files changed, 676 insertions(+), 86 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-27-upload-doc-access.md create mode 100644 deploy/postgres/goose/00076_2026-09-27-freeform-source-file-meta.sql create mode 100644 web/src/lib/fileSize.test.ts create mode 100644 web/src/lib/fileSize.ts create mode 100644 web/src/lib/freeformDocSync.test.ts create mode 100644 web/src/lib/freeformDocSync.ts diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 1b9b82df..206e6ed5 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -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 投影经验;跨机结论待实施验证 | diff --git a/.doc/ops/short-term/2026-09-27-upload-doc-access.md b/.doc/ops/short-term/2026-09-27-upload-doc-access.md new file mode 100644 index 00000000..63b0792d --- /dev/null +++ b/.doc/ops/short-term/2026-09-27-upload-doc-access.md @@ -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 ` + 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 保留至集成验收后由独立维护者复查。 + +## 相关链接 + +- 问题分析见本文件"根因"节(用户会话内交付)。 diff --git a/deploy/postgres/goose/00076_2026-09-27-freeform-source-file-meta.sql b/deploy/postgres/goose/00076_2026-09-27-freeform-source-file-meta.sql new file mode 100644 index 00000000..e9379c9b --- /dev/null +++ b/deploy/postgres/goose/00076_2026-09-27-freeform-source-file-meta.sql @@ -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; diff --git a/internal/freedoc/create_document_test.go b/internal/freedoc/create_document_test.go index 8bbd9b53..48c7114f 100644 --- a/internal/freedoc/create_document_test.go +++ b/internal/freedoc/create_document_test.go @@ -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) +} diff --git a/internal/freedoc/service_create.go b/internal/freedoc/service_create.go index bb9b46c3..e0ac0261 100644 --- a/internal/freedoc/service_create.go +++ b/internal/freedoc/service_create.go @@ -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, diff --git a/internal/freedoc/service_read.go b/internal/freedoc/service_read.go index fbe08bd3..183db49a 100644 --- a/internal/freedoc/service_read.go +++ b/internal/freedoc/service_read.go @@ -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()) } } } diff --git a/internal/freedoc/service_update.go b/internal/freedoc/service_update.go index 623b0d5e..db83dff1 100644 --- a/internal/freedoc/service_update.go +++ b/internal/freedoc/service_update.go @@ -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 diff --git a/internal/freedoc/types.go b/internal/freedoc/types.go index a0f0898e..5d7fe097 100644 --- a/internal/freedoc/types.go +++ b/internal/freedoc/types.go @@ -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, + } +} diff --git a/web/e2e/full-system-browser-e2e.spec.ts b/web/e2e/full-system-browser-e2e.spec.ts index d8059568..d8844235 100644 --- a/web/e2e/full-system-browser-e2e.spec.ts +++ b/web/e2e/full-system-browser-e2e.spec.ts @@ -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'; diff --git a/web/src/api/freeformDoc.ts b/web/src/api/freeformDoc.ts index b7dd26f7..e505a9ee 100644 --- a/web/src/api/freeformDoc.ts +++ b/web/src/api/freeformDoc.ts @@ -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 { 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) { diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 1975d555..3e67e223 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -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 = ({ 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, diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index 9cde7843..98c928a0 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -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 { + return new Promise((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 { - 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 => { 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 = []; + 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); - } - } } } diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts index 3cca7a55..a26df0f5 100644 --- a/web/src/hooks/useServerDocumentSync.ts +++ b/web/src/hooks/useServerDocumentSync.ts @@ -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; diff --git a/web/src/lib/fileSize.test.ts b/web/src/lib/fileSize.test.ts new file mode 100644 index 00000000..12665122 --- /dev/null +++ b/web/src/lib/fileSize.test.ts @@ -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), '未知大小'); + }); +}); diff --git a/web/src/lib/fileSize.ts b/web/src/lib/fileSize.ts new file mode 100644 index 00000000..f31f0b6a --- /dev/null +++ b/web/src/lib/fileSize.ts @@ -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]}`; +} diff --git a/web/src/lib/freeformDocSync.test.ts b/web/src/lib/freeformDocSync.test.ts new file mode 100644 index 00000000..d4312fa5 --- /dev/null +++ b/web/src/lib/freeformDocSync.test.ts @@ -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 { + 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'); + }); +}); diff --git a/web/src/lib/freeformDocSync.ts b/web/src/lib/freeformDocSync.ts new file mode 100644 index 00000000..5a3f9de5 --- /dev/null +++ b/web/src/lib/freeformDocSync.ts @@ -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, +): 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), + }, + } + : {}), + }; +} diff --git a/web/src/lib/uploadFreeformRegister.test.ts b/web/src/lib/uploadFreeformRegister.test.ts index dd358c83..777f24d3 100644 --- a/web/src/lib/uploadFreeformRegister.test.ts +++ b/web/src/lib/uploadFreeformRegister.test.ts @@ -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> = []; + 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); + }); }); diff --git a/web/src/lib/uploadFreeformRegister.ts b/web/src/lib/uploadFreeformRegister.ts index 11a05345..807d200b 100644 --- a/web/src/lib/uploadFreeformRegister.ts +++ b/web/src/lib/uploadFreeformRegister.ts @@ -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; now?: number; }): Promise { @@ -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); From 307e9e6569882bef50c02e442712430b099d9274 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 27 Sep 2026 07:45:36 +0000 Subject: [PATCH 2/2] docs(current): claim upload-doc-access-2026-09-27 (docs apply receipt upload-doc-access-2026-09-27-state-1) --- .doc/ops/CURRENT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 6a38bf85..19dfbb33 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| pi | upload-doc-access-2026-09-27 | fix/upload-doc-access-2026-09-27 | .worktrees/upload-doc-access-2026-09-27 | freedoc+web-upload | in_progress | | pi | frontend-demo-fallback-2026-09-27-r3c | fix/frontend-demo-fallback-2026-09-27-r3 | (已清理) | web-chat-fallback | done | | pi | frontend-demo-fallback-2026-09-27-r2 | fix/frontend-demo-fallback-2026-09-27 | (已清理) | web-chat-fallback | done | | Cursor | full-system-browser-e2e-2026-09-26 | feature/full-system-browser-e2e-2026-09-26 | .worktrees/full-system-browser-e2e-2026-09-26 | env:dev+docs-test-report | done |