diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ec9f3..75b5a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。 +- 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。 + ## 0.69.1 — 2026-08-27 - 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。 diff --git a/DESIGN.md b/DESIGN.md index bca5a59..ab169fe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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。 - **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,其他文件照常删除;回收内容仍计入用户配额。上传入口默认以原子独占创建保留同名两份,只有用户从文件菜单明确选择并确认「替换文件」时才允许原子覆盖;替换保持原路径和 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 元数据,真要细粒度审计再补(届时是新需求非技术债)。 - **本地也用 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 2f8887a..86f38a3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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.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。 diff --git a/RUN.md b/RUN.md index c108538..7add795 100644 --- a/RUN.md +++ b/RUN.md @@ -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`。 - **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。 -- **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`。 - **旧 `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 @@ -823,23 +823,29 @@ docker rm -f zcbot-sandbox- docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null ``` -磁盘配额查看(0008 表): +磁盘配额与回收站查看(0008/0037 表): ```bash -# 当前所有 user 用量 +# 当前所有 user 的配额占用、回收站与文件物理合计 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;" # 强制扫描某 user(改 yaml interval 太久不耐烦时) .venv/bin/python -c " from pathlib import Path 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('') -b, c = scan_user_dir(Path('/home/lighthouse/zcbot/workspace/users') / str(uid)) -upsert_user_usage(uid, b, c) -print(f'{b/1024/1024:.1f} MB / {c} files')" +root = Path('/home/lighthouse/zcbot/workspace/users') / str(uid) +b, c = scan_user_dir(root) +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 侧): diff --git a/core/storage/disk_quota.py b/core/storage/disk_quota.py index e890a6b..e9deebc 100644 --- a/core/storage/disk_quota.py +++ b/core/storage/disk_quota.py @@ -1,8 +1,9 @@ """Per-user 工作目录配额(§7.5 #4 软配额,应用层 gate)。 调用入口: -- `scan_user_dir(user_root) -> (bytes, count)` ── os.walk 累加,跳 dotfile / 损坏 stat -- `upsert_user_usage(user_id, bytes, count)` ── 落 user_disk_usage 表 +- `scan_user_dir(user_root) -> (bytes, count)` ── 累加计入用户配额的文件 +- `scan_user_trash(user_root) -> (bytes, count)` ── 单独统计隐藏回收站物理占用 +- `upsert_user_usage(...)` ── 同时落配额占用与回收站快照 - `check_disk_quota(user_id, limit_bytes) -> Optional[str]` ── 写前查,返 None=放行 / str=拒绝原因。`limit_bytes <= 0` 短路放行(不限) - `scan_all_users(user_root_base, limit_bytes)` ── lifespan 后台 task 周期跑, @@ -64,15 +65,18 @@ def parse_bytes(value) -> Optional[int]: _SKIP_TOPLEVEL = frozenset({ ".zcbot_cache", ".zcbot_external_results", + ".zcbot_artifact_trash", ".zcbot_tmp", ".memory", }) +_ARTIFACT_TRASH_DIR = ".zcbot_artifact_trash" def scan_user_dir(user_root: Path) -> Tuple[int, int]: """os.walk 累加 user_root 下所有文件大小,返 (bytes, count)。 - 跳过顶层平台缓存/临时区与 .memory(均有独立生命周期,不算入产品配额); + 跳过顶层平台缓存/临时区、隐藏回收站与 .memory(均有独立生命周期, + 不算入产品配额); follow_symlinks=False 防 symlink 循环爆。 """ 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 +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]: total_b, total_c = 0, 0 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 -def upsert_user_usage(user_id: UUID, bytes_used: int, file_count: int) -> None: - """落 user_disk_usage 单行;首次 INSERT,后续 UPDATE。""" +def upsert_user_usage( + 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 with session_scope() as s: stmt = pg_insert(UserDiskUsage).values( user_id=user_id, bytes_used=bytes_used, file_count=file_count, + trash_bytes=trash_bytes, + trash_file_count=trash_file_count, ).on_conflict_do_update( index_elements=["user_id"], set_={ "bytes_used": bytes_used, "file_count": file_count, + "trash_bytes": trash_bytes, + "trash_file_count": trash_file_count, "scanned_at": func.now(), }, ) @@ -202,8 +224,10 @@ def scan_all_users(user_root_base: Path) -> int: count = 0 for uid in list_user_ids_with_root(user_root_base): try: - b, c = scan_user_dir(user_root_base / str(uid)) - upsert_user_usage(uid, b, c) + user_root = user_root_base / str(uid) + 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 except Exception: # 单 user 扫挂不阻塞其他 user;下次周期重试。日志靠 caller 注入。 diff --git a/core/storage/models.py b/core/storage/models.py index a3ffd3d..6d1ef46 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -355,8 +355,9 @@ class UserDiskUsage(Base): """per-user 工作目录字节使用快照(0008,§7.5 #4 软配额表)。 每个 user_id 单行 upsert,lifespan 后台 task 周期(默 15min)扫描 user_root 落库; - write 前 gate(DockerExecutor / /v1/files/upload)查这表对比 yaml `quotas.disk_bytes_per_user`, - 超额返 [Error] 硬阻。 + write 前 gate(DockerExecutor / /v1/files/upload)用 bytes_used 对比 yaml + `quotas.disk_bytes_per_user`,超额返 [Error] 硬阻。trash_* 只记录隐藏回收站 + 的物理占用,不参与用户配额判断。 扫描间隙写入会突破上限一点(race-tolerant,跟 image/video 配额一致接受);外部用户 开放前 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) 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( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/db/migrations/versions/20260828_1400_0037_user_disk_trash_usage.py b/db/migrations/versions/20260828_1400_0037_user_disk_trash_usage.py new file mode 100644 index 0000000..a001db5 --- /dev/null +++ b/db/migrations/versions/20260828_1400_0037_user_disk_trash_usage.py @@ -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") diff --git a/tests/test_admin_storage.py b/tests/test_admin_storage.py new file mode 100644 index 0000000..9df51cf --- /dev/null +++ b/tests/test_admin_storage.py @@ -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() diff --git a/tests/test_disk_quota.py b/tests/test_disk_quota.py index b37c36a..8024d3d 100644 --- a/tests/test_disk_quota.py +++ b/tests/test_disk_quota.py @@ -12,7 +12,7 @@ from pathlib import Path 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): @@ -75,6 +75,7 @@ class TestScanUserDir(unittest.TestCase): for hidden in ( ".zcbot_cache", ".zcbot_external_results", + ".zcbot_artifact_trash", ".zcbot_tmp", ".memory", ): @@ -84,6 +85,17 @@ class TestScanUserDir(unittest.TestCase): self.assertEqual(b, 7) 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__": unittest.main() diff --git a/web/admin.py b/web/admin.py index 32186e1..a33689e 100644 --- a/web/admin.py +++ b/web/admin.py @@ -99,6 +99,7 @@ def _users_section(s: Any, cutoff_7d: datetime) -> dict: def _storage_page(s: Any, page: int, page_size: int) -> dict: """分页的各用户磁盘用量(bytes desc + user_id 兜底);附 per-user 配额。 + bytes_used 是用户配额占用;trash_bytes 是不计配额但仍占磁盘的隐藏回收站。 数据源 user_disk_usage(后台扫描快照,只含扫过的用户);total 为该表行数。 """ 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 "", "bytes_used": int(b 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, } - 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( UserDiskUsage.user_id, User.email, @@ -124,6 +128,8 @@ def _storage_page(s: Any, page: int, page_size: int) -> dict: User.user_name, UserDiskUsage.bytes_used, UserDiskUsage.file_count, + UserDiskUsage.trash_bytes, + UserDiskUsage.trash_file_count, UserDiskUsage.scanned_at, ) .join(User, User.user_id == UserDiskUsage.user_id, isouter=True) diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 93c5792..a1644e5 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -775,12 +775,14 @@ function renderStorage(d) { + `${humanSize(r.bytes_used)}` + `${pctTxt}` + `${r.file_count || 0}` + + `${humanSize(r.trash_bytes || 0)}` + + `${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}` + `${r.scanned_at ? fmtTime(r.scanned_at) : "—"}` + ``; - }).join("") || `无数据`; + }).join("") || `无数据`; $("s-storage").innerHTML = `

存储用量(${quotaLabel})

` + `
` - + `` + + `` + `${body}
用户已用占配额文件数扫描于
用户配额占用占配额文件数回收站物理合计扫描于
` + pagerHTML("st", page, maxPage, from, to, total) + `
`; @@ -1126,8 +1128,9 @@ function buildReport(ov, models, users, storage, health) { const stBody = (storage.rows || []).slice(0, 10).map(r => { const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—"; return `${escapeHtml(userLabelText(r))}${humanSize(r.bytes_used)}` - + `${pct}${r.file_count || 0}`; - }).join("") || `无数据`; + + `${pct}${r.file_count || 0}${humanSize(r.trash_bytes || 0)}` + + `${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))}`; + }).join("") || `无数据`; $("print-report").innerHTML = `

zcbot 管理后台报告

` @@ -1148,7 +1151,7 @@ function buildReport(ov, models, users, storage, health) { + `

按模型(all-time,Top 10)

` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody) + `

各用户用量(all-time,Top 10)

` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody) + `

存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)

` - + tbl(["用户", "已用", "占配额", "文件数"], stBody); + + tbl(["用户", "配额占用", "占配额", "文件数", "回收站", "物理合计"], stBody); } // ───── 自动刷新 ─────