feat: implement folder upload service with directory tree building, batch pre-checks, and comprehensive unit testing

This commit is contained in:
dolphin
2026-06-11 13:50:16 +08:00
parent d6b93cc22c
commit 646dbccbfa
19 changed files with 1158 additions and 83 deletions
+137
View File
@@ -0,0 +1,137 @@
# Design: <特性名称>
> **本文档定位 — 现状快照(Why this How**
>
> - `spec.md` 回答 **做什么**(目标、AC、边界)
> - `design.md`(本文)回答 **为什么这么实现**:关键决策、运行时不直观的事实、对外契约
> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动
>
> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):
> - 实现变化 → **覆盖更新本文档**,只留"今天的状态"、不留旧快照;但每个决策保留"为什么 + 被否方案"和坑(护栏,见 §3/§5)
> - **偏差分级**:推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design,不必停
> - `tasks.md`「实际偏差记录」只留一行指针(如"T7 偏离 → 更新 design 决策3"),论证在本文档,不重复
**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)
**版本**: v<X.Y.Z>
**最后更新**: YYYY-MM-DD(同步实现变更时一并更新)
---
## 1. 目标与非目标
- **目标**1-3 句话讲清这个 feature 在系统里扮演什么角色
- **非目标**:明确**不**做什么(防止后人误扩范围)
---
## 2. 关键约束
本功能**特有**的硬性约束(决定下面方案对比的取舍空间)。
- **全局架构铁律(双 DB / 多租户 / 权限 / 分层 / 错误码)不在此重抄** —— 一句"遵循 `docs/constitution.md` C1–C7"带过;本节只写本功能特有的:性能 / 容量 / 部署形态 / 上游数据格式依赖 等。
- 引用上游:`docs/constitution.md`(铁律)、`release-contract.md`(版本契约 / 模块编码)。
---
## 3. 方案对比与选定
> **核心设计决策**逐条记录。每条 3 段:备选 / 选定 / 原因。
> 这一节是 design.md 的**最高价值部分** —— agent 接手时,先读这里就知道有哪些"想当然会走但被否决"的路。
### 决策 1<决策主题>
- **备选**
- A. <方案 A> — 优点 / 缺点
- B. <方案 B> — 优点 / 缺点
- **选定**A
- **原因**<关键约束 / trade-off / 已知证据>
- **何时该重新考虑**:<触发条件,例:QPS > X、双 DB 兼容性松动、上游数据格式变更>
### 决策 2:…
---
## 4. 系统现状(接手必读)
> **这里写"今天代码长什么样"**,让接手 agent 不用从 950 行 service 里反推。
> 不写实现细节代码,写**业务话的流程 + 关键文件名 + 关键函数名**。
### 4.1 数据流
`<入口> → <处理 A> → <处理 B> → <出口>`
每一步一句话:做什么 + 主要文件:行号或函数名。
### 4.2 关键数据结构 / 字段约定
> 写**对外可见**的契约(API request/response、消息字段、文件命名规则、ID 规则)。
> 内部数据结构不用写(看代码即可)。
| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 |
|---|---|---|---|
| `xxx.query` | JSON envelope `{"query": str, "files": [...]}` | 用户输入消息 | 导出 / 历史回放 |
### 4.3 关键模块职责
| 模块 / 文件 | 职责 | 不做什么 |
|---|---|---|
| `xxx_service.py` | 业务编排 | 不直接写 ORM |
| `xxx_renderer.py` | 输出格式化 | 不取数据 |
---
## 5. 已知坑 / 反直觉事实
> **最防失传的内容**。代码里看不出、commit message 里散落、踩过才知道的东西。
> 每条要带"如果不知道会怎样",让后人知道严重性。
| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
|---|---|---|---|
| 1 | 运行时 `parentMessageId` 一直是空串,不能用来配对消息 | 配对全错,导出空白 | `useMessageSelection.ts:buildPairGroup` 改用数组位置 |
| 2 | 答案文本在 v2.5 agent-native 格式下只在 `events[]` 里,`msg` 可能为空 | 导出空答复 + RAG 角标残留 | `_extract_answer_text` 兜底从 events 取 + 重跑 strip |
---
## 6. 对外契约与依赖
> 让"改了我会破坏谁"和"我依赖谁"显式化,跨 feature 重构时能搜到。
### 6.1 我提供给别人的(Outgoing
| 契约 | 形式 | 谁在用 |
|---|---|---|
| `/api/v1/xxx/yyy` POST | HTTP API | platform 前端、external script |
| `XxxService.do_yyy()` | 内部 Python API | <其他 service 名> |
### 6.2 我依赖别人的(Incoming
| 依赖 | 形式 | 风险点 |
|---|---|---|
| chat 消息 `query` 字段为 JSON envelope | 隐式数据契约 | chat 模块若改格式会静默坏掉本 feature |
| `libreoffice` 二进制 docx→pdf | 系统依赖 | Docker 镜像必须装 |
---
## 7. 测试与可观测
> 不重复 tasks.md 的测试清单。只写**整体策略**和**怎么手动验证**。
- 单元 / 集成 / e2e 各覆盖哪些层
- 真实环境怎么手动跑一遍(命令、URL、账号)
- 关键日志 / 指标 / 报警在哪
---
## 8. 后续改进 / 不打算做的事
- 已知短板 + 暂时不投入的理由
- 重写或拆分的触发条件
---
## 修订历史
| 日期 | 改动 | 触发原因 |
|---|---|---|
| YYYY-MM-DD | 初版 | feature 完成 |
| YYYY-MM-DD | §5 增坑 X | 用户报障 / e2e 暴露 |
@@ -0,0 +1,229 @@
# Design: 工作台会话回答级导出 + 导入到知识空间(F028)
> **本文档定位 — 现状快照(Why this How**
>
> - spec.md 回答 **做什么**(目标、31 条 AC、边界)
> - design.md(本文)回答 **为什么这么实现**:关键决策、运行时不直观的事实、对外契约
> - tasks.md 是 **流水账**:拆了哪些任务、做了什么改动
>
> 实现变化触发的更新规则:触及"系统认知"(数据模型/接口契约/配对逻辑/依赖格式)必须**覆盖更新本文档**,
> 同时在 tasks.md「实际偏差记录」追加一笔。
**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [e2e-checklist.md](./e2e-checklist.md)
**版本**: v2.6.0-beta3
**最后更新**: 2026-06-02PDF 引擎从 libreoffice 切到 chromium/playwright
---
## 1. 目标与非目标
- **目标**:用户在工作台聊天页面,按"对话轮次"勾选问答对,导出成 docx / pdf / md / txt,或直接导入到已选的知识空间作为可被检索的资料。
- **非目标**
- 不导出未发送 / 撤回 / 编辑历史
- 不做异步导出队列(同步返回文件流即可,单次最多 N 轮)
- 不在导出时做语义抽取或摘要(原文导出)
- 不支持跨会话拼接(只导出当前一条会话的选中轮次)
---
## 2. 关键约束
- **双 DB 兼容**CLAUDE.md §3.2):本 feature 取数走现有 chat 消息表,无新 DDL,天然兼容
- **多租户**CLAUDE.md §3.3):取数依赖现有 `chat_message` 表的 tenant 自动注入,无需手动 WHERE
- **权限**CLAUDE.md §3.4):导入到知识空间走 `PermissionService.check` + 写 OpenFGA owner 元组,复用现有 add_file 路径
- **依赖**:服务端必须有 `pandoc`(系统二进制)+ `pypandoc`Python 包,docx 路径)+ `playwright` / chromium binarypdf 路径,md→html→pdf+ `python-markdown`
- **错误码段**120**20-12069** 段分配给本 feature,定义在 `common/errcode/workstation.py`
---
## 3. 方案对比与选定
### 决策 1:消息配对策略 — 数组位置 vs parentMessageId
- **备选**
- A. 用 `parentMessageId` 字段配对(业界常见做法,按父子关系)
- B. 按消息数组位置配对(assistant 的前一条 user 就是它的配对)
- **选定****B**
- **原因**:实测工作台运行时 `parentMessageId` 一直是空串,无法用来配对。前端消息流是有序的,按位置配对在所有运行时分支上都成立。
- **何时该重新考虑**:上游 chat 模块给 `parentMessageId` 落地真实值(届时 B 仍能工作,但 A 更语义化、能支持分支会话)
### 决策 2:用户 query 字段的形态 — 平文本 vs JSON envelope
- **备选**
- A. 把 `query` 当成纯文本字段直接取
- B. 兜底把 `query` 当作 JSON envelope 解包
- **选定****B(必须解包)**
- **原因**:实测两种运行时下 `query` 都是 JSON 包裹:
- 日常模式 `{"query": "<text>", "files": []}`
- 工作流模式 `{"data": {...}, "input": "<text>"}`
- 平文本读会拿到带 `{...}` 的乱码
- **何时该重新考虑**:上游 chat 统一改为平文本(届时把 `_extract_query_text` 简化为直读)
### 决策 3assistant 答案文本来源 — agent_answer.msg vs events[]
- **备选**
- A. 只读 `agent_answer.msg`
- B. `msg` 空时回退到 `events[type=text]`
- **选定****B(必须兜底)**
- **原因**v2.5 agent-native 格式下 `msg` 可能为空,正文文本只存在于 `events[]` 数组中(每个 chunk 一个事件)。工作流 OUTPUT 节点 `output_type=text` 也走 events。如果不兜底,导出空答复 + RAG 角标残留(因为 strip 拿不到文本)。
- **何时该重新考虑**agent-native 格式统一回写 `msg` 字段后
### 决策 4:导出执行 — 同步流式返回 vs 异步任务
- **备选**
- A. 同步:HTTP POST 直接返文件流(docx/pdf/md/txt
- B. 异步:Celery 任务 + 回调 URL 下载
- **选定****A**
- **原因**:单次导出体量小(最多 N 轮 + 若干图片),渲染 < 30s 可接受;同步实现简单、用户体验好、无状态。
- **何时该重新考虑**:单次导出范围放开到整个会话或多会话拼接、平均耗时 > 30s、渲染进程阻塞 worker
- **注**PDF 渲染引擎已从 libreoffice 改为 chromium(见决策 6);本决策只关乎同步 vs 异步,与引擎无关。
### 决策 5:知识空间下拉数据源 — 复用 list vs 新增 uploadable 端点
- **备选**
- A. 复用现有知识空间列表接口,前端过滤
- B. 新增 `GET /api/v1/knowledge/space/uploadable` 专用端点,后端按"可上传文件类型"过滤
- **选定****B**
- **原因**:可上传性不只是 read 权限,还涉及空间类型(QA / 文件库)/ 文件格式白名单,过滤逻辑放后端更稳。复用 `AddToKnowledgeModal``dataSourceApi` prop 切换数据源即可。
- **何时该重新考虑**:知识空间类型扩展或前端需要更多元数据
### 决策 6PDF 渲染引擎 — libreoffice vs chromium(playwright)
- **备选**
- A. docx → libreoffice 子进程 → pdf(复用 docx 渲染产物)
- B. md → html → chromium(playwright) → pdf(绕开 docx
- **选定****B(改用 chromium**
- **原因**:实测真实数据下,libreoffice 解析 pandoc 生成的 docx 时表格 / 列表布局崩塌,排版不可用。直接 md→html→chromium 打印 pdf,绕开 docx 中间产物,布局可控。
- **何时该重新考虑**pandoc/libreoffice 的 docx 排版兼容性显著改善,或需要 docx 与 pdf 完全像素一致
---
## 4. 系统现状(接手必读)
### 4.1 数据流
```
工作台聊天页面
↓ 用户勾选轮次
前端 useMessageSelection (Recoil store)
↓ POST /api/v1/chat/messages/export 或 /import-to-knowledge
后端 conversation_export endpoint
ConversationExportService.export_messages()
├─ 取数:chat_message 表(按 chat_id + 选中 message_id 列表)
├─ 配对:_pair_user_assistant(按数组位置)
├─ 抽文本:_extract_query_textunwrap JSON + _extract_answer_textmsg + events 兜底)
├─ 预处理:图片预下载(httpx 并发 8,超时 5s+ RAG 角标 strip
├─ 渲染:renderer 工厂(md/txt 直拼,docx 走 pypandoc + 模板,pdf 走 chromium(playwright)md→html→pdf,绕开 docx
└─ 返回:StreamingResponse(file_bytes)
```
import-to-knowledge 路径相同,最后一步改为:渲染成 docx 临时文件 → 调用现有 `add_file` → 失败重试同名。
### 4.2 关键数据结构 / 字段约定
| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 |
|---|---|---|---|
| `chat_message.message` (user 行) | JSON envelope `{"query": str, "files": [...]}``{"data": {...}, "input": str}` | 用户输入;必须用 `_extract_query_text` 解包 | 导出、历史回放 |
| `chat_message.message` (assistant 行) | `{"msg": str, "events": [{"type": "text", "content": str}, ...]}` 等 | 答案;`msg` 空时回退 events | 导出、历史回放 |
| `POST /chat/messages/export` 请求 | `{chat_id, message_ids: int[], format: "docx"\|"pdf"\|"md"\|"txt"}` | 同步导出 | 前端 sheet 组件 |
| `POST /chat/messages/import-to-knowledge` 请求 | `{chat_id, message_ids: int[], knowledge_id: int, filename?: str}` | 同步导入 | 前端 sheet 组件 |
| `GET /knowledge/space/uploadable` 响应 | `PageData[KnowledgeSpaceItem]` | 过滤后的可写入空间列表 | `AddToKnowledgeModal` |
| 文件名约定 | `<会话标题>_<YYYYMMDD-HHmm>.<ext>` | 服务端生成;中英日字符已 escape | 浏览器下载 / add_file 入参 |
### 4.3 关键模块职责
| 模块 / 文件 | 职责 | 不做什么 |
|---|---|---|
| `workstation/api/endpoints/conversation_export.py` | HTTP 入口、auth、参数校验 | 不写业务逻辑 |
| `workstation/domain/services/conversation_export_service.py` (~950 行) | 取数 + 配对 + 文本抽取 + 渲染编排 | 不直接读 ORM 之外的存储(图片走 service 内 httpx |
| `workstation/domain/services/conversation_export_renderers/*` | 4 个 renderermd/txt/docx/pdf+ 图片预处理 + 文件名生成;`_render_pdf` 走 chromium(playwright) md→html→pdfdocx 路径仍 pandoc + 模板 | 不取数据 |
| `workstation/assets/conversation_export_template.docx` | pypandoc reference doc,控制字体/段距/代码块样式 | 渲染脚本由 `scripts/gen_conversation_export_template.py` 再生 |
| 前端 `useMessageSelection.ts` + `messageSelectionStore.ts` | 选择态:意图驱动 + `computeSelectedIds` 实时算。三种意图:显式 `selectedIds``globalSelectAllOn`(全选)、`selectAllBelowAnchor`(全选以下,覆盖式;锚点为答案时 `computeSelectedIds``buildPairGroup` 补回上方关联问题) | 不发请求 |
| 前端 `Chat/MessageSelection/*` | UItoolbar / checkbox(带 `data-message-id` 供锚点定位)/ sheet / `SelectAllBelowBanner`(常驻 sticky pill + 校准线,按滚动位置算锚点)/ export-button / Provider | 不取业务数据 |
| 前端 `pages/appChat/components/*` + `Chat/AiChatMessages` | 两个聊天入口(日常 vs 工作流/助手)的挂载点 | 各自独立的 chat 容器,不要重构合并 |
---
## 5. 已知坑 / 反直觉事实
| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
|---|---|---|---|
| 1 | 工作台运行时 `parentMessageId` 始终为空串,不能用来配对消息 | 配对全错,按选中渲染时 user/assistant 错位 | `useMessageSelection.ts:buildPairGroup` 改用数组位置 |
| 2 | 用户 `query` 字段在所有运行时下都是 JSON envelope,需 unwrap | 导出"query"原文包含 `{...}` 串,看起来像乱码 | `_extract_query_text`commit `4394ba524` |
| 3 | v2.5 agent-native 格式下 `agent_answer.msg` 可能为空,正文只在 `events[type=text]` 数组里 | 导出空答复 + RAG 角标 `[^1]` 残留(strip 拿不到文本) | `_extract_answer_text` 兜底从 events 取,取出后必须重跑 `strip_rag_marks`commit `4394ba524` |
| 4 | 工作流 OUTPUT 节点 `output_type=text` 也走 events,不走 `msg` | 工作流场景导出空白 | 同 #3 |
| 5 | 第二次点击同一条消息要"取消选中"(不是再次选中) | 用户取消勾选不生效 | `useMessageSelection.ts` toggle 语义 |
| 6 | 图片预下载并发 8、超时 5s;失败的图片**保留原 URL** | 失败图片在文件里是裸链接 | `_prefetch_images`(design 取舍:宁可有链接,不要整次导出失败) |
| 7 | pandoc 默认要求 list 前有空行,否则列表不被识别 | md→html 列表渲染成普通段落 | 渲染时加 `+lists_without_preceding_blankline` extension |
| 8 | 有的来源把 PUA marker 字面化成 `` 这种 6 字符串(非真正的码点) | strip 漏掉,marker 残留在正文 | 兜底同时处理真实 PUA 码点和字面化的 6 字符串 |
| 9 | 兜底 strip 的 `\s*` 不能吃换行 | 吃掉 `ul` 项之间的分隔,多个列表项被粘连 | strip 正则用不含换行的空白类,别用 `\s*` |
| 10 | WenQuanYi / Liberation 字体没有 emoji 字形,docx 路径不会自动 fallback | docx 里 emoji 显示成豆腐块 | docx 路径预替换 emoji 为 `●`chromium 路径不受影响) |
| 14 | 答案里的图片是 `/bisheng/knowledge/images/...` 根相对路径(前端 nginx 代理路径,后端无此代理);且 MinIO 返回 `application/octet-stream` | docx/pdf 渲染时图片抓不到(被当不支持 scheme 丢)或扩展名猜成 `.bin` → pandoc 嵌不进 → docx 无图(txt/md 只输出链接文本不受影响) | `_fetch_image_bytes` 相对路径补 `get_minio_share_host()` 再抓;content-type 为泛型时用 URL 后缀兜底扩展名 |
| 13 | 平台 `process_one_file` 按**内容 md5/文件名**去重(重复内容→旧文件标 FAILED、不新建);F028 的 `(1)` 改名是文件名级,绕不过 md5 内容去重 | 重导同一会话内容一致 → md5 命中 → 被当重复拒收,知识空间"只看到一份",`(1)` 永不出现 | F028 导入给 `add_file`/`process_one_file``skip_dedup=True` 强制新建(已用 `_resolve_unique_filename` 保证文件名唯一) |
| 12 | 工作流/助手答案落库 category 是 `stream_msg`/`output_msg`(文本在 JSON `msg` 字段),QA 答案是 `{"content":...}`,工作流提问是 `is_bot=1` 的纯字符串 | 后端 `_extract_answer_text` 旧版只认 `answer`/`agent_answer` → 工作流/助手导出**答案整块空** | `_extract_answer_text` 统一处理 `stream_msg`/`output_msg`/`agent_answer`(取 msg+events+ `{"content"}` + 纯字符串,丢弃 `reasoning_answer`/`input`/thinking/tool |
| 11 | **实时会话中前端消息 id 是临时值,不是真实 DB 主键**:daily 路径提问消息曾被钉死成临时 UUID(`useAiChat.onCreated``messageId: userMessageId` 覆盖了 `created` 事件带回的真实 id);workflow 路径答案消息在 `end``message_id` 时保留合成 id `unique_id+output_key` | 导出按 `parseInt(messageId)` 取整数 id:UUID→NaN 被丢(提问整轮丢失,导出只剩答案);纯数字合成 id→发出去后端查无此 id 报 12060「消息不存在」。刷新后走历史接口才映射成真实 id,所以"刷新后正确" | daily`useAiChat.onCreated` 改为用 `created` 事件的真实 `messageId` 回填,`onFinal` 同步按真实 id 匹配。workflow 路径同类问题(answer 合成 id)尚未修,见 §8 短板 |
---
## 6. 对外契约与依赖
### 6.1 我提供给别人的(Outgoing
| 契约 | 形式 | 谁在用 |
|---|---|---|
| `POST /api/v1/chat/messages/export` | HTTP(同步返文件流) | 前端 chat sheet |
| `POST /api/v1/chat/messages/import-to-knowledge` | HTTP(同步返 add_file 结果) | 前端 chat sheet |
| `GET /api/v1/knowledge/space/uploadable` | HTTP `PageData[KnowledgeSpaceItem]` | `AddToKnowledgeModal`(复用,dataSourceApi prop |
| `ConversationExportService.export_messages(...)` | 内部 Python API | 仅 endpoint 调用,暂无其他 service |
| 错误码 12060-12069 | 5 位 MMMEE | 前端 toast / e2e 断言 |
| 导出文件命名规则 `<标题>_<YYYYMMDD-HHmm>.<ext>` | 隐式契约 | 前端展示、用户文件管理 |
### 6.2 我依赖别人的(Incoming
| 依赖 | 形式 | 风险点 |
|---|---|---|
| `chat_message` 表 schema | 直接 ORM 读 | 若 chat 模块改 `message` 字段格式(如把 envelope 改成平文本、或把 events 整合进 msg),F028 静默坏掉 |
| chat 消息 `query` 字段为 JSON envelope | 隐式数据契约 | 同上,需在 chat 改动 PR 中提前告知 |
| `agent_answer.events[type=text]` 作为答案文本兜底 | 隐式数据契约 | agent-native 格式重构会破坏;评估时必须检查本 feature 的 extractor |
| `add_file` 服务 | 内部 Python API | knowledge 模块若改 add_file 签名/语义,要同步本 feature |
| `pypandoc` + `pandoc` 系统二进制 | 运行时依赖(docx 路径) | Dockerfile 必须装;缺失时 docx 渲染抛错(在 `scripts/check_export_dependencies.sh` 兜底) |
| `playwright` + chromium binary | 运行时依赖(仅 pdf 路径) | 镜像里 chromium 装在 `/root/.cache/ms-playwright/chromium-*`;首次冷启动慢;本地 mac 需 `playwright install chromium`(测试时 mock|
| `python-markdown` | 运行时依赖(pdf 路径 md→html) | pip 包,缺失时 pdf 渲染抛错 |
| `PermissionService.check` / `authorize` | 内部 Python API | 导入到知识空间必走,权限模型若改要同步 |
---
## 7. 测试与可观测
- **单元/集成(80 个)**:覆盖 service 取数、turn 配对、两个 bug 修复路径、4 个 renderer、文件名、导入流程、同名重试、uploadable spaces。(原 94 个,API + renderer 测试因 mock playwright 重写后收敛到 80)命令:
```bash
cd src/backend && uv run pytest test/workstation/ test/knowledge/test_uploadable_spaces*.py
```
- **API e2e9 个)**:契约层,需要 backend + admin token。命令:
```bash
cd src/backend && E2E_ADMIN_PASSWORD='Bisheng@top1' uv run pytest test/e2e/test_e2e_conversation_export.py -v
```
- **手动 UI 验证**[e2e-checklist.md](./e2e-checklist.md)(覆盖 UI 类 AC + 真实导出视觉巡检)
- **可观测**:失败走标准错误码 + service 内 `logger.exception`;目前没有专门的 Prometheus 指标(短板,见 §8
---
## 8. 后续改进 / 不打算做的事
- **不做异步导出**:除非范围放开到整会话或多会话拼接(见 §3 决策 4)
- **不做语义抽取/摘要**:原文导出更可信,摘要应是另一个 feature
- **短板:chromium 渲染开销** —— 启动 ~1-2s/page、渲染 ~0.5s,首次冷启动尤其慢;高并发或大批量导出时是瓶颈,考虑后续做 browser 复用 / 进程池
- **短板:图片失败保留原 URL** —— 离线打开就是死链;后续可考虑改为占位图 + 旁注
- **短板:缺导出耗时指标** —— 后续接 Prometheus / 业务日志埋点
- **短板:workflow/技能聊天入口(appChat)的临时 id 未修** —— 该入口答案消息在流 `end` 缺 `message_id` 时保留合成数字 id(曾观测到导出请求 `message_ids:[1,71]`、`[9394]`,均小于/不在真实 id 段 → 12060)。daily 入口已修(见 §5 #11),appChat 入口待同样回填真实 id,或在导出侧加"非真实 id 过滤"兜底
- **不打算合并两个聊天入口**(日常 vs 工作流/助手):两个 chat 容器历史独立,强行合并会牵连大量无关代码
---
## 修订历史
| 日期 | 改动 | 触发原因 |
|---|---|---|
| 2026-06-01 | 初版(覆盖到 commit `4394ba524` | F028 主体完成,从 handoff 文档沉淀到 SDD 流程 |
| 2026-06-02 | PDF 引擎 libreoffice→chromium/playwright(决策 6);§4.1/§4.3 渲染路径、§5 坑(删 libreoffice 超时,加 pandoc 空行/PUA 字面化/strip 换行/emoji fallback 4 条)、§6.2 依赖、§7 测试数 94→80、§8 短板同步 | libreoffice 解析 pandoc docx 表格/列表布局崩塌 |
@@ -1,4 +1,4 @@
# Design: 知识空间文件 / 文件夹移动(同空间 + 跨空间)
# Design: 知识空间文件 / 文件夹移动(同空间 + 跨空间)+ 文件夹上传
> 现状快照(Why this How)。spec=做什么,本文=为什么这么实现 + 今天代码长什么样,tasks=流水账。
@@ -174,7 +174,7 @@
| 3 | AC-09 继承权限重算**不用写代码**——OpenFGA parent tuple 一换,tupleToUserset 自动重算;直绑 tuple 不碰 | 手动重算继承,白做且易错 | parent tuple 双操作 |
| 4 | 「只校验被操作对象、不校验子项」(AC-08):跨空间移动文件夹时**子文件即使无 move 权限也跟着走**——这是 PRD 明确语义,不是漏洞 | 误加递归权限校验,行为与 PRD 不符 | move_items 权限只查入参项 |
| 5 | **版本链整链迁移**:漏掉历史版本会违反「链内同空间」不变式(version_service 写路径会炸);历史版本文件**各有自己的向量和 minio 对象**,迁移任务也要覆盖它们 | 历史版本残留旧空间,版本管理页报错 | move_items 按 document 展开整链 |
| 6 | **MinIO 不用搬**:对象路径按 file_id`original/{id}.ext`)。**例外是文档图片目录** `knowledge/images/{knowledge_id}/{doc_id}`——chunk 里的图片引用指向旧空间目录;不搬也能用,但**旧空间被删除时图会丢** | 删旧空间后,已移走文件的图片裂开 | 迁移任务顺带把 images 目录拷到新空间路径并更新 chunk 引用;或至少在 tasks 里列为已知债 |
| 6 | **MinIO 不用搬**:对象路径按 file_id`original/{id}.ext`)。文档图片目录 `knowledge/images/{knowledge_id}/{doc_id}`chunk 引用指向旧空间路径,**但已核实(2026-06-11)删旧空间不会丢**——空间删除的 MinIO 清理(`delete_knowledge_file_in_minio`)只按文件记录逐个删 4 个对象字段,全代码库无按 `knowledge/images/{kid}/` 前缀的删除逻辑,且该目录配匿名读策略,不依赖空间存在 | 误以为要赶在删空间前迁移图片,做多余的搬运工程 | 无需处理;真实债务是 images 目录**从来没人清理**(连本空间文件的图都留着)=存储泄漏,见 §8 |
| 7 | 跨空间迁移任务**执行中途**源/目标空间可能再变(连环移动):任务按 file 当前 `knowledge_id` 解析目标,以「最后归属」为准;删源时按 file_id 删,幂等 | 重复迁移/删错空间 | migrate 任务读 DB 最新归属 + 幂等删 |
| 8 | 排队中/解析中文件跨空间移动:解析 celery 任务**执行时**才解析 knowledge_id → 自然写入新空间;但若任务已初始化旧空间客户端(极小窗口),产物会落旧空间 | 偶发:移动后文件在新空间永远搜不到 | 迁移任务对此类文件不派发;解析完成后若发现归属已变,由解析任务终段按当前归属写入(实现时验证该窗口,必要时解析完成回查一次归属) |
| 9 | 同空间撤回是反向 move + 重新校验:原位置被占/原目录被删 → 撤回失败,保留当前态 | 不处理失败分支 | 撤回失败 toast |
@@ -192,6 +192,8 @@
| 权限 id `move_file` / `move_folder`can_edit 档) | 细粒度权限 | 权限配置 UI 自动出现 |
| `SpaceMoveInvalidTargetError`(18033) | 错误码 | 前端循环/无效目标提示 |
| `migrate_file_vectors` celery 任务 | 内部任务 | 跨空间迁移;失败重试入口 |
| `POST .../space/{id}/folders/upload`(文件夹上传,§9 | HTTP API | client SpaceDetail 文件夹上传(picker + 拖拽) |
| `SpaceFolderUploadCountExceededError`(18025,§9,可选兜底) | 错误码 | 前端 1000 超限兜底提示 |
### 6.2 我依赖(Incoming
@@ -219,7 +221,125 @@
- **模型相同时的纯向量拷贝加速**:当前统一走重嵌入,模型相同场景可省 embedding 算力,待量级需要时再做。
- **跨空间撤回**:PRD 砍掉;若将来要做 = 反向迁移 + 后端撤回记录,另立项。
- **图片目录迁移**(坑 6):**实现阶段定为记债,不在 T007 做物理拷贝**。原因——文档图片目录键为 `knowledge/images/{kid}/{doc_id}`,而 `doc_id` 与 file_id 的对应在解析侧不确定;按错误的键拷贝反而会弄坏图片引用。当前实现:移动后 chunk 内的图片引用仍指向**空间路径**,图片照常解析(源 MinIO 对象不被移动删除,移动只删源向量/ES)。仅当**源空间整体被删除**时这些图才会裂开——届时由空间删除流程或一次性清理脚本统一迁移。T007 已在任务体注释中标注该行为
- **图片目录迁移**(坑 6):**不做,且已核实无需做**2026-06-11)。移动后 chunk 内的图片引用仍指向**源空间路径**,图片照常解析;原先担心「删旧空间会丢图」经核实**不成立**——空间删除只按文件记录删对象,从不按 `knowledge/images/{kid}/` 前缀清理,图不会裂。真实遗留问题是反方向的:**images 目录从来没人清理**(空间删除后本空间文件的图片也全留在 MinIO),属存储泄漏,与移动无关,如需治理另立清理脚本项。move_worker.py 头注释中"objects only need migrating before the source space is deleted"的说法同样基于旧假设,下次动该文件时顺手修正
---
## 9. 文件夹上传(§5.5)设计
> 与「移动」正交:移动 = 空间内内容换位置;上传 = 把本地文件夹整树搬进空间。本节自包含,AC-24~31。
### 9.1 现状(接手必读):前端已有「文件夹上传」半成品,但只做一层平铺
- 前端早已具备:`webkitdirectory` 选择器(`client/.../SpaceDetail/index.tsx:961`)、拖拽目录读取(`useFileDragDrop.ts:41` `readTopLevelFolderFiles`)、文件夹上传编排(`useFileUpload.ts:288` `handleUploadFolder`)、过滤工具(`knowledgeUtils.ts:224` `filterFolderUploadFiles`)。
- **但现有行为 = 被作废的旧规则 3.2.4**:`filterFolderUploadFiles``rel.split("/").length !== 2` **只留文件夹根目录直属文件、过滤掉所有子文件夹文件**;拖拽 `readTopLevelFolderFiles` 也只读一层。即现有「上传文件夹」实际是「把根层文件平铺传上来,不重建子目录树」。
- 后端无「按相对路径重建目录树」能力:`add_folder`(单建一个文件夹,`knowledge_space_service.py:2772`,含深度 18011 / 同目录重名 18012 校验)与 `add_file`(往一个 `parent_id` 放平铺文件,`:2940`,含权限/容量/MinIO/celery 派发)各自独立。
- **结论**:§5.5 是把这套半成品从「一层平铺」升级为「全量嵌套 + 服务端重建目录树」——前端递归化 + 后端新增批量编排,不是从零起。
### 9.2 复用的现成零件(几乎全有)
| 能力 | 现成件 | 锚点 |
|---|---|---|
| 目录读取 | webkitdirectory 选择器 / `webkitGetAsEntry` 递归 | `index.tsx:961` / `useFileDragDrop.ts:174` |
| 相对路径 | `file.webkitRelativePath``getRootFolderName` | `knowledgeUtils.ts:208` |
| 格式白名单 | `ALLOWED_EXTENSIONS` / `getAllowedExtensions(etl4lm)`(后端 `FileExtensionMap``base_file_pipeline.py:25` | `knowledgeUtils.ts:56` |
| 单文件大小 | `DEFAULT_MAX_FILE_SIZE_MB=200``bishengConfig.uploaded_files_maximum_size` | `knowledgeUtils.ts:111` / `index.tsx:540` |
| 文件本体上传 | `uploadFileToServerApi``file_path` | `api/knowledge.ts:1405` |
| 建文件夹 | `add_folder`(深度/重名已校验) | `service:2772` |
| 注册文件 + 派发解析 | `add_file`(权限/容量/MinIO/celery | `service:2940` |
| 容量校验 | 用户档 `QuotaService.get_knowledge_space_upload_limit_bytes`(18024) / 租户 `get_tenant_storage_remaining_bytes`(19403) | `quota_service.py:514` / `:482` |
| 文件重名 | md5/name 冲突 → 临时对象 + FAILED + 前端覆盖(**不报错** | `knowledge_service.py:1259` |
### 9.3 关键决策
#### 决策 U1:目录树重建放**后端新增批量接口**,不靠前端多次调现有接口编排
- **备选 A**(前端编排):前端解析 `relativePath` → 需建文件夹集合,按层序逐个 `createFolderApi` 建树拿 id,再逐文件 `addFile` 到对应 parent。后端零改。
- **备选 B**(后端批量接口):新增 `POST .../space/{id}/folders/upload`,收 `{parent_id, items:[{file_path, relative_path}]}`,服务端一次性:顶层文件夹重名校验 → 解析相对路径建目录树(事务)→ 层级校验 → **容量整批预校验** → 逐文件注册 + 派发解析 → 返回每文件结果(含重名 FAILED)。
- **选定 B**。
- **原因**:① spec 把「层级 / 文件夹重名 / 容量」定为**服务端为准 + 整批拒**,B 让这三项在一个事务里集中判、整批 reject 干净;A 的逐请求建树把校验摊到几十上百次往返,部分失败后半建的目录树难回滚。② 容量校验现有是 `add_file` 内「上传后查 `current_total > limit`」的逐文件式(`service:2974-2993`),逐文件编排会传一半才超限——违反 AC-30「上传后超出 → 拒整批」;B 可在建树前按「本批总大小 + 已用 vs 上限」预判。③ 目录父子顺序、`path→id` 映射在服务端一次算清,比前端管理更稳。
- **代价**:后端新写批量编排 service(约等于 `add_folder×N + add_file×N` 的合并体)。
- **何时重新考虑**:若产品要求边传边显示每个子文件夹的创建进度(强前端编排感),再回到 A。
> 注:文件**本体**仍走现有 `uploadFileToServerApi` 逐个传 MinIO 拿 `file_path`——本设计不改这条;1000 文件即 1000 次本体上传 + 1000 个解析任务入队,属现有上传模式(坑 U6)。批量接口只做「注册 + 建树 + 集中校验」。
#### 决策 U2:全量嵌套——前端递归读取 + 废弃「只留根层」过滤
- 去掉 `filterFolderUploadFiles``split("/").length !== 2` 单层过滤,改为保留所有层级文件、只按「格式 / 隐藏 / 超大」过滤;拖拽侧 `readTopLevelFolderFiles` 改为递归读全部子目录(`FileSystemDirectoryEntry` 递归)。
- **原因**:对齐产品拍板「3.2.4 作废、全量嵌套」。
#### 决策 U3:1000 上限按「过滤前原始总数」前端主挡、后端兜底
- 前端读到的原始文件总数(含所有嵌套层、**过滤之前**)> 1000 → 直接报错整批不传(产品拍板计数口径)。后端批量接口对收到的文件数兜底校验(防绕 UI 直调)。
#### 决策 U4:静默过滤(格式 / 隐藏 / 超大)在前端,后端格式兜底
- 前端按 `ALLOWED_EXTENSIONS` + 200MB + 隐藏文件静默剔除,不报错、不占名额,合规文件才进上传;后端 `add_file` 链路本就有格式校验(`base_file_pipeline``KnowledgeFileNotSupportedError`/18022)兜底。
> **AC-32(产品 2026-06-11 补充)**:所有拒批场景(数量 / 层级 / 顶层夹重名 / 容量)前端一律以 **toast** 提示具体原因——前端挡下的直接 toast;服务端拒的按错误码映射 toast 文案。
### 9.4 数据流 + 接口契约
数据流:
`选文件夹(picker/拖拽) → 前端递归取全部文件+relativePath → 校验:总数>1000 整批拒 / 静默过滤格式·隐藏·超大 → 逐个 uploadFileToServerApi 传本体拿 file_path → POST .../folders/upload(parent_id + [{file_path, relative_path}]) → 服务端:顶层夹重名 → 建目录树 → 层级≤10 → 容量整批预校验 → 逐文件注册+派发解析 → 返回每文件结果 → 列表刷新`
**接口** `POST /api/v1/knowledge/space/{space_id}/folders/upload`
请求:
```
{
"parent_id": 456 | null, // 上传落点(当前目录),null=空间根
"items": [
{"file_path": "tmp/uuid.pdf", "relative_path": "我的资料/子目录/a.pdf", "size": 1048576}
]
}
```
- `relative_path` 第一段 = 待建顶层文件夹名;中间段 = 子目录树;末段 = 文件名。
- 同一顶层文件夹名只校验一次重名(U3 坑)。
- `size`(实现时新增)= 前端报告的文件字节数,仅用于**容量整批预校验**(整批拒的 UX);权威配额校验仍由注册环节(add_file 复用)逐文件执行,客户端谎报 size 只会让预校验放行、随后被逐文件校验挡住。
响应:复用 `add_file` 的每文件结果结构(成功项 + 重名 FAILED 项,前端走现有覆盖弹窗)。整批校验失败(数量 / 层级 / 顶层夹重名 / 容量)→ 4xx + 错误码,**整批不落库**。
**权限口径(C4**
- 入口权限与现有单文件上传同口径——对落点(`parent_id` 文件夹,或空间根)`_require_permission_id('upload_file')`,复用 `add_file` 开头的写法(`service:2952`)。不引入新权限 id。
- 批量新建的**每个文件夹节点**必须初始化权限 tuple(FGA parent 继承),复用 `add_folder` 内的 `_initialize_child_resource_permissions``service:2814`)——constitution C4「资源创建必须 authorize」硬规定,批量编排合并时**最容易漏的就是这步**,漏掉会建出无主文件夹(继承链断裂,后续移动/授权都异常)。
校验落点:
| 校验 | 落点 | 错误码 |
|---|---|---|
| 入口权限 `upload_file`(落点文件夹/空间) | 服务端(C4 统一入口) | 18040 已有 |
| 总数 ≤ 1000 | 前端主挡 + 后端兜底 | 新增 18025(或复用参数错误) |
| 格式 / 隐藏 / 超大 | 前端静默过滤 + 后端格式兜底 | 18022(格式)已有 |
| 层级 ≤ 10 | 服务端(建树后最深层) | 18011 已有 |
| 顶层文件夹重名 | 服务端(parent 下 `count_folder_by_name` | 18012 已有 |
| 容量(用户档 / 租户) | 服务端**整批预校验** | 18024 / 19403 已有 |
| 文件重名 | 服务端复用现有 md5/name → FAILED | 无(走覆盖流程) |
### 9.5 已知坑
| # | 反直觉事实 | 不知道会怎样 | 处理 |
|---|---|---|---|
| U1 | 现有「文件夹上传」= 旧 3.2.4(只传根层、过滤子文件夹),不是没做——是做了旧规则 | 以为从零做,漏改 `filterFolderUploadFiles`/`readTopLevelFolderFiles` | 决策 U2 改造点 |
| U2 | 容量校验现有是 `add_file` 内「逐文件上传后查超限」,批量逐文件会传一半才超 | 部分文件已落库才报容量错,违反「整批拒」 | 批量接口建树前按本批总大小预校验 |
| U3 | 文件夹重名只校验**顶层**那个文件夹(parent 下);子目录树是新建的不会撞 | 误对每层子目录做重名校验 | 仅顶层 `count_folder_by_name` |
| U4 | 文件重名**不报错**(现有 = 临时对象 + FAILED + 前端覆盖),与「文件夹重名报错拒」是两套语义 | 误把文件重名也做成整批拒 | AC-31 复用现有,前端覆盖弹窗 |
| U5 | 隐藏判定要看 `relativePath` **每一段**(子目录可能是隐藏夹如 `.git/`) | 只判文件名,漏掉隐藏目录下文件 | 前端过滤按 path 段 |
| U6 | 解析与单文件上传同管线,1000 文件 = 1000 次 MinIO 本体上传 + 1000 个解析任务入队 | 误以为批量接口「一次传完」 | 本体仍逐个传,批量接口只做注册 + 建树 |
| U7 | 批量建树的每个文件夹节点都要走权限 tuple 初始化(C4 authorize),不是只插 DB 行 | 建出"无主"文件夹:继承链断裂,后续授权/移动异常 | 复用 `_initialize_child_resource_permissions``service:2814`),逐节点 |
### 9.6 对外契约(增量)
- **新增**`POST .../space/{id}/folders/upload`client SpaceDetail 用);可能新增错误码 `SpaceFolderUploadCountExceededError`(18025) 作后端兜底。
- **复用**18011 / 18012 / 18022 / 18024 / 19403`uploadFileToServerApi` / `add_folder` / `add_file` / `QuotaService` / 解析管线。
- **依赖**`webkitRelativePath`(浏览器能力)、`file_level_path`/`level` 层级模型、QuotaService 配额口径。
- **release-contract 待登记**F034 行追加「§5.5 文件夹上传:新增 `folders/upload` 批量接口 + 18025;无新增领域对象(复用 SpaceFile/解析/配额)」。
### 9.7 测试
- **后端**:目录树重建(多层嵌套 path → 正确父子)、层级 > 10 整批拒、顶层夹重名拒、容量整批预校验(本批总和触顶 → 整批拒、不留半成品)、文件重名走 FAILED 不拒批、数量兜底。
- **前端**:递归取全部嵌套文件、1000(过滤前)挡、静默过滤格式/隐藏/超大、两入口(picker + 拖拽)、覆盖弹窗复用。
- **手动**:传一个 3 层嵌套文件夹(混入不支持格式 + 隐藏文件 + 超大文件)→ 只合规文件按树重建;传顶层重名文件夹 → 拒;构造超容量 → 整批拒。
---
@@ -231,3 +351,6 @@
| 2026-06-10 | v2:纳入跨空间(复用复制管线/版本链整链/REBUILDING 状态/二次确认无撤回);编号 032→034032=OFD、033=linsight 已占用) | PRD 范围升级,产品确认三决策 |
| 2026-06-10 | 实现完成(Wave 1-4 全通,21 后端测试绿,本地验证移动+toast)。3 项降级:①跨空间拖到左侧空间列表未做(跨组件树成本高,弹窗已覆盖)②同空间撤回未做(toast 无动作按钮,后端已留 old_parent_id)③图片目录物理迁移记债(坑 6)。i18n 占位符须用 `{{0}}` 双花括号 | 实现落地 + 务实降级 |
| 2026-06-10 | 本地测试后修订:①同空间撤回**改用 confirm 弹窗实现**(不再降级)②修复内部拖拽误触发上传遮罩(`useFileDragDrop``isExternalFileDrag` 守卫)③卡片视图补拖拽(抽 `useKnowledgeMoveDrag` 共用)④拖拽高亮:列表=整行背景变色、卡片=边框变色。§4.3 前端实现索引按实际重写 | 联调修 bug + UX 调整 |
| 2026-06-10 | 纳入 §5.5 文件夹上传设计(§9,AC-24~31):后端新增 `folders/upload` 批量接口重建目录树 + 容量整批预校验,复用配额/解析/重名/层级管线;前端从「一层平铺」升级全量嵌套(废 `filterFolderUploadFiles` 单层过滤、拖拽递归读全树);关键现状=前端已有半成品但行为=旧 3.2.4。可选新增错误码 18025 | spec 扩 §5.5,探明前端已有半成品 |
| 2026-06-11 | 修正坑 6 / §8:核实「删旧空间丢图」不成立——空间删除(`delete_knowledge_file_in_minio`)只按文件记录删对象,从不按 `knowledge/images/` 前缀清理,移走文件的图不会裂;真实问题是 images 目录无人清理(存储泄漏,另立项) | 用户要求核实删除链路 |
| 2026-06-11 | Wave 5 文件夹上传实现完成(T013~T016):后端 `upload_folder_items` + `POST /{space_id}/folders/upload` + 18025(13 个新测试绿,知识模块零回归);前端递归读全树、全层级静默过滤(隐藏按 path 段)、`uploadFolderApi``skip403Redirect` 统一 toastAC-32)。契约偏差:items 增加 `size` 字段(§9.4 已更新)。AC-32 产品补充已落 spec | Wave 5 实现落地 |
@@ -1,9 +1,9 @@
# Feature: 知识空间文件 / 文件夹移动(同空间 + 跨空间)
# Feature: 知识空间文件 / 文件夹移动(同空间 + 跨空间)+ 文件夹上传
> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
> spec 只回答 做什么 / 验收标准 / 不做什么。所有 How(数据流、字段、API、OpenFGA 模型、向量迁移管线、前端组件、错误码定义)写在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
**关联 PRD**: 2.6 PRD §5.2 知识空间适配支持文件夹和文件移动做到 beta4,中粮升级为 beta4
**关联 PRD**: 2.6 PRD §5.2 知识空间适配支持文件夹和文件移动 + §5.5 知识空间内上传文件夹(均做到 beta4,中粮升级为 beta4
**优先级**: P1
**所属版本**: v2.6.0
**依赖**: F004 / F008ReBAC core / resource-rebac-adaptation);F027(列表 cursor 分页 INV-6);F039(文档版本管理——跨空间移动需整链迁移)
@@ -14,6 +14,7 @@
> - **跨知识空间移动**(文件与文件夹都支持):元数据即时迁移 + 检索数据异步迁移,迁移期间文件显示「处理中」。
> - 新增 `move_folder` / `move_file` 两个关系类型权限。
> - 移动的循环 / 层级 / 重名(仅同空间)/ 权限校验,批量部分失败处理,同空间移动成功后的「撤回」。
> - **文件夹上传**(§5.5):拖拽 + 「新增」两入口上传文件夹,**全量嵌套**(子文件夹内所有文件按目录树重建一并上传);单次批量≤1000 文件、层级≤10 级、文件夹重名、容量上限等上传校验;不支持格式 / 隐藏 / 超大文件静默过滤。
> - **本次明确排除**(PRD 已拍板的跨空间简化):
> - 跨空间移动**不做重名校验**。
> - 跨空间移动**不迁移知识空间标签**——移动完成后清空文件原有的空间标签(AC-23)。
@@ -33,6 +34,10 @@
我希望 **移动权限能按关系档位(所有 / 管理 / 编辑 / 查看)默认配置**
以便 **只让有编辑及以上权限的人移动内容,查看者不能动。**
作为 **有上传权限的知识空间成员**
我希望 **把本地整个文件夹(含子文件夹)一次拖拽或新增上传到知识空间,系统自动按原目录结构重建目录树**
以便 **批量导入已有资料而不必逐个建文件夹再上传。**
---
## 2. 验收标准
@@ -78,6 +83,18 @@
- **AC-22** — IF 跨空间移动的检索数据迁移失败, THEN THE SYSTEM SHALL 把该文件标记为可重试的失败态(不丢失文件本体与版本关系),并允许重试。
- **AC-23** — WHEN 跨空间移动完成, THE SYSTEM SHALL 清空被移动文件原有的知识空间标签(不迁移到目标空间)。
### 2.7 文件夹上传(PRD §5.5
- **AC-24** — THE SYSTEM SHALL 支持两个入口上传文件夹:① 把文件夹拖拽到知识空间列表区;② 右上角「新增」中选择上传文件夹。两个入口均支持**嵌套**——文件夹内所有子文件夹中的文件都按原目录结构一并上传。
- **AC-25** — THE SYSTEM SHALL 由前端读取所上传文件夹内每个文件自带的相对目录结构,后端据此在目标位置**重建目录树**,文件落入对应层级后继续走既有的解析 / 检索流程。
- **AC-26** — THE SYSTEM SHALL 以**前端读取到的原始文件总数**(含所有嵌套子文件夹、过滤之前)为准限制单次批量上传 ≤ 1000 个文件;IF 超过 1000, THEN THE SYSTEM SHALL 提示「单次批量文件总数最多 1000 个」并整批不上传。
- **AC-27** — WHEN 上传文件夹, THE SYSTEM SHALL **静默过滤**以下文件(不报错、不进入上传流程、不占用合规文件的上传名额),其余合规文件正常上传:① 平台不支持的格式;② 隐藏文件;③ 超过单文件大小上限(默认 200MB)的文件。
- **AC-28** — IF 上传后在目标位置生成的文件夹最深层级超过 10 级, THEN THE SYSTEM SHALL 拒绝该文件夹上传并报错。
- **AC-29** — IF 当前目录下已存在与待上传文件夹同名的文件夹, THEN THE SYSTEM SHALL 拒绝上传该文件夹并提示「该位置已存在同名文件夹」。
- **AC-30** — IF 上传后将超出用户 / 角色的知识空间文件容量上限,或超出企业 / 租户的总存储容量上限, THEN THE SYSTEM SHALL 拒绝上传并报错。
- **AC-31** — IF 上传的文件在知识空间内已存在同名文件, THEN THE SYSTEM SHALL 按现有重复文件逻辑处理(复用既有行为,本需求不重定义)。
- **AC-32** — 文件夹上传被拒绝的所有场景(数量超 1000 / 层级超 10 / 文件夹重名 / 容量超限), THE SYSTEM SHALL 以 **toast 提示**告知用户具体拒绝原因(产品 2026-06-11 补充)。
---
## 3. 边界情况
@@ -89,6 +106,8 @@
- **并发**:同空间移动与他人同时在目标目录创建同名对象竞态时,以服务端重名校验(AC-12)为准。
- **跨空间检索窗口**:迁移完成前,文件在目标空间列表可见但内容暂不可检索(AC-19 已声明),属预期行为。
- **空间检索模型不同**:源 / 目标空间使用不同向量模型时,迁移自动适配,对用户透明(仅迁移时长不同)。
- **上传后全部被过滤(§5.5)**:若一个文件夹内的文件经 AC-27 过滤后无任何合规文件,则没有可上传内容,系统提示无可上传文件,不创建空目录树。
- **上传校验分工(§5.5)**:文件总数 / 格式 / 单文件大小可在前端读取目录结构时即时判定;文件夹层级、文件夹重名、容量上限以**服务端校验为准**(与他人并发上传 / 建夹竞态时同理)。具体校验落点写在 design.md。
---
@@ -110,4 +129,4 @@
- 设计真相: [design.md](./design.md)
- 执行与落档: [tasks.md](./tasks.md)
- 版本契约: [features/v2.6.0/release-contract.md](../release-contract.md)
- PRD: 2.6 PRD §5.2
- PRD: 2.6 PRD §5.2(移动)、§5.5(文件夹上传)
@@ -9,10 +9,10 @@
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | ✅ 已评审 | 2026-06-10;产品拍板三点已回写;AC-03 已补「拖拽支持跨空间(投放到左侧空间项)」 |
| design.md | ✅ 已评审 | 2026-06-10Constitution Check 无 BLOCKER;评审 4 项发现已修复(C2 子树 SQL 方言 / 同租户边界 / 决策备选与触发条件 / 手动验证入口) |
| tasks.md | ✅ 已拆解 | 2026-06-10 /sdd-review tasks LGTM(修复 4 项:T006/T007 任务内 Test-First、T007 tenant_id 传递、T009 数据源、T012 AC 标注格式) |
| 实现 | ✅ 完成 | 12 / 12本地验证通过。仅 2 项降级见偏差记录):跨空间拖拽未做(弹窗已覆盖)/ 图片物理迁移记债。同空间撤回已改用 confirm 弹窗实现 |
| spec.md | ✅ 已评审 | 2026-06-10;产品拍板三点已回写;AC-03 已补「拖拽支持跨空间(投放到左侧空间项)」。2026-06-10 扩充 §5.5 文件夹上传(2.7 节 AC-24~31),/sdd-review spec 实质 LGTM(仅 2 条 lowAC-25/校验分工略带 How、3.2.4 作废系产品决策) |
| design.md | ✅ 已评审 | 2026-06-10Constitution Check 无 BLOCKER;评审 4 项发现已修复(C2 子树 SQL 方言 / 同租户边界 / 决策备选与触发条件 / 手动验证入口)。同日 §9 文件夹上传(AC-24~31)评审:无 BLOCKER1 项 medium 已修(补 C4 权限口径——入口 `upload_file` + 建夹逐节点 authorize,坑 U7);2 low 记录跳过(U2 无重考触发条件、上传 Wave 待拆解) |
| tasks.md | ✅ 已拆解 | 2026-06-10 /sdd-review tasks LGTM(修复 4 项:T006/T007 任务内 Test-First、T007 tenant_id 传递、T009 数据源、T012 AC 标注格式)。2026-06-11 新增 Wave 5 文件夹上传 T013~T016/sdd-review tasks LGTM1 low 记录跳过:18025 并入 T013 |
| 实现 | 🔨 Wave 5 进行中 | Wave 1-4(移动)12/12 完成、本地验证通过2 项降级见偏差记录;同空间撤回已改用 confirm 弹窗)。Wave 5(文件夹上传)4/4 代码完成;T016 手动回归待跑(design §9.7 清单) |
---
@@ -115,12 +115,46 @@
**逻辑**: 弹窗/确认/toast/错误提示全部 key 化;按 design §7 手动验证一遍(两空间互移、多版本文件、不同 embedding 模型、各状态文件),作为对 AC-01 至 AC-23 的端到端人工回归
**依赖**: T011
### Wave 5 — 文件夹上传(§5.5 / AC-24~32design §9
- [x] **T013**: 后端 upload_folder_items 服务(任务内 Test-First+ 错误码 18025
**文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py``src/backend/bisheng/common/errcode/knowledge_space.py``src/backend/test/knowledge/test_knowledge_space_folder_upload.py`
**逻辑**: 新增 `upload_folder_items(parent_id, items[{file_path, relative_path}])` 编排(design 决策 U1):入口 `_require_permission_id('upload_file')`(同 `add_file:2952` 口径)→ 数量兜底 ≤1000(18025)→ 顶层夹重名 `count_folder_by_name`18012)→ 解析 relative_path 建目录树(每节点初始化权限 tuple,**坑 U7**,复用 `_initialize_child_resource_permissions`)→ 建树后最深层级 ≤10(18011)→ **容量整批预校验**(本批总大小+已用 vs 用户档/租户上限,**坑 U2**18024/19403)→ 逐文件复用现有注册+解析派发逻辑(文件重名走现有 FAILED+覆盖语义,**坑 U4**,不拒批)。整批校验失败不留半成品(事务)
**测试**: 先红后绿——多层 path 建树父子正确 / 层级>10 整批拒 / 顶层重名拒 / 容量触顶整批拒不留半成品 / >1000 拒 / 文件重名不拒批 / 每个新建夹有权限初始化调用
**覆盖 AC**: AC-25, AC-26, AC-28, AC-29, AC-30, AC-31
**依赖**: 无(复用 Wave 1-2 现成件)
- [x] **T014**: API 端点 POST /{space_id}/folders/upload + 集成测试
**文件**: `src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py``src/backend/test/knowledge/test_knowledge_space_folder_upload_api.py`
**逻辑**: 请求/响应契约=design §9.4items[{file_path, relative_path}];响应复用 add_file 每文件结果结构);收参调 service 不写业务。**任务内先写集成测试(红)再实现端点(绿)**
**测试**: happy path(嵌套树)+ 各拒批场景返回对应错误码(前端据此 toast,AC-32
**覆盖 AC**: AC-25, AC-32(错误码可观测侧)
**依赖**: T013
- [x] **T015**: 前端目录读取升级(全量嵌套)+ 过滤 + 1000 挡 + toast
**文件**: `src/frontend/client/src/pages/knowledge/hooks/useFileDragDrop.ts``src/frontend/client/src/pages/knowledge/knowledgeUtils.ts``SpaceDetail/index.tsx`
**逻辑**: 拖拽 `readTopLevelFolderFiles` 改递归读全部子目录(保真 relativePath);废 `filterFolderUploadFiles` 单层过滤(design 决策 U2),改为全层级保留 + 静默过滤(格式/隐藏/超大;隐藏判定按 path **每一段**,**坑 U5**);原始总数(过滤前)>1000 → toast「单次批量文件总数最多 1000 个」整批不传(决策 U3);全被过滤 → toast 无可上传文件,不发请求
**覆盖 AC**: AC-24, AC-26, AC-27, AC-32(前端侧)
**手动验证**: 拖 3 层嵌套文件夹(混入不支持格式/隐藏/超大);picker 入口同验
**依赖**: 无(与后端并行)
- [x] **T016**: 前端接通批量接口 + 拒批 toast + i18n + 手动回归
**文件**: `src/frontend/client/src/api/knowledge.ts``src/frontend/client/src/pages/knowledge/hooks/useFileUpload.ts``src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`
**逻辑**: 新增 `uploadFolderApi(spaceId, {parent_id, items})``handleUploadFolder` 改为:逐文件 `uploadFileToServerApi` 传本体 → 调批量接口注册建树 → 刷新列表;**服务端拒批错误码(18011/18012/18025/18024/19403)逐一映射 toast 文案(AC-32)**;文件重名 FAILED 项复用现有覆盖弹窗;文案 key 化三语;按 design §9.7 手动回归(含顶层重名、构造超容量)
**覆盖 AC**: AC-24, AC-25, AC-29, AC-30, AC-31, AC-32
**手动验证**: design §9.7 清单全跑
**依赖**: T014, T015
---
## 实际偏差记录
> 只留一行指针,论证在 design.md。
- Wave 5 / T013:接口契约 items 增加 `size` 字段(前端报告字节数)——容量整批预校验的数据源;权威配额校验仍在注册环节逐文件执行。design §9.4 已更新。
- Wave 5 / T016:服务端拒批 toast 未逐码手写映射——`uploadFolderApi``skip403Redirect` 统一拦截管线,自动按 `api_errors.<code>` 翻译并 toast18011/18012/18024/19403 文案已存在,新增 18025 三语);前端挡下的(>1000、全被过滤、顶层重名预查)显式 showToast。AC-32 全覆盖。
- Wave 5:隐藏的顶层文件夹整体拖入 → 静默不上传(沿用既有行为,属 AC-27 静默过滤语义延伸)。
- T007:图片目录物理迁移由「做全」改为「记债」(doc_id↔file_id 键不确定,误拷会弄坏引用);移动后图片引用仍指向源空间路径、照常解析,仅源空间被删时才需迁移。论证见 design §8 + §5 坑 6。
- T010:拖拽**仅做同空间拖到文件夹**(用户 2026-06-10 拍板)。跨空间拖到左侧空间列表项降级——左侧空间列表在 `pages/knowledge/sidebar/KnowledgeSpaceItem.tsx`,与 SpaceDetail 不同组件树,跨树传 payload + 外层接移动编排成本高;跨空间移动已由「移动到」弹窗完整覆盖。AC-03 跨空间拖拽部分未实现。
- T011:同空间**撤回(AC-16/17)已实现**——因 toast 无动作按钮,改用 confirm 弹窗承载(移动成功后弹「已移动 N 项 / [撤回][关闭]」),撤回按 `moved[].old_parent_id` 分组反向移动(见 `useKnowledgeMove.undoMove`)。其余(冲突两步 / 跨空间二次确认 / 处理中状态)均已实现。
@@ -1,6 +1,6 @@
# 技术评审:知识空间 文件 / 文件夹移动(F034)
# 技术评审:知识空间 文件 / 文件夹移动 + 文件夹上传F034
> **一句话**:让用户把文件 / 文件夹移到本空间的其它目录,或移到别的知识空间——支持「移动到」弹窗和多选拖拽两种操作
> **一句话**:让用户把文件 / 文件夹移到本空间的其它目录别的知识空间(弹窗 + 拖拽两种操作);并支持把本地整个文件夹(含子文件夹)一次上传到知识空间
---
@@ -9,7 +9,8 @@
| | 内容 |
|---|---|
| ✅ 做 | 同空间移动、跨空间移动(文件和文件夹都行,弹窗和拖拽都行);新增「移动文件 / 移动文件夹」权限;各类校验;批量部分失败处理;同空间移动可「撤回」 |
| ❌ 不做 | 跨空间不查重名、不迁移标签(移完清空)、不做撤回(用二次确认替代);移动不重新解析文档 |
| ✅ 做(§5.5 | **上传文件夹**:拖拽 + 「新增」两入口,全量嵌套(子文件夹里的文件按原目录结构一起传);上传校验(≤1000 文件 / ≤10 层 / 文件夹重名 / 容量上限);不支持格式、隐藏文件、超大文件静默过滤 |
| ❌ 不做 | 跨空间不查重名、不迁移标签(移完清空)、不做撤回(用二次确认替代);移动不重新解析文档;上传不重新定义文件重名逻辑(复用现有) |
---
@@ -108,6 +109,44 @@
---
## 三·补、文件夹上传(§5.5
> 和移动是两件事:移动是「空间里的内容换位置」,上传是「把本地文件夹整个搬进空间」。
### 用户怎么用
```
两个入口,都支持嵌套(子文件夹里的文件一起传):
① 把本地文件夹直接拖到知识空间列表区
② 右上角「新增」→ 选上传文件夹
前端读出每个文件自带的相对路径(哪个文件在哪个子文件夹里)
前端先过滤 + 先校验(能当场判的):
· 文件总数 > 1000 → 报错,整批不传(按【过滤前的原始总数】算)
· 静默丢掉不合规文件(不报错、不占名额):不支持格式 / 隐藏文件 / 超 200MB
后端按相对路径【重建目录树】,文件落到对应层级,再走老的解析/检索流程
后端校验(以服务端为准):
· 重建后最深 > 10 层 → 拒
· 当前目录已有同名文件夹 → 拒,提示「该位置已存在同名文件夹」
· 上传后超出 用户/角色 空间容量上限、或 企业/租户 总容量上限 → 拒
· 文件重名 → 按现有重复文件逻辑处理(不另造规则)
```
### 几个要点
- **全量嵌套**:PRD 草稿里规则 3.2.4 写了「过滤子文件夹中的文件」,与「支持嵌套」矛盾——产品已拍板**作废该条**,子文件夹的文件全部按目录树传上来。
- **1000 怎么数**:按前端读到的**原始文件总数**(过滤之前)算,不是过滤后的有效数。
- **校验分工**:数量 / 格式 / 大小前端就能挡;层级 / 文件夹重名 / 容量**以服务端为准**(防并发建夹竞态)。具体落点在 design。
- **全被过滤的情况**:一个文件夹里没有任何合规文件 → 没东西可传,提示一下,不建空目录。
---
## 四、权限怎么管
- 新增两个权限:**移动文件(move_file**、**移动文件夹(move_folder**。
@@ -142,7 +181,7 @@
| 移动文件夹漏改子孙路径 → 目录树断裂 | 子树级联重写是后端核心逻辑,单测重点覆盖 |
| 漏掉历史版本 → 版本管理直接报错(系统强制"版本链必须同空间" | 按"逻辑文档"为单位整链迁移,参照删除级联的现成写法 |
| 跨空间搬运窗口内"看得见搜不到" | 产品已接受;状态显示「处理中」明示用户 |
| 文档里的图片按空间目录存,不搬将来删旧空间会丢图 | 搬运任务连图片目录一起搬 |
| ~~文档里的图片按空间目录存,不搬将来删旧空间会丢图~~(已核实**不成立**:删空间从不清理图片目录,图不会丢) | 无需搬图。真实遗留是图片目录从来没人清理(存储泄漏),与移动无关,另立清理项 |
| 解析中的文件被移走,产物落错空间(极小窗口) | 解析完成处回查归属兜底 |
| 撤回失败(原位置被占/原目录被删) | 明确提示撤回失败,不破坏当前状态 |
| 数据库兼容(达梦) | 子树批量改路径不用 MySQL 专有 SQL |
@@ -57,6 +57,11 @@ class SpaceFileSizeLimitError(BaseErrorCode):
Msg: str = "File size limit exceeded"
class SpaceFolderUploadCountExceededError(BaseErrorCode):
Code: int = 18025
Msg: str = "A single batch upload supports at most 1000 files"
# ── Subscribe ────────────────────────────────────────────────────────────────
@@ -24,6 +24,7 @@ from bisheng.knowledge.domain.schemas.knowledge_space_schema import (
FileRenameReq,
FolderCreateReq,
FolderRenameReq,
FolderUploadReq,
KnowledgeSpaceCreateReq,
KnowledgeSpaceUpdateReq,
RemoveSpaceMemberRequest,
@@ -398,6 +399,21 @@ async def add_folder(
return resp_200(folder)
@router.post("/{space_id}/folders/upload")
async def upload_folder(
space_id: int,
req: FolderUploadReq,
svc: KnowledgeSpaceService = Depends(get_knowledge_space_service),
) -> Any:
"""F034 §5.5: register a whole client-side folder (nested) in one batch."""
files = await svc.upload_folder_items(
knowledge_id=space_id,
items=req.items,
parent_id=req.parent_id,
)
return resp_200(files)
@router.put("/{space_id}/folders/{folder_id}")
async def rename_folder(
space_id: int,
@@ -53,9 +53,7 @@ class KnowledgeSpaceInfoResp(KnowledgeBase):
space_kind: Literal["normal", "department"] = Field(default="normal", description="Knowledge space kind")
department_id: int | None = Field(default=None, description="Bound department id for department spaces")
department_name: str | None = Field(default=None, description="Bound department name for department spaces")
approval_enabled: bool | None = Field(
default=None, description="Whether department-space uploads require approval"
)
approval_enabled: bool | None = Field(default=None, description="Whether department-space uploads require approval")
sensitive_check_enabled: bool | None = Field(
default=None,
description="Whether department-space uploads require content safety check",
@@ -124,6 +122,22 @@ class FileRenameReq(BaseModel):
name: str = Field(..., description="New File Name")
class FolderUploadItem(BaseModel):
"""F034 §5.5 folder upload: one already-uploaded file body + its relative path."""
file_path: str = Field(..., description="MinIO path returned by the upload endpoint")
relative_path: str = Field(..., description="Path relative to the drop point, e.g. 'Top/Sub/a.pdf'")
# Client-reported size, used only for the batch-level capacity pre-check
# (all-or-nothing UX). The authoritative per-file quota check still runs
# during registration (add_file).
size: int = Field(0, ge=0, description="File size in bytes")
class FolderUploadReq(BaseModel):
parent_id: int | None = Field(None, description="Target folder id; None = space root")
items: list[FolderUploadItem] = Field(..., min_length=1, description="Files with relative paths")
class MoveItem(BaseModel):
id: int = Field(..., description="File or folder id")
type: str = Field(..., description="'file' or 'folder'")
@@ -26,6 +26,7 @@ from bisheng.common.errcode.knowledge_space import (
SpaceFolderDepthError,
SpaceFolderDuplicateError,
SpaceFolderNotFoundError,
SpaceFolderUploadCountExceededError,
SpaceLimitError,
SpaceNotFoundError,
SpaceOrganizationGrantExitDeniedError,
@@ -76,6 +77,7 @@ from bisheng.knowledge.domain.models.knowledge_space_tag_library import (
KnowledgeSpaceTagLibraryDao,
)
from bisheng.knowledge.domain.schemas.knowledge_space_schema import (
FolderUploadItem,
KnowledgeSpaceFileResponse,
KnowledgeSpaceInfoResp,
RemoveSpaceMemberRequest,
@@ -3118,6 +3120,141 @@ class KnowledgeSpaceService(KnowledgeUtils):
await KnowledgeDao.async_update_knowledge_update_time_by_id(knowledge_id)
return failed_files + process_files
MAX_FOLDER_UPLOAD_FILES = 1000
async def upload_folder_items(
self,
knowledge_id: int,
items: list[FolderUploadItem],
parent_id: int | None = None,
) -> list[KnowledgeSpaceFileResponse]:
"""F034 §5.5 folder upload: rebuild the client-side directory tree, then
register every file through the regular add_file pipeline.
Batch pre-checks (count / depth / top-level folder duplicate / capacity)
are all-or-nothing: any failure rejects the whole batch before a single
row is created. Per-file duplicates keep the existing FAILED+overwrite
semantics from add_file and never reject the batch.
"""
if len(items) > self.MAX_FOLDER_UPLOAD_FILES:
raise SpaceFolderUploadCountExceededError()
# Entry permission: same gate as single-file upload (design §9.4 / C4).
if parent_id:
await self._require_permission_id("folder", parent_id, "upload_file", space_id=knowledge_id)
else:
await self._require_permission_id("knowledge_space", knowledge_id, "upload_file")
db_knowledge = await KnowledgeDao.aquery_by_id(knowledge_id)
if not db_knowledge:
raise SpaceNotFoundError()
self._ensure_space_async_task_tenant_consistency(db_knowledge, "upload_file")
base_path = ""
base_child_level = 0
if parent_id:
parent_folder = await self._get_folder_for_action(knowledge_id, parent_id)
base_path = f"{parent_folder.file_level_path}/{parent_id}"
base_child_level = parent_folder.level + 1
# Parse relative paths into (dir chain, file path); collect every
# directory that needs creating. "." / ".." / empty segments are
# dropped so a crafted path cannot escape the drop point.
parsed: list[tuple[tuple[str, ...], str]] = []
dir_set: set[tuple[str, ...]] = set()
for item in items:
parts = [p for p in item.relative_path.replace("\\", "/").split("/") if p and p not in (".", "..")]
if not parts:
raise SpaceFolderNotFoundError()
dir_parts = tuple(parts[:-1])
parsed.append((dir_parts, item.file_path))
for depth in range(1, len(dir_parts) + 1):
dir_set.add(dir_parts[:depth])
# Depth: deepest created folder must stay within the 10-level limit.
max_chain = max((len(d) for d in dir_set), default=0)
if max_chain and base_child_level + max_chain - 1 > 10:
raise SpaceFolderDepthError()
# Duplicate check only for the top-level folder names (design 坑 U3):
# everything below them is freshly created and cannot clash.
for top_name in {d[0] for d in dir_set}:
if await SpaceFileDao.count_folder_by_name(knowledge_id, top_name, base_path) > 0:
raise SpaceFolderDuplicateError()
# Capacity pre-check on the whole batch (design 坑 U2): client-reported
# sizes give the all-or-nothing UX; add_file keeps the authoritative
# per-file check during registration.
total_size = sum(item.size or 0 for item in items)
role_user_limit_bytes = await QuotaService.get_knowledge_space_upload_limit_bytes(self.login_user)
if role_user_limit_bytes is not None:
current_user_total = int(await SpaceFileDao.get_user_total_file_size(self.login_user.user_id))
if current_user_total + total_size > role_user_limit_bytes:
raise SpaceFileSizeLimitError()
target_tid = db_knowledge.tenant_id
tenant_remaining_bytes = await QuotaService.get_tenant_storage_remaining_bytes(target_tid)
if tenant_remaining_bytes is not None and total_size > tenant_remaining_bytes:
tenant_used_bytes = await QuotaService.get_tenant_storage_used_bytes(target_tid)
blocker = (
target_tid,
"tenant_limit",
round((tenant_used_bytes + total_size) / (1024**3), 2),
round((tenant_used_bytes + tenant_remaining_bytes) / (1024**3), 2),
"",
)
raise QuotaService._make_storage_quota_error(blocker, "storage_gb")
# Build the directory tree parents-first. node_info maps a dir chain to
# (folder_id, file_level_path for its children, level for its children);
# the empty chain is the drop point itself.
node_info: dict[tuple[str, ...], tuple[int | None, str, int]] = {(): (parent_id, base_path, base_child_level)}
created_folders: list[KnowledgeFile] = []
try:
for dir_parts in sorted(dir_set, key=len):
parent_node_id, child_path, child_level = node_info[dir_parts[:-1]]
folder_row = await KnowledgeFileDao.aadd_file(
KnowledgeFile(
knowledge_id=knowledge_id,
user_id=self.login_user.user_id,
user_name=self.login_user.user_name,
updater_id=self.login_user.user_id,
updater_name=self.login_user.user_name,
file_name=dir_parts[-1],
file_type=0,
level=child_level,
file_level_path=child_path,
status=KnowledgeFileStatus.SUCCESS.value,
)
)
created_folders.append(folder_row)
# Every created node must get its permission tuple (design 坑 U7);
# skipping this leaves an ownerless folder with a broken
# inheritance chain.
await self._initialize_child_resource_permissions(
"folder",
folder_row.id,
"folder" if parent_node_id else "knowledge_space",
parent_node_id or knowledge_id,
)
node_info[dir_parts] = (folder_row.id, f"{child_path}/{folder_row.id}", child_level + 1)
except Exception:
try:
await self._cleanup_resource_tuples([("folder", row.id) for row in created_folders])
finally:
await KnowledgeFileDao.adelete_batch([row.id for row in created_folders])
raise
# Register files per directory through the regular pipeline (quota,
# dedup/FAILED semantics, doc+V1 rows, parse dispatch all reused).
groups: dict[tuple[str, ...], list[str]] = {}
for dir_parts, file_path in parsed:
groups.setdefault(dir_parts, []).append(file_path)
results: list[KnowledgeSpaceFileResponse] = []
for dir_parts, file_paths in groups.items():
results.extend(await self.add_file(knowledge_id, file_paths, parent_id=node_info[dir_parts][0]))
await KnowledgeDao.async_update_knowledge_update_time_by_id(knowledge_id)
return results
async def rename_file(self, file_id: int, new_name: str) -> KnowledgeFile:
from bisheng.worker.knowledge.rebuild_knowledge_worker import (
rebuild_knowledge_file_chunk,
@@ -0,0 +1,210 @@
"""F034 Wave 5 — KnowledgeSpaceService.upload_folder_items unit tests (§5.5 folder upload).
Strategy: mock DAO / PermissionService / QuotaService / add_file (same approach as
test_knowledge_space_move.py). upload_folder_items is pure orchestration — batch
pre-checks (count / depth / top-level dup / capacity) + directory-tree build +
per-directory delegation to the already-tested add_file. See
features/v2.6.0/034-knowledge-space-file-move/design.md §9.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bisheng.common.errcode.knowledge_space import (
SpaceFileSizeLimitError,
SpaceFolderDepthError,
SpaceFolderDuplicateError,
SpaceFolderUploadCountExceededError,
)
from bisheng.knowledge.domain.schemas.knowledge_space_schema import FolderUploadItem
from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService
_SVC = "bisheng.knowledge.domain.services.knowledge_space_service"
def _items(*paths, size=10):
return [FolderUploadItem(file_path=f"minio://tmp/{i}.bin", relative_path=p, size=size) for i, p in enumerate(paths)]
def _svc():
svc = KnowledgeSpaceService(request=MagicMock(), login_user=MagicMock(user_id=1, user_name="u1", tenant_id=1))
svc._require_permission_id = AsyncMock(return_value=None)
svc._initialize_child_resource_permissions = AsyncMock(return_value=None)
svc._cleanup_resource_tuples = AsyncMock(return_value=None)
svc._ensure_space_async_task_tenant_consistency = MagicMock(return_value=None)
svc.add_file = AsyncMock(return_value=[])
return svc
class _FakeFileDao:
"""Records created folder rows and hands out incremental ids."""
def __init__(self):
self.rows = []
self.deleted = []
self._next_id = 100
async def aadd_file(self, row):
row.id = self._next_id
self._next_id += 1
self.rows.append(row)
return row
async def adelete_batch(self, ids):
self.deleted.extend(ids)
@pytest.fixture()
def fake_dao():
return _FakeFileDao()
@pytest.fixture(autouse=True)
def _patch_module(fake_dao):
space = MagicMock(id=5, tenant_id=1)
with (
patch(f"{_SVC}.KnowledgeDao.aquery_by_id", new=AsyncMock(return_value=space)),
patch(f"{_SVC}.KnowledgeDao.async_update_knowledge_update_time_by_id", new=AsyncMock()),
patch(f"{_SVC}.SpaceFileDao.count_folder_by_name", new=AsyncMock(return_value=0)),
patch(f"{_SVC}.SpaceFileDao.get_user_total_file_size", new=AsyncMock(return_value=0)),
patch(f"{_SVC}.QuotaService.get_knowledge_space_upload_limit_bytes", new=AsyncMock(return_value=None)),
patch(f"{_SVC}.QuotaService.get_tenant_storage_remaining_bytes", new=AsyncMock(return_value=None)),
patch(f"{_SVC}.KnowledgeFileDao.aadd_file", new=fake_dao.aadd_file),
patch(f"{_SVC}.KnowledgeFileDao.adelete_batch", new=fake_dao.adelete_batch),
):
yield
# ── tree build + grouping (AC-25) ──────────────────────────────────────────
async def test_builds_nested_tree_and_groups_files(fake_dao):
svc = _svc()
items = _items("Root/a.pdf", "Root/Sub/b.pdf", "Root/Sub/Deep/c.pdf")
await svc.upload_folder_items(5, items, parent_id=None)
by_name = {r.file_name: r for r in fake_dao.rows}
assert set(by_name) == {"Root", "Sub", "Deep"}
root, sub, deep = by_name["Root"], by_name["Sub"], by_name["Deep"]
assert all(r.file_type == 0 for r in fake_dao.rows)
assert (root.level, root.file_level_path) == (0, "")
assert (sub.level, sub.file_level_path) == (1, f"/{root.id}")
assert (deep.level, deep.file_level_path) == (2, f"/{root.id}/{sub.id}")
# files registered per directory via add_file(knowledge_id, paths, parent_id=...)
calls = {c.kwargs.get("parent_id") or c.args[2]: c.args[1] for c in svc.add_file.await_args_list}
assert calls[root.id] == ["minio://tmp/0.bin"]
assert calls[sub.id] == ["minio://tmp/1.bin"]
assert calls[deep.id] == ["minio://tmp/2.bin"]
# U7: every created folder got its permission tuple initialised, chained to its parent
perm_calls = {c.args[1]: (c.args[2], c.args[3]) for c in svc._initialize_child_resource_permissions.await_args_list}
assert perm_calls[root.id] == ("knowledge_space", 5)
assert perm_calls[sub.id] == ("folder", root.id)
assert perm_calls[deep.id] == ("folder", sub.id)
async def test_upload_under_existing_parent_folder(fake_dao):
svc = _svc()
parent = MagicMock(id=9, level=2, file_level_path="/7/8")
svc._get_folder_for_action = AsyncMock(return_value=parent)
await svc.upload_folder_items(5, _items("Top/x.pdf"), parent_id=9)
top = fake_dao.rows[0]
assert (top.level, top.file_level_path) == (3, "/7/8/9")
assert svc._initialize_child_resource_permissions.await_args_list[0].args[2:4] == ("folder", 9)
# ── batch pre-checks: all-or-nothing (AC-26 / AC-28 / AC-29 / AC-30) ───────
async def test_count_over_1000_rejected(fake_dao):
svc = _svc()
items = _items(*[f"Root/f{i}.pdf" for i in range(1001)])
with pytest.raises(SpaceFolderUploadCountExceededError):
await svc.upload_folder_items(5, items)
assert fake_dao.rows == []
svc.add_file.assert_not_awaited()
async def test_depth_over_10_rejected_whole_batch(fake_dao):
svc = _svc()
parent = MagicMock(id=9, level=8, file_level_path="/1/2/3/4/5/6/7/8")
svc._get_folder_for_action = AsyncMock(return_value=parent)
# deepest folder level = 8+1 (A) +1 (B) +1 (C) = 11 > 10
with pytest.raises(SpaceFolderDepthError):
await svc.upload_folder_items(5, _items("A/B/C/d.pdf"), parent_id=9)
assert fake_dao.rows == []
svc.add_file.assert_not_awaited()
async def test_depth_exactly_10_allowed(fake_dao):
svc = _svc()
parent = MagicMock(id=9, level=8, file_level_path="/1/2/3/4/5/6/7/8")
svc._get_folder_for_action = AsyncMock(return_value=parent)
await svc.upload_folder_items(5, _items("A/B/d.pdf"), parent_id=9)
assert {r.level for r in fake_dao.rows} == {9, 10}
async def test_top_level_name_conflict_rejected(fake_dao):
svc = _svc()
with patch(f"{_SVC}.SpaceFileDao.count_folder_by_name", new=AsyncMock(return_value=1)):
with pytest.raises(SpaceFolderDuplicateError):
await svc.upload_folder_items(5, _items("Root/a.pdf"))
assert fake_dao.rows == []
async def test_capacity_precheck_user_limit_rejects_batch(fake_dao):
svc = _svc()
with (
patch(f"{_SVC}.QuotaService.get_knowledge_space_upload_limit_bytes", new=AsyncMock(return_value=100)),
patch(f"{_SVC}.SpaceFileDao.get_user_total_file_size", new=AsyncMock(return_value=50)),
):
with pytest.raises(SpaceFileSizeLimitError):
await svc.upload_folder_items(
5, _items("R/a.pdf", "R/b.pdf", "R/c.pdf", "R/d.pdf", "R/e.pdf", "R/f.pdf", size=10)
)
assert fake_dao.rows == []
svc.add_file.assert_not_awaited()
async def test_capacity_precheck_tenant_limit_rejects_batch(fake_dao):
svc = _svc()
with (
patch(f"{_SVC}.QuotaService.get_tenant_storage_remaining_bytes", new=AsyncMock(return_value=10)),
patch(f"{_SVC}.QuotaService.get_tenant_storage_used_bytes", new=AsyncMock(return_value=0)),
):
with pytest.raises(Exception) as exc_info:
await svc.upload_folder_items(5, _items("R/a.pdf", "R/b.pdf", size=10))
assert "quota" in type(exc_info.value).__name__.lower() or "Quota" in type(exc_info.value).__name__
assert fake_dao.rows == []
# ── rollback on folder-create failure ──────────────────────────────────────
async def test_folder_create_failure_rolls_back_created_folders(fake_dao):
svc = _svc()
svc._initialize_child_resource_permissions = AsyncMock(side_effect=[None, RuntimeError("fga down")])
with pytest.raises(RuntimeError):
await svc.upload_folder_items(5, _items("Root/Sub/a.pdf"))
created_ids = [r.id for r in fake_dao.rows]
assert sorted(fake_dao.deleted) == sorted(created_ids)
cleanup_arg = svc._cleanup_resource_tuples.await_args.args[0]
assert sorted(cleanup_arg) == sorted([("folder", i) for i in created_ids])
svc.add_file.assert_not_awaited()
# ── file-level duplicates do NOT reject the batch (AC-31) ──────────────────
async def test_file_dup_failed_entries_aggregate_without_raise(fake_dao):
svc = _svc()
failed_entry = MagicMock(status=3)
ok_entry = MagicMock(status=2)
svc.add_file = AsyncMock(side_effect=[[failed_entry], [ok_entry]])
res = await svc.upload_folder_items(5, _items("Root/a.pdf", "Root/Sub/b.pdf"))
assert failed_entry in res and ok_entry in res
@@ -0,0 +1,54 @@
"""F034 Wave 5 — folder-upload endpoint contract tests (signature-level, AST-based).
Mirrors test_knowledge_space_move_api.py: importing the FastAPI app pulls a long
chain of repo modules, so we verify the endpoint↔service wiring structurally.
Behavioural coverage is in test_knowledge_space_folder_upload.py (service layer).
"""
import ast
import inspect
from pathlib import Path
from bisheng.knowledge.domain.schemas.knowledge_space_schema import FolderUploadItem, FolderUploadReq
_BACKEND_ROOT = Path(__file__).resolve().parents[2] / "bisheng"
def _find_func(source: str, name: str):
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
return node
return None
def test_folder_upload_request_schema_shape():
"""FolderUploadReq carries the folder-upload contract (design §9.4)."""
req_fields = FolderUploadReq.model_fields
assert {"parent_id", "items"} <= set(req_fields)
assert req_fields["parent_id"].default is None
item_fields = FolderUploadItem.model_fields
assert {"file_path", "relative_path", "size"} <= set(item_fields)
assert item_fields["size"].default == 0
def test_folder_upload_endpoint_registered_and_delegates():
"""POST /{space_id}/folders/upload exists, takes FolderUploadReq, calls upload_folder_items."""
ep_file = _BACKEND_ROOT / "knowledge" / "api" / "endpoints" / "knowledge_space.py"
source = ep_file.read_text()
fn = _find_func(source, "upload_folder")
assert fn is not None, "upload_folder endpoint not found"
decos = "\n".join(ast.get_source_segment(source, d) for d in fn.decorator_list)
assert "folders/upload" in decos
annotations = {a.arg: getattr(a.annotation, "id", None) for a in fn.args.args}
assert annotations.get("req") == "FolderUploadReq"
assert "upload_folder_items" in ast.get_source_segment(source, fn)
def test_service_upload_folder_items_signature():
"""KnowledgeSpaceService.upload_folder_items must accept the upload contract args."""
from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService
sig = inspect.signature(KnowledgeSpaceService.upload_folder_items)
params = set(sig.parameters)
assert {"knowledge_id", "items", "parent_id"} <= params
+39
View File
@@ -1458,6 +1458,45 @@ export async function addFilesApi(
});
}
/** One file of a folder upload: uploaded body path + its path inside the picked folder. */
export interface FolderUploadItemPayload {
file_path: string;
relative_path: string;
size: number;
}
/**
* F034 §5.5: register a whole folder (nested) in one batch — the backend
* rebuilds the directory tree from each item's relative_path.
* POST /api/v1/knowledge/space/{space_id}/folders/upload
*
* `skip403Redirect` routes batch rejections (18011 depth / 18012 dup folder /
* 18024 user quota / 18025 count / 19403 tenant quota) through the unified
* interceptor: api_errors.<code> is translated, toasted (AC-32), and the
* promise rejects so the caller's catch fires.
*/
export async function uploadFolderApi(
space_id: string,
data: { parent_id?: number | null; items: FolderUploadItemPayload[] }
): Promise<KnowledgeFile[]> {
const res = await request.post(
`/api/v1/knowledge/space/${space_id}/folders/upload`,
data,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ skip403Redirect: true } as any,
) as ApiResponse<RawSpaceChild[]>;
const payload: any = res?.data ?? {};
const list = extractList<RawSpaceChild>(payload);
return list.map(raw => {
const file = mapChild(raw, space_id);
// Preserve raw object for status 3 (duplicate) so retry API can use it
if (raw?.status === 3) {
(file as any)._raw = raw;
}
return file;
});
}
/**
* Add article(s) to a knowledge space folder
* POST /api/v1/channel/manager/articles/add_to_knowledge_space
@@ -1326,6 +1326,7 @@
"15024": "MCP config parse error: {{exception}}",
"16000": "Dataset name already exists",
"18024": "The current account's knowledge space file upload capacity has reached the limit. Please contact the administrator to adjust the configuration.",
"18025": "A single batch upload supports at most 1000 files.",
"19401": "Tenant quota has been exhausted. Please contact the administrator to adjust the configuration.",
"19402": "The current role quota has been exhausted. Please contact the administrator to adjust the configuration.",
"19403": "The current tenant storage quota has been exhausted ({{used_gb}}/{{quota_gb}} GB). Please contact the platform administrator to expand the quota.",
@@ -1496,6 +1497,7 @@
"folder_already_exists": "A folder named \"{{0}}\" already exists here.",
"folder_name_empty": "Folder name cannot be empty",
"folder_upload_exceed_limit": "A folder upload can contain at most {{0}} files.",
"folder_upload_no_valid_files": "No uploadable files in the folder (unsupported, hidden, and oversize files are filtered out).",
"format_list": "pdf (including scans), ofd, txt, docx, ppt, pptx, md, html, xls, xlsx, csv, doc, png, jpg, jpeg, bmp, wps, dps, et",
"get_download_link_failed": "Failed to get download link",
"go_to_square": "Go to square",
@@ -1250,6 +1250,7 @@
"15024": "MCP設定解析に失敗しました:{{exception}}",
"16000": "データセット名がすでに存在します",
"18024": "現在の В аккаунтのナレッジベースファイルアップロード容量は上限に達しました。管理者に連絡して設定を調整してください",
"18025": "1回の一括アップロードで最大 1000 件までです。",
"19401": "テナントのクォータを使い切りました。管理者に連絡して設定を調整してください。",
"19402": "現在のロールのクォータを使い切りました。管理者に連絡して設定を調整してください。",
"19403": "現在のテナントのストレージ容量を使い切りました({{used_gb}}/{{quota_gb}} GB)。プラットフォーム管理者に容量拡張を依頼してください。",
@@ -1420,6 +1421,7 @@
"folder_already_exists": "「{{0}}」という名前のフォルダーが既に存在します。",
"folder_name_empty": "フォルダー名を入力してください",
"folder_upload_exceed_limit": "1 回のフォルダーアップロードで最大 {{0}} 件までです。",
"folder_upload_no_valid_files": "フォルダー内にアップロード可能なファイルがありません(未対応形式・隠しファイル・サイズ超過のファイルは除外されます)",
"format_list": "pdf(スキャンを含む)、ofd、txt、docx、ppt、pptx、md、html、xls、xlsx、csv、doc、png、jpg、jpeg、bmp、wps、dps、et",
"get_download_link_failed": "ダウンロードリンクの取得に失敗しました",
"go_to_square": "広場へ行く",
@@ -1253,6 +1253,7 @@
"15024": "mcp工具配置解析失败,请检查内容是否符合mcp配置格式: {{exception}}",
"16000": "数据集名称已存在",
"18024": "当前账号知识空间文件上传容量已达上限,请联系管理员调整配置",
"18025": "单次批量文件总数最多 1000 个",
"90001": "您当前角色没有访问管理后台的权限。如有需要,请联系管理员开通。",
"10810": "请配置工作台向量模型。",
"10916": "切分结果过长,请尝试在自定义策略中减少表格切分行数",
@@ -1423,6 +1424,7 @@
"folder_already_exists": "该位置已存在同名文件夹「{{0}}」",
"folder_name_empty": "文件夹名称不能为空",
"folder_upload_exceed_limit": "单次批量文件总数最多 {{0}} 个",
"folder_upload_no_valid_files": "文件夹内没有可上传的文件(不支持格式、隐藏文件及超大文件已被过滤)",
"format_list": "pdf(含扫描件)、ofd、txt、docx、ppt、pptx、md、html、xls、xlsx、csv、doc、png、jpg、jpeg、bmp、wps、dps、et",
"get_download_link_failed": "下载链接获取失败",
"go_to_square": "前往广场",
@@ -31,20 +31,24 @@ interface UseFileDragDropOptions {
}
/**
* Read the *direct child files* of a dropped directory (single-level, matching
* the folder-picker button). Each returned File gets a synthetic
* `webkitRelativePath` of `"<dir>/<file>"` so it flows through the existing
* folder-upload pipeline (getRootFolderName / filterFolderUploadFiles) unchanged.
* Sub-directories are ignored. `readEntries` returns in batches, so it must be
* called repeatedly until it yields an empty list.
* Recursively read every file under a dropped directory (F034 §5.5: nested
* upload the backend rebuilds the whole tree). Each returned File gets a
* synthetic `webkitRelativePath` of `"<dir>/<sub>/<file>"` so it flows through
* the folder-upload pipeline exactly like the `webkitdirectory` picker.
* `readEntries` returns in batches, so it must be called repeatedly until it
* yields an empty list.
*/
function readTopLevelFolderFiles(dirEntry: FileSystemDirectoryEntry): Promise<File[]> {
function readFolderFilesRecursive(
dirEntry: FileSystemDirectoryEntry,
pathPrefix: string,
): Promise<File[]> {
const prefix = pathPrefix ? `${pathPrefix}/${dirEntry.name}` : dirEntry.name;
return new Promise((resolve) => {
const reader = dirEntry.createReader();
const filePromises: Promise<File | null>[] = [];
const collected: Promise<File[] | File | null>[] = [];
const finish = () =>
Promise.all(filePromises).then((files) =>
resolve(files.filter((f): f is File => f != null)),
Promise.all(collected).then((parts) =>
resolve(parts.flat().filter((f): f is File => f != null)),
);
const readBatch = () => {
reader.readEntries((batch) => {
@@ -55,13 +59,13 @@ function readTopLevelFolderFiles(dirEntry: FileSystemDirectoryEntry): Promise<Fi
for (const ent of batch) {
if (ent.isFile) {
const fileEntry = ent as FileSystemFileEntry;
filePromises.push(
collected.push(
new Promise<File | null>((res) => {
fileEntry.file(
(f) => {
try {
Object.defineProperty(f, "webkitRelativePath", {
value: `${dirEntry.name}/${f.name}`,
value: `${prefix}/${f.name}`,
configurable: true,
});
} catch {
@@ -74,6 +78,10 @@ function readTopLevelFolderFiles(dirEntry: FileSystemDirectoryEntry): Promise<Fi
);
}),
);
} else if (ent.isDirectory) {
collected.push(
readFolderFilesRecursive(ent as FileSystemDirectoryEntry, prefix),
);
}
}
readBatch();
@@ -179,10 +187,11 @@ export function useFileDragDrop({
}
if (dirEntry) {
// handleUploadFolder owns count cap / hidden / dup-name / silent
// filtering, so just read one level deep and hand the files over.
// Any loose files in the same drop are ignored (button parity: one folder).
// filtering, so read the whole tree (nested, F034 §5.5) and hand
// the files over. Any loose files in the same drop are ignored
// (button parity: one folder).
onDragStateChange?.(false);
void readTopLevelFolderFiles(dirEntry).then((files) => {
void readFolderFilesRecursive(dirEntry, "").then((files) => {
if (files.length > 0) {
onUploadFolder(files, { allowedExtensions: allowedExt, maxSizeMB: limitMB });
}
@@ -9,7 +9,9 @@ import {
renameFolderApi,
deleteFolderApi,
uploadFileToServerApi,
uploadFolderApi,
addFilesApi,
type FolderUploadItemPayload,
renameFileApi,
deleteFileApi,
retryDuplicateFilesApi,
@@ -273,17 +275,20 @@ export function useFileUpload({
setDuplicateFiles([]);
}, []);
// ─── Folder upload (pick a local folder; one-level only) ─────────────
// ─── Folder upload (pick a local folder; nested, F034 §5.5) ──────────
/**
* Upload a single picked folder. The browser populates
* `File.webkitRelativePath` like "Docs/a.pdf" (root file) or
* "Docs/Sub/b.pdf" (nested). We:
* 1. Reject the whole batch if the picked folder is hidden,
* has a name already used at the current location, or the raw
* file count exceeds MAX_FOLDER_UPLOAD_COUNT.
* 2. Silently filter to root-level + supported + size-ok files.
* 3. Create one folder on the backend and register the kept files
* under it, reusing the existing upload + register pipeline.
* Upload a single picked folder, keeping its whole nested structure. The
* browser populates `File.webkitRelativePath` like "Docs/a.pdf" (root
* file) or "Docs/Sub/b.pdf" (nested). We:
* 1. Reject the whole batch if the picked folder is hidden, has a name
* already used at the current location, or the raw (pre-filter) file
* count exceeds MAX_FOLDER_UPLOAD_COUNT each with a toast (AC-32).
* 2. Silently filter out hidden / unsupported / oversize files at every
* nesting level (AC-27).
* 3. Upload each file body, then register the batch via
* uploadFolderApi the backend rebuilds the directory tree and runs
* the regular parse pipeline; batch rejections toast via
* api_errors.<code>.
*/
const handleUploadFolder = useCallback(
async (
@@ -336,36 +341,31 @@ export function useFileUpload({
}
const validFiles = filterFolderUploadFiles(allFiles, options);
if (validFiles.length === 0) return;
// Create the destination folder first so we have a parent_id to
// register the uploaded files against.
let folder: KnowledgeFile;
try {
folder = await createFolderApi(activeSpace.id, {
name: rootName,
parent_id: currentFolderId || null,
if (validFiles.length === 0) {
// Every file was silently filtered (format / hidden / oversize):
// nothing to upload, and no empty tree is created (AC-27 edge).
showToast({
message: localize("com_knowledge.folder_upload_no_valid_files"),
severity: NotificationSeverity.WARNING,
});
} catch {
showToast({ message: localize("com_knowledge.create_folder_failed"), severity: NotificationSeverity.ERROR });
return;
}
// Show the new folder in the current listing immediately.
setFiles((prev) => [folder, ...prev]);
setTotal((prev) => prev + 1);
dispatchKnowledgeSpaceFilesRefresh(activeSpace.id);
// Upload each file to object storage (sequential, mirrors existing
// single-file upload — keeps load predictable for 1k batches).
const uploadedPaths: string[] = [];
// Upload each file body to object storage (sequential, mirrors the
// existing single-file upload — keeps load predictable for 1k
// batches), keeping its relative path + size for the tree rebuild.
const uploadedItems: FolderUploadItemPayload[] = [];
const failures: { name: string; reason: string }[] = [];
for (const file of validFiles) {
try {
// Pass `file.name` explicitly to strip the folder prefix
// Chromium would otherwise put in the multipart filename.
const res: UploadFileResponse = await uploadFileToServerApi(activeSpace.id, file, file.name);
uploadedPaths.push(res.file_path);
uploadedItems.push({
file_path: res.file_path,
relative_path: file.webkitRelativePath || file.name,
size: file.size,
});
} catch (err) {
failures.push({
name: file.name,
@@ -386,36 +386,34 @@ export function useFileUpload({
showToast({ message, severity: NotificationSeverity.ERROR });
}
if (uploadedPaths.length === 0) {
// Folder created but every file upload failed — list refresh
// ensures the empty folder shows up with the correct counts.
await loadFiles(currentPage);
return;
}
if (uploadedItems.length === 0) return;
// Register files under the new folder. Duplicates inside this new
// folder shouldn't be possible (a fresh folder is empty), but the
// backend may still flag global duplicates by md5; reuse the
// existing duplicate-overwrite flow.
// Register the whole batch: the backend rebuilds the directory tree
// from each item's relative_path, then runs the regular pipeline.
// Batch rejections (depth / dup folder / quota / count) are toasted
// by the interceptor via api_errors.<code> (AC-32).
try {
const registeredFiles = await addFilesApi(activeSpace.id, {
file_path: uploadedPaths,
parent_id: Number(folder.id),
const registeredFiles = await uploadFolderApi(activeSpace.id, {
parent_id: currentFolderId ? Number(currentFolderId) : null,
items: uploadedItems,
});
const dupes = extractDuplicateFileEntries(registeredFiles);
if (dupes.length > 0) {
setDuplicateFiles(dupes);
}
} catch {
// Swallow — the refresh below will reflect whatever made it in.
// Whole batch rejected before any row was created; the toast
// already fired in the interceptor. Nothing to refresh.
return;
}
dispatchKnowledgeSpaceFilesRefresh(activeSpace.id);
await loadFiles(currentPage);
} finally {
folderUploadInFlightRef.current = false;
}
},
[activeSpace, currentFolderId, currentPage, loadFiles, localize, setFiles, setTotal, showToast],
[activeSpace, currentFolderId, currentPage, loadFiles, localize, showToast],
);
// ─── Folder creation ─────────────────────────────────────────────────
@@ -211,10 +211,17 @@ export function getRootFolderName(relativePath: string): string {
}
/**
* Folder upload silently keeps only files at the *root* of the picked folder
* (one path segment after the folder name) and drops:
* - files nested inside any sub-folder
* - hidden files (leading dot)
* A relative path is hidden when ANY of its segments is hidden a visible
* file inside a hidden directory (e.g. `.git/config`) must also be dropped.
*/
export function isHiddenPath(relativePath: string): boolean {
return relativePath.split("/").some((segment) => isHiddenName(segment));
}
/**
* Folder upload keeps files at *every* nesting level (F034 §5.5: the backend
* rebuilds the directory tree from `webkitRelativePath`) and silently drops:
* - hidden files, and files inside hidden directories (checked per path segment)
* - unsupported extensions
* - files exceeding the size limit
*
@@ -228,8 +235,7 @@ export function filterFolderUploadFiles(
const maxBytes = options.maxSizeMB * 1024 * 1024;
return files.filter((file) => {
const rel = file.webkitRelativePath || file.name;
if (rel.split("/").length !== 2) return false;
if (isHiddenName(file.name)) return false;
if (isHiddenPath(rel)) return false;
if (file.size > maxBytes) return false;
const ext = file.name.split(".").pop()?.toLowerCase();
if (!ext || !options.allowedExtensions.includes(ext)) return false;