fix(storage): 删除文件后释放用户配额

This commit is contained in:
caoqianming 2026-08-28 13:23:28 +08:00
parent 9728fc3dd2
commit bff6125cd1
11 changed files with 153 additions and 25 deletions

View File

@ -10,6 +10,8 @@
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。 - GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
- 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。
## 0.69.1 — 2026-08-27 ## 0.69.1 — 2026-08-27
- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。 - 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。

View File

@ -298,7 +298,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。 - **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,失强制力且端点翻倍。 - **files API 单一 mutation 入口**(2026-05-18):"顶层目录分支"从数据状态派生而非客户端意图,放服务端才有强制力;双命名空间(/folders vs /files)把分支搬给 client,失强制力且端点翻倍。
- **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。 - **task 软删除(2026-06-17 推翻 hard cascade)**:公测后对话轨迹是训练/研究语料,`deleted_at` 置位 + restore,避免用户误删立即永久丢失。**当前实现仍无限期保留软删数据**,物理清理仅有管理员手段;后续生命周期已定为“软删除后保留 30 天再物理清理”(待容量信号实施,见 §8.5),届时恢复能力明确限于宽限期内。
- **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact其他文件照常删除回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。 - **文件留存**:普通用户文件仍是 FS 直接删除;已写入结构化 `messages.artifact_refs` 的已发布产物,在统一 files delete 入口删除时原子移动到用户根目录的平台隐藏区 `.zcbot_artifact_trash/`,原路径与历史卡片立即表现为已删除。递归目录只回收其中 artifact其他文件照常删除回收内容不占用户逻辑配额,但后台扫描在 `user_disk_usage` 中单列其字节数和文件数Admin 展示“配额占用 / 回收站 / 物理合计”,避免释放用户额度后失去服务器真实容量可见性。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 artifact 身份、内容更新。该机制仍不防 agent/shell 绕过 files API 或整盘损坏。完整地基仍采用 restic/borg 定时增量备份(与应用解耦,捕获删除+覆盖+所有写入口),后续容量需要时再补 `data_events` 用户意图事件和回收区清理/恢复管理。
- **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。 - **0004 删 runs/usage_events 旧表**:只写不读的死代码;代价是失历史 run 元数据,真要细粒度审计再补(届时是新需求非技术债)。
- **本地也用 PG 不用 SQLite**:dogfood ≡ 真实路径;Docker 已是必然依赖;双 adapter 维护税 > 一次性配置。 - **本地也用 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 都覆盖不了)。 - **API-only,UI 由 platform 实现**(2026-05-15):本仓库再维护一套 UI 是双套浪费;SSE payload 从 HTML 切 JSON;沉淀的 sink/broker/路径安全全保留。**dev SPA 留一份**作 dogfood 主路径(SSE 调试 curl/Swagger 都覆盖不了)。

View File

@ -128,6 +128,7 @@
- **08-12 / 0.64.1 / PDF/PPT 连续预览滚轮与触摸修复**:外层预览的防滚动穿透逻辑不再拦截来自 PDF 内层 viewport 的普通 wheel 事件,保留图片 Ctrl+滚轮缩放和其他预览的边界隔离PDF viewport 同步声明触摸平移与 WebView/iOS 惯性滚动使鼠标滚轮、触控板和手机上下滑动都直接驱动连续页列表。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 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。
- **08-12 / 0.64.0 / artifact 稳定身份 + 隐藏回收**:新增 0028 `artifacts` 生命周期表并回填存量结构化引用;新发布消息写带 `artifact_id` 的 v2 引用,移动/重命名保持身份,复制创建独立身份并记录来源,历史卡片按身份解析最新路径。删除已发布产物时移动到用户隐藏目录 `.zcbot_artifact_trash/` 并软删记录普通文件仍物理删除Python 35 项(测试库门控 1 skip、Node 前端 11 项、mypy、Alembic 单 head、编译、Ruff 致命规则及 diff 检查通过,未连接或写入生产 DB。 - **08-12 / 0.64.0 / artifact 稳定身份 + 隐藏回收**:新增 0028 `artifacts` 生命周期表并回填存量结构化引用;新发布消息写带 `artifact_id` 的 v2 引用,移动/重命名保持身份,复制创建独立身份并记录来源,历史卡片按身份解析最新路径。删除已发布产物时移动到用户隐藏目录 `.zcbot_artifact_trash/` 并软删记录普通文件仍物理删除Python 35 项(测试库门控 1 skip、Node 前端 11 项、mypy、Alembic 单 head、编译、Ruff 致命规则及 diff 检查通过,未连接或写入生产 DB。
- **08-28 / Unreleased / 回收站与用户配额分离**:0037 为 `user_disk_usage` 增加回收站字节与文件数快照;后台扫描从用户配额中排除 `.zcbot_artifact_trash`同时保留其物理占用统计Admin 分列展示配额占用、回收站和物理合计。相关 pytest 48 项与 41 个子测试、Python 编译、JavaScript 语法、Alembic 单 head、Ruff 致命规则及 diff 检查通过;本机 Docker daemon 未运行DB 集成测试按安全门控跳过,未连接或写入生产 DB。
- **08-12 / 0.63.11 / PDF/PPT 连续预览滚动布局修复**:补齐 PDF 预览根容器的纵向 Flex 布局与可收缩高度约束,使内部连续页 viewport 获得实际可用高度和独立滚动区域,不再被外层 `overflow:hidden` 裁断回归测试同步锁定根容器、viewport 与页列表三层布局契约。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。 - **08-12 / 0.63.11 / PDF/PPT 连续预览滚动布局修复**:补齐 PDF 预览根容器的纵向 Flex 布局与可收缩高度约束,使内部连续页 viewport 获得实际可用高度和独立滚动区域,不再被外层 `overflow:hidden` 裁断回归测试同步锁定根容器、viewport 与页列表三层布局契约。Node 前端预览 11 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。

22
RUN.md
View File

@ -156,7 +156,7 @@
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019` - **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **OpenAPI / MCP 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`。③ admin 进入管理后台「外部系统」,选择通用 OpenAPI 或通用 MCP具体 MES/ERP/LIMS 都作为数据库 definition 配置,不新增专用 provider。MCP 填写与登录 Base URL 同源的 Streamable HTTP URL可选填写期望 Server 名称;连接后以 `tools/list` 为事实源。④ 普通用户点击左栏 **「外部」**,页面按 definition 动态显示用户名密码、API Key 或 Bearer Token目标、Server 身份、登录、认证绑定或 TLS 变化后保留密文并暂停调用重新测试成功后恢复。OpenAPI spec 和 MCP tool catalog 只在进程内按连接身份有界缓存,登录与业务响应均限长,普通用户和模型不能传任意 URL。 - **OpenAPI / MCP 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`。③ admin 进入管理后台「外部系统」,选择通用 OpenAPI 或通用 MCP具体 MES/ERP/LIMS 都作为数据库 definition 配置,不新增专用 provider。MCP 填写与登录 Base URL 同源的 Streamable HTTP URL可选填写期望 Server 名称;连接后以 `tools/list` 为事实源。④ 普通用户点击左栏 **「外部」**,页面按 definition 动态显示用户名密码、API Key 或 Bearer Token目标、Server 身份、登录、认证绑定或 TLS 变化后保留密文并暂停调用重新测试成功后恢复。OpenAPI spec 和 MCP tool catalog 只在进程内按连接身份有界缓存,登录与业务响应均限长,普通用户和模型不能传任意 URL。
- **Artifact 生命周期(0028)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理且继续计入磁盘配额。普通文件删除语义不变。 - **Artifact 生命周期(0028/0037)**:部署本版本必须先执行 `.venv/Scripts/python.exe main.py db upgrade head`。migration 会从存量 `messages.artifact_refs` 回填 active artifact 身份;删除后的已发布产物保存在用户根目录隐藏区 `.zcbot_artifact_trash/`,默认不自动清理、不计用户配额,但其物理占用由后台扫描单列并展示在 Admin。普通文件删除语义不变。
- **用户消息结构化附件(0031)**:部署新版前执行 `.venv/Scripts/python.exe main.py db upgrade head`,为 `messages` 增加 nullable `attachment_refs`。migration 不回写或改动存量正文;旧客户端仍可只传 `content`,新版会发送结构化 `attachments` - **用户消息结构化附件(0031)**:部署新版前执行 `.venv/Scripts/python.exe main.py db upgrade head`,为 `messages` 增加 nullable `attachment_refs`。migration 不回写或改动存量正文;旧客户端仍可只传 `content`,新版会发送结构化 `attachments`
- **旧 `factory_mes` definition 一次性转换**:新版代码不再识别 `factory_mes`;部署时保持旧服务进程运行,先从新代码目录执行数据脚本,转换成功后再重启到新版。脚本不加载 `.env`、不读取 `ZCBOT_DB_URL`,只认显式的 `ZCBOT_MIGRATION_DB_URL`;默认 dry-run检查同名冲突与配置合法性。确认输出后加 `--apply`,脚本把 definition 转为 `generic_openapi + query`、物化 JWT/提示/只读 POST 配置并同步 active connection revision不解密或改写用户凭据。 - **旧 `factory_mes` definition 一次性转换**:新版代码不再识别 `factory_mes`;部署时保持旧服务进程运行,先从新代码目录执行数据脚本,转换成功后再重启到新版。脚本不加载 `.env`、不读取 `ZCBOT_DB_URL`,只认显式的 `ZCBOT_MIGRATION_DB_URL`;默认 dry-run检查同名冲突与配置合法性。确认输出后加 `--apply`,脚本把 definition 转为 `generic_openapi + query`、物化 JWT/提示/只读 POST 配置并同步 active connection revision不解密或改写用户凭据。
```powershell ```powershell
@ -823,23 +823,29 @@ docker rm -f zcbot-sandbox-<user-uuid>
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
``` ```
磁盘配额查看(0008 表): 磁盘配额与回收站查看(0008/0037 表):
```bash ```bash
# 当前所有 user 用量 # 当前所有 user 的配额占用、回收站与文件物理合计
psql "$ZCBOT_DB_URL" -c " psql "$ZCBOT_DB_URL" -c "
SELECT user_id, bytes_used/1024/1024 AS mb, file_count, scanned_at SELECT user_id,
bytes_used/1024/1024 AS quota_mb,
trash_bytes/1024/1024 AS trash_mb,
(bytes_used+trash_bytes)/1024/1024 AS physical_mb,
file_count, trash_file_count, scanned_at
FROM user_disk_usage ORDER BY bytes_used DESC;" FROM user_disk_usage ORDER BY bytes_used DESC;"
# 强制扫描某 user(改 yaml interval 太久不耐烦时) # 强制扫描某 user(改 yaml interval 太久不耐烦时)
.venv/bin/python -c " .venv/bin/python -c "
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
from core.storage.disk_quota import scan_user_dir, upsert_user_usage from core.storage.disk_quota import scan_user_dir, scan_user_trash, upsert_user_usage
uid = UUID('<your-user-uuid>') uid = UUID('<your-user-uuid>')
b, c = scan_user_dir(Path('/home/lighthouse/zcbot/workspace/users') / str(uid)) root = Path('/home/lighthouse/zcbot/workspace/users') / str(uid)
upsert_user_usage(uid, b, c) b, c = scan_user_dir(root)
print(f'{b/1024/1024:.1f} MB / {c} files')" tb, tc = scan_user_trash(root)
upsert_user_usage(uid, b, c, tb, tc)
print(f'quota={b/1024/1024:.1f} MB, trash={tb/1024/1024:.1f} MB')"
``` ```
DNS / resolv.conf 文件位置(host 侧): DNS / resolv.conf 文件位置(host 侧):

View File

@ -1,8 +1,9 @@
"""Per-user 工作目录配额(§7.5 #4 软配额,应用层 gate)。 """Per-user 工作目录配额(§7.5 #4 软配额,应用层 gate)。
调用入口: 调用入口:
- `scan_user_dir(user_root) -> (bytes, count)` os.walk 累加, dotfile / 损坏 stat - `scan_user_dir(user_root) -> (bytes, count)` 累加计入用户配额的文件
- `upsert_user_usage(user_id, bytes, count)` user_disk_usage - `scan_user_trash(user_root) -> (bytes, count)` 单独统计隐藏回收站物理占用
- `upsert_user_usage(...)` 同时落配额占用与回收站快照
- `check_disk_quota(user_id, limit_bytes) -> Optional[str]` 写前查, None=放行 / - `check_disk_quota(user_id, limit_bytes) -> Optional[str]` 写前查, None=放行 /
str=拒绝原因`limit_bytes <= 0` 短路放行(不限) str=拒绝原因`limit_bytes <= 0` 短路放行(不限)
- `scan_all_users(user_root_base, limit_bytes)` lifespan 后台 task 周期跑, - `scan_all_users(user_root_base, limit_bytes)` lifespan 后台 task 周期跑,
@ -64,15 +65,18 @@ def parse_bytes(value) -> Optional[int]:
_SKIP_TOPLEVEL = frozenset({ _SKIP_TOPLEVEL = frozenset({
".zcbot_cache", ".zcbot_cache",
".zcbot_external_results", ".zcbot_external_results",
".zcbot_artifact_trash",
".zcbot_tmp", ".zcbot_tmp",
".memory", ".memory",
}) })
_ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash"
def scan_user_dir(user_root: Path) -> Tuple[int, int]: def scan_user_dir(user_root: Path) -> Tuple[int, int]:
"""os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。 """os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。
跳过顶层平台缓存/临时区与 .memory(均有独立生命周期,不算入产品配额); 跳过顶层平台缓存/临时区隐藏回收站与 .memory(均有独立生命周期,
不算入产品配额);
follow_symlinks=False symlink 循环爆 follow_symlinks=False symlink 循环爆
""" """
if not user_root.exists() or not user_root.is_dir(): if not user_root.exists() or not user_root.is_dir():
@ -102,6 +106,14 @@ def scan_user_dir(user_root: Path) -> Tuple[int, int]:
return total_bytes, total_count return total_bytes, total_count
def scan_user_trash(user_root: Path) -> Tuple[int, int]:
"""统计隐藏 artifact 回收站的物理占用,不计入用户配额。"""
trash = user_root / _ARTIFACT_TRASH_DIR
if not trash.is_dir():
return 0, 0
return _walk_dir(trash)
def _walk_dir(d: Path) -> Tuple[int, int]: def _walk_dir(d: Path) -> Tuple[int, int]:
total_b, total_c = 0, 0 total_b, total_c = 0, 0
for root, dirs, files in os.walk(d, followlinks=False, onerror=lambda _e: None): for root, dirs, files in os.walk(d, followlinks=False, onerror=lambda _e: None):
@ -115,19 +127,29 @@ def _walk_dir(d: Path) -> Tuple[int, int]:
return total_b, total_c return total_b, total_c
def upsert_user_usage(user_id: UUID, bytes_used: int, file_count: int) -> None: def upsert_user_usage(
"""落 user_disk_usage 单行;首次 INSERT,后续 UPDATE。""" user_id: UUID,
bytes_used: int,
file_count: int,
trash_bytes: int = 0,
trash_file_count: int = 0,
) -> None:
"""落 user_disk_usage 单行;首次 INSERT后续 UPDATE。"""
from sqlalchemy import func from sqlalchemy import func
with session_scope() as s: with session_scope() as s:
stmt = pg_insert(UserDiskUsage).values( stmt = pg_insert(UserDiskUsage).values(
user_id=user_id, user_id=user_id,
bytes_used=bytes_used, bytes_used=bytes_used,
file_count=file_count, file_count=file_count,
trash_bytes=trash_bytes,
trash_file_count=trash_file_count,
).on_conflict_do_update( ).on_conflict_do_update(
index_elements=["user_id"], index_elements=["user_id"],
set_={ set_={
"bytes_used": bytes_used, "bytes_used": bytes_used,
"file_count": file_count, "file_count": file_count,
"trash_bytes": trash_bytes,
"trash_file_count": trash_file_count,
"scanned_at": func.now(), "scanned_at": func.now(),
}, },
) )
@ -202,8 +224,10 @@ def scan_all_users(user_root_base: Path) -> int:
count = 0 count = 0
for uid in list_user_ids_with_root(user_root_base): for uid in list_user_ids_with_root(user_root_base):
try: try:
b, c = scan_user_dir(user_root_base / str(uid)) user_root = user_root_base / str(uid)
upsert_user_usage(uid, b, c) b, c = scan_user_dir(user_root)
trash_b, trash_c = scan_user_trash(user_root)
upsert_user_usage(uid, b, c, trash_b, trash_c)
count += 1 count += 1
except Exception: except Exception:
# 单 user 扫挂不阻塞其他 user;下次周期重试。日志靠 caller 注入。 # 单 user 扫挂不阻塞其他 user;下次周期重试。日志靠 caller 注入。

View File

@ -355,8 +355,9 @@ class UserDiskUsage(Base):
"""per-user 工作目录字节使用快照(0008,§7.5 #4 软配额表)。 """per-user 工作目录字节使用快照(0008,§7.5 #4 软配额表)。
每个 user_id 单行 upsert,lifespan 后台 task 周期( 15min)扫描 user_root 落库; 每个 user_id 单行 upsert,lifespan 后台 task 周期( 15min)扫描 user_root 落库;
write gate(DockerExecutor / /v1/files/upload)查这表对比 yaml `quotas.disk_bytes_per_user`, write gate(DockerExecutor / /v1/files/upload) bytes_used 对比 yaml
超额返 [Error] 硬阻 `quotas.disk_bytes_per_user`,超额返 [Error] 硬阻trash_* 只记录隐藏回收站
的物理占用不参与用户配额判断
扫描间隙写入会突破上限一点(race-tolerant, image/video 配额一致接受);外部用户 扫描间隙写入会突破上限一点(race-tolerant, image/video 配额一致接受);外部用户
开放前 OS xfs prjquota 兜底真上限 DESIGN §7.5 #4 / PROGRESS。 开放前 OS xfs prjquota 兜底真上限 DESIGN §7.5 #4 / PROGRESS。
@ -371,6 +372,8 @@ class UserDiskUsage(Base):
) )
bytes_used: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) bytes_used: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
trash_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
trash_file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
scanned_at: Mapped[datetime] = mapped_column( scanned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )

View File

@ -0,0 +1,31 @@
"""Track artifact trash separately from quota usage.
Revision ID: 0037
Revises: 0036
Create Date: 2026-08-28
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0037"
down_revision: str | None = "0036"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"user_disk_usage",
sa.Column("trash_bytes", sa.BigInteger(), nullable=False, server_default="0"),
)
op.add_column(
"user_disk_usage",
sa.Column("trash_file_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("user_disk_usage", "trash_file_count")
op.drop_column("user_disk_usage", "trash_bytes")

View File

@ -0,0 +1,40 @@
"""Admin 存储快照的配额、回收站与物理合计口径。"""
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from web.admin import _storage_page
class AdminStorageTests(unittest.TestCase):
def test_storage_page_exposes_trash_and_physical_total(self) -> None:
uid = uuid4()
scanned = datetime(2026, 8, 28, tzinfo=timezone.utc)
total_result = MagicMock()
total_result.scalar_one.return_value = 1
rows_result = MagicMock()
rows_result.all.return_value = [
(uid, "user@example.com", "测试用户", "tester", 100, 2, 40, 1, scanned)
]
session = MagicMock()
session.execute.side_effect = [total_result, rows_result]
with patch("core.agent_builder.load_config", return_value={"quotas": {}}):
page = _storage_page(session, 0, 20)
row = page["rows"][0]
self.assertEqual(row["bytes_used"], 100)
self.assertEqual(row["trash_bytes"], 40)
self.assertEqual(row["trash_file_count"], 1)
self.assertEqual(row["physical_bytes"], 140)
if __name__ == "__main__":
unittest.main()

View File

@ -12,7 +12,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.storage.disk_quota import parse_bytes, scan_user_dir from core.storage.disk_quota import parse_bytes, scan_user_dir, scan_user_trash
class TestParseBytes(unittest.TestCase): class TestParseBytes(unittest.TestCase):
@ -75,6 +75,7 @@ class TestScanUserDir(unittest.TestCase):
for hidden in ( for hidden in (
".zcbot_cache", ".zcbot_cache",
".zcbot_external_results", ".zcbot_external_results",
".zcbot_artifact_trash",
".zcbot_tmp", ".zcbot_tmp",
".memory", ".memory",
): ):
@ -84,6 +85,17 @@ class TestScanUserDir(unittest.TestCase):
self.assertEqual(b, 7) self.assertEqual(b, 7)
self.assertEqual(c, 1) self.assertEqual(c, 1)
def test_trash_is_excluded_from_quota_and_counted_separately(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "active.bin").write_bytes(b"active")
trash = root / ".zcbot_artifact_trash" / "2026" / "08"
trash.mkdir(parents=True)
(trash / "deleted.bin").write_bytes(b"deleted")
self.assertEqual(scan_user_dir(root), (6, 1))
self.assertEqual(scan_user_trash(root), (7, 1))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -99,6 +99,7 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict:
def _storage_page(s: Any, page: int, page_size: int) -> dict: def _storage_page(s: Any, page: int, page_size: int) -> dict:
"""分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。 """分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。
bytes_used 是用户配额占用trash_bytes 是不计配额但仍占磁盘的隐藏回收站
数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数 数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数
""" """
from core.agent_builder import load_config from core.agent_builder import load_config
@ -114,9 +115,12 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
"user_name": uname or "", "user_name": uname or "",
"bytes_used": int(b or 0), "bytes_used": int(b or 0),
"file_count": int(fc or 0), "file_count": int(fc or 0),
"trash_bytes": int(tb or 0),
"trash_file_count": int(tfc or 0),
"physical_bytes": int(b or 0) + int(tb or 0),
"scanned_at": scanned.isoformat() if scanned else None, "scanned_at": scanned.isoformat() if scanned else None,
} }
for uid, email, name, uname, b, fc, scanned in s.execute( for uid, email, name, uname, b, fc, tb, tfc, scanned in s.execute(
select( select(
UserDiskUsage.user_id, UserDiskUsage.user_id,
User.email, User.email,
@ -124,6 +128,8 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict:
User.user_name, User.user_name,
UserDiskUsage.bytes_used, UserDiskUsage.bytes_used,
UserDiskUsage.file_count, UserDiskUsage.file_count,
UserDiskUsage.trash_bytes,
UserDiskUsage.trash_file_count,
UserDiskUsage.scanned_at, UserDiskUsage.scanned_at,
) )
.join(User, User.user_id == UserDiskUsage.user_id, isouter=True) .join(User, User.user_id == UserDiskUsage.user_id, isouter=True)

View File

@ -775,12 +775,14 @@ function renderStorage(d) {
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>` + `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
+ `<td class="num">${pctTxt}</td>` + `<td class="num">${pctTxt}</td>`
+ `<td class="num">${r.file_count || 0}</td>` + `<td class="num">${r.file_count || 0}</td>`
+ `<td class="num" title="${r.trash_file_count || 0} 个文件">${humanSize(r.trash_bytes || 0)}</td>`
+ `<td class="num">${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}</td>`
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>` + `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`; }).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>` $("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>`
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>` + `<thead><tr><th>用户</th><th>配额占用</th><th>占配额</th><th>文件数</th><th>回收站</th><th>物理合计</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>` + `<tbody>${body}</tbody></table></div>`
+ pagerHTML("st", page, maxPage, from, to, total) + pagerHTML("st", page, maxPage, from, to, total)
+ `</div>`; + `</div>`;
@ -1126,8 +1128,9 @@ function buildReport(ov, models, users, storage, health) {
const stBody = (storage.rows || []).slice(0, 10).map(r => { const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—"; const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>` return `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`; + `<td>${pct}</td><td>${r.file_count || 0}</td><td>${humanSize(r.trash_bytes || 0)}</td>`
}).join("") || `<tr><td colspan="4">无数据</td></tr>`; + `<td>${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}</td></tr>`;
}).join("") || `<tr><td colspan="6">无数据</td></tr>`;
$("print-report").innerHTML = $("print-report").innerHTML =
`<h1>zcbot 管理后台报告</h1>` `<h1>zcbot 管理后台报告</h1>`
@ -1148,7 +1151,7 @@ function buildReport(ov, models, users, storage, health) {
+ `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody) + `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `<h2>各用户用量all-timeTop 10</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody) + `<h2>各用户用量all-timeTop 10</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10</h2>` + `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10</h2>`
+ tbl(["用户", "用", "占配额", "文件数"], stBody); + tbl(["用户", "配额占用", "占配额", "文件数", "回收站", "物理合计"], stBody);
} }
// ───── 自动刷新 ───── // ───── 自动刷新 ─────