Files

499 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Handover — 近期变更与待办
面向接手运维 / 二次开发的人。记录最近几次 commit 的动机、改动范围、未尽事项,
以及「线上有旧数据时怎么过渡」。详细架构见 `ARCHITECTURE.md`;本仓库的开发规约
`DEVELOP.md`
---
## 0. 当前交接状态(2026-08-20
### 0.1 代码与服务
- 基线代码:`ae3b6d6``develop`)。本次改动尚未提交,修改范围见下方「0.4」。
- Docker Compose 项目名:`model-platform-develop14`
- 主入口:<http://127.0.0.1:9120>;浏览器通过 web/Nginx 访问后端 API。
- 当前启用的 `.env` 已配置 MySQL 连接和存储后端。**不要把 `.env` 中的连接串、密码或
Token 提交到 Git。**
检查服务:
```powershell
docker compose -p model-platform-develop14 ps
Invoke-WebRequest http://127.0.0.1:9120/api/v1/health
```
完整重建并启动:
```powershell
docker compose -p model-platform-develop14 up -d --build
```
仅后端 Python 代码变动时,使用下面命令即可;它不会删除数据库或 `data/`
```powershell
docker compose -p model-platform-develop14 build backend
docker compose -p model-platform-develop14 up -d --force-recreate --no-deps backend
```
### 0.2 调度模块本次行为
调度页面位于 `frontend/app/features/schedules/`,对应 API 位于
`backend/src/backend/schedules.py`
- 删除调度方案:删除该方案的节点、连线、运行记录,以及可删除的运行日志/结果产物;若方案
仍有运行中的任务,后端返回 409,避免删到一半。
- 删除节点:运行中的节点不可删除;有历史运行记录时,前端会提示“该节点有运行日志,是否一并删除?”。
用户确认后会删除节点、关联连线、该节点的 `schedule_node_runs` 记录和可删除日志/结果。
- `StorageObjects.is_immutable=1` 的审计原件受存储层保护,不能物理删除;删除节点时会删除其
运行记录引用,使其不再在调度页面展示,但保留原件,避免再次出现
`immutable object cannot be deleted` 并导致节点删除回滚。
- 运行记录列表已加入轮询:有运行中任务时约 1.5 秒刷新一次,空闲时约 5 秒刷新一次;右侧刷新
按钮仍可手动刷新。
### 0.3 关键排查位置
| 现象 | 首先查看 |
|---|---|
| 页面请求报错 | 浏览器 F12 → Network → Fetch/XHR,查看请求 URL、状态码和响应内容 |
| API / 数据库异常 | `docker compose -p model-platform-develop14 logs --tail=200 backend` |
| Cron 未触发 | `docker compose -p model-platform-develop14 logs --tail=200 schedule` |
| 脚本执行失败 | 调度页面“运行记录”→“查看结果”→ 节点日志 |
| 容器状态或端口问题 | `docker compose -p model-platform-develop14 ps` |
### 0.4 未提交改动清单
当前工作区包含以下代码改动,提交前应按需执行 typecheck / 后端测试:
| 文件 | 作用 |
|---|---|
| `backend/src/backend/schedule_schemas.py` | 节点删除请求支持历史记录确认参数 |
| `backend/src/backend/schedules.py` | 调度/节点删除、运行中保护、日志清理与不可变产物兼容 |
| `frontend/app/services/api.ts` | 节点删除 API 参数 |
| `frontend/app/context/AuthContext.tsx` | API 参数透传 |
| `frontend/app/features/schedules/state/schedulesStore.ts` | 删除确认及删除后刷新状态 |
| `frontend/app/features/schedules/SchedulePage.tsx` | 运行记录轮询 |
`data/` 是本地运行数据,不应作为本次代码改动一起提交。
---
## 1. 最近 8 个 commit(按时间倒序)
### `85b2916` — fix: local_storage_base_dirbucket 标识符语义统一:最终 fix)
**背景**:见 §3。「删除文件存在报错 `failed to move object to trash: 'workspace'`
的真实根因 —— `StorageObjects.bucket_name` 写的是裸字符串(`"workspace"`),
`app.state.object_stores` 的 key 是 local 模式拼出来的路径(`"data/workspace"`),
两边错位导致 KeyError。
**改动**1 个文件,19+/15-):
| 文件 | 改动 |
|---|---|
| `common/src/common/storage/factory.py` | `actual_bucket_name(purpose)` 合并 local/s3 两个分支,单一返回 `settings.s3_<purpose>_bucket``build_storage_uri` local 分支从 `Path(bucket_name).absolute()` 改为 `(Path(local_storage_base_dir) / bucket_name).resolve()`;注释 / docstring 同步重写 |
**重要语义区分**
- `actual_bucket_name(purpose)` → 返回 **s3 风格 bucket 标识符**(如 `"workspace"`
`"run-logs"`),local 和 s3 模式返回相同字符串。这是 `app.state.object_stores`
的 dict key,也是 `StorageObjects.bucket_name` / `UploadSessions.bucket_name`
存储的值。
- `build_storage_config(purpose)` → 内部仍然 `Path(local_storage_base_dir) / purpose`
`base_dir`,所以 local backend 实例的磁盘根仍是 `./data/<purpose>/`。这层
`bucket_name` 字段解耦,是设计上正确的。
### `25e563d` — update: ruff check --fix
`ruff` 自动格式化,13 个文件,0 逻辑改动。可视为「`25e563d` 之前最近一次大改
`5d49ff5` / `2e070fa` / `5c5dec9` / `d855912` / `85b2916`)的格式统一」。如果
review 时看到大量 import 重排 / 引号风格变化,都是 ruff 干的。
### `d855912` — fix: soft delete helper(写入端 bucket_name 修复)
**背景**:承接 `85b2916` 的语义重写。`85b2916` 之前 `scripts.py:482` 用的是
`settings.s3_workspace_bucket`(裸字符串);修完 `actual_bucket_name` 之后
该函数返回的是 s3 风格标识符,**逻辑等价**,但 `scripts.py` 调用方式必须跟着改
才能保持一致性。`_resolve_bucket_for_usage` 的 fallback 路径有同一类 bug。
**改动**2 个文件,4+/2-):
| 文件 | 改动 |
|---|---|
| `backend/src/backend/scripts.py` | import 增加 `actual_bucket_name``bucket_name = settings.s3_workspace_bucket``bucket_name = actual_bucket_name("workspace")` |
| `backend/src/backend/services/storage.py` | `_resolve_bucket_for_usage` fallback 从 `BUCKET_FOR_USAGE.get(usage_type, settings.s3_workspace_bucket)` 改为 `BUCKET_FOR_USAGE.get(usage_type, actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace")))` |
### `139f2c0` — fix: delete errortrash 路径统一 + 删除后 DB 字段更新)
**背景**:删除文件统一改为移动到 trash 桶后,存在三个连锁 bug:
1. **local 模式 trash 路径双重 `data`**`trash_key = f"{item.bucket_name}/{item.object_key}"`
local 模式下 `bucket_name` 是绝对路径(如 `/data/workspace`),最终落到
`data/trash/data/workspace/{ws_id}/{user_id}/...`
2. **s3 模式桶名 / 路径名错位**`.env` 之前是 `S3_WORKSPACE_BUCKET=workspaces`(复数),
代码 default / `PURPOSE_BUCKETS` / `USAGE_TYPE_TO_PURPOSE` 全部是单数 `workspace`
跨环境部署产生 `KeyError: 'workspaces'`(软删时 `object_stores[item.bucket_name]` 抛错)。
3. **软删后 `storage_objects` 路径字段未更新**:只翻 `trash_key` / `object_status` /
`deleted_at` / `is_deleted``bucket_name` / `object_key` / `storage_uri` 仍指向源,
与物理位置不一致,`purge_trash_object` 必须读旧 `trash_key` 才能定位 trash 文件。
**改动**5 个文件,44+/24-):
| 文件 | 改动 |
|---|---|
| `backend/src/backend/services/storage.py` | `trash_key = f"{source_purpose}/{item.object_key}"`;软删成功后更新 `bucket_name` / `object_key` / `storage_uri` 指向 trash 位置 |
| `backend/src/backend/storage_api.py` | `restore_object``item.object_key` 直接定位 trash,按 `/``source_purpose`,写回源桶,更新 DB 字段指回 source;`purge_trash_object``(actual_bucket_name("trash"), item.object_key)` 删除,加 `bucket_name != trash` 的 409 防御 |
| `common/src/common/storage/factory.py` | 引入 `USAGE_TYPE_TO_PURPOSE` 字典(替代 `storage_api.py` 的本地定义) |
| `common/src/common/storage/__init__.py` | 导出 `USAGE_TYPE_TO_PURPOSE` |
| `.env.example` | `S3_WORKSPACE_BUCKET=workspaces``workspace``S3_VERSION_BUCKET=versions``version``S3_RUN_LOG_BUCKET=run-logs``run-log`(统一单数) |
**注意点**
- `workspaces_root()` 函数名 / `WORKSPACES_ROOT` 环境变量名不动(这是 path 概念,不是 bucket 名)。
- `Workspaces` 数据库表名 / `auth.py``"workspaces"` JSON key(API 响应)不动(这些不是桶名)。
- `visibility="workspace"` 是 ACL Literal,不动。
### `2e070fa` — fix: pre check(前端 script 创建/上传前置校验)
`CreateScriptModal.tsx`+25 行)— 文件名 / workspace_id / 类型前置校验。
`ScriptsPage.tsx`+1/ `scriptWorkspaceStore.ts`+19/-10)— store action 增加
`precheck` 步骤,提前拦截无效请求。
### `5c5dec9` — fix: allows_same_name_different_parent
允许同名 script 出现在不同父目录下。`scripts.py`+32/-?)、`services/storage.py`
`storage_api.py` 微调;`test_scripts.py` 新增 57 行回归测试。
### `5d49ff5` — fix: file upload error(大重写)
修复 file upload 路径上一连串 schema / API / 上传流程的不一致。改动面最大
14 个文件,872+/604-):
- 新增索引 / 调整唯一约束(去掉了 scripts 和 data_resources 的旧 unique index
- 把多个小 migration 合并进 `e1f2a3b4c5d6_rebuild_baseline.py` 一份大 baseline
- `resources.py` / `scripts.py` 上传流程对齐
- `test_resources.py` / `test_scripts.py` 大量回归
- `API.md` 增加 3 行
### `c65d6dc` — fix: build error
`backend/Dockerfile` 单行修复(镜像构建报错)。
---
## 2. Trash 修复详解(commit `139f2c0`
### 2.1 修复后的语义
- **trash_key**`f"{source_purpose}/{item.object_key}"``source_purpose` 来自
`USAGE_TYPE_TO_PURPOSE[item.usage_type]`,与 `item.bucket_name` 解耦 —
避免 local 模式绝对路径泄漏 + 命名错位风险。
- **软删后 `storage_objects` 行的 7 个字段同步**
| 字段 | 软删前 | 软删后 |
|---|---|---|
| `bucket_name` | 源桶 | trash 桶 |
| `object_key` | 源 key | trash key |
| `trash_key` | NULL | trash key |
| `storage_uri` | 源 URI | trash URI`build_storage_uri` 重建) |
| `object_status` | `available` | `deleted` |
| `is_deleted` | `0` | `1` |
| `deleted_at` | NULL | `<utcnow>` |
- **物理文件落点**
| 模式 | 源文件(被删) | trash 文件(新落) |
|---|---|---|
| S3 | `s3://<source_bucket>/<key>` | `s3://trash/<source_purpose>/<key>` |
| local | `<LOCAL_STORAGE_BASE_DIR>/<source_purpose>/<key>` | `<LOCAL_STORAGE_BASE_DIR>/trash/<source_purpose>/<key>` |
`source_purpose``{workspace, version, run_log}`
### 2.2 `restore_object` 反向流程
```python
data = await object_stores[trash_bucket].get(item.object_key) # 从 trash 读
source_purpose, _, source_key = item.object_key.partition("/") # 拆 source_purpose
target_bucket = actual_bucket_name(source_purpose) # 还原目标桶
await object_stores[target_bucket].put(source_key, data) # 写回源
item.bucket_name = target_bucket
item.object_key = source_key
item.storage_uri = build_storage_uri(target_bucket, source_key)
# trash_key 保留不动 → 未来 reaper 可据此清理 trash 里的 orphan 副本
item.object_status = "available"
item.deleted_at = None
```
**trash 文件不会被 restore 主动删除**(保留现有安全语义:restore 失败时
数据仍在 trash 里有兜底)。未实现的 reaper 未来可扫
`object_status='available' AND trash_key IS NOT NULL` 清理 orphan。
### 2.3 `purge_trash_object` 防御
加 409 防御:`if item.bucket_name != actual_bucket_name("trash"): raise 409`
避免误删源数据(用 `item.object_key` 直接删 trash 文件,不再读旧 `trash_key`)。
### 2.4 USAGE_TYPE_TO_PURPOSE 提升到 common 层
`backend/storage_api.py``backend/services/storage.py` 都依赖这个映射。
提到 `common/storage/factory.py` 后两边都 `from common.storage import USAGE_TYPE_TO_PURPOSE`
避免互相 import 循环。
---
## 3. Bucket 标识符语义统一(commits `d855912` + `85b2916`
### 3.1 错位现象
`actual_bucket_name(purpose)``85b2916` 之前有两个分支:
```python
def actual_bucket_name(purpose: str) -> str:
if settings.storage_backend == "local":
return str(Path(settings.local_storage_base_dir) / purpose) # → "data/workspace"
return getattr(settings, f"s3_{purpose}_bucket") # → "workspace"
```
这条函数被三个地方用:
| 消费方 | 期望的值 | 实际拿到的(local 模式,修前) |
|---|---|---|
| `app.state.object_stores` dict key | 与 DB `bucket_name` 一致 | `"data/workspace"` |
| `StorageObjects.bucket_name` 写入 | 与 dict key 一致 | `"data/workspace"`(来自 `scripts.py:482` 的旧 `settings.s3_workspace_bucket`|
| `UploadSessions.bucket_name` 写入 | 与 dict key 一致 | `settings.s3_workspace_bucket` = `"workspace"`(来自 `_resolve_bucket_for_usage` 的 fallback|
表里第三行就是本次出问题的根本:`UploadSessions.bucket_name` 写的是裸字符串 `"workspace"`,但
`app.state.object_stores` 的 key 是路径 `"data/workspace"`。两者 lookup 时必然 KeyError。
**注意**:本部署所有 DB row 的 `bucket_name` 都是裸字符串(`"workspace"`),跟
`object_stores` dict key 错位 — 软删时 `object_stores["workspace"]` 直接抛
`KeyError: 'workspace'`,被 `soft_delete_object` 的 try/except 包成
`HTTPException(502, "failed to move object to trash: 'workspace'")`
`scripts.py:482` 写出的 row 反而是错的 `"data/workspace"`,**但**所有现存 row 写于本会话
之前的旧版本,所以 DB 里没有这种 row。`scripts.py` 是用户从 Jupyter 创建新脚本时触发的
路径,本次会话没有产生新 row。
### 3.2 `85b2916` 的最终语义
`actual_bucket_name(purpose)` 合并为单一返回:
```python
def actual_bucket_name(purpose: str) -> str:
from common.config import settings # 延迟 import 避免循环
return getattr(settings, f"s3_{purpose}_bucket")
```
返回值约定(local / s3 两种模式相同):
| purpose | 返回(与 `.env` 对齐) |
|---|---|
| `workspace` | `settings.s3_workspace_bucket` |
| `version` | `settings.s3_version_bucket` |
| `run_log` | `settings.s3_run_log_bucket` |
| `trash` | `settings.s3_trash_bucket` |
这是**纯标识符**,不是文件系统路径。
`build_storage_config(purpose)` 内部仍然走 `Path(local_storage_base_dir) / purpose`
`base_dir`,所以 local backend 实例的磁盘根目录没变(仍是 `./data/<purpose>/`)。
**这里有个两层抽象**
- `bucket_name` 字段(DB 存储 / dict key / URI 字符串)— s3 风格标识符,`actual_bucket_name` 负责
- `base_dir`local backend 实际写入的磁盘根)— 路径,由 `build_storage_config` 内部负责
两层解耦后,修改 `actual_bucket_name` 不会移动任何字节。
### 3.3 `build_storage_uri` 配套修复
旧版本 local 分支:
```python
absolute_path = Path(bucket_name).absolute().as_posix() # 假设 bucket_name 是路径
```
修后:
```python
absolute_path = (Path(settings.local_storage_base_dir) / bucket_name).resolve().as_posix()
```
因为 `bucket_name` 不再是路径,必须显式拼上 `local_storage_base_dir`
### 3.4 写入端对齐(commit `d855912`
| 文件 | 改动 |
|---|---|
| `backend/src/backend/scripts.py:482` | `bucket_name = settings.s3_workspace_bucket``bucket_name = actual_bucket_name("workspace")` |
| `backend/src/backend/services/storage.py:_resolve_bucket_for_usage` | fallback 从 `settings.s3_workspace_bucket` 改为 `actual_bucket_name(USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace"))` |
**为什么 `d855912` 是必要的**`85b2916` 改了 `actual_bucket_name` 的返回语义,
`scripts.py:482` 直接读 `settings.s3_workspace_bucket``_resolve_bucket_for_usage`
直接读 `settings.s3_workspace_bucket` —— 这两处必须改成 `actual_bucket_name(...)`
否则它们绕过了新语义,写出的 row 又是裸字符串,新 dict key 是 s3 风格标识符,
两边重新错位。**两 commit 必须一起部署**,单独 `85b2916` 会让 dict key 是 `"workspace"
DB 仍是 `settings.s3_workspace_bucket` 写出的 `"workspace"` —— 看似 OK 但万一某天
`.env` 改了 `S3_WORKSPACE_BUCKET=workspaces`,就立刻重新炸。
---
## 4. 线上数据兼容(重要)
### 4.1 当前部署(local 模式 + 单数桶名)
`d855912` + `85b2916` 之后,**新代码对 DB 里已有的旧行完全兼容**:
现有 7 个 `bucket_name='workspace'` 的 row 直接命中 dict key,无需迁移。
重启 backend 后就能正常软删。
### 4.2 历史异构部署(如果你接手的环境曾跑过下面任一形态)
#### 4.2.1 `.env` 曾是复数桶名(`S3_WORKSPACE_BUCKET=workspaces` 等)
如果该部署曾用复数 `.env` 上传过文件,`storage_objects.bucket_name` 列里会有
复数残留。新代码 dict key 是单数,访问旧行仍会 `KeyError: 'workspaces'`。
**部署新代码前必须先跑一次数据修复**(先 SELECT 看数量):
```sql
SELECT storage_backend, bucket_name, COUNT(*)
FROM storage_objects
WHERE object_status = 'available'
GROUP BY storage_backend, bucket_name
ORDER BY storage_backend, bucket_name;
UPDATE storage_objects
SET bucket_name = 'workspace'
WHERE bucket_name = 'workspaces'
AND object_status = 'available';
-- 同样模式处理 version / run-log / trash 残留
```
#### 4.2.2 `bucket_name` 残留 `Path` 形式(`/data/workspace` 等)
如果历史版本曾把 `actual_bucket_name("workspace")` 的路径形式(`/data/workspace`、
`./data/workspace`、`data/workspace`)写进 `bucket_name` 列,需要归一化成裸字符串:
```sql
UPDATE storage_objects
SET bucket_name = 'workspace'
WHERE bucket_name LIKE '%/workspace' AND storage_backend = 'local'
AND object_status IN ('available', 'deleted');
```
**只改 `available` / `deleted` 行**;正在上传中的 row`object_status='uploading'`
不要碰,会丢数据。
#### 4.2.3 异构 backend 的旧行
如果某个 row 的 `storage_backend` 与当前部署不一致(例如旧 row 是 `s3`、当前部署是
`local`),物理文件位置在旧 backend 那边,**软删时只翻 DB 状态,物理文件没移动**。
处理:人工导出后清理,或者重新上传。
### 4.3 监控(建议加)
`storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例
(按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。
```sql
SELECT usage_type, bucket_name, COUNT(*)
FROM storage_objects
WHERE object_status = 'available'
GROUP BY usage_type, bucket_name
ORDER BY usage_type, bucket_name;
```
---
## 5. 本地验收清单
```bash
# 1. 语法
uv run python -m compileall common/src backend/src runtime/src schedule/src
# 2. 测试
uv run --package backend pytest backend/tests -q
# 期望:30 passed
# 3. bucket 标识符一致性(s3 风格字符串,不带路径)
grep -rn "bucket_name = settings\." \
--include='*.py' backend/src common/src 2>/dev/null
# 期望:零结果 — bucket_name 全部走 actual_bucket_name(...)
# 4. 单复数对齐
grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUCKET' \
.env.example
# 期望:所有桶名与代码 default 一致(参考 .env.example 注释)
# 5. 命名残留扫
grep -rn '"workspaces"\|"versions"\|"run-logs"' \
--include='*.py' --include='*.yml' --include='*.env*' \
backend/src common/src docker-compose.yml .env .env.example 2>/dev/null
# 期望:除 `__tablename__ = "workspaces"`DB 表名)和 `auth.py` 的 JSON key 外,零结果
```
---
## 6. 回归测试覆盖
| 场景 | 触发方式 | 期望 |
|---|---|---|
| 同名 script 不同父目录 | `test_soft_delete_object_sets_is_deleted` 旁边的新测试 | 两条 row 同名共存 |
| 软删 → 物理文件落到 trash 桶的 `source_purpose/{key}` | 手工:上传 → 删 → `mc ls s3/trash/` | 文件在;源桶里已删 |
| 软删后 `storage_objects` 7 字段同步 | 软删后 `SELECT * FROM storage_objects WHERE storage_object_id=...` | 见 §2.1 表格 |
| `restore_object` 把 trash 文件写回源桶 | 软删 → restore → 查源桶 | 源桶恢复;trash 文件保留(orphan |
| `purge_trash_object` 拒绝误删源数据 | 手工:把 `bucket_name` 改成源桶后调 purge | 409 |
| local 模式软删走通 | local 部署下软删一个 script | 不抛 `KeyError: 'workspace'`trash 落到 `./data/trash/<purpose>/<key>` |
| `actual_bucket_name` 两种模式返回相同字符串 | 写测试或 trace | s3 模式 = local 模式(与 `.env` 对齐)|
---
## 7. 监控项(建议加)
1. `storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例
(按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。
2. `object_status='deleted'` 但 `bucket_name != actual_bucket_name('trash')` 的行数 —
应为 0(软删失败回滚残留)。
3. trash 桶目录大小 vs `object_status='deleted'` 行数(孤儿文件比例)。
没实现 reaper 前这个会单调上升。
---
## 8. 已知未实现项
- **Trash reaper**`s3_trash_retention_days` 已声明,但代码没有扫表清理。
`restore_object` 故意保留 trash 文件作 orphan,等 reaper 兜底。
第一次实现建议在 `schedule/src/schedule/` 加一个 tick job
```sql
SELECT storage_object_id FROM storage_objects
WHERE deleted_at < UTC_TIMESTAMP() - INTERVAL :retention_days DAY
AND object_status = 'deleted'
LIMIT 100;
```
然后调 `/internal/v1/admin/trash/purge`。
- **跨 backend trash 兼容**:当前只在 `item.storage_backend == settings.storage_backend`
的行上做物理移动。异构 backend 的旧行(少)软删时只翻 DB 状态,trash 文件实际没移动。
远程 8.153.151.51:8890 应该都是同一 backend,目前无影响。
- **历史 trash_key 兼容读**:新代码用 `item.object_key` 读 trash**不**回退到
`item.trash_key`。如果迁移前有按旧 `trash_key = f"{bucket_name}/{object_key}"`
写入的已删行,`object_key` 还是源 keyrestore 会找不到 trash 文件(404)。
处理:在 `restore_object` 加 fallback — 先按 `item.object_key` 试,失败再按 `item.trash_key`
试一次(兼容窗口期)。建议在 reaper 上线后删除 fallback。
- **DB 测试种子数据**`storage_objects` 表里有 `workspace_id='00000000000000000000000002'`
的行(全 0 + 02,不是有效 ULID 格式),对应的 `object_key` 在磁盘 `data/workspace/` 下
找不到对应文件(磁盘上是真实 workspace ULID 子目录 `00000000000BM630VT9ARVFZPC`、
`01KZR60SWWTXXM0KNZ2GJMT36A`)。这些是测试种子。修复 bucket 标识符后,软删会从 KeyError
变成 StorageNotFoundError —— KeyError 解决后真实地暴露了「字节缺失」。处理:清掉这些
测试 row,或补齐磁盘文件。
---
## 9. 仍未合并到 develop 的本地改动
```text
M frontend/vite.config.ts (4+/2-)
?? data/ (untracked — 本地 docker volume 残留,可忽略)
```
`frontend/vite.config.ts` 的改动不在本次 trash / bucket 修复范围内,等下次 commit 一起提。
`data/` 是 `STORAGE_BACKEND=local` 的对象存储本地根,git ignore 不应跟踪。