# Handover — 近期变更与待办 面向接手运维 / 二次开发的人。记录最近几次 commit 的动机、改动范围、未尽事项, 以及「线上有旧数据时怎么过渡」。详细架构见 `ARCHITECTURE.md`;本仓库的开发规约 见 `DEVELOP.md`。 --- ## 0. 当前交接状态(2026-08-20) ### 0.1 代码与服务 - 基线代码:`ae3b6d6`(`develop`)。本次改动尚未提交,修改范围见下方「0.4」。 - Docker Compose 项目名:`model-platform-develop14`。 - 主入口:;浏览器通过 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_dir(bucket 标识符语义统一:最终 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__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//`。这层 与 `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 error(trash 路径统一 + 删除后 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 | `` | - **物理文件落点**: | 模式 | 源文件(被删) | trash 文件(新落) | |---|---|---| | S3 | `s3:///` | `s3://trash//` | | local | `//` | `/trash//` | `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//`)。 **这里有个两层抽象**: - `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//` | | `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` 还是源 key,restore 会找不到 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 不应跟踪。