fix(storage): name attachments by uploader, not by conversation

The design had attachments under chat/<chat_id>/ so deleting a conversation
could sweep one prefix. That can't work: 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 there is no conversation id at naming
time. Group by uploader instead and let deletion collect object names from the
conversation's messages, which already carry them.

Also records that /knowledge/upload is shared with the knowledge base, QA
import and dataset flows, so the workflow-chat path needs its own endpoint
rather than a change to that one.

Refs: features/v2.6.0/043-chat-file-permanent-storage design §3 decision 1, §5 pits 8-9
This commit is contained in:
dolphin
2026-07-28 22:08:28 +08:00
parent fd7bff2341
commit c7d2ef6b14
4 changed files with 54 additions and 43 deletions
@@ -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)。
- 选 Buuid 保证唯一(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` 为多场景共用接口 |
@@ -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)
@@ -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)}"
@@ -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]