From d5ecf2927cc90cec1946e6ece9af9cd93ea012d0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 25 Oct 2026 11:28:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=E6=98=BE=E7=A4=BA=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=8A=E4=BC=A0=E9=80=9F=E5=BA=A6=E5=92=8C=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E7=A3=81=E7=9B=98=E7=A9=BA=E9=97=B4=20(#7177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/dependencies/dashboard.py | 86 ++++++++++++ app/api/dependencies/widget.py | 7 +- app/api/endpoints/dashboard.py | 27 +--- app/application/widget.py | 58 +++++++- docs/architecture-overview.md | 2 +- docs/widgets.md | 8 ++ .../architecture/dependency-baseline.json | 20 ++- tests/test_downloader.py | 132 ++++++++++++++++++ tests/test_system_utils.py | 3 +- tests/test_widget.py | 83 ++++++++++- 10 files changed, 389 insertions(+), 37 deletions(-) create mode 100644 app/api/dependencies/dashboard.py create mode 100644 tests/test_downloader.py diff --git a/app/api/dependencies/dashboard.py b/app/api/dependencies/dashboard.py new file mode 100644 index 000000000..984bea4cb --- /dev/null +++ b/app/api/dependencies/dashboard.py @@ -0,0 +1,86 @@ +""" +为仪表盘和受限小组件装配同一下载器与本地目录采样口径。 +""" + +import math +from pathlib import Path + +from app.adapters.system.host import SystemUtils +from app.application.directory import DirectoryHelper +from app.chain.dashboard import DashboardChain +from app.schemas.dashboard import DownloaderInfo + + +def _download_free_space(*, btrfs_fsid_dedup: bool, require_source: bool) -> float | None: + """ + 复用目录空间去重,严格读取不把未配置或不完整的容量误报为零。 + + :param btrfs_fsid_dedup: 原仪表盘使用的文件系统去重配置 + :param require_source: 小组件需要可确认的完整本地目录来源 + :return: 可确认的剩余字节数;严格模式的未知容量为 None + :raises OSError: 普通仪表盘保留原有的空间读取异常 + """ + try: + directories = DirectoryHelper().get_local_download_dirs() + paths = [Path(directory.download_path) for directory in directories if directory.download_path] + if require_source and (not paths or not all(path.exists() for path in paths)): + return None + total, available = SystemUtils.space_usage(paths, btrfs_fsid_dedup=btrfs_fsid_dedup) + if require_source and ( + not math.isfinite(total) or not math.isfinite(available) or not 0 <= available <= total or total <= 0 + ): + return None + return available + except OSError: + if require_source: + return None + raise + + +def _check_speeds(info: DownloaderInfo) -> None: + """ + 拒绝缺失、负数和非有限的原始速度,真实零速率仍是有效采样。 + + :param info: 单个下载器或汇总后的原始字节统计 + :raises ValueError: 速率来源不能代表本次真实采样 + """ + if any( + value is None or not math.isfinite(value) or value < 0 for value in (info.download_speed, info.upload_speed) + ): + raise ValueError("下载器速度来源暂不可用") + + +def sample_downloader( + name: str | None = None, + *, + btrfs_fsid_dedup: bool = False, + require_source: bool = False, +) -> DownloaderInfo: + """ + 汇总原下载器的字节速率和累计量,包含下载列表之外的做种上传。 + + :param name: 原仪表盘可选择的下载器,None 表示全部 + :param btrfs_fsid_dedup: 本次请求的原目录空间去重配置 + :param require_source: 仅小组件严格拒绝缺失或非法速度;普通页面保留零值兼容 + :return: 共用口径的统计,严格模式的未知磁盘容量为 None + :raises ValueError: 严格模式下缺少真实下载器采样或速度非法 + """ + free_space = _download_free_space(btrfs_fsid_dedup=btrfs_fsid_dedup, require_source=require_source) + transfer_infos = DashboardChain().downloader_info(name) + if not transfer_infos: + if require_source: + raise ValueError("下载器统计来源暂不可用") + return DownloaderInfo() + if require_source: + for info in transfer_infos: + _check_speeds(info) + result = DownloaderInfo( + download_speed=sum(info.download_speed or 0 for info in transfer_infos), + upload_speed=sum(info.upload_speed or 0 for info in transfer_infos), + download_size=sum(info.download_size or 0 for info in transfer_infos), + upload_size=sum(info.upload_size or 0 for info in transfer_infos), + free_space=free_space, + ) + if require_source: + _check_speeds(result) + return result diff --git a/app/api/dependencies/widget.py b/app/api/dependencies/widget.py index 132c06d38..e2956eebc 100644 --- a/app/api/dependencies/widget.py +++ b/app/api/dependencies/widget.py @@ -8,8 +8,10 @@ from typing import NoReturn from fastapi import Depends, HTTPException, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from app.api.context import get_host_runtime +from app.api.context import get_api_runtime_config, get_host_runtime +from app.api.dependencies.dashboard import sample_downloader from app.api.dependencies.history import get_dashboard_query_service, get_history_query_service +from app.application.configuration import ApiRuntimeConfig from app.application.dashboard import DashboardQueryService from app.application.history import HistoryQueryService from app.application.query import get_configured_data_query_service @@ -74,12 +76,14 @@ def get_widget_grant( def get_widget_snapshot_service( dashboard: DashboardQueryService = Depends(get_dashboard_query_service), history: HistoryQueryService = Depends(get_history_query_service), + runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config), ) -> WidgetSnapshotService: """ 只连接现有的查询与业务链,普通页面不会调用小组件专用接口。 :param dashboard: 原仪表板管理员查询服务 :param history: 原历史列表查询服务 + :param runtime_config: 本次请求的空间去重配置,不从组件凭据或参数读取 :return: 使用原有 owner、计数和成功筛选的快照投影服务 """ return WidgetSnapshotService( @@ -87,5 +91,6 @@ def get_widget_snapshot_service( history=history, dashboard=dashboard, downloads=partial(DownloadChain().downloading, require_source=True), + downloader=partial(sample_downloader, btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup, require_source=True), storage=partial(DashboardChain().storage, require_source=True), ) diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index 10eb0a107..30c0076e7 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -2,7 +2,6 @@ 汇总仪表盘所需的服务统计、系统状态与活动数据。 """ -from pathlib import Path from typing import Annotated, Any, List, Optional from fastapi import Depends @@ -11,6 +10,7 @@ from app.adapters.system.host import SystemUtils from app.adapters.web.security.access import verify_apitoken from app.api.context import get_api_runtime_config, resolve_api_runtime_config from app.api.dependencies.auth import get_current_active_superuser +from app.api.dependencies.dashboard import sample_downloader from app.api.dependencies.history import get_dashboard_query_service from app.api.response import ( CompatibleCountParam, @@ -19,7 +19,6 @@ from app.api.response import ( ) from app.application.configuration import ApiRuntimeConfig from app.application.dashboard import DashboardQueryService -from app.application.directory import DirectoryHelper from app.application.scheduling import get_scheduler from app.chain.dashboard import DashboardChain from app.runtime.execution import run_in_threadpool @@ -49,25 +48,13 @@ def _build_downloader( btrfs_fsid_dedup: bool = False, ) -> _SchemaDownloaderInfo: """ - 构建下载器统计信息。 + 使用共用采样口径构建下载器统计,保持普通页面的宽容来源语义。 + + :param name: 下载器名称,None 表示全部 + :param btrfs_fsid_dedup: 当前请求的 Btrfs 文件系统去重配置 + :return: 原仪表盘的全局字节统计与本地目录可用容量 """ - # 下载目录空间 - download_dirs = DirectoryHelper().get_local_download_dirs() - _, free_space = SystemUtils.space_usage( - [Path(d.download_path) for d in download_dirs if d.download_path], - btrfs_fsid_dedup=btrfs_fsid_dedup, - ) - # 下载器信息 - downloader_info = _SchemaDownloaderInfo() - transfer_infos = DashboardChain().downloader_info(name) - if transfer_infos: - for transfer_info in transfer_infos: - downloader_info.download_speed = (downloader_info.download_speed or 0) + (transfer_info.download_speed or 0) - downloader_info.upload_speed = (downloader_info.upload_speed or 0) + (transfer_info.upload_speed or 0) - downloader_info.download_size = (downloader_info.download_size or 0) + (transfer_info.download_size or 0) - downloader_info.upload_size = (downloader_info.upload_size or 0) + (transfer_info.upload_size or 0) - downloader_info.free_space = free_space - return downloader_info + return sample_downloader(name, btrfs_fsid_dedup=btrfs_fsid_dedup) @router.get("/statistic", summary="媒体数量统计", response_model=_SchemaStatistic) diff --git a/app/application/widget.py b/app/application/widget.py index 5afd4afd4..a0bcf9f4f 100644 --- a/app/application/widget.py +++ b/app/application/widget.py @@ -15,7 +15,7 @@ from app.application.security.widget import WidgetGrant, available_widget_conten from app.foundation.size import format_size from app.runtime.execution import run_in_threadpool from app.runtime.log import logger -from app.schemas.dashboard import Storage +from app.schemas.dashboard import DownloaderInfo, Storage from app.schemas.query import QueryPageRequest, SubscriptionFilter from app.schemas.transfer import DownloaderTorrent from app.schemas.types import MediaType @@ -84,6 +84,7 @@ class WidgetSnapshotService: history: HistoryQueryService, dashboard: DashboardQueryService, downloads: Callable[[], list[DownloaderTorrent] | None], + downloader: Callable[[], DownloaderInfo], storage: Callable[[], Storage], clock: Callable[[], float] = time.time, ) -> None: @@ -94,6 +95,7 @@ class WidgetSnapshotService: :param history: 既有整理历史查询,保留成功筛选和数据库分页 :param dashboard: 既有管理员媒体统计查询 :param downloads: 原有下载链的采样回调,仅在工作线程内执行 + :param downloader: 共用下载器字节统计;只由管理员在工作线程内读取 :param storage: 既有存储汇总的严格读取回调 :param clock: 可信 UTC Unix 秒来源 """ @@ -101,6 +103,7 @@ class WidgetSnapshotService: self._history = history self._dashboard = dashboard self._downloads = downloads + self._downloader = downloader self._storage = storage self._clock = clock @@ -208,11 +211,13 @@ class WidgetSnapshotService: async def _download_section(self, user: UserSnapshot) -> WidgetSection: """ - 按原网页精确 owner 规则过滤采样,只投影前六项,不输出 hash 或文件路径。 + 按原网页精确 owner 过滤任务,仅管理员读取全局速率与本地目录空间。 + + :param user: 本次重新鉴权的用户与既有权限快照 + :return: 最多六项任务及同作用域的真实计数,管理员附加三项全局指标 + :raises ValueError: 任务或统计来源暂不可用,或原始进度超出自然范围 """ - rows = await run_in_threadpool(self._downloads) - if rows is None: - raise ValueError("下载来源暂不可用") + rows, info = await run_in_threadpool(self._download_sources, user.is_superuser) visible = ( rows if user.is_superuser else [row for row in rows if row.userid == user.name or row.username == user.name] ) @@ -229,7 +234,48 @@ class WidgetSnapshotService: progress=progress / 100 if progress is not None else None, ) ) - return self._ready("downloads", len(visible), items, icon="download") + section = self._ready("downloads", len(visible), items, icon="download") + if info is not None: + section.metrics = self._download_metrics(info) + section.metrics + # 无下载任务仍可正在做种上传,有效速率不能被空列表状态隐藏。 + section.state = "ready" + return section + + def _download_sources(self, administrator: bool) -> tuple[list[DownloaderTorrent], DownloaderInfo | None]: + """ + 在同一个受管工作线程采样任务与统计,普通用户不触发全局读取。 + + :param administrator: 当前重新鉴权用户是否为超级管理员 + :return: 真实任务列表与仅管理员可读的原始下载器统计 + :raises ValueError: 原下载任务来源尚不可用 + """ + rows = self._downloads() + if rows is None: + raise ValueError("下载来源暂不可用") + return rows, self._downloader() if administrator else None + + def _download_metrics(self, info: DownloaderInfo) -> list[WidgetMetric]: + """ + 投影管理员全局速率与剩余空间,未知容量和真实磁盘满明确区分。 + + :param info: 既有下载器的原始字节采样,不从任务展示文字重算 + :return: 固定标识的上传、下载和空间指标 + :raises ValueError: 速度缺失、负数或非有限;不能伪造为成功的零值 + """ + download, upload, available = info.download_speed, info.upload_speed, info.free_space + if download is None or upload is None or not all( + math.isfinite(value) and value >= 0 for value in (download, upload) + ): + raise ValueError("下载器速度来源暂不可用") + free_space = ( + format_size(int(available)) + if available is not None and math.isfinite(available) and available >= 0 else "—" + ) + return [ + _metric("downloadSpeed", "下载速度", f"{format_size(int(download))}/s", "download"), + _metric("uploadSpeed", "上传速度", f"{format_size(int(upload))}/s", "upload"), + _metric("freeSpace", "剩余空间", free_space, "storage"), + ] async def _overview(self) -> WidgetSection: """ diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 556d9a76e..d35d97bbd 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -817,7 +817,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1139 | -| 内部导入边 | 9,813 | +| 内部导入边 | 9,824 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/docs/widgets.md b/docs/widgets.md index 7d33fb6a6..14640b0fc 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -47,6 +47,14 @@ Cookie、主登录 JWT、资源令牌或 `API_TOKEN`。小组件凭据也不能 分类;设置中的 `admin` 标记不能授予管理员权限。订阅按数据库 owner/type 分页并返回真实总数, 不把展示的六项摘要当作全部数量。 +下载状态对管理员额外返回 `downloadSpeed`(下载速度)、`uploadSpeed`(上传速度)和 +`freeSpace`(剩余空间),之后保留 `count`(任务数量)。速率直接汇总原下载器的字节采样, +包含做种上传;不解析任务列表中的格式化速度。空间沿用仪表盘的本地下载目录与 +`BTRFS_FSID_DEDUP` 去重口径。无下载任务但统计有效时仍可显示真实的零下载和上传速度。 +磁盘未配置、目录缺失或容量读取失败时,空间显示 `—`;有效的零剩余空间显示 `0 B`。 +速率来源缺失或非法时,该节为 `unavailable`。普通用户继续只返回本人任务及数量, +不读取或暴露实例全局速度和磁盘信息。App 小尺寸优先显示两种速度,中大尺寸增加剩余空间。 + ## 快照协议 v1 根字段为 `version`、`owner`、`generatedAt`、`expiresAt`、`availableContents`、`sections`。 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 8ae1787b9..3e9e5c3e8 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -959,8 +959,8 @@ "runtime_only": true } }, - "edge_count": 9813, - "edge_sha256": "7024bc0d1b5fafa109cffbc4e75336893fb34ad4cbc1eec31e5172701d3fede2", + "edge_count": 9824, + "edge_sha256": "fce305ba6e4ef2ac441ef9bf5fc5a9166618f28e92833066ec0adf6b86cc1e90", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2195,6 +2195,15 @@ "app.api.dependencies.auth -> app.startup", "app.api.dependencies.auth -> app.startup.composition", "app.api.dependencies.auth -> app.startup.composition.context", + "app.api.dependencies.dashboard -> app.adapters", + "app.api.dependencies.dashboard -> app.adapters.system", + "app.api.dependencies.dashboard -> app.adapters.system.host", + "app.api.dependencies.dashboard -> app.application", + "app.api.dependencies.dashboard -> app.application.directory", + "app.api.dependencies.dashboard -> app.chain", + "app.api.dependencies.dashboard -> app.chain.dashboard", + "app.api.dependencies.dashboard -> app.schemas", + "app.api.dependencies.dashboard -> app.schemas.dashboard", "app.api.dependencies.data -> app.api", "app.api.dependencies.data -> app.api.data", "app.api.dependencies.github -> app.api", @@ -2289,8 +2298,10 @@ "app.api.dependencies.widget -> app.api", "app.api.dependencies.widget -> app.api.context", "app.api.dependencies.widget -> app.api.dependencies", + "app.api.dependencies.widget -> app.api.dependencies.dashboard", "app.api.dependencies.widget -> app.api.dependencies.history", "app.api.dependencies.widget -> app.application", + "app.api.dependencies.widget -> app.application.configuration", "app.api.dependencies.widget -> app.application.dashboard", "app.api.dependencies.widget -> app.application.history", "app.api.dependencies.widget -> app.application.query", @@ -2444,12 +2455,12 @@ "app.api.endpoints.dashboard -> app.api.context", "app.api.endpoints.dashboard -> app.api.dependencies", "app.api.endpoints.dashboard -> app.api.dependencies.auth", + "app.api.endpoints.dashboard -> app.api.dependencies.dashboard", "app.api.endpoints.dashboard -> app.api.dependencies.history", "app.api.endpoints.dashboard -> app.api.response", "app.api.endpoints.dashboard -> app.application", "app.api.endpoints.dashboard -> app.application.configuration", "app.api.endpoints.dashboard -> app.application.dashboard", - "app.api.endpoints.dashboard -> app.application.directory", "app.api.endpoints.dashboard -> app.application.scheduling", "app.api.endpoints.dashboard -> app.chain", "app.api.endpoints.dashboard -> app.chain.dashboard", @@ -10776,7 +10787,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1139, + "module_count": 1140, "modules": [ "app", "app.adapters", @@ -10977,6 +10988,7 @@ "app.api.dependencies", "app.api.dependencies.agent", "app.api.dependencies.auth", + "app.api.dependencies.dashboard", "app.api.dependencies.data", "app.api.dependencies.github", "app.api.dependencies.history", diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 000000000..95f729d6e --- /dev/null +++ b/tests/test_downloader.py @@ -0,0 +1,132 @@ +""" +共用下载器摘要的原始单位、目录空间与普通仪表盘兼容回归。 +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.api.dependencies import dashboard as sampling +from app.api.endpoints import dashboard as endpoint +from app.schemas.dashboard import DownloaderInfo + + +@pytest.fixture +def downloader_sources(tmp_path, monkeypatch): + """ + 将所有目录和下载器 I/O 替换为可核验边界,不访问用户磁盘或网络。 + """ + paths = [tmp_path / "first", tmp_path / "second"] + for path in paths: + path.mkdir() + directories = Mock(return_value=[SimpleNamespace(download_path=str(path)) for path in paths]) + usage = Mock(return_value=(8192, 4096)) + transfers = Mock(return_value=[DownloaderInfo(download_speed=0, upload_speed=0)]) + monkeypatch.setattr(sampling.DirectoryHelper, "get_local_download_dirs", directories) + monkeypatch.setattr(sampling.SystemUtils, "space_usage", usage) + monkeypatch.setattr(sampling.DashboardChain, "downloader_info", transfers) + return SimpleNamespace(paths=paths, directories=directories, usage=usage, transfers=transfers) + + +def test_downloader_aggregates_original_bytes_and_forwards_disk_policy(downloader_sources): + """ + 多下载器的全局速率包含做种上传,原累计量及目录去重参数不丢失。 + """ + case = downloader_sources + case.transfers.return_value = [ + DownloaderInfo(download_speed=2048, upload_speed=512, download_size=100, upload_size=200), + DownloaderInfo(download_speed=0, upload_speed=1536, download_size=300, upload_size=400), + ] + result = sampling.sample_downloader("chosen", btrfs_fsid_dedup=True, require_source=True) + assert result.model_dump() == dict( + download_speed=2048, upload_speed=2048, download_size=400, upload_size=600, free_space=4096 + ) + case.transfers.assert_called_once_with("chosen") + case.usage.assert_called_once_with(case.paths, btrfs_fsid_dedup=True) + + +@pytest.mark.parametrize("transfers", [None, []]) +def test_downloader_absent_source_is_strict_only(downloader_sources, transfers): + """ + 小组件不能把采样失败当零,普通仪表盘仍保留既有全部零的返回值。 + """ + case = downloader_sources + case.transfers.return_value = transfers + with pytest.raises(ValueError, match="暂不可用"): + sampling.sample_downloader(require_source=True) + assert endpoint._build_downloader() == DownloaderInfo() + + +@pytest.mark.parametrize("field", ["download_speed", "upload_speed"]) +@pytest.mark.parametrize("value", [None, -1, float("nan"), float("inf")]) +def test_downloader_strict_speed_requires_every_source(downloader_sources, field, value): + """ + 即使另一个下载器有效,也不能将缺失或非法的速率漏计为有效总量。 + """ + case = downloader_sources + case.transfers.return_value = [DownloaderInfo(), DownloaderInfo(**{field: value})] + with pytest.raises(ValueError, match="速度来源"): + sampling.sample_downloader(require_source=True) + + +def test_downloader_aggregate_overflow_is_not_fresh_data(downloader_sources): + """ + 单项有限但求和溢出的速率仍应拒绝,不向原生端输出无限值。 + """ + downloader_sources.transfers.return_value = [DownloaderInfo(download_speed=1e308) for _ in range(2)] + with pytest.raises(ValueError, match="速度来源"): + sampling.sample_downloader(require_source=True) + + +@pytest.mark.parametrize( + "usage, expected", + [ + ((8192, 0), 0), + ((0, 0), None), + ((8192, -1), None), + ((8192, 8193), None), + ((float("inf"), 1), None), + ((8192, float("nan")), None), + ], +) +def test_downloader_disk_unknown_is_not_full(downloader_sources, usage, expected): + """ + 有效零剩余代表磁盘满,缺少总容量、越界或非有限容量代表未知。 + """ + downloader_sources.usage.return_value = usage + result = sampling.sample_downloader(require_source=True) + assert result.download_speed == 0 and result.upload_speed == 0 + assert result.free_space == expected + + +@pytest.mark.parametrize("missing", ["none", "partial", "error"]) +def test_downloader_missing_disk_preserves_valid_rates(downloader_sources, missing): + """ + 未配置、部分目录失效或磁盘读取失败只标容量未知,速度仍保持可用。 + """ + case = downloader_sources + if missing == "none": + case.directories.return_value = [] + elif missing == "partial": + case.paths[0].rmdir() + else: + case.usage.side_effect = OSError("private-path") + result = sampling.sample_downloader(require_source=True) + assert result.free_space is None and result.download_speed == 0 + if missing != "error": + case.usage.assert_not_called() + + +def test_downloader_regular_page_retains_nullable_fields_and_disk_error(downloader_sources): + """ + 提取公共采样不改变原网页的空字段聚合及磁盘异常传播行为。 + """ + case = downloader_sources + case.transfers.return_value = [DownloaderInfo(download_speed=None, upload_size=None)] + assert endpoint._build_downloader().model_dump() == dict( + download_speed=0, upload_speed=0, download_size=0, upload_size=0, free_space=4096 + ) + case.usage.side_effect = OSError("unreadable") + with pytest.raises(OSError, match="unreadable"): + endpoint._build_downloader() diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 5128a74d2..868a7196d 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -1032,10 +1032,11 @@ def test_dashboard_downloader_forwards_btrfs_fsid_setting(): """ 下载器仪表盘应转交 Btrfs 文件系统去重开关。 """ + from app.api.dependencies import dashboard as dashboard_dependencies from app.api.endpoints import dashboard as dashboard_module download_dir = MagicMock(download_path="/downloads") - with patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs", + with patch.object(dashboard_dependencies.DirectoryHelper, "get_local_download_dirs", return_value=[download_dir]), \ patch.object(SystemUtils, "space_usage", return_value=(4.0, 2.0)) as usage_mock, \ patch.object(dashboard_module.DashboardChain, "downloader_info", return_value=[]): diff --git a/tests/test_widget.py b/tests/test_widget.py index 30d87fc2a..eeb26e1dc 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -7,7 +7,7 @@ import hashlib import importlib from concurrent.futures import ThreadPoolExecutor from functools import partial -from threading import Barrier +from threading import Barrier, get_ident from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from uuid import uuid4 @@ -26,6 +26,7 @@ from app.api.dependencies import widget as dependencies from app.api.dependencies.auth import get_current_active_user, get_current_user from app.api.dependencies.widget import get_widget_auth_service, get_widget_snapshot_service from app.api.endpoints import widget as endpoint +from app.application.configuration import ApiRuntimeConfig from app.application.dashboard import DashboardQueryService from app.application.history import HistoryQueryService from app.application.query import DataQueryService @@ -48,7 +49,7 @@ from app.db.models.subscribe import Subscribe from app.db.models.transferhistory import TransferHistory from app.db.models.user import User from app.db.models.widget import WidgetRegistration -from app.schemas.dashboard import Statistic, Storage +from app.schemas.dashboard import DownloaderInfo, Statistic, Storage from app.schemas.transfer import DownloaderTorrent from app.schemas.types import MediaType @@ -110,9 +111,11 @@ def widget_case(tmp_path): dashboard = DashboardQueryService(repository=Mock(), media_statistics=Mock(return_value=[Statistic(movie_count=7)])) dashboard.statistic = Mock(return_value=Statistic(movie_count=7, tv_count=3, episode_count=None, music_count=2)) downloads = Mock(return_value=[]) + downloader = Mock(return_value=DownloaderInfo(download_speed=2048, upload_speed=1024, free_space=4096)) storage = Mock(return_value=Storage(total_storage=1000, used_storage=600)) snapshots = WidgetSnapshotService( - data=data, history=history, dashboard=dashboard, downloads=downloads, storage=storage, clock=lambda: clock[0] + data=data, history=history, dashboard=dashboard, downloads=downloads, + downloader=downloader, storage=storage, clock=lambda: clock[0] ) case = SimpleNamespace( auth=auth, @@ -122,6 +125,7 @@ def widget_case(tmp_path): snapshots=snapshots, dashboard=dashboard, downloads=downloads, + downloader=downloader, storage=storage, data=data, history=history, @@ -378,6 +382,7 @@ def test_widget_snapshot_uses_real_owner_type_counts_and_success_history(widget_ assert len(sections["imports"].items) == 6 assert sections["downloads"].metrics[0].value == "2" assert sections["downloads"].items[0].progress == 0.25 + case.downloader.assert_not_called() encoded = result.model_dump_json(exclude_none=True) assert all(secret not in encoded for secret in ("bob", "secret-hash", "/secret", "/private/path", "失败记录")) assert result.expiresAt - result.generatedAt == 86400 @@ -430,9 +435,73 @@ def test_widget_admin_overview_and_all_download_owners(widget_case, source_failu assert overview.metrics[2].value == "—" assert len(overview.metrics) == 6 assert result.sections[1].items[0].title == "bob 下载" + assert [(metric.id, metric.value) for metric in result.sections[1].metrics] == [ + ("downloadSpeed", "2.00 KB/s"), ("uploadSpeed", "1.00 KB/s"), ("freeSpace", "4.00 KB"), ("count", "1") + ] + case.downloader.assert_called_once_with() case.dashboard.statistic.assert_called_once_with(require_source=True) +@pytest.mark.parametrize("free_space, expected", [(0, "0 B"), (None, "—"), (-1, "—"), (float("nan"), "—")]) +def test_widget_download_metrics_without_tasks_and_unknown_disk(widget_case, free_space, expected): + """ + 无下载仍展示真实零下载和做种上传,容量未知不误显示磁盘满。 + """ + case = widget_case + _update_user(case, is_superuser=True) + main_thread = get_ident() + + def sample(): + """ + 断言磁盘和下载器采样离开事件循环线程,再返回原始字节。 + """ + assert get_ident() != main_thread + return DownloaderInfo(download_speed=0, upload_speed=1048576, free_space=free_space) + + case.downloader.side_effect = sample + result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret))) + section = result.sections[1] + assert section.state == "ready" and section.updatedAt == case.clock[0] and not section.items + assert [metric.value for metric in section.metrics] == ["0 B/s", "1.00 MB/s", expected, "0"] + + +@pytest.mark.parametrize("field", ["download_speed", "upload_speed"]) +@pytest.mark.parametrize("value", [None, -1, float("nan"), float("inf")]) +def test_widget_invalid_global_speed_is_independent_unavailable(widget_case, field, value): + """ + 非法全局速率只使下载节不可用,不产生新鲜零值或影响其它成功来源。 + """ + case = widget_case + _update_user(case, is_superuser=True) + case.downloader.return_value = DownloaderInfo(**{field: value}) + result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret))) + section = result.sections[1] + assert section.state == "unavailable" and section.updatedAt is None + assert not section.metrics and not section.items + assert result.sections[0].state == "ready" and result.sections[2].state == "empty" + + +def test_widget_global_speed_source_failure_does_not_leak_to_ordinary_user(widget_case): + """ + 普通用户只看到本人任务,管理员全局采样失败不影响这个权限边界。 + """ + case = widget_case + case.downloader.side_effect = RuntimeError("private-download-host") + case.downloads.return_value = [ + DownloaderTorrent(title="本人", userid="alice"), DownloaderTorrent(title="其他用户", userid="bob") + ] + grant = case.auth.authenticate(_register(case).secret) + result = asyncio.run(case.snapshots.snapshot(grant)) + assert [metric.id for metric in result.sections[0].metrics] == ["count"] + assert [item.title for item in result.sections[0].items] == ["本人"] + case.downloader.assert_not_called() + _update_user(case, is_superuser=True) + result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret))) + assert result.sections[1].state == "unavailable" + assert result.sections[0].state == "ready" + assert "private-download-host" not in result.model_dump_json() + + def test_widget_snapshot_expiry_and_invalid_source_progress(widget_case): """ 采样跨过凭据到期不能延长展示期,非有限或越界进度不能进入 Swift 模型。 @@ -468,10 +537,16 @@ def test_widget_dependency_assembly_and_absent_capability(widget_case, monkeypat dashboard_chain.storage.return_value = Storage(total_storage=1, used_storage=0) monkeypatch.setattr(dependencies, "DownloadChain", lambda: download_chain) monkeypatch.setattr(dependencies, "DashboardChain", lambda: dashboard_chain) - snapshots = dependencies.get_widget_snapshot_service(case.dashboard, case.history) + sampler = Mock(return_value=DownloaderInfo()) + monkeypatch.setattr(dependencies, "sample_downloader", sampler) + snapshots = dependencies.get_widget_snapshot_service(case.dashboard, case.history, ApiRuntimeConfig(60, True, False)) result = asyncio.run(snapshots.snapshot(case.auth.authenticate(_register(case).secret))) assert result.sections[0].state == "empty" download_chain.downloading.assert_called_once_with(require_source=True) + sampler.assert_not_called() + _update_user(case, is_superuser=True) + asyncio.run(snapshots.snapshot(case.auth.authenticate(_register(case).secret))) + sampler.assert_called_once_with(btrfs_fsid_dedup=True, require_source=True) def test_widget_strict_sources_preserve_regular_page_fallbacks(monkeypatch):