From 838d635bc5d19d2f78933f16a1c0cb29b2423c4b Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:52:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=81=97=E7=95=99=E7=BC=BA=E9=99=B7=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20pyright=20=E6=A3=80=E6=9F=A5=E7=9B=B2=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一次重构(8446166)声称「pyright 0 错误」,但 pyproject 里 pyright 的 include 只覆盖 core/web/services —— 根目录 6 个应用模块与整个 tests/ 从未被检查,而 requests→sync_http 迁移的遗留恰好全部藏在那里。补齐 include 后立刻暴露 7 errors + 9 warnings,其中数处在运行时静默失效。 修复: - services/runtime.py:删除 _podcast_billing_count/_billing_ids_count 两个 无调用方的死函数。它们仍调用 api_service._safe_get_request_data(),而该方法 在剥离 Flask 时已被删除,异常又被 except 吞掉 —— 一旦被调用,播客/歌单计费 恒为 1 次且不报任何错。路由层已各自实现同口径版本,故整体删除。 - web/routers/admin_site.py:备份上传的类型判定必须用 starlette 的 UploadFile。 fastapi.UploadFile 是它的子类,而 request.form() 返回基类实例,用错会让 isinstance 恒为 False,把每一次真实上传都判成「请选择数据库文件」。 已补 4 例真实 multipart 回归测试,并用变异测试确认能抓住该缺陷。 - auth_system.py:修正 create_user(实返 sqlite3.Row|None)与 list_cards (实返 (cards,total) 元组)与实现不符的返回注解;注解一改正,立刻暴露出被它 掩盖的 register.py 中 int(user['id']) 未做 None 检查的 500 崩溃路径,已补守卫。 - music_downloader.py:修正注解引用的 core.sync_http.Response(正确名应为 SyncResponse);封面缓存淘汰不再读类型为 Optional 的 deque.maxlen,改用命名常量。 - services/runtime.py:zip worker 的 result.music_info 可能为 None,原样使用会 炸掉整批任务,改为按单曲失败处理。 - music_api.py:三处 isinstance 三元表达式收敛为 _as_dict() 帮助函数。 - web/main.py:文档串移除已删除的 Flask 兜底描述,补充 data/ 相对 CWD 解析的提醒。 验证:ruff check 通过;pyright 0 errors / 0 warnings(filesAnalyzed=67); pytest 144 passed;真实 uvicorn 启动冒烟通过(/health、/、静态资源 200, 未鉴权管理端点 403)。生产库仅做只读 integrity_check,未被写入。 --- auth_system.py | 11 +++-- music_api.py | 16 ++++-- music_downloader.py | 9 ++-- pyproject.toml | 15 +++++- services/runtime.py | 65 +++---------------------- tests/test_admin_backup_upload.py | 81 +++++++++++++++++++++++++++++++ tests/test_aiohttp_compat.py | 2 +- tests/test_db.py | 1 + tests/test_download_billing.py | 1 + tests/test_http_client.py | 2 +- tests/test_stream.py | 4 +- web/main.py | 8 +-- web/routers/admin_site.py | 8 ++- web/routers/register.py | 7 +++ 14 files changed, 152 insertions(+), 78 deletions(-) create mode 100644 tests/test_admin_backup_upload.py diff --git a/auth_system.py b/auth_system.py index fbccde0..2f32112 100644 --- a/auth_system.py +++ b/auth_system.py @@ -288,7 +288,7 @@ def _verify_password(stored: str, password: str, salt: str) -> tuple[bool, bool] def create_user(username: str, password: str, email: str = '', credits: int = 0, role: str = 'user', device_id: str = '', ip: str = '', ua: str = '', register_ip_limit: int = 0, register_ip_window: int = 86400, - register_device_once: bool = False) -> dict[str, Any]: + register_device_once: bool = False) -> sqlite3.Row | None: # F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限" try: credits = int(credits) @@ -994,7 +994,9 @@ def send_email(to: str, subject: str, body: str) -> None: raise ValueError('收件邮箱格式不正确') cfg = get_smtp_config() msg = MIMEText(body, 'plain', 'utf-8') - msg['Subject'] = Header(subject, 'utf-8') + # email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码 + # (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确) + msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment] msg['From'] = formataddr(('云村小助手', cfg['sender'])) msg['To'] = to with _smtp_connect() as server: @@ -1183,8 +1185,9 @@ def generate_cards(count: int, credits: int, created_by: str = '', return codes -def list_cards(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: - """卡密列表(F85:支持分页 limit/offset,管理路由透传)""" +def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]: + """卡密列表(F85:支持分页 limit/offset,管理路由透传) + 返回 (当页卡密列表, 卡密总数)——总数供前端分页控件用,调用方按元组解包""" limit = max(1, min(int(limit), 500)) offset = max(0, int(offset)) rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset)) diff --git a/music_api.py b/music_api.py index 1048998..be29bea 100644 --- a/music_api.py +++ b/music_api.py @@ -176,6 +176,16 @@ def _ensure_dict(result: Any, msg: str) -> dict[str, Any]: raise APIException(msg) +def _as_dict(v: Any) -> dict[str, Any]: + """上游嵌套字段类型不稳定(dict / None / 列表都真实出现过):非 dict 一律回落空 dict。 + + 收窄成显式函数,避免满屏 `x if isinstance(x, dict) else {}` 三元式—— + 那种写法读起来累,且把字段来自 `user.get(...)`(类型 Any|None)时的收窄责任 + 散落在各处,容易被后来的改动改漏。 + """ + return v if isinstance(v, dict) else {} + + class HTTPClient: """HTTP客户端类""" @@ -773,9 +783,9 @@ class NeteaseAPI: be_replied = c.get('beReplied') or [] replied = (be_replied[0] or {}) if be_replied else {} replied_user = replied.get('user') or {} - vip_rights = user.get('vipRights') if isinstance(user.get('vipRights'), dict) else {} - associator = vip_rights.get('associator') if isinstance(vip_rights.get('associator'), dict) else {} - location_info = user.get('locationInfo') if isinstance(user.get('locationInfo'), dict) else {} + vip_rights = _as_dict(user.get('vipRights')) + associator = _as_dict(vip_rights.get('associator')) + location_info = _as_dict(user.get('locationInfo')) return { 'id': c.get('commentId'), 'content': c.get('content', ''), diff --git a/music_downloader.py b/music_downloader.py index 50a38fa..a0cd14d 100644 --- a/music_downloader.py +++ b/music_downloader.py @@ -158,8 +158,11 @@ def get_file_lock(path: Path) -> _FileLockHandle: # 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用) +# 容量抽成常量:淘汰循环按它判定,不再读 deque.maxlen(该属性类型为 int|None, +# 一旦有人把 deque 改成无界,len() >= None 会直接 TypeError) +_COVER_CACHE_MAX = 128 _COVER_CACHE: dict[str, bytes] = {} -_COVER_CACHE_ORDER: deque = deque(maxlen=128) +_COVER_CACHE_ORDER: deque[str] = deque(maxlen=_COVER_CACHE_MAX) _COVER_CACHE_LOCK = threading.Lock() @@ -198,7 +201,7 @@ def fetch_cover_bytes(pic_url: str) -> bytes | None: with _COVER_CACHE_LOCK: if pic_url not in _COVER_CACHE: # 简单 FIFO 淘汰,防止缓存无界增长 - while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_ORDER.maxlen: + while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_MAX: evict = _COVER_CACHE_ORDER.popleft() _COVER_CACHE.pop(evict, None) _COVER_CACHE[pic_url] = data @@ -710,7 +713,7 @@ class MusicDownloader: progress_cb(resume_from, music_info.file_size or 0) except Exception: pass # 进度回调绝不影响下载主流程 - resp: requests.Response | None = None + resp: requests.SyncResponse | None = None try: resp = get_http_session().get( music_info.download_url, diff --git a/pyproject.toml b/pyproject.toml index cb09aea..7410b98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,20 @@ quote-style = "preserve" # ============ Pyright:类型检查 ============ [tool.pyright] # 全量纳入(Flask 巨石删除后不再有例外清单) -include = ["core", "web", "services"] +# 顶层模块与 tests 此前不在名单里——「pyright 0 错误」只覆盖了 core/web/services, +# 而 requests→sync_http 迁移的遗留恰好全部躲在漏检的顶层模块中,故补齐。 +include = [ + "core", + "web", + "services", + "tests", + "music_api.py", + "auth_system.py", + "podcast_api.py", + "music_downloader.py", + "qr_login.py", + "_mkpkg.py", +] exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"] venvPath = "." venv = ".venv" diff --git a/services/runtime.py b/services/runtime.py index 3b3784b..de0d1d2 100644 --- a/services/runtime.py +++ b/services/runtime.py @@ -723,39 +723,6 @@ _QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', '/audio_stream', '/api/fe_error'} -def _podcast_billing_count() -> int: - """播客打包计费:指定单集列表按数量扣;整台电台提交时先扣 1 次, - worker 解析完实际单集数后补扣差额(一首单集 = 1 次) - F22:与路由完全同口径——ids 模式先做与路由一致的 limit 截断,避免多扣""" - try: - data = api_service._safe_get_request_data() - if data.get('radio_id'): - return 1 - try: - limit = int(data.get('limit', 200) or 200) - except (TypeError, ValueError): - limit = 200 - limit = max(1, min(limit, 500)) - raw = data.get('ids') - if isinstance(raw, str): - raw_list = raw.split(',') - else: - raw_list = list(raw or []) - # 与路由完全同口径(提取→去重):URL 形式与纯数字形式的同一单集不再重复计费 - ids, seen = [], set() - for x in raw_list: - try: - mid = api_service._extract_music_id(x) - except Exception: - mid = str(x).strip() - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - return max(1, min(len(ids), limit)) - except Exception: - return 1 - - def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str: """文件名安全化(去除非法字符)""" s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip() @@ -910,32 +877,6 @@ def _user_client() -> QRLoginClient: return QRLoginClient(USER_COOKIE_FILE) -def _billing_ids_count() -> int: - """按次计费:解析请求中的 ids 数量(一首歌 = 1 次;100 首歌单打包 = 100 次) - F23:与路由的清洗规则完全一致(提取→去重),预扣数=实际执行数,重复 ID 不多扣""" - try: - data = api_service._safe_get_request_data() - raw = data.get('ids') - if isinstance(raw, str): - raw_list = raw.split(',') - else: - raw_list = list(raw or []) - ids, seen = [], set() - for x in raw_list: - try: - mid = api_service._extract_music_id(x) - except Exception: - mid = str(x).strip() - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - # 与路由同口径:单任务上限 500(超出部分路由会 400 拒绝并退款, - # 预扣数先封顶可让"请分批提交"提示优先于"次数不足") - return max(1, min(len(ids), 500)) - except Exception: - return 1 - - def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int: """批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款; 重复调用/重启后调用都只会退一次)""" @@ -1114,6 +1055,12 @@ def _batch_zip_worker(task_id: str) -> None: _update_progress() continue mi = result.music_info + if mi is None: + # 与上面 fp.exists() 同口径:成功但缺元信息(上游详情缺失) + # 按单曲失败计,不让 mi.name 抛 AttributeError 把整批转 error + failed.append(str(mid)) + _update_progress() + continue base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists}) # 同名去重:歌单里同一首歌出现两次/同名不同版本时, # 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首) diff --git a/tests/test_admin_backup_upload.py b/tests/test_admin_backup_upload.py new file mode 100644 index 0000000..26f38f7 --- /dev/null +++ b/tests/test_admin_backup_upload.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""备份上传端点回归:钉住「用哪个 UploadFile 做 isinstance」这个坑。 + +坑的由来:`fastapi.UploadFile` 是 `starlette.datastructures.UploadFile` 的**子类** +(MRO: fastapi.datastructures.UploadFile → starlette.datastructures.UploadFile), +而 `request.form()` 返回的是 **starlette 那个基类**的实例。所以 +`isinstance(f, fastapi.UploadFile)` 对真实上传恒为 False ——端点会把每一次上传 +都判成「请选择数据库文件」,功能静默失效且不报错。 + +本测试用真实 multipart 请求穿过端点,确保三种分支(接受 / 后缀拒 / 内容拒)行为正确。 +""" + +import io + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from web.deps import SiteUser +from web.routers import admin_site + +UPLOAD_URL = '/admin/site/backup/upload' +SQLITE_MAGIC = b'SQLite format 3\x00' + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + """沙箱库 + 绕过管理员闸(本测试只关心 Form/UploadFile 处理,不测鉴权)。""" + import auth_system as auth + import core.db as db + + monkeypatch.setattr(auth, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth, 'DB_PATH', tmp_path / 'site.db') + monkeypatch.setattr(auth, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') + auth.init_db() + + app = FastAPI() + app.include_router(admin_site.router) + app.dependency_overrides[admin_site._require_admin] = lambda: SiteUser( + id=1, username='boss', role='admin', credits=-1) + return TestClient(app) + + +def _upload(client, filename, payload): + return client.post( + UPLOAD_URL, + files={'file': (filename, io.BytesIO(payload), 'application/octet-stream')}, + ) + + +def test_real_upload_is_accepted(client, tmp_path): + """真实 SQLite 文件必须被接收并落盘(若 isinstance 用错类,这里会拿到「请选择数据库文件」)。""" + r = _upload(client, 'up.db', SQLITE_MAGIC + b'\x00' * 512) + assert r.status_code == 200 + body = r.json() + assert body['success'] is True, body + assert body['data']['name'].endswith('.db') + assert (tmp_path / 'backups' / body['data']['name']).exists() + + +def test_missing_file_field_is_rejected(client): + r = client.post(UPLOAD_URL, data={'note': 'no file here'}) + assert r.json()['success'] is False + + +def test_non_db_extension_is_rejected(client): + r = _upload(client, 'evil.txt', SQLITE_MAGIC + b'\x00' * 64) + body = r.json() + assert body['success'] is False + assert '.db' in body['message'] + + +def test_valid_extension_but_bad_content_is_rejected(client, tmp_path): + """后缀对但内容不是 SQLite:必须拒绝,且不得把半成品留在备份目录。""" + r = _upload(client, 'fake.db', b'this is definitely not a sqlite database') + body = r.json() + assert body['success'] is False + assert not list((tmp_path / 'backups').glob('*.db')) diff --git a/tests/test_aiohttp_compat.py b/tests/test_aiohttp_compat.py index 532357d..4eae370 100644 --- a/tests/test_aiohttp_compat.py +++ b/tests/test_aiohttp_compat.py @@ -31,7 +31,7 @@ async def server(): await runner.setup() site = web.TCPSite(runner, '127.0.0.1', 0) await site.start() - port = site._server.sockets[0].getsockname()[1] + port = runner.addresses[0][1] try: yield f'http://127.0.0.1:{port}' finally: diff --git a/tests/test_db.py b/tests/test_db.py index 30af5d0..1f3902c 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -21,6 +21,7 @@ async def test_write_then_read(temp_db): rid = await db.execute('INSERT INTO t (v) VALUES (?)', ('hello',)) assert rid == 1 row = await db.fetch_one('SELECT v FROM t WHERE id=?', (1,)) + assert row is not None, '刚写入的行必须能读回' assert row['v'] == 'hello' assert await db.fetch_val('SELECT COUNT(*) FROM t') == 1 diff --git a/tests/test_download_billing.py b/tests/test_download_billing.py index f4a4e9c..7500534 100644 --- a/tests/test_download_billing.py +++ b/tests/test_download_billing.py @@ -62,6 +62,7 @@ def _login(env, username): def _balance(username): u = auth.get_user_by_name(username) + assert u is not None, f'用户 {username} 不存在,余额无从校验' return int(u['duration_seconds'] or 0) diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 191b5ac..7242def 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -38,7 +38,7 @@ async def server(): await runner.setup() site = web.TCPSite(runner, '127.0.0.1', 0) await site.start() - port = site._server.sockets[0].getsockname()[1] + port = runner.addresses[0][1] base = f'http://127.0.0.1:{port}' try: yield base, hits diff --git a/tests/test_stream.py b/tests/test_stream.py index 93a5140..5c7790a 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- """音频流代理测试:签名校验、HEAD、Range 透传、无效 token。""" +from typing import Any + import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -32,7 +34,7 @@ def client(monkeypatch): def close(self): self.closed = True - state = {'last_range': None, 'upstream': None} + state: dict[str, Any] = {'last_range': None, 'upstream': None} async def fake_open(method, url, *, headers=None, timeout=None, **kw): state['last_range'] = (headers or {}).get('Range') diff --git a/web/main.py b/web/main.py index 246238e..2f600ae 100644 --- a/web/main.py +++ b/web/main.py @@ -4,9 +4,11 @@ 运行(生产/开发一致): python web/main.py -迁移期(strangler):默认开启 Flask 兜底——未迁移的路由由遗留 Flask 应用处理, -已迁移的 /api/v1 路由走 FastAPI。全部迁移完成后把 FASTAPI_FLASK_FALLBACK 默认值 -改为 0 即可完全脱离 Flask(届时删除 setdefault 行为)。 +Flask 巨石已删除,本进程是唯一服务端:默认在根路径服务前端(API_PREFIX 留空); +把 API_PREFIX 设为非空(如 /api/v1)可把全部路由挂到子前缀下,供契约比对脚本使用。 + +数据目录 `data/` 是**相对当前工作目录**解析的(可由 SITE_DATA_DIR 覆盖), +所以务必从项目根启动——否则会在别处新建一份空库。 """ import os import sys diff --git a/web/routers/admin_site.py b/web/routers/admin_site.py index 401fcc5..48fd554 100644 --- a/web/routers/admin_site.py +++ b/web/routers/admin_site.py @@ -16,6 +16,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, Request from fastapi.responses import FileResponse +from starlette.datastructures import UploadFile import auth_system as auth from web.deps import SiteUser, current_user @@ -467,7 +468,10 @@ async def backup_upload(admin: AdminUser, request: Request): try: form = await request.form() f = form.get('file') - if not f or not getattr(f, 'filename', None): + # 必须用 starlette 的 UploadFile 做 isinstance:fastapi.UploadFile 是它的**子类**, + # 实测 isinstance(request.form() 取到的对象, fastapi.UploadFile) 为 False, + # 换成 fastapi 那个会把所有真实上传判成「未选择文件」(顺带让类型检查器收窄 f)。 + if not isinstance(f, UploadFile) or not f.filename: return error('请选择数据库文件') if not f.filename.endswith('.db'): return error('仅支持 .db 文件') @@ -479,7 +483,7 @@ async def backup_upload(admin: AdminUser, request: Request): name = f'{base}-{k}.db' k += 1 dest = auth.BACKUP_DIR / name - content = await f.read() # type: ignore[union-attr] - form 校验后必为 UploadFile + content = await f.read() await asyncio.to_thread(dest.write_bytes, content) with dest.open('rb') as hf: header = hf.read(16) diff --git a/web/routers/register.py b/web/routers/register.py index aaa463c..5537ad3 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -197,6 +197,13 @@ async def register(request: Request): await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', str(e)) return error(str(e)) + # 建号事务已提交却读不回(并发删除/库损坏):按注册失败处理并把验证码还回去, + # 不让 user['id'] 直接 TypeError 崩成 500 + if user is None: + await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') + await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', '建号后读取用户失败') + return error('注册失败,请稍后重试') + # 建立会话(login_seq 递增 + 签发 Cookie) uid = int(user['id']) now = int(time.time())