diff --git a/core/db.py b/core/db.py index 32a05e1..3c14f6e 100644 --- a/core/db.py +++ b/core/db.py @@ -14,7 +14,7 @@ import asyncio import os -from collections.abc import AsyncIterator, Iterable, Sequence +from collections.abc import AsyncGenerator, Iterable, Sequence from contextlib import asynccontextmanager from pathlib import Path from typing import Any @@ -135,7 +135,7 @@ async def execute_many(sql: str, seq: Iterable[Sequence]) -> int: @asynccontextmanager -async def write_transaction() -> AsyncIterator[aiosqlite.Connection]: +async def write_transaction() -> AsyncGenerator[aiosqlite.Connection, None]: """写事务上下文:多条写语句原子提交(串行锁内)。 用法: @@ -157,7 +157,7 @@ async def write_transaction() -> AsyncIterator[aiosqlite.Connection]: @asynccontextmanager -async def read_connection() -> AsyncIterator[aiosqlite.Connection]: +async def read_connection() -> AsyncGenerator[aiosqlite.Connection, None]: """只读连接上下文(无锁、可并发);需要在一个连接上做多次读时使用。""" conn = await connect() try: diff --git a/core/http_client.py b/core/http_client.py index 9ccb450..6acd5e0 100644 --- a/core/http_client.py +++ b/core/http_client.py @@ -15,7 +15,7 @@ import asyncio import json -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any @@ -235,7 +235,7 @@ class AsyncHTTPClient: cookies: Mapping[str, str] | None = None, timeout: float | None = None, allow_redirects: bool = True, - ) -> AsyncIterator[aiohttp.ClientResponse]: + ) -> AsyncGenerator[aiohttp.ClientResponse, None]: """流式请求(下载/音视频代理用):yield 原始响应,边读边写不整段入内存。""" session = await self._session_for_loop() req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None diff --git a/core/state.py b/core/state.py index d9853f5..fd9ce97 100644 --- a/core/state.py +++ b/core/state.py @@ -15,7 +15,7 @@ web/ 与各服务模块共用,并为后续封装成带锁的单例(State 对 import collections import threading -from typing import Any +from typing import Any, NotRequired, TypedDict # ============ 短链展开结果缓存 ============ # 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) @@ -57,15 +57,45 @@ REQ_LOG: list[dict[str, Any]] = [] # ============ 上游探测缓存 ============ +# 带时间戳的缓存一律用 TypedDict 显式声明字段类型:无标注的 dict 字面量会被类型 +# 检查器推断为「所有键的值类型取并集」(如 `{'v': None, 'ts': 0.0}` → float | None), +# 于是每个读写点都退化成联合类型,既丢失精确性也淹没真实缺陷。 +class _TimedValue(TypedDict): + """值 + 写入时刻的二元缓存。""" + v: str | None + ts: float + + +class _NickCache(TypedDict): + """服务器账号昵称缓存。""" + nick: str | None + ts: float + + +class _VipCache(TypedDict): + """全局 VIP 缓存:data 为 None 表示尚未探测(读侧需判空)。""" + data: dict[str, Any] | None + ts: float + ttl: NotRequired[int] + + +class _VipEntry(TypedDict): + """已写入的 VIP 缓存条目——三个字段齐备,data 必定存在。""" + data: dict[str, Any] + ts: float + ttl: int + + # 全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值) -GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} +GLOBAL_MUSIC_U_CACHE: _TimedValue = {'v': None, 'ts': 0.0} # 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 -SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} +SERVER_NICK_CACHE: _NickCache = {'nick': None, 'ts': 0.0} SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游 VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待) # 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, # 公网下账号栏高频刷新会连累服务器账号触发风控。 -VIP_CACHE = {'data': None, 'ts': 0.0} -VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存 +VIP_CACHE: _VipCache = {'data': None, 'ts': 0.0} +# user 路径按 cookie 哈希的 5 分钟缓存 +VIP_USER_CACHE: dict[str, _VipEntry] = {} diff --git a/pyproject.toml b/pyproject.toml index e8b7bf2..cd16ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,11 +92,14 @@ exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"] venvPath = "." venv = ".venv" pythonVersion = "3.12" -typeCheckingMode = "basic" +typeCheckingMode = "standard" reportMissingImports = true +# standard 比 basic 多出「变量可能未绑定 / 实参类型不符 / 无意义重定义 / 未使用符号」 +# 等**可执行缺陷**(basic 全部漏报,实测 standard 多报 7 处真实缺陷), +# 故作为门禁级别;strict 的 reportUnknown* 需要给全部第三方库补存根,暂不启用。 +# 以下三项在 standard 下已由类型系统覆盖,降为 warning 以避免第三方存根不全的噪声 reportOptionalMemberAccess = "warning" reportAttributeAccessIssue = "warning" -reportGeneralTypeIssues = "warning" # ============ Pytest ============ [tool.pytest.ini_options] diff --git a/services/batch.py b/services/batch.py index 8320cc2..c824553 100644 --- a/services/batch.py +++ b/services/batch.py @@ -46,6 +46,9 @@ def _batch_zip_worker(task_id: str) -> None: from concurrent.futures import ThreadPoolExecutor, as_completed zip_tmp_path: str | None = None + # zip_valid 必须在函数头初始化:ZIP 构建前的业务错误(参数缺失/无歌曲等) + # 会 early raise,异常处理器若引用未绑定名会 NameError 二次崩溃 worker 线程 + zip_valid = False ok_count = 0 failed: list[str] = [] used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖) @@ -133,7 +136,7 @@ def _batch_zip_worker(task_id: str) -> None: # 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物 _task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id _task_dir_for_zip.mkdir(parents=True, exist_ok=True) - zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费 + # 中央目录完整写出后(下方)才置 True,否则一律按失败全额退费 with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip', dir=str(_task_dir_for_zip), delete=False) as zip_tmp: diff --git a/services/downloads.py b/services/downloads.py index 001d335..d46e383 100644 --- a/services/downloads.py +++ b/services/downloads.py @@ -71,7 +71,8 @@ def _global_music_u() -> str: except Exception: _GLOBAL_MUSIC_U_CACHE['v'] = '' _GLOBAL_MUSIC_U_CACHE['ts'] = now - return _GLOBAL_MUSIC_U_CACHE['v'] + # 契约返回 str:缓存未命中且读取失败时 v 会是 None,统一收敛为空串 + return _GLOBAL_MUSIC_U_CACHE['v'] or '' def _vip_name(vip_type: int) -> str: diff --git a/services/podcast.py b/services/podcast.py index b027bde..9434593 100644 --- a/services/podcast.py +++ b/services/podcast.py @@ -79,6 +79,7 @@ def _podcast_stream_download(url: str, target: Path, total_size: int, now = time.time() if now - last_update > 0.5: last_update = now + pct = 0 if total: pct = min(99, int(done / total * 100)) msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}" @@ -293,9 +294,10 @@ def _podcast_zip_worker(task_id: str) -> None: zip_tmp_path: str | None = None ok_count = 0 failed: list[str] = [] - # zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等 + # zip_valid / radio_name 必须在函数头初始化——空电台/无单集/补扣失败等 # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 zip_valid = False + radio_name = '' try: with DOWNLOAD_TASKS_LOCK: task = DOWNLOAD_TASKS[task_id] diff --git a/web/auth_state.py b/web/auth_state.py index 46d0bc0..1c071ef 100644 --- a/web/auth_state.py +++ b/web/auth_state.py @@ -142,7 +142,7 @@ def _send_code_ip_check(ip: str) -> bool: """返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)""" now = time.time() if len(_SEND_CODE_IP) > 10000: - stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] + stale = [k for k, (_, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] for k in stale: _SEND_CODE_IP.pop(k, None) cnt, win = _SEND_CODE_IP.get(ip, (0, now)) diff --git a/web/routers/download.py b/web/routers/download.py index fc6f0ed..1a3b3cb 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -379,6 +379,9 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500)) quality = _podcast_quality(data) cookies = _cookies() + # id_list 只在「按单集 ID 列表」分支赋值,但下面组装任务参数时两个分支都会 + # 引用它(radio_id 为假则取 id_list)——先绑定,避免异常/类型检查下的未绑定名 + id_list: list[str] = [] import podcast_api if raw_radio: