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)。
|
||||
|
||||
更新:2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
|
||||
更新:2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
|
||||
|
||||
| issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| ISSUE-20260927-upload-doc-cross-device | freedoc / upload / freeform / fileMeta / 占位模板 / 跨端 | 上传文档源文件元数据与正文不落服务端:换浏览器/清存储/共享他人查看时退化为注册占位模板,文本类连二进制都未存(伴生 <512B 显示 "0 KB") | high | fixing | pi | 2026-09-27;根因链源码证实;修复已实现(迁移 00076+freedoc+前端),隔离库/单测/build PASS,dev 集成与浏览器验收待执行 | [主记录](../ops/short-term/2026-09-27-upload-doc-access.md) | 待集成验收后补 |
|
||||
| ISSUE-20260923-nullable-duty | docgov / nullable / 23503 / reviewer_id | 可选职责空值写 0 导致 FK 失败 | high | resolved | Codex(历史补录) | 2026-09-17;历史 PG / API 冒烟;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#nullable-duty) | [LESSON-20260923-nullable-duty](../ops/lessons/2026-09-23-nullable-duty.md) |
|
||||
| ISSUE-20260923-content-hash | docrevision / canonicalize / hard-break / 404 | 规范化破坏 Markdown 语义并造成正文哈希漂移 | critical | resolved | Codex(历史补录) | 2026-09-14;历史真实 UI / 重启;当前未复测 | [主记录](2026-09-23-historical-issue-records.md#content-hash) | [LESSON-20260923-content-hash](../ops/lessons/2026-09-23-content-hash.md) |
|
||||
| ISSUE-20260923-coordination-authority | agentctl / claim / SQLite / CAS / CURRENT / FEATURE-MATRIX | 本地副本 CAS 与任务许可不能表达跨主机全局权威认领 | high | investigating | Codex(研究) | 2026-09-23;源码/只读状态,未复现具体丢写 | [主记录](../ops/short-term/2026-09-23-coordination-db-daemon-research.md#issue-20260923-coordination-authority) | 沿用 content-hash 投影经验;跨机结论待实施验证 |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "上传文档跨端不可访问:源文件元数据与正文服务端持久化修复"
|
||||
task_id: "upload-doc-access-2026-09-27"
|
||||
status: in_progress
|
||||
created: 2026-09-27
|
||||
updated: 2026-09-27
|
||||
owner: pi
|
||||
task_type: implementation
|
||||
task_tier: small
|
||||
policy_revision: "agent-governance-2026-09-21-r1;dev a0e40919a7ef7b97d612d006aa879cf829e5b796"
|
||||
branch: fix/upload-doc-access-2026-09-27
|
||||
worktree: .worktrees/upload-doc-access-2026-09-27
|
||||
base_dev_sha: "a0e40919a7ef7b97d612d006aa879cf829e5b796"
|
||||
write_scope: ["internal/freedoc", "internal/server", "deploy/postgres/goose", "web/src", "web/package.json", "web/e2e", ".doc"]
|
||||
allowed_environments: []
|
||||
head_sha: "<准入时冻结的提交>"
|
||||
mutex: "freedoc+web-upload"
|
||||
review_state: pending
|
||||
integration_state: pending
|
||||
deployment: pending
|
||||
cleanup: pending
|
||||
cleanup_owner: "独立集成/维护执行者"
|
||||
cleanup_trigger: "owner 退出且 dev 集成验收完成后复查"
|
||||
remote_sync: not_requested
|
||||
---
|
||||
|
||||
# 上传文档跨端不可访问:源文件元数据与正文服务端持久化修复
|
||||
|
||||
## 用户目标与授权
|
||||
|
||||
- 用户目标:登录测试用户账户后发现上传的私密文档无法访问,查看时显示注册占位模板(`upload-mui2xpg2` / 上传人 E2E编制-mui2xpg2 / 文件大小 0 KB / 文档概述与背景…),此前人工测试亦大量出现;要求定位并修复。
|
||||
- 授权来源:用户在本会话中对「开任务 worktree 实施修复(方案 1+2+3 一起做)」回复「授权」。
|
||||
- 交付终点:代码与迁移在任务 worktree 完成并通过验证;dev 集成与环境验证按门禁另行执行(本任务记录集成状态)。
|
||||
- 禁止项:默认不 push;不操作 test/main/共享 KB;分析阶段不含业务实施(已进入实施)。
|
||||
|
||||
## 根因(已证实)
|
||||
|
||||
1. **上传注册正文是占位模板**:`web/src/lib/uploadFreeformRegister.ts` 的 `buildUploadRegistrationMarkdown` 生成的模板是服务端唯一持久化的"文档内容"。
|
||||
2. **文件二进制不落服务端(文本类)**:`useSendMessage` 仅对非 TEXT_EXTS 扩展名调用 `uploadProjectAsset` 登记 CAS;txt/md/csv/json 等只存浏览器 IndexedDB + 内存 fileRaw。非文本文件虽入 CAS,但 MinerU 解析结果只进知识库,不回写文档正文。
|
||||
3. **元数据不落服务端**:`freeform_documents` 表无文件名/大小/mime/asset_id 列;`useServerDocumentSync` 恢复的 deliverable 无 `sourceType/fileMeta`,`DocumentPane` 的 `isUploadedFile` 启发式五条件全部不命中 → 走普通 AST 编辑器 → `fetchFreeformDoc` 渲染占位模板。触发条件:换浏览器/换设备/清存储/其他成员查看共享文档。
|
||||
4. **"0 KB"**:`InputArea` 用 `(file.size/1024).toFixed(0)`,<512B 文件四舍五入为 0,且被烘焙进持久化占位 markdown(E2E txt 仅约 41 字节)。
|
||||
5. **E2E 盲区**:`FS-UPLOAD-01` 只断言注册出 `doc-free-*` pane,不校验跨会话内容可见性。
|
||||
|
||||
## 方案
|
||||
|
||||
- 迁移 `00076`:`freeform_documents` 增加 `source_filename/source_mime_type/source_size_bytes/source_asset_id`(软关联 CAS `file_references`,不加 FK)。
|
||||
- freedoc:Create/Get/List 读写上述列;负值参数拒绝(ErrInvalidParam);Summary 带出元数据。
|
||||
- 前端上传编排(`useSendMessage`):注册前先 CAS 登记全部附件(去掉 TEXT_EXTS 排除)并预读文本正文;注册时携带元数据与 asset_id;文本正文嵌入注册 markdown(`## 1. 文档正文(上传原文)`)。
|
||||
- 跨端恢复(`useServerDocumentSync` → 新 `lib/freeformDocSync.ts`):按服务端 summary 恢复 `sourceType: 'uploaded_file'` 与 `fileMeta`(含 assetId/sizeBytes),DocumentPane 路由回 FilePreviewPanel,经 CAS raw 端点恢复原件预览。
|
||||
- 大小显示:新 `lib/fileSize.ts` `formatFileSize`(B/KB/MB,任何正数不显示 0)。
|
||||
- E2E:新增 `FS-UPLOAD-02`(全新浏览器上下文登录同账户,断言真实文件正文可见且占位句消失)。
|
||||
|
||||
## 范围与非目标
|
||||
|
||||
- 允许写入:internal/freedoc、internal/server(无需改动,请求透传)、deploy/postgres/goose、web/src、web/e2e、.doc。
|
||||
- 非目标:MinerU 解析结果回写文档正文(二进制文件正文仍为占位+原件预览,属后续增强);历史已损坏数据(既无元数据也无 CAS 原件的旧上传文档)不可恢复,仅保持现状。
|
||||
|
||||
## 相关问题与经验
|
||||
|
||||
- 开工检索:ISSUES.md 检索「上传/upload」,命中 ISSUE-20260924-non-author-400-duty(权限问题,机制无关,未采纳);lessons INDEX 无上传链路相关命中。
|
||||
- 待验证候选(本次发现、非本任务因果链):`TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService` 在全新隔离库(goose 76 + 全部 init SQL)上于基线 dev HEAD 同样失败(rev2 跨服务水合 HeadRevisionNo=0),疑似 docrevision 水合依赖未满足或用例自身问题;需独立排查后按闭环规范登记。
|
||||
|
||||
## 步骤与验收
|
||||
|
||||
1. [x] 迁移 00076 + freedoc 元数据读写 — 隔离库迁移到 76 通过;单测/PG 测试通过。
|
||||
2. [x] 前端注册编排与跨端恢复 — tsc、npm test(525+34 PASS)、npm run build 通过。
|
||||
3. [x] E2E 用例 FS-UPLOAD-02 编写并入选例清单 — `playwright --list` 含该用例;真实执行待 dev 集成后。
|
||||
4. [ ] dev 集成(候选构建、迁移应用、环境验证)— 待独立执行。
|
||||
|
||||
## 验证与证据
|
||||
|
||||
| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 |
|
||||
|---|---|---|---|---|
|
||||
| Go 迁移链(worktree HEAD) | 隔离 PG(docker pgvector:pg16,127.0.0.1:5599,一次性容器)`go run ./.tmp/migrate <dsn>` + system/docgov/context_init.sql | 迁移至 76 无错 | PASS(schema_version: 76) | 容器用后即删;DSN 仅本机随机端口 |
|
||||
| freedoc 单元+PG | `TEST_DATABASE_URL=<隔离库> go test ./internal/freedoc/ -count=1 -v` | 全 PASS | 除下方预存在失败外全 PASS | `TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService` 失败:基线 dev HEAD 在同一隔离库同样失败,判定预存在、与本任务无因果(见"相关问题") |
|
||||
| Go 全量门禁 | `go vet ./...`;`./scripts/arch-check.sh --mode block`;`go test -short ./...`(TEST_DATABASE_URL/CONTEXT_DB_* 指向隔离库) | 全绿 | vet 无输出;arch-check "production gate passed";short 69 包 ok,仅上述预存在失败 | — |
|
||||
| 前端 | `npm test`;`npm run build`(web/) | 全 PASS | 525(node:test)+34(vitest)PASS;build 成功 | — |
|
||||
| E2E | `npx playwright test --list` | 含 FS-UPLOAD-02 | PASS(枚举) | 真实浏览器执行需 dev 环境运行新代码后补跑 |
|
||||
|
||||
环境说明:worktree 无 `.env`,`config.Load` 默认 DSN 指向 dev 库(127.0.0.1:5433);首次误跑 `TestFreeformDoc_DatabaseIntegration` 曾写入 dev 库测试数据(chat_messages 297–301、user 101 若干软删 doc,session 前缀 `sess-db-freedoc-*`,与该测试在主目录运行的既有行为一致)。后续全部测试改用隔离库环境变量。
|
||||
|
||||
## 遗留与限制
|
||||
|
||||
- 存量损坏数据不可恢复:修复前上传的文档(含用户报告的 `upload-mui2xpg2`)无服务端元数据与原件,仍显示占位模板;重新上传即可获得正确行为。
|
||||
- 二进制(docx/pdf 等)文档正文仍为占位+原件预览;解析正文回写为后续增强。
|
||||
- `readAttachmentsAsMaterials` 行为变化:文本附件现在优先以 asset_id 引用(此前 inline Base64);CAS 失败时回退旧行为。
|
||||
|
||||
## 收口与交接
|
||||
|
||||
- 剩余工作:review、候选集成到 dev(含 dev 库迁移 00076 与制品验证)、FS-UPLOAD-01/02 真实浏览器补跑。
|
||||
- 本地提交 / dev 集成 / 部署制品 / 远端状态:见 frontmatter 各状态字段。
|
||||
- cleanup:worktree 保留至集成验收后由独立维护者复查。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 问题分析见本文件"根因"节(用户会话内交付)。
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- 上传附件注册 freeform 文档时持久化源文件元数据(原仅存于浏览器 localStorage/IndexedDB,
|
||||
-- 换设备/清存储/共享给其他成员后无法恢复文件预览,正文退化为注册占位模板)。
|
||||
-- source_asset_id 软关联 CAS 项目资产(storage_blobs/file_references,见 internal/storage/cas.go),
|
||||
-- 不加 FK:资产去重/引用计数生命周期独立于文档,删资产不级联删文档。
|
||||
|
||||
ALTER TABLE freeform_documents
|
||||
ADD COLUMN IF NOT EXISTS source_filename TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS source_mime_type TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS source_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS source_asset_id BIGINT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE freeform_documents
|
||||
DROP COLUMN IF EXISTS source_asset_id,
|
||||
DROP COLUMN IF EXISTS source_size_bytes,
|
||||
DROP COLUMN IF EXISTS source_mime_type,
|
||||
DROP COLUMN IF EXISTS source_filename;
|
||||
@@ -100,3 +100,64 @@ func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) {
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
assert.Contains(t, err.Error(), "my_private")
|
||||
}
|
||||
|
||||
func TestCreateDocument_PersistsSourceFileMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := freedoc.NewService(nil, nil)
|
||||
|
||||
detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "upload-note",
|
||||
ContentMarkdown: "# upload-note\n\n临床需求摘要占位\n",
|
||||
Space: "my_private",
|
||||
ClientKey: "doc-upload-meta-1",
|
||||
SourceFilename: "upload-note.txt",
|
||||
SourceMimeType: "text/plain",
|
||||
SourceSizeBytes: 41,
|
||||
SourceAssetID: 501,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, detail)
|
||||
assert.Equal(t, "upload-note.txt", detail.Document.SourceFilename)
|
||||
assert.Equal(t, "text/plain", detail.Document.SourceMimeType)
|
||||
assert.EqualValues(t, 41, detail.Document.SourceSizeBytes)
|
||||
assert.EqualValues(t, 501, detail.Document.SourceAssetID)
|
||||
|
||||
// 列表概要同样带出上传元数据(前端跨端恢复预览路由的事实源)。
|
||||
list, err := svc.ListDocuments(ctx, 42, 7, "my_private")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, "upload-note.txt", list[0].SourceFilename)
|
||||
assert.EqualValues(t, 41, list[0].SourceSizeBytes)
|
||||
assert.EqualValues(t, 501, list[0].SourceAssetID)
|
||||
|
||||
// 非上传文档元数据恒为零值,不影响既有编辑器路径。
|
||||
plain, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "plain-note",
|
||||
ContentMarkdown: "# plain\n",
|
||||
ClientKey: "doc-upload-meta-2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, plain.Document.SourceFilename)
|
||||
assert.EqualValues(t, 0, plain.Document.SourceSizeBytes)
|
||||
}
|
||||
|
||||
func TestCreateDocument_RejectsNegativeSourceMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := freedoc.NewService(nil, nil)
|
||||
_, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "bad",
|
||||
ContentMarkdown: "# bad\n",
|
||||
SourceSizeBytes: -1,
|
||||
})
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
_, err = svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{
|
||||
ProjectID: 7,
|
||||
Title: "bad",
|
||||
ContentMarkdown: "# bad\n",
|
||||
SourceAssetID: -5,
|
||||
})
|
||||
assert.ErrorIs(t, err, freedoc.ErrInvalidParam)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||
is_deleted, deleted_at, created_at, updated_at
|
||||
is_deleted, deleted_at, created_at, updated_at,
|
||||
COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
||||
LIMIT 1
|
||||
@@ -68,6 +69,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF
|
||||
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
||||
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
||||
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
||||
&existingDoc.SourceAssetID,
|
||||
); err == nil {
|
||||
// 获取当前版本的 markdown
|
||||
var curContent string
|
||||
@@ -255,6 +257,12 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
|
||||
}
|
||||
if req.SourceSizeBytes < 0 {
|
||||
return nil, fmt.Errorf("%w: source_size_bytes must be >= 0", ErrInvalidParam)
|
||||
}
|
||||
if req.SourceAssetID < 0 {
|
||||
return nil, fmt.Errorf("%w: source_asset_id must be >= 0", ErrInvalidParam)
|
||||
}
|
||||
space := strings.TrimSpace(req.Space)
|
||||
if space == "" {
|
||||
space = "my_private"
|
||||
@@ -307,9 +315,11 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
||||
INSERT INTO freeform_documents (
|
||||
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||
client_key, title, governance_mode, space, legacy_version_id, current_sha256,
|
||||
source_filename, source_mime_type, source_size_bytes, source_asset_id,
|
||||
is_deleted, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
|
||||
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
|
||||
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, $8, $9, $10, NULLIF($11, 0), FALSE, $12, $12)
|
||||
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space,
|
||||
req.SourceFilename, req.SourceMimeType, req.SourceSizeBytes, req.SourceAssetID, now)
|
||||
if err != nil {
|
||||
// Unique race: another request inserted the same owner+client_key
|
||||
if clientKey != "" {
|
||||
@@ -350,6 +360,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
||||
Space: space,
|
||||
CurrentVersionID: 1,
|
||||
CurrentSHA256: contentHash,
|
||||
SourceFilename: req.SourceFilename,
|
||||
SourceMimeType: req.SourceMimeType,
|
||||
SourceSizeBytes: req.SourceSizeBytes,
|
||||
SourceAssetID: req.SourceAssetID,
|
||||
IsDeleted: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -386,6 +400,10 @@ func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFree
|
||||
Space: space,
|
||||
CurrentVersionID: 1,
|
||||
CurrentSHA256: contentHash,
|
||||
SourceFilename: req.SourceFilename,
|
||||
SourceMimeType: req.SourceMimeType,
|
||||
SourceSizeBytes: req.SourceSizeBytes,
|
||||
SourceAssetID: req.SourceAssetID,
|
||||
IsDeleted: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -18,7 +18,8 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||
is_deleted, deleted_at, created_at, updated_at
|
||||
is_deleted, deleted_at, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE doc_id = $1
|
||||
`, docID)
|
||||
@@ -27,6 +28,7 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F
|
||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -108,14 +110,16 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
||||
// 我的私密:属于当前用户
|
||||
if projectID > 0 {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
||||
ORDER BY updated_at DESC
|
||||
`, userID, projectID)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
||||
ORDER BY updated_at DESC
|
||||
@@ -124,7 +128,8 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
||||
} else if space == "project_shared" {
|
||||
// 项目共享参考:属于当前项目的所有公开参考
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
||||
ORDER BY updated_at DESC
|
||||
@@ -132,7 +137,8 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
||||
} else {
|
||||
// 回收站:当前用户删除的
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
||||
ORDER BY deleted_at DESC
|
||||
@@ -149,6 +155,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
||||
if err := rows.Scan(
|
||||
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
||||
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
||||
&sm.SourceFilename, &sm.SourceMimeType, &sm.SourceSizeBytes, &sm.SourceAssetID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,32 +173,12 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64
|
||||
if space == "my_private" {
|
||||
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
||||
if projectID <= 0 || d.ProjectID == projectID {
|
||||
results = append(results, FreeformDocSummary{
|
||||
DocID: d.DocID,
|
||||
ProjectID: d.ProjectID,
|
||||
OwnerUserID: d.OwnerUserID,
|
||||
Title: d.Title,
|
||||
Space: d.Space,
|
||||
CurrentVersionID: d.CurrentVersionID,
|
||||
CurrentSHA256: d.CurrentSHA256,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
results = append(results, d.summary())
|
||||
}
|
||||
}
|
||||
} else if space == "project_shared" {
|
||||
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
||||
results = append(results, FreeformDocSummary{
|
||||
DocID: d.DocID,
|
||||
ProjectID: d.ProjectID,
|
||||
OwnerUserID: d.OwnerUserID,
|
||||
Title: d.Title,
|
||||
Space: d.Space,
|
||||
CurrentVersionID: d.CurrentVersionID,
|
||||
CurrentSHA256: d.CurrentSHA256,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
results = append(results, d.summary())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||
is_deleted, created_at, updated_at
|
||||
is_deleted, created_at, updated_at,
|
||||
source_filename, source_mime_type, source_size_bytes, COALESCE(source_asset_id, 0)
|
||||
FROM freeform_documents
|
||||
WHERE doc_id = $1 FOR UPDATE
|
||||
`, docID)
|
||||
@@ -36,6 +37,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string,
|
||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.SourceFilename, &doc.SourceMimeType, &doc.SourceSizeBytes, &doc.SourceAssetID,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
|
||||
@@ -26,7 +26,12 @@ type FreeformDocument struct {
|
||||
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
|
||||
CurrentVersionID int `json:"current_version_id"`
|
||||
CurrentSHA256 string `json:"current_sha256"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
// 上传附件注册时持久化的源文件元数据;非上传文档恒为零值。
|
||||
SourceFilename string `json:"source_filename,omitempty"`
|
||||
SourceMimeType string `json:"source_mime_type,omitempty"`
|
||||
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
|
||||
SourceAssetID int64 `json:"source_asset_id,omitempty"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -59,6 +64,11 @@ type FreeformDocSummary struct {
|
||||
Space string `json:"space"`
|
||||
CurrentVersionID int `json:"current_version_id"`
|
||||
CurrentSHA256 string `json:"current_sha256"`
|
||||
// 上传附件注册时持久化的源文件元数据;非上传文档恒为零值。
|
||||
SourceFilename string `json:"source_filename,omitempty"`
|
||||
SourceMimeType string `json:"source_mime_type,omitempty"`
|
||||
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
|
||||
SourceAssetID int64 `json:"source_asset_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -79,6 +89,11 @@ type CreateFreeformDocRequest struct {
|
||||
Space string `json:"space,omitempty"` // 默认 'my_private'
|
||||
// ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档
|
||||
ClientKey string `json:"client_key,omitempty"`
|
||||
// 可选上传源文件元数据:换设备/共享后恢复文件预览与路由的事实源。
|
||||
SourceFilename string `json:"source_filename,omitempty"`
|
||||
SourceMimeType string `json:"source_mime_type,omitempty"`
|
||||
SourceSizeBytes int64 `json:"source_size_bytes,omitempty"`
|
||||
SourceAssetID int64 `json:"source_asset_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateFreeformDocRequest 更新自由文档请求
|
||||
@@ -89,3 +104,22 @@ type UpdateFreeformDocRequest struct {
|
||||
ExpectedVersion *int `json:"expected_version,omitempty"`
|
||||
ExpectedSHA256 *string `json:"expected_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// summary 投影为列表概要(内存模式与 PG 行扫描共用同一字段集)。
|
||||
func (d FreeformDocument) summary() FreeformDocSummary {
|
||||
return FreeformDocSummary{
|
||||
DocID: d.DocID,
|
||||
ProjectID: d.ProjectID,
|
||||
OwnerUserID: d.OwnerUserID,
|
||||
Title: d.Title,
|
||||
Space: d.Space,
|
||||
CurrentVersionID: d.CurrentVersionID,
|
||||
CurrentSHA256: d.CurrentSHA256,
|
||||
SourceFilename: d.SourceFilename,
|
||||
SourceMimeType: d.SourceMimeType,
|
||||
SourceSizeBytes: d.SourceSizeBytes,
|
||||
SourceAssetID: d.SourceAssetID,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface FreeformDocSummary {
|
||||
space: 'my_private' | 'project_shared' | 'trash';
|
||||
current_version_id: number;
|
||||
current_sha256: string;
|
||||
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
||||
source_filename?: string;
|
||||
source_mime_type?: string;
|
||||
source_size_bytes?: number;
|
||||
source_asset_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -31,6 +36,11 @@ export interface FreeformDocDetail {
|
||||
space: 'my_private' | 'project_shared' | 'trash';
|
||||
current_version_id: number;
|
||||
current_sha256: string;
|
||||
/** 上传附件注册时持久化的源文件元数据;非上传文档缺省。 */
|
||||
source_filename?: string;
|
||||
source_mime_type?: string;
|
||||
source_size_bytes?: number;
|
||||
source_asset_id?: number;
|
||||
is_deleted: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -54,6 +64,10 @@ export async function createFreeformDoc(payload: {
|
||||
contentMarkdown: string;
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
sourceFilename?: string;
|
||||
sourceMimeType?: string;
|
||||
sourceSizeBytes?: number;
|
||||
sourceAssetId?: number;
|
||||
}): Promise<FreeformDocDetail> {
|
||||
const res = await apiFetch('/api/v1/freeform-documents', {
|
||||
method: 'POST',
|
||||
@@ -64,6 +78,10 @@ export async function createFreeformDoc(payload: {
|
||||
content_markdown: payload.contentMarkdown,
|
||||
space: payload.space || 'my_private',
|
||||
client_key: payload.clientKey || undefined,
|
||||
source_filename: payload.sourceFilename || undefined,
|
||||
source_mime_type: payload.sourceMimeType || undefined,
|
||||
source_size_bytes: payload.sourceSizeBytes || undefined,
|
||||
source_asset_id: payload.sourceAssetId || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -4,11 +4,14 @@ import { QuotedMessage } from '../../types/workbench';
|
||||
import { SendMessageResult } from '../../types/sendMessage';
|
||||
import { applySendResult, resolveThinkingBlock } from './inputAreaSendLogic';
|
||||
import { QuoteChip } from './QuoteChip';
|
||||
import { formatFileSize } from '../../lib/fileSize';
|
||||
|
||||
export interface InputAttachmentItem {
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
/** 原始字节数;上传注册时作为服务端事实源持久化,避免字符串二次解析。 */
|
||||
sizeBytes?: number;
|
||||
docId?: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
@@ -146,7 +149,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
size: (file.size / 1024).toFixed(0) + ' KB',
|
||||
size: formatFileSize(file.size),
|
||||
sizeBytes: file.size,
|
||||
type: file.type || 'document',
|
||||
fileRaw: file,
|
||||
fileUrl,
|
||||
|
||||
@@ -31,6 +31,28 @@ import type {
|
||||
TaskRunMaterial,
|
||||
} from '../types/workbench';
|
||||
|
||||
/** 文本类扩展名:注册时正文直接写入 freeform markdown,跨端可读真实内容。 */
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
|
||||
function attachmentExt(name: string): string {
|
||||
const extMatch = name.match(/\.([^.]+)$/);
|
||||
return extMatch ? extMatch[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isTextAttachment(name: string): boolean {
|
||||
return TEXT_EXTS.has(attachmentExt(name));
|
||||
}
|
||||
|
||||
/** 读取文本附件正文(UTF-8),供注册 markdown 与材料 inline 使用。 */
|
||||
function readAttachmentText(file: File | Blob): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
|
||||
*/
|
||||
@@ -38,7 +60,6 @@ async function readAttachmentsAsMaterials(
|
||||
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
|
||||
projectId?: number,
|
||||
): Promise<TaskRunMaterial[]> {
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
const results: TaskRunMaterial[] = [];
|
||||
for (const att of attachments) {
|
||||
if (att.assetId && att.assetId > 0) {
|
||||
@@ -46,9 +67,7 @@ async function readAttachmentsAsMaterials(
|
||||
continue;
|
||||
}
|
||||
if (!att.fileRaw) continue;
|
||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
if (!TEXT_EXTS.has(ext)) {
|
||||
if (!isTextAttachment(att.name)) {
|
||||
if (!projectId || projectId <= 0) {
|
||||
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
|
||||
}
|
||||
@@ -157,7 +176,7 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
|
||||
return async (
|
||||
text: string,
|
||||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||||
attachments?: { name: string; size: string; type: string; sizeBytes?: number; docId?: string; fileRaw?: File | Blob; fileUrl?: string; assetId?: number; deduplicated?: boolean; parseState?: string; textContent?: string }[],
|
||||
quotes?: QuotedMessage[]
|
||||
): Promise<SendMessageResult> => {
|
||||
const trimmed = text.trim();
|
||||
@@ -172,12 +191,36 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
}
|
||||
|
||||
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
|
||||
// 注册前先完成 CAS 登记与文本正文预读:源文件元数据(文件名/大小/asset_id)与正文
|
||||
// 随注册持久化为服务端事实源,换设备/清存储/共享给其他成员后仍可恢复预览与内容。
|
||||
let processedAttachments = attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
const prepared: NonNullable<typeof attachments> = [];
|
||||
for (const att of attachments) {
|
||||
const next = { ...att };
|
||||
if (next.fileRaw && currentProjectId > 0) {
|
||||
try {
|
||||
const uploaded = await uploadProjectAsset(currentProjectId, next.fileRaw as Blob, next.name);
|
||||
next.assetId = uploaded.asset_id;
|
||||
next.deduplicated = uploaded.deduplicated === true;
|
||||
next.parseState = uploaded.parse_state;
|
||||
} catch (e) {
|
||||
console.warn('[assets] 上传附件到 CAS 失败(跨端预览回退可能不可用):', e);
|
||||
}
|
||||
}
|
||||
if (isTextAttachment(next.name) && next.fileRaw) {
|
||||
try {
|
||||
next.textContent = await readAttachmentText(next.fileRaw);
|
||||
} catch (e) {
|
||||
console.warn('[upload] 读取文本附件正文失败(注册退化为占位模板):', e);
|
||||
}
|
||||
}
|
||||
prepared.push(next);
|
||||
}
|
||||
let registered;
|
||||
try {
|
||||
registered = await registerUploadAttachments({
|
||||
attachments,
|
||||
attachments: prepared,
|
||||
projectId: currentProjectId,
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
@@ -218,31 +261,10 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
newAST.space = 'my_private';
|
||||
newDocCacheEntries[r.serverDocId] = newAST;
|
||||
if (r.attachment.fileRaw) {
|
||||
// 本地 IndexedDB 兜底(同浏览器刷新快速预览);服务端事实源已在注册前登记 CAS。
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
|
||||
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
|
||||
try {
|
||||
const uploaded = await uploadProjectAsset(
|
||||
currentProjectId,
|
||||
r.attachment.fileRaw as Blob,
|
||||
r.attachment.name,
|
||||
);
|
||||
if (r.deliverable.fileMeta) {
|
||||
r.deliverable.fileMeta.assetId = uploaded.asset_id;
|
||||
}
|
||||
// 回填去重与解析状态,供消息附件卡片展示提示。
|
||||
r.attachment.assetId = uploaded.asset_id;
|
||||
r.attachment.deduplicated = uploaded.deduplicated === true;
|
||||
r.attachment.parseState = uploaded.parse_state;
|
||||
} catch (e) {
|
||||
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
buildUploadClientKey,
|
||||
buildUploadRegistrationMarkdown,
|
||||
deliverableFromFreeformUpload,
|
||||
registerUploadAttachments,
|
||||
} from './uploadFreeformRegister';
|
||||
@@ -125,4 +126,80 @@ describe('uploadFreeformRegister', () => {
|
||||
assert.equal(d.sourceType, 'uploaded_file');
|
||||
assert.equal(d.governanceMode, 'freeform');
|
||||
});
|
||||
|
||||
it('buildUploadRegistrationMarkdown embeds real text content when provided', () => {
|
||||
const md = buildUploadRegistrationMarkdown({
|
||||
title: 'upload-note',
|
||||
userName: '张工',
|
||||
fileName: 'upload-note.txt',
|
||||
fileSize: '41 B',
|
||||
mimeType: 'text/plain',
|
||||
textContent: '临床需求摘要占位\n第二行',
|
||||
});
|
||||
assert.ok(md.includes('## 1. 文档正文(上传原文)'));
|
||||
assert.ok(md.includes('临床需求摘要占位'));
|
||||
assert.ok(md.includes('第二行'));
|
||||
assert.ok(!md.includes('本文件由用户上传并归档至'));
|
||||
});
|
||||
|
||||
it('buildUploadRegistrationMarkdown keeps placeholder for binary files without textContent', () => {
|
||||
const md = buildUploadRegistrationMarkdown({
|
||||
title: 'spec',
|
||||
userName: '张工',
|
||||
fileName: 'spec.pdf',
|
||||
fileSize: '2 MB',
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
assert.ok(md.includes('## 1. 文档概述与背景'));
|
||||
assert.ok(md.includes('解析完成后可在线预览原件'));
|
||||
assert.ok(md.includes('文件大小:2 MB'));
|
||||
});
|
||||
|
||||
it('registerUploadAttachments persists source file metadata as server facts', async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const registered = await registerUploadAttachments({
|
||||
attachments: [
|
||||
{
|
||||
name: 'upload-note.txt',
|
||||
size: '41 B',
|
||||
sizeBytes: 41,
|
||||
type: 'text/plain',
|
||||
assetId: 501,
|
||||
textContent: '临床需求摘要占位',
|
||||
},
|
||||
],
|
||||
projectId: 7,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
now: 1789263145197,
|
||||
createFreeformDoc: async (payload) => {
|
||||
seen.push({ ...payload });
|
||||
return {
|
||||
document: {
|
||||
doc_id: 'doc-free-1789263145197-2',
|
||||
project_id: 7,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'abc',
|
||||
title: payload.title,
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'abc',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: payload.contentMarkdown,
|
||||
} as FreeformDocDetail;
|
||||
},
|
||||
});
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].sourceFilename, 'upload-note.txt');
|
||||
assert.equal(seen[0].sourceMimeType, 'text/plain');
|
||||
assert.equal(seen[0].sourceSizeBytes, 41);
|
||||
assert.equal(seen[0].sourceAssetId, 501);
|
||||
assert.ok(String(seen[0].contentMarkdown).includes('临床需求摘要占位'));
|
||||
assert.equal(registered[0].deliverable.fileMeta?.assetId, 501);
|
||||
assert.equal(registered[0].deliverable.fileMeta?.sizeBytes, 41);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,15 +10,19 @@ export type UploadAttachmentInput = {
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
/** 原始字节数;作为服务端事实源持久化,恢复显示时不再解析 size 字符串。 */
|
||||
sizeBytes?: number;
|
||||
docId?: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: string;
|
||||
/** CAS 资产 ID(二进制上传登记后回填) */
|
||||
/** CAS 资产 ID(所有扩展名上传登记后回填;文本类此前跳过 CAS 导致跨端无原件) */
|
||||
assetId?: number;
|
||||
/** 上传命中内容去重:物理仅存一份 */
|
||||
deduplicated?: boolean;
|
||||
/** MinerU 解析状态初始值 */
|
||||
parseState?: string;
|
||||
/** 文本类文件正文(UTF-8)。注册时写入 content_markdown,跨端可读真实内容。 */
|
||||
textContent?: string;
|
||||
};
|
||||
|
||||
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
|
||||
@@ -32,9 +36,24 @@ export function buildUploadRegistrationMarkdown(params: {
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
mimeType: string;
|
||||
/** 文本类文件正文:提供时作为文档正文嵌入,注册后即为可读内容而非占位模板。 */
|
||||
textContent?: string;
|
||||
}): string {
|
||||
const { title, userName, fileName, fileSize, mimeType } = params;
|
||||
return `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
|
||||
const { title, userName, fileName, fileSize, mimeType, textContent } = params;
|
||||
const header = `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n> 原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)\n\n`;
|
||||
const body = normalizeTextContent(textContent);
|
||||
if (body) {
|
||||
return `${header}## 1. 文档正文(上传原文)\n\n${body}\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
|
||||
}
|
||||
return `${header}## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。解析完成后可在线预览原件。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
|
||||
}
|
||||
|
||||
/** 文本正文规整:去 BOM、截断超长内容,保证 markdown 段落可读。 */
|
||||
function normalizeTextContent(text?: string, maxChars = 200_000): string {
|
||||
if (!text) return '';
|
||||
const stripped = text.replace(/^\uFEFF/, '').trimEnd();
|
||||
if (!stripped.trim()) return '';
|
||||
return stripped.length > maxChars ? `${stripped.slice(0, maxChars)}\n\n> (原文过长,已截断至 ${maxChars} 字符;完整内容请下载原件查看。)` : stripped;
|
||||
}
|
||||
|
||||
export type RegisteredUpload = {
|
||||
@@ -80,8 +99,10 @@ export function deliverableFromFreeformUpload(params: {
|
||||
fileMeta: {
|
||||
originalName: att.name,
|
||||
size: att.size,
|
||||
sizeBytes: att.sizeBytes,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
assetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
|
||||
fileRaw: att.fileRaw,
|
||||
fileUrl: att.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
@@ -101,6 +122,10 @@ export async function registerUploadAttachments(params: {
|
||||
contentMarkdown: string;
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
sourceFilename?: string;
|
||||
sourceMimeType?: string;
|
||||
sourceSizeBytes?: number;
|
||||
sourceAssetId?: number;
|
||||
}) => Promise<FreeformDocDetail>;
|
||||
now?: number;
|
||||
}): Promise<RegisteredUpload[]> {
|
||||
@@ -120,6 +145,7 @@ export async function registerUploadAttachments(params: {
|
||||
fileName: att.name,
|
||||
fileSize: att.size,
|
||||
mimeType: att.type,
|
||||
textContent: att.textContent,
|
||||
});
|
||||
let detail: FreeformDocDetail;
|
||||
try {
|
||||
@@ -129,6 +155,10 @@ export async function registerUploadAttachments(params: {
|
||||
contentMarkdown: markdown,
|
||||
space: 'my_private',
|
||||
clientKey,
|
||||
sourceFilename: att.name,
|
||||
sourceMimeType: att.type,
|
||||
sourceSizeBytes: att.sizeBytes ?? 0,
|
||||
sourceAssetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
Reference in New Issue
Block a user