v2-refactor-temp/docs/file-manager/file-arch-problems-response.md
问题来源:
v2-refactor-temp/docs/file-manager/file-arch-problems.md回应对象:新文件管理架构(docs/zh/references/file/architecture.md+file-manager-architecture.md+rfc-file-manager.md) 目的:逐条回应 13 项问题在新架构下的解决状态、方案出处与设计权衡,供评审追溯。
原问题:物理文件由 main 落地,逻辑引用由 renderer 维护,跨进程无原子性。
解决方案:
ops/ 是整个 file module 内唯一直接 import node:fs 的位置,成为 FS 的唯一所有者FileEntryService / FileRefService 是 main 侧的 data repository,纯 DB 操作FileManager 作为 lifecycle service,统一协调 FS(via ops)与 DB(via repository),也是唯一 IPC 注册方db.files 表参考:architecture.md §3(分层架构)、§4.1(交互全景)。
原问题:uploadFile 完成后 renderer 再写 db.files;addFile 可绕过 main 直接登记。
解决方案:
createInternalEntry / ensureExternalEntry IPC 在 main 侧一次性完成 "FS 落地 + DB 登记"(internal)或 "upsert + stat 验证"(external),成功/失败整体可见{userData}/files/{id}.{ext} 后 insert file_entry 行externalPath 纯 upsert(同 path 有行就 reuse + 刷 snapshot;否则新建);external 不进入 trashed 生命周期,fe_external_no_delete CHECK 在 schema 层兜底addFile 这类绕路被类型系统封闭参考:architecture.md §2.3 的 createInternalEntry / ensureExternalEntry(及批量版本)、file-manager-architecture.md §1.2(external path unique)。
原问题:renderer 可调 addFile 写 db.files,FileMetadata.path 可能指向不存在/越权文件。
解决方案:
FileRef 的写操作仅由 main 侧业务 service 直接调用 fileRefServiceFileEntry 的所有写操作仅经 File IPC → FileManager,无 renderer 直写路径ensureExternalEntry 时 stat 验证,不信任 renderer 传入的任意字符串参考:architecture.md §3.1(DataApi vs File IPC 判定)、§4.2 (1)(2)。
原问题:旧架构按 "size + MD5" 去重,同内容不同文件名被合并,与 "用户视角每个文件独立存在" 冲突。
解决方案:
createInternalEntry 产生独立 entrycontentHash(xxhash-128)仅用于版本检测与 upload 缓存失效,不作为身份键externalPath 去重(同路径复用 entry),这是路径语义的同一性,非内容去重,不与"文件名独立"相冲突TODO:在 file-manager-architecture.md §1.1 增一行显式声明 "No content-based deduplication for internal entries",避免未来再次被当遗漏。
参考:file-manager-architecture.md §1.1(FileEntry 扁平个体记录)、§1.2(path 唯一性)。
原问题:count 是聚合数字,引用关系本身不可见,无法反查 / 无法解释。
解决方案:
file_ref 表显式 polymorphic:(fileEntryId, sourceType, sourceId, role) + UNIQUE 约束/files/entries/:id/refs — 某文件被谁引用/files/refs?sourceType=…&sourceId=… — 某业务对象引用了哪些文件sourceType / role 由业务模块在 SourceTypeChecker 注册,编译期闭合GET /files/entries/ref-counts?entryIds=... 按需 SQL 聚合计数,不再持久化 count 字段(见 migration-plan.md §2.3)。注:旧设计曾用 includeRefCount opt-in 字段,已废弃——DataApi 边界收紧为纯 SQL + 固定 shape。参考:file-manager-architecture.md §1.3 / §7(三层 ref 清理)。
原问题:文件页缺少 in-app 目录树,用户无法按目录组织。
决策:不在 file module DB 层引入目录树(file_entry 扁平,无 parentId、无 mount);但在 primitive 层预留 DirectoryTreeBuilder 供业务按需消费。
设计分层:
| 层 | 决策 | 位置 |
|---|---|---|
| 持久化模型 | 扁平,不做目录树 | file_entry schema |
| 运行时树构建 | 抽象为可复用 primitive | src/main/file/tree/(与 watcher/、ops/ 同级) |
| UI 状态 / 业务语义 | 由消费者自治 | Notes / 未来文件浏览器各自持有 |
运行时 primitive:DirectoryTreeBuilder(接口草案见 rfc-file-manager.md §14):
createDirectoryTree(path, options) 构建内存树createDirectoryWatcher()(已自动接入 DanglingCache),事件 → 树 mutationTreeNode<T>),业务可附加元数据file_entry 耦合按来源组织的另一维度:业务聚合用 FileRef.sourceType / sourceId,和目录树正交并存,两者都能用。
实施节奏:primitive 接口草案先进 RFC(已完成),Lean 实现随 Notes Phase 5 集成落地;第二消费者(如未来的文件浏览器)到来时再抽公共、补能力。
参考:rfc-file-manager.md §14(DirectoryTreeBuilder)、file-manager-architecture.md §1.1(FileEntry 扁平)、architecture.md §1.3(Notes 不在 file module 范围)。
原问题:知识库/对话/笔记上传混在一起,无来源维度。
解决方案:
FileRef.sourceType 提供 "chat_message" / "knowledge_item" / "painting" 等来源标签,DataApi 可按来源反查笔记来源问题:见 §9(笔记解耦设计)。笔记文件不默认进入文件页列表。如未来需要"统一的文件页",业务可显式为笔记文件创建 external FileEntry 挂 FileRef,但这是业务决策。
参考:architecture.md §4.2(业务服务接触点)、file-manager-architecture.md §1.3(FileRef 结构)。
原问题:对话输入只能从 OS 选文件,不能复用已上传 entry。
解决方案:
/files/entries?origin=internal 已经可以列出所有内部文件供选择;对话附件仅需在 FileRef 中加一条 sourceType='chat_message' 的引用即可复用既有 entry参考:architecture.md §3.1.2(典型 renderer 调用流示例)。
原问题:笔记文件树独立管理,未纳入 db.files,对用户"文件能力不一致"。
决策:采纳解耦设计,file module 不与笔记功能耦合。
理由:
file_entry 会引入双向同步复杂度(见原问题 §10 "问题" 部分,已验证得不偿失)origin='external' 的 FileEntry,通过 externalPath 引用笔记路径;但这是按需而非默认createDirectoryWatcher() 基于 FS 扫描实时构建(见 §6)对用户呈现的一致性:
参考:architecture.md §1.3(Notes 不在范围内 + 注:同一物理文件可同时属于 FS-first domain 与 external FileEntry)。
原问题:笔记文件树不进 db.files,与其他文件管理割裂。
决策:同 §9,保持解耦。Notes 的文件树交由 Notes domain 基于 watcher 在内存中构建,不入 file_entry 表。
原问题中已列出的 "问题" 在新架构下的处理:
| 原文列出的问题 | 新架构处理 |
|---|---|
| "与 db.files 体系割裂,无法统一检索" | 通过 FileRef 按来源聚合;笔记显式不参与统一检索(视图级分离) |
| "业务维度难以叠加(来源、标签、引用)" | 笔记域内的标签/引用由 Notes domain 自治;需要跨域引用时走 external FileEntry |
| "一致性依赖监听与扫描,逻辑分散在页面中" | 新架构提供 createDirectoryWatcher() 工厂作为通用 primitive,监听逻辑收敛在 Notes service 而非散在页面 |
| "未来引入单一节点表需要重建模或双向同步" | 明确不再追求"单一节点表"方向(见 §6 决策) |
参考:同 §9 / §6。
原问题:缺少 "写入 + 登记" 事务;缺少启动期对齐机制。
解决方案:
createInternalEntry / ensureExternalEntry / permanentDelete 在 main 侧一次完成 FS + DB(或 DB + stat),错误路径整体回滚FileManager.runOrphanSweep,Background):
{userData}/files/ 下 UUID 文件名但 DB 无对应行的孤儿 → unlink*.tmp-<uuidv7> 原子写残留OrphanRefScanner,延迟 30s 启动):
Record<FileRefSourceType, SourceTypeChecker> 编译期约束,每个 sourceType 必有 checkerfile_ref 中 sourceId 已不存在的行并清理DanglingCache):
Map<path, Set<entryId>>getDanglingState / batchGetDanglingStates 按需暴露(不再走 DataApi——FS 副作用一律走 IPC)参考:file-manager-architecture.md §7(三层 ref 清理)、§11(DanglingCache)、architecture.md §5.1-5.2(启动时序)。
原问题:FileMetadata 结构不易扩展到 "单一节点表 + 文件树"。
解决方案:
| 扩展方向 | 新架构提供的接入点 |
|---|---|
| AI provider 上传 | 延后引入 FileUploadService + file_upload 表,FileEntry 结构不变,additive migration |
| 新增业务引用来源 | 新增 sourceType 枚举值 + 注册 SourceTypeChecker(编译期强制) |
| 业务监控外部目录 | 通过 createDirectoryWatcher() 工厂;DanglingCache 自动同步 |
| Dangling 实时推送 | 当前走 DataApi query-time lookup;未来可在 DanglingCache 状态变化时触发 DataApi invalidation |
| 全文搜索 | ops/search.ts 基于 ripgrep;持久化索引由 Knowledge 等业务自行管理 |
目录树方向:见 §6 非目标决策。新架构不追求 "单一节点表 + 文件树" 模型。
参考:architecture.md §8(扩展点)。
原问题:ext / type 生成逻辑分散在多个入口,main 与 renderer 推断策略不一致。深层原因是 FileMetadata 为普通 interface,对象字面量就能满足类型——于是各处自拼,收口无从谈起。
解决方案:旧 FileMetadata 整体被 FileEntry 替代,并通过 brand type 在类型系统层禁止鸭子类型(参见 migration-plan.md §1.1 "字段级退役" 主线)。
renderer 与 main 共享的 v2 入口类型是 FileEntry(来自 @shared/data/types/file),旧的 FileMetadata 随各消费域切换逐步退役(见 migration-plan.md §2 字段退役清单 + §3 消费域切换计划)。
FileEntry 有派生字段——name/ext 由 basename 切分、type 由 ext 派生、refCount/dangling/path/url 是 DataApi 按需聚合。这些派生只有在 sanctioned 路径(main 侧)才能正确产生,renderer 或业务代码自拼对象就会破坏统一。
解法:只给 FileEntry 加 brand——让对象字面量无法满足类型:
// src/shared/data/types/file/fileEntry.ts
export const FileEntryIdSchema = z.uuid() // 普通字符串,不 brand
export const FileEntrySchema = z
.discriminatedUnion('origin', [InternalEntrySchema, ExternalEntrySchema])
.brand<'FileEntry'>()
export type FileEntryId = z.infer<typeof FileEntryIdSchema>
export type FileEntry = z.infer<typeof FileEntrySchema>
效果:
const e: FileEntry = { id, origin, name, ... } → 编译错误(缺 brand,拒绝绕过派生的鸭子对象)const e = FileEntrySchema.parse(raw) → OK(parse 成功时自动施加 brand)const e2: FileEntry = { ...e, name: 'new' } → 编译错误(spread 丢 brand)——修改必须走 rename IPC 等 sanctioned mutator,与"mutation 统一走 FileManager"天然对齐范围严控:仅 FileEntry 一个类型加 brand。其他类型(FileEntryId / FileRef / FileRefId)保持普通 z.infer 类型——它们没有派生字段(ID 是纯字符串,FileRef 是纯行),加 brand 只会给测试和 main 内部代码增加无谓的 parse 样板,不换保护。
唯一能 emit 已 branded FileEntry 的路径:
| 生产者 | 位置 | parse 时机 |
|---|---|---|
createInternalEntry / ensureExternalEntry / 批量版本 IPC | FileManager | 返回前 FileEntrySchema.parse |
| DataApi handler(row → DTO) | src/main/data/api/handlers/files.ts | 响应前 FileEntrySchema.parse;固定 shape,无 opt-in 派生字段 |
| File IPC enrichment(dangling / path) | FileManager | 通过专用 IPC 返回(getDanglingState / getPhysicalPath 及其批量版本);与 DataApi 边界互斥。file:// URL 不再走 IPC——由共享纯函数 toSafeFileUrl(path, ext)(@shared/file/urlUtil)在进程内合成 |
| FileMigrator insert | src/main/data/migration/v2/migrators/FileMigrator.ts | 写入前转换并 parse |
renderer 不再在前端拼接 entry 对象,类型系统层封堵"直接搓 FileMetadata 鸭子对象"的旧式入口。
ext(不含前导点)与 name(不含扩展名)在 createInternalEntry / ensureExternalEntry 内由 main 侧统一切分(见 migration-plan.md §2.7)ext 的运行期防御由 shared file SafeExtSchema(common.ts)集中保障:禁前导点、禁 path separator、禁 null bytes、禁 whitespace-only——与 SafeNameSchema 的威胁模型对齐(都是会拼进 {dir}/{name}.{ext} 参与 fs.* 路径的字段)。类型层保持 string | null,不 brand、不加编译期 template literal 限制——convention first,FileEntrySchema.parse 是系统边界的权威检查type 不再持久化,改为读时派生(默认按 ext,getMetadata 时可 buffer 升级,见 §2.5)ops/metadata.ts,renderer 不再自行判断 MIME / exttests/__mocks__/factories.ts 提供 makeFileEntry(overrides),内部仍走 FileEntrySchema.parse——mock 数据被强制经过 schema 校验。不设 unbranded 后门,严守"只有 parse 过的才是 FileEntry"。
brand 是编译期约束,运行期 as FileEntry 仍可绕(code review 可见)。真正的运行时防线是 schema parse 本身——IPC 边界、DataApi 响应边界都会施加 parse,保证即便 TS 被绕过,数据形状依然合法。
不再存在"同一个文件在 renderer 和 main 得出不同 type/ext"的可能——renderer 拿到的始终是 main 已归一化且经 Zod parse的 branded FileEntry。
参考:rfc-file-manager.md §4.5(DTO 类型 + brand 设计)、migration-plan.md §1.1(两条主线)、§2.5(type 迁移)、§2.7(name/ext 切分)、§3(消费域切换)。
createInternalEntry 的 source discriminator(A-7 延伸)关联:本节是对 PR #13451 架构评审 A-7(
createEntry({ origin })拆分)的延伸决策——A-7 已拆出createInternalEntry/ensureExternalEntry两个语义方法,本节进一步收紧createInternalEntry的入参类型。
原签名:
type CreateInternalEntryParams = {
name: string
ext?: string | null
content: FileContent // FilePath | URLString | Base64String | Uint8Array
}
审视后发现:
name 一刀切必填,造成全仓重复代码。对 FilePath / URLString 两类 content,name 可由 basename(path) / URL 末段派生;Phase 2 的大头消费者(chat attach / knowledge ingest / painting download)都是这两类,却被迫每一处手写 basename,违反 DRY。ext? 的 JSDoc 写 "Derived from name if omitted",与 CommonEntryFields.name 的"不含扩展名"语义直接冲突——要从 name 派生 ext,就必须要求 name 携带后缀,反向破坏 FileEntry 不变量。真正应该派生 ext 的源是 content(path extname / URL 后缀 / mime / sniff),不是 name。FileContent union 压平成同一契约——调用方无法通过类型感知"我这个分支究竟允许省略什么"。| content 形态 | name 可派生? | ext 可派生? |
|---|---|---|
FilePath | basename(path) ✅ | extname(path) ✅ |
URLString | URL 末段 / CD header ✅ | URL 后缀 / Content-Type ✅ |
Base64String | ❌ 无原名 | mime 查表 ✅ |
Uint8Array | ❌ 无原名 | ❌(需 caller 提供或 sniff) |
source discriminator unionexport type CreateInternalEntryIpcParams =
| { source: 'path'; path: FilePath }
| { source: 'url'; url: URLString }
| { source: 'base64'; data: Base64String; name?: string }
| { source: 'bytes'; data: Uint8Array; name: string; ext: string | null }
类型门约束(对应上面的矩阵):
Pasted Image {timestamp})source 字段(而非让 TS 按 content 字面量类型自动收窄)考虑过"方案 A:保留单一 content: FileContent 字段,靠 template literal narrowing 推断分支"。否决喵,原因:
FilePath = '/${string}' | '${string}:\\${string}' 与 URLString = 'http://${string}' | 'https://${string}' 都是 string 子类型。TS 对动态 string 变量不会将其收窄到具体 template literal 分支——只有字面量或显式 as FilePath 才生效。而调用点几乎都是动态 string(从 dialog / fetch / drag-drop 拿到),narrow 失败率极高。Uint8Array 与 Base64String 能被 narrow,但混合形态下类型行为不一致,读者心智负担大。source: 'path'|'url'|'base64'|'bytes' 是 literal union,narrow 100% 稳定;switch (params.source) 内部 dispatch 零歧义;调用点"声明自己在从什么创建"也与 audit 里旧 uploadFile / saveBase64Image / savePastedImage / downloadFile 四套 API 的分类天然对齐,语义更自解释。source: '...' 字面量 → 比"调用方手写 basename(path) + 自己推 ext"反而更轻量。copy() 伪代码需同步调整:原本 copy 直接塞 { name, content: readStream },新 API 里拿不到 stream 分支——copy 改为:先 resolveFileHandle → absPath,调 createInternalEntry({ source: 'path', path }),如有 newName 再 follow-up 调用 rename。语义更分离,copy 独有的 UX 需求(改名)不污染 createInternalEntry 核心 API。src/shared/file/types/ipc.ts — CreateInternalEntryIpcParams 重写为 discriminator union,附完整决策注释src/main/file/FileManager.ts — 本地 CreateInternalEntryParams 删除,改为 alias 自 shared 类型v2-refactor-temp/docs/file-manager/rfc-file-manager.md §5.1 / §5.6 / §7.3 — 伪代码与 renderer 示例同步docs/zh/references/file/file-manager-architecture.md §1.6.3 — facade 伪代码签名注释参考:src/shared/file/types/ipc.ts 头部 JSDoc、rfc-file-manager.md §5.1、filemetadata-consumer-audit.md §4.1(旧 API 到新 source 分支的对应)。
| # | 问题 | 状态 |
|---|---|---|
| 1 | 职责边界割裂 | ✅ 已解决 |
| 2 | 上传与登记非原子 | ✅ 已解决 |
| 3 | Renderer 可直写引用 | ✅ 已解决 |
| 4 | 去重对用户可见性冲突 | ✅ 已解决 |
| 5 | 引用计数语义不足 | ✅ 已解决 |
| 6 | 缺少结构化目录树 | 🚫 DB 层非目标 / ⚠️ primitive 已预留 |
| 7 | 业务来源不可区分 | ⚠️ 架构已使能 |
| 8 | 对话上传无法复用内部文件 | ⚠️ 架构已使能 |
| 9 | 笔记与全局文件管理割裂 | 🚫 非目标(解耦) |
| 10 | 笔记文件树未纳入 DB | 🚫 非目标(解耦) |
| 11 | 跨进程一致性难验证 | ✅ 已解决 |
| 12 | 可扩展性受限 | ⚠️ 主要方向已解决 |
| 13 | FileMetadata 生产不统一 | ✅ 已解决 |
结论:13 项问题中,架构层面的 7 项全部闭合;3 项(6 / 9 / 10)经评估后作为解耦设计决策明确列为非目标并给出依据;3 项(7 / 8 / 12)的架构基础已就绪,剩余交付属业务 PR 范围。