diff --git a/CHANGELOG.md b/CHANGELOG.md index 058b812..fdf06fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.64.3 — 2026-08-12 + +- 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。 + ## 0.64.1 — 2026-08-12 - PDF 和 PPT 连续预览现在支持鼠标滚轮与手机手势滑动,不再只能拖动右侧滚动条。 diff --git a/DESIGN.md b/DESIGN.md index d8c5e17..c1295c1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -287,7 +287,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) - **path-as-identity 而非 folder_id**:folder 真实存在于 FS,folder_id 是第二份 source of truth;rename 走 DB-aware 同事务 cascade。 - **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。 - **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。 -- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。该机制只防误删产物,不防覆盖、agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。 +- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact,其他文件照常删除;回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。 - **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。 - **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。 - **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。 diff --git a/PROGRESS.md b/PROGRESS.md index 769c246..f8a5060 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-12(数据库热路径与消息序号优化,bump 0.64.2) +最后更新:2026-08-12(同名上传保护 + 明确替换文件,bump 0.64.3) --- @@ -23,6 +23,8 @@ ### 2026-08-12 +- **08-12 / 0.64.3 / 同名上传保护 + 明确替换文件**:粘贴、拖拽与普通上传默认由服务端原子独占创建,同名按 `name (n).ext` 自动编号且并发请求不会互相覆盖;文件菜单新增「替换文件」,选择本地文件并二次确认后才以同目录临时文件原子替换指定目标。Python 冲突策略 4 项、Node 前端契约 2 项、JavaScript/Python 语法及 diff 检查通过;DB 路由回归已补,未配置显式测试库时安全跳过;无 schema、migration 或依赖变化,未连接生产 DB。 + - **08-12 / 0.64.2 / 数据库热路径 + 消息序号原子化**:新增 0029 migration 平滑回填 `tasks.next_message_idx`,Web、agent 与渠道消息统一在 task 行锁下分配序号,并以实际 `max(idx)` 兼容蓝绿旧实例并行;任务列表、调度扫描/历史和 usage 时间序列补部分/BRIN 索引,列表追加 UUID 稳定排序,artifact 目录操作只查询并锁定目标子树。完整 532 项 unittest 全绿(17 skip),0029 PostgreSQL DDL 编译、Alembic 单 head、Ruff 致命规则及 diff 检查通过;未配置显式测试库,DB 集成测试安全跳过,未连接或写入生产 DB。 - **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离;PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动,使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。 diff --git a/RUN.md b/RUN.md index 794c5fa..89e300d 100644 --- a/RUN.md +++ b/RUN.md @@ -375,7 +375,7 @@ $env:ZCBOT_EVAL_TOKEN = "" | `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/tasks/{id}/files/download?path=` | 下载结构化产物;`path` 以该 task 当前 working_dir 为根,顶层目录改名后无需改历史消息;跨用户/越界/不存在均拒绝。`legacy=true` 仅供前端读取迁移前卡片,按旧 user-root→两种 task-relative 含义顺序兼容 | 必填 | | `GET /v1/tasks/{id}/files/preview_pdf?path=` | PPT/PPTX 结构化产物按 task-relative 路径转 PDF 预览;状态码与旧 `/v1/files/preview_pdf` 一致;旧卡片可同样传 `legacy=true` | 必填 | -| `POST /v1/files/upload` | multipart 上传到 `//`;路径不存在自动 mkdir,重名覆盖 | 必填 | +| `POST /v1/files/upload` | multipart 上传到 `//`;路径不存在自动 mkdir;`conflict` 默认 `rename`,重名原子保存为 `name (n).ext`;仅显式 `conflict=overwrite` 才覆盖,单文件可用 `target_name` 指定要替换的目标文件名 | 必填 | | `POST /v1/files/mkdir` | `{path?="", name}`;在现有 `//` 下新建直接子目录,禁止点目录和路径式名称;重名 → 409 | 必填 | | `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 | | `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 | diff --git a/core/__init__.py b/core/__init__.py index b4017d7..ad39139 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.64.2" +__version__ = "0.64.3" diff --git a/docs/操作说明书-精简版.md b/docs/操作说明书-精简版.md index 4f8d1a9..9c3f496 100644 --- a/docs/操作说明书-精简版.md +++ b/docs/操作说明书-精简版.md @@ -75,7 +75,7 @@ ## 5. 文件操作 -- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。 +- **上传**:点右栏 `⬆`、或直接拖文件进右栏、或在输入框 **Ctrl+V 粘贴**图片(随消息发给助手)。遇到同名文件会自动编号保留两份;确需覆盖时,在目标文件的 `⋯` 菜单选择「替换文件」并确认。 - **选入**:点 `⊕` 从其他目录勾选文件,复制 / 移动到当前目录复用。 - **预览**:点文件即在线看——图片可 Ctrl+滚轮缩放,Word/PDF/PPT/文本/表格直接渲染,无需下载。 - **下载**:预览弹窗右上角 `下载`。 diff --git a/docs/操作说明书.md b/docs/操作说明书.md index 4cc01cb..fc2d044 100644 --- a/docs/操作说明书.md +++ b/docs/操作说明书.md @@ -224,7 +224,7 @@ 1. **点 `⬆` 按钮**,选文件上传到当前目录。 2. **直接把文件拖进右栏**(出现「松开以上传」提示时放手)。 -3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。 +3. **在对话输入框 Ctrl+V 粘贴**文件(如截图、图片),会生成一个可预览的小卡片,随消息一起发给助手。同名文件会自动编号保留两份;确需覆盖时,在右侧目标文件的 `⋯` 菜单选择「替换文件」并确认。 ### 6.2 选入(从其他目录带文件进来) diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs new file mode 100644 index 0000000..cd8d96b --- /dev/null +++ b/tests/frontend_files.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const filesJs = fs.readFileSync("web/static/js/files.js", "utf8"); + +test("file menu exposes an explicit confirmed replacement action", () => { + assert.match(filesJs, /label: "替换文件"/); + assert.match(filesJs, /title: "替换文件"/); + assert.match(filesJs, /okText: "确认替换"/); + assert.match(filesJs, /danger: true/); + assert.match(filesJs, /conflict: "overwrite"/); + assert.match(filesJs, /targetName: name/); +}); + +test("ordinary uploads default to rename conflict policy", () => { + assert.match(filesJs, /fd\.append\("conflict", opts\.conflict \|\| "rename"\)/); + assert.match(filesJs, /同名文件已保留为/); +}); diff --git a/tests/test_file_upload_conflicts.py b/tests/test_file_upload_conflicts.py new file mode 100644 index 0000000..f6678e3 --- /dev/null +++ b/tests/test_file_upload_conflicts.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from web.routers.files import _numbered_upload_name, _replace_upload, _write_upload_without_overwrite + + +class FileUploadConflictTests(unittest.TestCase): + def test_number_is_inserted_before_complete_extension(self): + self.assertEqual(_numbered_upload_name("data.tar.gz", 2), "data (2).tar.gz") + self.assertEqual(_numbered_upload_name("报告", 1), "报告 (1)") + self.assertEqual(_numbered_upload_name(".env", 1), ".env (1)") + + def test_existing_file_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "报告.pdf").write_bytes(b"old") + saved = _write_upload_without_overwrite(root, "报告.pdf", b"new") + self.assertEqual(saved.name, "报告 (1).pdf") + self.assertEqual((root / "报告.pdf").read_bytes(), b"old") + self.assertEqual(saved.read_bytes(), b"new") + + def test_concurrent_uploads_get_distinct_names(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with ThreadPoolExecutor(max_workers=4) as pool: + saved = list(pool.map( + lambda i: _write_upload_without_overwrite(root, "same.txt", str(i).encode()), + range(4), + )) + self.assertEqual(len({path.name for path in saved}), 4) + self.assertEqual(len(list(root.glob("same*.txt"))), 4) + + def test_explicit_replace_changes_only_target(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + target = root / "target.txt" + target.write_bytes(b"old") + _replace_upload(target, b"new") + self.assertEqual(target.read_bytes(), b"new") + self.assertFalse(list(root.glob(".zcbot-upload-*"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index b7aac17..6ee8591 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -527,6 +527,21 @@ class FilesDbAwareTests(unittest.TestCase): files={"files": ("hello.txt", b"hi", "text/plain")}, headers=_AUTH) self.assertEqual(r.status_code, 200, r.text) self.assertEqual(r.json()["count"], 1) + self.assertEqual(r.json()["saved"][0]["name"], "hello.txt") + # 默认重名保留两份,完整扩展名之前追加编号。 + r = _client.post("/v1/files/upload", data={"path": "上传目录"}, + files={"files": ("hello.txt", b"new", "text/plain")}, headers=_AUTH) + self.assertEqual(r.status_code, 200, r.text) + self.assertEqual(r.json()["saved"][0]["name"], "hello (1).txt") + self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"hi") + self.assertEqual((_user_root() / "上传目录" / "hello (1).txt").read_bytes(), b"new") + # 明确覆盖可用本地文件内容替换指定目标名。 + r = _client.post("/v1/files/upload", + data={"path": "上传目录", "conflict": "overwrite", "target_name": "hello.txt"}, + files={"files": ("local-name.txt", b"replaced", "text/plain")}, headers=_AUTH) + self.assertEqual(r.status_code, 200, r.text) + self.assertEqual(r.json()["saved"][0]["name"], "hello.txt") + self.assertEqual((_user_root() / "上传目录" / "hello.txt").read_bytes(), b"replaced") names = {e["name"] for e in _client.get("/v1/files", headers=_AUTH).json()["entries"]} self.assertIn("上传目录", names) # 非法文件名 → 400 diff --git a/web/routers/files.py b/web/routers/files.py index c15b863..1074f89 100644 --- a/web/routers/files.py +++ b/web/routers/files.py @@ -7,6 +7,8 @@ web/userfiles.py。 from __future__ import annotations import asyncio +import os +import tempfile from pathlib import Path from uuid import UUID @@ -43,6 +45,47 @@ from ..userfiles import ( _pptx_preview_locks: dict[str, asyncio.Lock] = {} +def _numbered_upload_name(filename: str, index: int) -> str: + """在完整扩展名前追加编号,例如 data.tar.gz -> data (1).tar.gz。""" + path = Path(filename) + suffix = "" if filename.startswith(".") and filename.count(".") == 1 else "".join(path.suffixes) + stem = filename[:-len(suffix)] if suffix else filename + return f"{stem} ({index}){suffix}" + + +def _write_upload_without_overwrite(dest_dir: Path, filename: str, data: bytes) -> Path: + """以原子独占创建保存上传;并发重名时继续递增编号。""" + index = 0 + while True: + candidate = filename if index == 0 else _numbered_upload_name(filename, index) + dest = dest_dir / candidate + try: + fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL) + except FileExistsError: + index += 1 + continue + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + except Exception: + dest.unlink(missing_ok=True) + raise + return dest + + +def _replace_upload(dest: Path, data: bytes) -> None: + """先完整写入同目录临时文件,再原子替换明确选择的目标。""" + fd, tmp_name = tempfile.mkstemp(prefix=".zcbot-upload-", dir=dest.parent) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp, dest) + except Exception: + tmp.unlink(missing_ok=True) + raise + + def _pptx_lock_for(abs_path: str) -> asyncio.Lock: lock = _pptx_preview_locks.get(abs_path) if lock is None: @@ -293,11 +336,14 @@ def register_file_routes(app, *, require_user) -> None: @app.post("/v1/files/upload", tags=["files"]) async def upload_files( path: str = Form(""), + conflict: str = Form("rename"), + target_name: str = Form(""), files: list[UploadFile] = File(...), user_id: UUID = Depends(require_user), ): """multipart 多文件上传到 `//`。 - 路径不存在自动 mkdir(parents=True);重名直接覆盖。 + 路径不存在自动 mkdir(parents=True);默认重名自动编号保留两份。 + 仅显式 `conflict=overwrite` 可覆盖;`target_name` 用于替换指定文件。 文件名严格校验(含 `/ \\ ..` 或为空 → 400)。 """ # 磁盘配额 gate(§7.5 #4):超额 413 阻止上传,提示 user 清旧产物 @@ -316,9 +362,14 @@ def register_file_routes(app, *, require_user) -> None: raise HTTPException(400, f"upload target is a file, not a directory: {path}") dest_dir.mkdir(parents=True, exist_ok=True) + if conflict not in ("rename", "overwrite"): + raise HTTPException(400, f"invalid conflict policy: {conflict!r}") + if target_name and (conflict != "overwrite" or len(files or []) != 1): + raise HTTPException(400, "target_name requires one file and conflict=overwrite") + saved: list[dict] = [] for up in files or []: - raw_name = up.filename or "" + raw_name = target_name or up.filename or "" if ( not raw_name or raw_name in (".", "..") @@ -326,14 +377,20 @@ def register_file_routes(app, *, require_user) -> None: or any(part in (".", "..") for part in Path(raw_name).parts) ): raise HTTPException(400, f"invalid filename: {raw_name!r}") - dest = dest_dir / raw_name + requested_dest = dest_dir / raw_name try: - dest.resolve().relative_to(root.resolve()) + requested_dest.resolve().relative_to(root.resolve()) except ValueError: raise HTTPException(400, f"path escapes user_root: {raw_name!r}") data = await up.read() - dest.write_bytes(data) - saved.append({"name": raw_name, "size": len(data), "rel": rel_to(root, dest)}) + if conflict == "overwrite": + if requested_dest.exists() and not requested_dest.is_file(): + raise HTTPException(409, f"upload target is not a file: {raw_name!r}") + _replace_upload(requested_dest, data) + dest = requested_dest + else: + dest = _write_upload_without_overwrite(dest_dir, raw_name, data) + saved.append({"name": dest.name, "size": len(data), "rel": rel_to(root, dest)}) if not saved: raise HTTPException(400, "no files uploaded") return {"count": len(saved), "saved": saved} diff --git a/web/static/js/files.js b/web/static/js/files.js index ad1e976..392672a 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -1,4 +1,4 @@ -// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、 +// 文件面板:右栏列表浏览/导航/删除/重命名/明确替换、刷新、"选入"弹框(跨目录勾选复制/移动)、 // 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。 // 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈) // / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。 @@ -327,6 +327,8 @@ function fileMenuItems(e) { onclick: () => renameFile(e.rel, e.name, e.is_dir) }, ]; if (!e.is_dir) { + items.push({ act: "replace", label: "替换文件", cls: "act-replace", + onclick: () => replaceFile(e.rel, e.name) }); items.push({ act: "download", label: "下载", cls: "act-download", onclick: () => downloadFile(e.rel) }); } @@ -335,6 +337,31 @@ function fileMenuItems(e) { return items; } +async function replaceFile(rel, name) { + const input = document.createElement("input"); + input.type = "file"; + input.onchange = async () => { + const file = input.files && input.files[0]; + if (!file) return; + const confirmed = await dialogConfirm({ + title: "替换文件", + message: `将用 "${file.name}" 的内容替换 "${name}",原文件将被覆盖且无法恢复。`, + okText: "确认替换", + danger: true, + }); + if (!confirmed) return; + const slash = rel.lastIndexOf("/"); + const path = slash >= 0 ? rel.slice(0, slash) : ""; + const saved = await uploadFiles([file], { + path, + conflict: "overwrite", + targetName: name, + }); + if (saved && saved.length) message(`已替换 ${name}`, "success"); + }; + input.click(); +} + async function deleteFile(rel, name, isDir) { let recursive = false; if (!isDir) { @@ -452,7 +479,9 @@ async function uploadFilesWithPaneStatus(files) { export async function uploadFiles(files, opts = {}) { if (!files || !files.length) return null; const fd = new FormData(); - fd.append("path", state.filesPath || ""); + fd.append("path", opts.path ?? state.filesPath ?? ""); + fd.append("conflict", opts.conflict || "rename"); + if (opts.targetName) fd.append("target_name", opts.targetName); for (const f of files) fd.append("files", f); try { const data = await new Promise((resolve, reject) => { @@ -479,7 +508,13 @@ export async function uploadFiles(files, opts = {}) { xhr.send(fd); }); await loadFiles(); - return data.saved || []; + const saved = data.saved || []; + if ((opts.conflict || "rename") === "rename") { + const renamed = saved.filter((item, i) => item.name !== files[i]?.name); + if (renamed.length === 1) message(`同名文件已保留为 ${renamed[0].name}`, "success"); + else if (renamed.length > 1) message(`${renamed.length} 个同名文件已自动重命名`, "success"); + } + return saved; } catch (e) { if (e.status === 401) { logout(); return null; } message("上传失败:" + e.message, "error");