diff --git a/features/v2.6.0/043-chat-file-permanent-storage/design.md b/features/v2.6.0/043-chat-file-permanent-storage/design.md index 9e912dbff..f3aa7e7c8 100644 --- a/features/v2.6.0/043-chat-file-permanent-storage/design.md +++ b/features/v2.6.0/043-chat-file-permanent-storage/design.md @@ -35,12 +35,13 @@ - A. 落主桶,对象名 `chat/{chat_id}/{uuid}.{ext}`。 - B. 落主桶,对象名 `chat/{user_id}/{uuid}.{ext}`。 - C. 保留临时桶但取消 lifecycle 规则。 -- **选定**:A +- **选定**:**B**(初版曾选 A,实现时发现不可行,见下) - **原因**: - C 会把**所有**使用临时桶的业务(报告导出等)一起变成永不清理,副作用远超本特性范围。 - - A 与 B 都能解决唯一性;选 A 是因为按会话前缀归拢,删除会话时可以**按前缀批量清理**,无需先把消息里的附件逐条捞出来(AC-03 的实现直接受益)。 - - uuid 而非原始文件名,是 AC-02 的直接要求;原始文件名仅作为展示名保存在消息里。 -- **何时该重新考虑**:若将来附件需要跨会话复用(同一文件在多个会话引用),按会话前缀的物理归拢会变成阻碍,届时需要引用计数模型。 + - **A 不可行**:新会话的第一次上传发生在会话创建**之前**——客户端此时仍持 `"new"`,会话 ID 由后端在消息发送时才分配,命名时根本拿不到(§5 坑 8)。 + - 选 B:uuid 保证唯一(AC-02 的直接要求);按上传者归拢便于后续做配额 / 运维排查。删除会话改为**从该会话的消息里取对象名**——对象名本来就要存进消息(决策 2),所以这条信息现成,不需要前缀清扫。 + - 原始文件名仅作为展示名保存在消息里,不参与寻址。 +- **何时该重新考虑**:若将来上传能在会话已存在后才发生(例如前端先建会话再传文件),可回到按会话前缀,删除会退化为一次前缀清扫。 ### 决策 2:不新建附件表,用消息里的 files 结构承载 @@ -83,14 +84,14 @@ | 日常模式 / 工作流会话 | `/workstation/files`、`/knowledge/upload` | **临时桶** | 日常模式=**原始文件名**;工作流=uuid | **3 天** | | 任务模式(灵思) | `/linsight/workbench/upload-file` | 主桶 | uuid | 永久 | -改造后三者统一:主桶 + `chat/{chat_id}/{uuid}.{ext}`。 +改造后三者统一:主桶 + `chat/{user_id}/{uuid}.{ext}`(会话上传另开专用入口,不复用共用的 `/knowledge/upload`,见 §5 坑 9)。 ### 4.2 关键数据结构 / 字段约定 | 契约 | 形式 | 说明 | 谁会消费 | |---|---|---|---| | 消息附件项 | 现有字段(file_id / filename / type / filepath…)**新增对象名字段** | 对象名是换发链接的唯一依据;`filepath` 退化为"上传当时的链接",前端不再依赖它长期可用 | 换发接口、前端渲染 | -| 存储对象名 | `chat/{chat_id}/{uuid}.{ext}` | 前缀即会话,删除时按前缀清理 | 存储层、清理逻辑 | +| 存储对象名 | `chat/{user_id}/{uuid}.{ext}` | 前缀即上传者;删除会话时从消息取对象名(上传时拿不到会话 ID,见 §5 坑 8) | 存储层、清理逻辑 | | 换发链接接口 | 入参 `chat_id` + `file_id`,出参短时效 URL | 鉴权见 §3 决策 3 | client 端图片/附件渲染 | | 图片识别口径 | 文件名后缀 ∈ png/jpg/jpeg/gif/webp/bmp/svg | 与文件图标同口径 | 前端 | @@ -100,7 +101,7 @@ |---|---|---| | 会话上传接口(三处) | 生成会话内唯一对象名、落主桶、回传对象名 | 不再依赖 `save_uploaded_file` 的默认临时桶行为 | | 换发链接接口 | 鉴权 + 从服务端数据取对象名 + 签发 | **不接受**前端传入的对象名 | -| 会话删除逻辑 | 软删会话后按前缀清理附件 | 清理失败不阻断删除主流程 | +| 会话删除逻辑 | 软删会话后,从该会话消息中取出对象名并逐个删除 | 清理失败不阻断删除主流程 | | 共用「消息图片」组件(前端) | 换发链接、缩略图、全屏查看、失效占位 | 不判断"是不是图片"(调用方分流) | | 两套消息渲染 | 各自按后缀分流到图片组件 | 不改非图片附件的现状 | @@ -117,6 +118,8 @@ | 5 | 会话删除是**软删除**且无恢复入口 | 会因担心"删了还能恢复"而不敢清理附件,或反过来以为硬删而漏掉清理 | 决策:软删即终态,可清理 | | 6 | 换发链接接口若接受前端传对象名,等于任意文件读取漏洞 | 一个便利写法直接开一个安全洞 | 决策 3 的鉴权口径 | | 7 | client 端有**两套互不相干的消息渲染**(日常/任务一套,工作流会话另一套) | 只改一处,另一场景毫无变化 | 两处各自接入同一组件 | +| 8 | **上传发生在会话创建之前**:新会话第一条消息的附件上传时,客户端的会话 ID 还是 `"new"`,真实 ID 由后端在发消息时分配 | 会设计出「对象名按会话 ID 归拢 / 删除时按会话前缀清扫」这类拿不到 ID 的方案(本特性初版设计正是如此,实现时才发现) | 决策 1 改为按上传者归拢,删除从消息里取对象名 | +| 9 | `POST /api/v1/knowledge/upload` **不是**会话专用接口——API 接入示例、知识库 QA 导入、数据集创建都在用 | 直接改它的存储行为会波及知识库等无关场景 | 会话另开专用上传入口,不动这个共用接口 | --- @@ -147,7 +150,7 @@ 1. 两个不同用户各传一个同名 `1.png` → 各自会话里看到的都是自己的图(AC-02,最关键的一条) 2. 三个场景各传一张图 → 缩略图 → 点开全屏 → 关闭 3. 传非图片附件 → 与现状一致 - 4. 删除一个含附件的会话 → 对象存储中该会话前缀下的对象应被清空 + 4. 删除一个含附件的会话 → 该会话消息中记录的对象应被删除 5. 打开一个存量老会话 → 图片显示「已失效」(预期行为,非缺陷) - **可观测**:附件清理失败需留日志(会话删除仍应成功)。 @@ -157,7 +160,8 @@ - **存量文件**:已被临时桶策略清理,不可恢复;不做迁移。 - **会话保留期定期清理**:本期只做"随会话删除",长期存储增长需要独立的容量策略。 -- **附件跨会话复用 / 去重**:当前按会话物理归拢,不支持引用计数(决策 1 的已知取舍)。 +- **附件跨会话复用 / 去重**:当前不支持引用计数(决策 1 的已知取舍)。 +- **孤儿文件**:上传后未发送消息的文件不会被任何消息引用,因而不会被清理。文件永久化后这类残留会累积,需要一个独立的巡检任务(本期不做)。 - **知识库等非会话上传路径**:仍走原有策略,未统一。 --- @@ -167,3 +171,4 @@ | 日期 | 改动 | 触发原因 | |---|---|---| | 2026-07-28 | 初版:由原 v3.0.0-beta1 F045(纯前端图片展示)升级而来,纳入存储永久化与对象名唯一化 | 用户提出"会话文件需永久保存",排查发现临时桶 3 天清理 + 日常模式对象名未唯一化 | +| 2026-07-28 | 决策 1 由「按会话前缀」改为「按上传者前缀 + 删除时从消息取对象名」;新增 §5 坑 8/9 与 §8 孤儿文件条目 | 实现 Wave 2 时发现上传发生在会话创建之前、拿不到会话 ID;且 `/knowledge/upload` 为多场景共用接口 | diff --git a/features/v2.6.0/043-chat-file-permanent-storage/tasks.md b/features/v2.6.0/043-chat-file-permanent-storage/tasks.md index e099141f3..ec74d7e6b 100644 --- a/features/v2.6.0/043-chat-file-permanent-storage/tasks.md +++ b/features/v2.6.0/043-chat-file-permanent-storage/tasks.md @@ -31,7 +31,7 @@ - [ ] **T001**: 会话附件对象名生成 + 单元测试 **文件**: `src/backend/bisheng/core/storage/chat_attachment.py`(新建,或就近放入既有存储工具模块) `src/backend/test/core/test_chat_attachment_object_name.py`(新建) - **逻辑**: 纯函数 `build_chat_object_name(chat_id: str, filename: str) -> str` → `chat/{chat_id}/{uuid}{ext}`。扩展名从原文件名取并小写化;无扩展名时不加;文件名中的路径分隔符与 `..` 必须被丢弃(对象名不得由用户内容拼出目录穿越) + **逻辑**: 纯函数 `build_chat_object_name(user_id, filename) -> str` → `chat/{user_id}/{uuid}{ext}`。扩展名从原文件名取并小写化;无扩展名时不加;文件名中的路径分隔符与 `..` 必须被丢弃(对象名不得由用户内容拼出目录穿越) **测试**: 同名文件两次调用得到不同对象名;扩展名保留且小写;无扩展名;文件名含 `../` 与 `/`;超长文件名 **覆盖 AC**: AC-02 **依赖**: 无 @@ -54,10 +54,9 @@ **覆盖 AC**: AC-01, AC-02 **依赖**: T001 -- [ ] **T004**: 工作流会话上传入口 - **文件**: `src/backend/bisheng/knowledge/api/endpoints/knowledge.py`(`POST /upload`) - **逻辑**: 该入口已是 uuid 命名但仍落临时桶;改为主桶 + T001 对象名;响应新增对象名字段 - **⚠️ 影响面**: `POST /upload` 可能被会话之外的场景调用,改动前需确认调用方;若被非会话场景共用,则**另开一个会话专用入口**而不是改动共用接口 +- [ ] **T004**: 工作流会话上传入口(**另开专用入口**) + **文件**: 会话上传新端点(`bisheng/chat_session/api/endpoints/chat.py` 或同模块) + **逻辑**: 已查明 `POST /knowledge/upload` 被 API 接入示例、知识库 QA 导入、数据集创建等多处共用(design §5 坑 9),**不改它**;为会话新增专用上传端点:主桶 + T001 对象名,响应带对象名。client 端 `uploadChatFile` 无 mode 分支改指向新端点 **覆盖 AC**: AC-01 **依赖**: T001 @@ -85,7 +84,7 @@ - [ ] **T008**: 会话删除时清理附件 **文件**: `src/backend/bisheng/chat_session/domain/chat.py`(`delete_session`) - **逻辑**: 软删会话后,按 `chat/{chat_id}/` 前缀调 T002 批量删除。**清理失败只记日志,不得让删除会话失败**(spec §3) + **逻辑**: 软删会话后,从该会话的消息 files 中取出对象名并逐个删除(上传时拿不到会话 ID,无法按前缀清扫——design §5 坑 8)。T002 的前缀删除保留给未来按用户清理 / 巡检使用。**清理失败只记日志,不得让删除会话失败**(spec §3) **覆盖 AC**: AC-03 **依赖**: T002 @@ -119,7 +118,7 @@ 1. **两个不同用户各上传同名 `1.png`** → 各自会话里看到的都是自己那张(AC-02,**最关键**,验证数据泄露已修) 2. 三个场景各传一张图 → 缩略图 → 点开全屏 → 右上角关闭(AC-05/06/07/11) 3. 传一个非图片附件 → 展示与改动前一致(AC-10) -4. 删除一个含附件的会话 → 对象存储中 `chat/{该会话ID}/` 前缀下应为空(AC-03) +4. 删除一个含附件的会话 → 该会话消息中记录的对象在存储中应已消失(AC-03) 5. 用另一个账号调换发接口请求他人会话的附件 → 应被拒绝(AC-04) 6. 打开一个存量老会话 → 图片显示「图片已失效,无法查看」(预期行为,非缺陷) 7. 切 en / ja 检查新增文案(AC-09) @@ -130,4 +129,6 @@ > 只留一行指针,论证在 design.md。推翻已 ★ 确认的决策时先停下重新确认。 -- (待填) +- T001 偏离:对象名由 `chat/{chat_id}/` 改为 `chat/{user_id}/` → 更新 design 决策 1 + 新增坑 8(上传发生在会话创建之前,命名时拿不到会话 ID) +- T004 偏离:改为新增会话专用上传端点,不改共用的 `/knowledge/upload` → 新增坑 9(该接口被知识库/数据集/API 示例共用) +- T008 偏离:删除改为「从消息取对象名逐个删」,不再按前缀清扫(同坑 8) diff --git a/src/backend/bisheng/core/storage/chat_attachment.py b/src/backend/bisheng/core/storage/chat_attachment.py index 14c912448..33bbf44dd 100644 --- a/src/backend/bisheng/core/storage/chat_attachment.py +++ b/src/backend/bisheng/core/storage/chat_attachment.py @@ -1,10 +1,14 @@ """Object naming for files a user uploads inside a conversation. -Conversation attachments live in the main bucket under a per-conversation -prefix, which is what lets deleting a conversation wipe its files in one -prefixed sweep. The stored name is a uuid: the original filename is display -metadata carried on the message, never an identity -- two users uploading -"1.png" must not land on the same object. +Attachments live in the main bucket, grouped by uploader. They are NOT grouped +by conversation: the first upload of a new chat happens before the chat exists +(the client still holds "new" and the backend assigns the id when the message +is sent), so a conversation id simply isn't available at naming time. Deleting +a conversation therefore collects object names from its messages rather than +sweeping a prefix -- the message already carries them. + +The stored name is a uuid: the original filename is display metadata on the +message, never an identity -- two users uploading "1.png" must not collide. """ import os @@ -17,9 +21,9 @@ CHAT_OBJECT_PREFIX = "chat/" _MAX_EXT_LEN = 10 -def chat_object_prefix(chat_id: str) -> str: - """Prefix holding every attachment of one conversation.""" - return f"{CHAT_OBJECT_PREFIX}{chat_id}/" +def chat_object_prefix(user_id: int | str) -> str: + """Prefix holding one user's conversation attachments.""" + return f"{CHAT_OBJECT_PREFIX}{user_id}/" def _safe_extension(filename: str) -> str: @@ -41,6 +45,6 @@ def _safe_extension(filename: str) -> str: return ext -def build_chat_object_name(chat_id: str, filename: str) -> str: - """Storage object name for one attachment of one conversation.""" - return f"{chat_object_prefix(chat_id)}{uuid4().hex}{_safe_extension(filename)}" +def build_chat_object_name(user_id: int | str, filename: str) -> str: + """Storage object name for one conversation attachment.""" + return f"{chat_object_prefix(user_id)}{uuid4().hex}{_safe_extension(filename)}" diff --git a/src/backend/test/core/test_chat_attachment_object_name.py b/src/backend/test/core/test_chat_attachment_object_name.py index 657b13e39..95ec7dd91 100644 --- a/src/backend/test/core/test_chat_attachment_object_name.py +++ b/src/backend/test/core/test_chat_attachment_object_name.py @@ -14,41 +14,42 @@ from bisheng.core.storage.chat_attachment import CHAT_OBJECT_PREFIX, build_chat_ class TestBuildChatObjectName: def test_same_filename_never_collides(self): # AC-02 — the whole point: one user's upload must not clobber another's. - a = build_chat_object_name("chat-1", "1.png") - b = build_chat_object_name("chat-1", "1.png") + a = build_chat_object_name(1, "1.png") + b = build_chat_object_name(1, "1.png") assert a != b - def test_scoped_to_the_conversation(self): - # The prefix is what lets deletion wipe a conversation's files in one go. - name = build_chat_object_name("chat-42", "report.pdf") - assert name.startswith(f"{CHAT_OBJECT_PREFIX}chat-42/") + def test_scoped_to_the_uploader(self): + # Grouping by uploader keeps ops/quota work tractable later; deletion + # itself reads object names off the messages (see module docstring). + name = build_chat_object_name(42, "report.pdf") + assert name.startswith(f"{CHAT_OBJECT_PREFIX}42/") def test_extension_preserved_and_lowercased(self): - assert build_chat_object_name("c", "Photo.PNG").endswith(".png") + assert build_chat_object_name(7, "Photo.PNG").endswith(".png") def test_filename_without_extension(self): - name = build_chat_object_name("c", "README") + name = build_chat_object_name(7, "README") assert "." not in name.rsplit("/", 1)[-1] def test_path_separators_in_filename_cannot_escape_the_prefix(self): # The name is built from user-supplied content; a filename must never be # able to steer the object somewhere else in the bucket. - name = build_chat_object_name("c", "../../etc/passwd") - assert name.startswith(f"{CHAT_OBJECT_PREFIX}c/") + name = build_chat_object_name(7, "../../etc/passwd") + assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/") assert ".." not in name def test_windows_separators_are_not_taken_as_extension(self): - name = build_chat_object_name("c", r"C:\tmp\evil.exe") - assert name.startswith(f"{CHAT_OBJECT_PREFIX}c/") + name = build_chat_object_name(7, r"C:\tmp\evil.exe") + assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/") assert "\\" not in name assert name.endswith(".exe") def test_absurdly_long_extension_is_dropped(self): # A "." in a long name doesn't make everything after it an extension. - name = build_chat_object_name("c", "file." + "x" * 50) - assert name.startswith(f"{CHAT_OBJECT_PREFIX}c/") + name = build_chat_object_name(7, "file." + "x" * 50) + assert name.startswith(f"{CHAT_OBJECT_PREFIX}7/") assert len(name.rsplit("/", 1)[-1]) < 60 def test_hidden_file_has_no_extension(self): - name = build_chat_object_name("c", ".gitignore") + name = build_chat_object_name(7, ".gitignore") assert "." not in name.rsplit("/", 1)[-1]