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:
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。
|
维护规则:[问题记录与开发经验闭环](../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_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-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-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 投影经验;跨机结论待实施验证 |
|
| 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;
|
||||||
@@ -100,3 +100,64 @@ func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||||
assert.Contains(t, err.Error(), "my_private")
|
assert.Contains(t, err.Error(), "my_private")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDocument_PersistsSourceFileMetadata(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := freedoc.NewService(nil, nil)
|
||||||
|
|
||||||
|
detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||||
|
ProjectID: 7,
|
||||||
|
Title: "upload-note",
|
||||||
|
ContentMarkdown: "# upload-note\n\n临床需求摘要占位\n",
|
||||||
|
Space: "my_private",
|
||||||
|
ClientKey: "doc-upload-meta-1",
|
||||||
|
SourceFilename: "upload-note.txt",
|
||||||
|
SourceMimeType: "text/plain",
|
||||||
|
SourceSizeBytes: 41,
|
||||||
|
SourceAssetID: 501,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, detail)
|
||||||
|
assert.Equal(t, "upload-note.txt", detail.Document.SourceFilename)
|
||||||
|
assert.Equal(t, "text/plain", detail.Document.SourceMimeType)
|
||||||
|
assert.EqualValues(t, 41, detail.Document.SourceSizeBytes)
|
||||||
|
assert.EqualValues(t, 501, detail.Document.SourceAssetID)
|
||||||
|
|
||||||
|
// 列表概要同样带出上传元数据(前端跨端恢复预览路由的事实源)。
|
||||||
|
list, err := svc.ListDocuments(ctx, 42, 7, "my_private")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
assert.Equal(t, "upload-note.txt", list[0].SourceFilename)
|
||||||
|
assert.EqualValues(t, 41, list[0].SourceSizeBytes)
|
||||||
|
assert.EqualValues(t, 501, list[0].SourceAssetID)
|
||||||
|
|
||||||
|
// 非上传文档元数据恒为零值,不影响既有编辑器路径。
|
||||||
|
plain, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||||
|
ProjectID: 7,
|
||||||
|
Title: "plain-note",
|
||||||
|
ContentMarkdown: "# plain\n",
|
||||||
|
ClientKey: "doc-upload-meta-2",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, plain.Document.SourceFilename)
|
||||||
|
assert.EqualValues(t, 0, plain.Document.SourceSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDocument_RejectsNegativeSourceMetadata(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := freedoc.NewService(nil, nil)
|
||||||
|
_, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||||
|
ProjectID: 7,
|
||||||
|
Title: "bad",
|
||||||
|
ContentMarkdown: "# bad\n",
|
||||||
|
SourceSizeBytes: -1,
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||||
|
_, err = svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||||
|
ProjectID: 7,
|
||||||
|
Title: "bad",
|
||||||
|
ContentMarkdown: "# bad\n",
|
||||||
|
SourceAssetID: -5,
|
||||||
|
})
|
||||||
|
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
|
|||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
title, governance_mode, space, legacy_version_id, current_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
|
FROM freeform_documents
|
||||||
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -68,6 +69,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
|
|||||||
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
||||||
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
||||||
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
||||||
|
&existingDoc.SourceAssetID,
|
||||||
); err == nil {
|
); err == nil {
|
||||||
// 获取当前版本的 markdown
|
// 获取当前版本的 markdown
|
||||||
var curContent string
|
var curContent string
|
||||||
@@ -255,6 +257,12 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
|||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
|
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)
|
space := strings.TrimSpace(req.Space)
|
||||||
if space == "" {
|
if space == "" {
|
||||||
space = "my_private"
|
space = "my_private"
|
||||||
@@ -307,9 +315,11 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
|||||||
INSERT INTO freeform_documents (
|
INSERT INTO freeform_documents (
|
||||||
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
client_key, title, governance_mode, space, legacy_version_id, current_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
|
is_deleted, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
|
) 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, now)
|
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space,
|
||||||
|
req.SourceFilename, req.SourceMimeType, req.SourceSizeBytes, req.SourceAssetID, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Unique race: another request inserted the same owner+client_key
|
// Unique race: another request inserted the same owner+client_key
|
||||||
if clientKey != "" {
|
if clientKey != "" {
|
||||||
@@ -350,6 +360,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
|||||||
Space: space,
|
Space: space,
|
||||||
CurrentVersionID: 1,
|
CurrentVersionID: 1,
|
||||||
CurrentSHA256: contentHash,
|
CurrentSHA256: contentHash,
|
||||||
|
SourceFilename: req.SourceFilename,
|
||||||
|
SourceMimeType: req.SourceMimeType,
|
||||||
|
SourceSizeBytes: req.SourceSizeBytes,
|
||||||
|
SourceAssetID: req.SourceAssetID,
|
||||||
IsDeleted: false,
|
IsDeleted: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
@@ -386,6 +400,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
|||||||
Space: space,
|
Space: space,
|
||||||
CurrentVersionID: 1,
|
CurrentVersionID: 1,
|
||||||
CurrentSHA256: contentHash,
|
CurrentSHA256: contentHash,
|
||||||
|
SourceFilename: req.SourceFilename,
|
||||||
|
SourceMimeType: req.SourceMimeType,
|
||||||
|
SourceSizeBytes: req.SourceSizeBytes,
|
||||||
|
SourceAssetID: req.SourceAssetID,
|
||||||
IsDeleted: false,
|
IsDeleted: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
|
|||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
title, governance_mode, space, legacy_version_id, current_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
|
FROM freeform_documents
|
||||||
WHERE doc_id = $1
|
WHERE doc_id = $1
|
||||||
`, docID)
|
`, docID)
|
||||||
@@ -27,6 +28,7 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
|
|||||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||||
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -108,14 +110,16 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
|||||||
// 我的私密:属于当前用户
|
// 我的私密:属于当前用户
|
||||||
if projectID > 0 {
|
if projectID > 0 {
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM freeform_documents
|
||||||
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
`, userID, projectID)
|
`, userID, projectID)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM freeform_documents
|
||||||
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
||||||
ORDER BY updated_at DESC
|
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" {
|
} else if space == "project_shared" {
|
||||||
// 项目共享参考:属于当前项目的所有公开参考
|
// 项目共享参考:属于当前项目的所有公开参考
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM freeform_documents
|
||||||
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -132,7 +137,8 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
|||||||
} else {
|
} else {
|
||||||
// 回收站:当前用户删除的
|
// 回收站:当前用户删除的
|
||||||
rows, err = s.pool.Query(ctx, `
|
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
|
FROM freeform_documents
|
||||||
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
||||||
ORDER BY deleted_at DESC
|
ORDER BY deleted_at DESC
|
||||||
@@ -149,6 +155,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
||||||
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
||||||
|
&sm.SourceFilename, &sm.SourceMimeType, &sm.SourceSizeBytes, &sm.SourceAssetID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -166,32 +173,12 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
|||||||
if space == "my_private" {
|
if space == "my_private" {
|
||||||
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
||||||
if projectID <= 0 || d.ProjectID == projectID {
|
if projectID <= 0 || d.ProjectID == projectID {
|
||||||
results = append(results, FreeformDocSummary{
|
results = append(results, d.summary())
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if space == "project_shared" {
|
} else if space == "project_shared" {
|
||||||
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
||||||
results = append(results, FreeformDocSummary{
|
results = append(results, d.summary())
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
|
|||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
title, governance_mode, space, legacy_version_id, current_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
|
FROM freeform_documents
|
||||||
WHERE doc_id = $1 FOR UPDATE
|
WHERE doc_id = $1 FOR UPDATE
|
||||||
`, docID)
|
`, docID)
|
||||||
@@ -36,6 +37,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
|
|||||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||||
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ type FreeformDocument struct {
|
|||||||
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
|
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
|
||||||
CurrentVersionID int `json:"current_version_id"`
|
CurrentVersionID int `json:"current_version_id"`
|
||||||
CurrentSHA256 string `json:"current_sha256"`
|
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"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -59,6 +64,11 @@ type FreeformDocSummary struct {
|
|||||||
Space string `json:"space"`
|
Space string `json:"space"`
|
||||||
CurrentVersionID int `json:"current_version_id"`
|
CurrentVersionID int `json:"current_version_id"`
|
||||||
CurrentSHA256 string `json:"current_sha256"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,11 @@ type CreateFreeformDocRequest struct {
|
|||||||
Space string `json:"space,omitempty"` // 默认 'my_private'
|
Space string `json:"space,omitempty"` // 默认 'my_private'
|
||||||
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
|
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
|
||||||
ClientKey string `json:"client_key,omitempty"`
|
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 更新自由文档请求
|
// UpdateFreeformDocRequest 更新自由文档请求
|
||||||
@@ -89,3 +104,22 @@ type UpdateFreeformDocRequest struct {
|
|||||||
ExpectedVersion *int `json:"expected_version,omitempty"`
|
ExpectedVersion *int `json:"expected_version,omitempty"`
|
||||||
ExpectedSHA256 *string `json:"expected_sha256,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }) => {
|
test('FS-KB-01 admin KB console readonly check', async ({ browser }) => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const label = 'kb-admin';
|
const label = 'kb-admin';
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface FreeformDocSummary {
|
|||||||
space: 'my_private' | 'project_shared' | 'trash';
|
space: 'my_private' | 'project_shared' | 'trash';
|
||||||
current_version_id: number;
|
current_version_id: number;
|
||||||
current_sha256: string;
|
current_sha256: string;
|
||||||
|
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
||||||
|
source_filename?: string;
|
||||||
|
source_mime_type?: string;
|
||||||
|
source_size_bytes?: number;
|
||||||
|
source_asset_id?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -31,6 +36,11 @@ export interface FreeformDocDetail {
|
|||||||
space: 'my_private' | 'project_shared' | 'trash';
|
space: 'my_private' | 'project_shared' | 'trash';
|
||||||
current_version_id: number;
|
current_version_id: number;
|
||||||
current_sha256: string;
|
current_sha256: string;
|
||||||
|
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
||||||
|
source_filename?: string;
|
||||||
|
source_mime_type?: string;
|
||||||
|
source_size_bytes?: number;
|
||||||
|
source_asset_id?: number;
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -54,6 +64,10 @@ export async function createFreeformDoc(payload: {
|
|||||||
contentMarkdown: string;
|
contentMarkdown: string;
|
||||||
space?: 'my_private' | 'project_shared';
|
space?: 'my_private' | 'project_shared';
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
|
sourceFilename?: string;
|
||||||
|
sourceMimeType?: string;
|
||||||
|
sourceSizeBytes?: number;
|
||||||
|
sourceAssetId?: number;
|
||||||
}): Promise<FreeformDocDetail> {
|
}): Promise<FreeformDocDetail> {
|
||||||
const res = await apiFetch('/api/v1/freeform-documents', {
|
const res = await apiFetch('/api/v1/freeform-documents', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -64,6 +78,10 @@ export async function createFreeformDoc(payload: {
|
|||||||
content_markdown: payload.contentMarkdown,
|
content_markdown: payload.contentMarkdown,
|
||||||
space: payload.space || 'my_private',
|
space: payload.space || 'my_private',
|
||||||
client_key: payload.clientKey || undefined,
|
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) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { QuotedMessage } from '../../types/workbench';
|
|||||||
import { SendMessageResult } from '../../types/sendMessage';
|
import { SendMessageResult } from '../../types/sendMessage';
|
||||||
import { applySendResult, resolveThinkingBlock } from './inputAreaSendLogic';
|
import { applySendResult, resolveThinkingBlock } from './inputAreaSendLogic';
|
||||||
import { QuoteChip } from './QuoteChip';
|
import { QuoteChip } from './QuoteChip';
|
||||||
|
import { formatFileSize } from '../../lib/fileSize';
|
||||||
|
|
||||||
export interface InputAttachmentItem {
|
export interface InputAttachmentItem {
|
||||||
name: string;
|
name: string;
|
||||||
size: string;
|
size: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** 原始字节数;上传注册时作为服务端事实源持久化,避免字符串二次解析。 */
|
||||||
|
sizeBytes?: number;
|
||||||
docId?: string;
|
docId?: string;
|
||||||
fileRaw?: File | Blob;
|
fileRaw?: File | Blob;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
@@ -146,7 +149,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: (file.size / 1024).toFixed(0) + ' KB',
|
size: formatFileSize(file.size),
|
||||||
|
sizeBytes: file.size,
|
||||||
type: file.type || 'document',
|
type: file.type || 'document',
|
||||||
fileRaw: file,
|
fileRaw: file,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ import type {
|
|||||||
TaskRunMaterial,
|
TaskRunMaterial,
|
||||||
} from '../types/workbench';
|
} 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。
|
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +60,6 @@ async function readAttachmentsAsMaterials(
|
|||||||
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
|
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
|
||||||
projectId?: number,
|
projectId?: number,
|
||||||
): Promise<TaskRunMaterial[]> {
|
): Promise<TaskRunMaterial[]> {
|
||||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
|
||||||
const results: TaskRunMaterial[] = [];
|
const results: TaskRunMaterial[] = [];
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
if (att.assetId && att.assetId > 0) {
|
if (att.assetId && att.assetId > 0) {
|
||||||
@@ -46,9 +67,7 @@ async function readAttachmentsAsMaterials(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!att.fileRaw) continue;
|
if (!att.fileRaw) continue;
|
||||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
if (!isTextAttachment(att.name)) {
|
||||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
|
||||||
if (!TEXT_EXTS.has(ext)) {
|
|
||||||
if (!projectId || projectId <= 0) {
|
if (!projectId || projectId <= 0) {
|
||||||
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
|
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
|
||||||
}
|
}
|
||||||
@@ -157,7 +176,7 @@ export function useSendMessage(input: SendMessageInput) {
|
|||||||
|
|
||||||
return async (
|
return async (
|
||||||
text: string,
|
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[]
|
quotes?: QuotedMessage[]
|
||||||
): Promise<SendMessageResult> => {
|
): Promise<SendMessageResult> => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
@@ -172,12 +191,36 @@ export function useSendMessage(input: SendMessageInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
|
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
|
||||||
|
// 注册前先完成 CAS 登记与文本正文预读:源文件元数据(文件名/大小/asset_id)与正文
|
||||||
|
// 随注册持久化为服务端事实源,换设备/清存储/共享给其他成员后仍可恢复预览与内容。
|
||||||
let processedAttachments = attachments;
|
let processedAttachments = attachments;
|
||||||
if (attachments && attachments.length > 0) {
|
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;
|
let registered;
|
||||||
try {
|
try {
|
||||||
registered = await registerUploadAttachments({
|
registered = await registerUploadAttachments({
|
||||||
attachments,
|
attachments: prepared,
|
||||||
projectId: currentProjectId,
|
projectId: currentProjectId,
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
userName: currentUser.name,
|
userName: currentUser.name,
|
||||||
@@ -218,31 +261,10 @@ export function useSendMessage(input: SendMessageInput) {
|
|||||||
newAST.space = 'my_private';
|
newAST.space = 'my_private';
|
||||||
newDocCacheEntries[r.serverDocId] = newAST;
|
newDocCacheEntries[r.serverDocId] = newAST;
|
||||||
if (r.attachment.fileRaw) {
|
if (r.attachment.fileRaw) {
|
||||||
|
// 本地 IndexedDB 兜底(同浏览器刷新快速预览);服务端事实源已在注册前登记 CAS。
|
||||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createEditorHydrationGate,
|
createEditorHydrationGate,
|
||||||
} from '../lib/editorHydrationGate';
|
} from '../lib/editorHydrationGate';
|
||||||
|
import { deliverableFromServerSummary } from '../lib/freeformDocSync';
|
||||||
import type { LoginResult } from '../types/auth';
|
import type { LoginResult } from '../types/auth';
|
||||||
import type { DocumentAST } from '../types/block';
|
import type { DocumentAST } from '../types/block';
|
||||||
import type { UserProfile } from '../types/docgov';
|
import type { UserProfile } from '../types/docgov';
|
||||||
@@ -68,28 +69,8 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
|
|||||||
let updated = [...prev];
|
let updated = [...prev];
|
||||||
for (const s of allSummaries) {
|
for (const s of allSummaries) {
|
||||||
const exists = updated.find((d) => d.docId === s.doc_id);
|
const exists = updated.find((d) => d.docId === s.doc_id);
|
||||||
const category = s.space === 'my_private' ? 'private' : 'shared';
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
updated.unshift({
|
updated.unshift(deliverableFromServerSummary(s, currentProjectId, currentUser));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -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), '未知大小');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
buildUploadClientKey,
|
buildUploadClientKey,
|
||||||
|
buildUploadRegistrationMarkdown,
|
||||||
deliverableFromFreeformUpload,
|
deliverableFromFreeformUpload,
|
||||||
registerUploadAttachments,
|
registerUploadAttachments,
|
||||||
} from './uploadFreeformRegister';
|
} from './uploadFreeformRegister';
|
||||||
@@ -125,4 +126,80 @@ describe('uploadFreeformRegister', () => {
|
|||||||
assert.equal(d.sourceType, 'uploaded_file');
|
assert.equal(d.sourceType, 'uploaded_file');
|
||||||
assert.equal(d.governanceMode, 'freeform');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ export type UploadAttachmentInput = {
|
|||||||
name: string;
|
name: string;
|
||||||
size: string;
|
size: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
/** 原始字节数;作为服务端事实源持久化,恢复显示时不再解析 size 字符串。 */
|
||||||
|
sizeBytes?: number;
|
||||||
docId?: string;
|
docId?: string;
|
||||||
fileRaw?: File | Blob;
|
fileRaw?: File | Blob;
|
||||||
fileUrl?: string;
|
fileUrl?: string;
|
||||||
/** CAS 资产 ID(二进制上传登记后回填) */
|
/** CAS 资产 ID(所有扩展名上传登记后回填;文本类此前跳过 CAS 导致跨端无原件) */
|
||||||
assetId?: number;
|
assetId?: number;
|
||||||
/** 上传命中内容去重:物理仅存一份 */
|
/** 上传命中内容去重:物理仅存一份 */
|
||||||
deduplicated?: boolean;
|
deduplicated?: boolean;
|
||||||
/** MinerU 解析状态初始值 */
|
/** MinerU 解析状态初始值 */
|
||||||
parseState?: string;
|
parseState?: string;
|
||||||
|
/** 文本类文件正文(UTF-8)。注册时写入 content_markdown,跨端可读真实内容。 */
|
||||||
|
textContent?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
|
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
|
||||||
@@ -32,9 +36,24 @@ export function buildUploadRegistrationMarkdown(params: {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: string;
|
fileSize: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
/** 文本类文件正文:提供时作为文档正文嵌入,注册后即为可读内容而非占位模板。 */
|
||||||
|
textContent?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { title, userName, fileName, fileSize, mimeType } = params;
|
const { title, userName, fileName, fileSize, mimeType, textContent } = 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 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 = {
|
export type RegisteredUpload = {
|
||||||
@@ -80,8 +99,10 @@ export function deliverableFromFreeformUpload(params: {
|
|||||||
fileMeta: {
|
fileMeta: {
|
||||||
originalName: att.name,
|
originalName: att.name,
|
||||||
size: att.size,
|
size: att.size,
|
||||||
|
sizeBytes: att.sizeBytes,
|
||||||
mimeType: att.type,
|
mimeType: att.type,
|
||||||
extension: ext,
|
extension: ext,
|
||||||
|
assetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
|
||||||
fileRaw: att.fileRaw,
|
fileRaw: att.fileRaw,
|
||||||
fileUrl: att.fileUrl,
|
fileUrl: att.fileUrl,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
@@ -101,6 +122,10 @@ export async function registerUploadAttachments(params: {
|
|||||||
contentMarkdown: string;
|
contentMarkdown: string;
|
||||||
space?: 'my_private' | 'project_shared';
|
space?: 'my_private' | 'project_shared';
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
|
sourceFilename?: string;
|
||||||
|
sourceMimeType?: string;
|
||||||
|
sourceSizeBytes?: number;
|
||||||
|
sourceAssetId?: number;
|
||||||
}) => Promise<FreeformDocDetail>;
|
}) => Promise<FreeformDocDetail>;
|
||||||
now?: number;
|
now?: number;
|
||||||
}): Promise<RegisteredUpload[]> {
|
}): Promise<RegisteredUpload[]> {
|
||||||
@@ -120,6 +145,7 @@ export async function registerUploadAttachments(params: {
|
|||||||
fileName: att.name,
|
fileName: att.name,
|
||||||
fileSize: att.size,
|
fileSize: att.size,
|
||||||
mimeType: att.type,
|
mimeType: att.type,
|
||||||
|
textContent: att.textContent,
|
||||||
});
|
});
|
||||||
let detail: FreeformDocDetail;
|
let detail: FreeformDocDetail;
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +155,10 @@ export async function registerUploadAttachments(params: {
|
|||||||
contentMarkdown: markdown,
|
contentMarkdown: markdown,
|
||||||
space: 'my_private',
|
space: 'my_private',
|
||||||
clientKey,
|
clientKey,
|
||||||
|
sourceFilename: att.name,
|
||||||
|
sourceMimeType: att.type,
|
||||||
|
sourceSizeBytes: att.sizeBytes ?? 0,
|
||||||
|
sourceAssetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
Reference in New Issue
Block a user