fix(storage): 删除文件后释放用户配额
This commit is contained in:
parent
9728fc3dd2
commit
bff6125cd1
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
|
||||
|
||||
- 删除已发布文件后会释放个人存储配额;管理员可分别查看用户当前文件、隐藏回收站及物理总占用。
|
||||
|
||||
## 0.69.1 — 2026-08-27
|
||||
|
||||
- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。
|
||||
|
|
|
|||
|
|
@ -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 都覆盖不了)。
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
22
RUN.md
22
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-<user-uuid>
|
|||
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('<your-user-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 侧):
|
||||
|
|
|
|||
|
|
@ -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 注入。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -775,12 +775,14 @@ function renderStorage(d) {
|
|||
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
|
||||
+ `<td class="num">${pctTxt}</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>`
|
||||
+ `</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>`
|
||||
+ `<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>`
|
||||
+ pagerHTML("st", page, maxPage, from, to, total)
|
||||
+ `</div>`;
|
||||
|
|
@ -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 `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
|
||||
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
|
||||
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
|
||||
+ `<td>${pct}</td><td>${r.file_count || 0}</td><td>${humanSize(r.trash_bytes || 0)}</td>`
|
||||
+ `<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 =
|
||||
`<h1>zcbot 管理后台报告</h1>`
|
||||
|
|
@ -1148,7 +1151,7 @@ function buildReport(ov, models, users, storage, health) {
|
|||
+ `<h2>按模型(all-time,Top 10)</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
|
||||
+ `<h2>各用户用量(all-time,Top 10)</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
|
||||
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)</h2>`
|
||||
+ tbl(["用户", "已用", "占配额", "文件数"], stBody);
|
||||
+ tbl(["用户", "配额占用", "占配额", "文件数", "回收站", "物理合计"], stBody);
|
||||
}
|
||||
|
||||
// ───── 自动刷新 ─────
|
||||
|
|
|
|||
Loading…
Reference in New Issue