fix: 修复重构遗留缺陷,补齐 pyright 检查盲区

上一次重构(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,未被写入。
This commit is contained in:
snowy-yang committed 2026-09-26 11:52:06 +08:00
1 parent 8446166120
commit 838d635bc5
14 files changed
+152 -78

No files matched your search

+7 -4
View File
@@ -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, def create_user(username: str, password: str, email: str = '', credits: int = 0,
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '', role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
register_ip_limit: int = 0, register_ip_window: int = 86400, 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 这类"假不限" # F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
try: try:
credits = int(credits) credits = int(credits)
@@ -994,7 +994,9 @@ def send_email(to: str, subject: str, body: str) -> None:
raise ValueError('收件邮箱格式不正确') raise ValueError('收件邮箱格式不正确')
cfg = get_smtp_config() cfg = get_smtp_config()
msg = MIMEText(body, 'plain', 'utf-8') 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['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to msg['To'] = to
with _smtp_connect() as server: with _smtp_connect() as server:
@@ -1183,8 +1185,9 @@ def generate_cards(count: int, credits: int, created_by: str = '',
return codes return codes
def list_cards(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
"""卡密列表(F85:支持分页 limit/offset,管理路由透传)""" """卡密列表(F85:支持分页 limit/offset,管理路由透传)
返回 (当页卡密列表, 卡密总数)——总数供前端分页控件用,调用方按元组解包"""
limit = max(1, min(int(limit), 500)) limit = max(1, min(int(limit), 500))
offset = max(0, int(offset)) offset = max(0, int(offset))
rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset)) rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset))
+13 -3
View File
@@ -176,6 +176,16 @@ def _ensure_dict(result: Any, msg: str) -> dict[str, Any]:
raise APIException(msg) 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: class HTTPClient:
"""HTTP客户端类""" """HTTP客户端类"""
@@ -773,9 +783,9 @@ class NeteaseAPI:
be_replied = c.get('beReplied') or [] be_replied = c.get('beReplied') or []
replied = (be_replied[0] or {}) if be_replied else {} replied = (be_replied[0] or {}) if be_replied else {}
replied_user = replied.get('user') or {} replied_user = replied.get('user') or {}
vip_rights = user.get('vipRights') if isinstance(user.get('vipRights'), dict) else {} vip_rights = _as_dict(user.get('vipRights'))
associator = vip_rights.get('associator') if isinstance(vip_rights.get('associator'), dict) else {} associator = _as_dict(vip_rights.get('associator'))
location_info = user.get('locationInfo') if isinstance(user.get('locationInfo'), dict) else {} location_info = _as_dict(user.get('locationInfo'))
return { return {
'id': c.get('commentId'), 'id': c.get('commentId'),
'content': c.get('content', ''), 'content': c.get('content', ''),
+6 -3
View File
@@ -158,8 +158,11 @@ def get_file_lock(path: Path) -> _FileLockHandle:
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用) # 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
# 容量抽成常量:淘汰循环按它判定,不再读 deque.maxlen(该属性类型为 int|None,
# 一旦有人把 deque 改成无界,len() >= None 会直接 TypeError)
_COVER_CACHE_MAX = 128
_COVER_CACHE: dict[str, bytes] = {} _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() _COVER_CACHE_LOCK = threading.Lock()
@@ -198,7 +201,7 @@ def fetch_cover_bytes(pic_url: str) -> bytes | None:
with _COVER_CACHE_LOCK: with _COVER_CACHE_LOCK:
if pic_url not in _COVER_CACHE: if pic_url not in _COVER_CACHE:
# 简单 FIFO 淘汰,防止缓存无界增长 # 简单 FIFO 淘汰,防止缓存无界增长
while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_ORDER.maxlen: while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_MAX:
evict = _COVER_CACHE_ORDER.popleft() evict = _COVER_CACHE_ORDER.popleft()
_COVER_CACHE.pop(evict, None) _COVER_CACHE.pop(evict, None)
_COVER_CACHE[pic_url] = data _COVER_CACHE[pic_url] = data
@@ -710,7 +713,7 @@ class MusicDownloader:
progress_cb(resume_from, music_info.file_size or 0) progress_cb(resume_from, music_info.file_size or 0)
except Exception: except Exception:
pass # 进度回调绝不影响下载主流程 pass # 进度回调绝不影响下载主流程
resp: requests.Response | None = None resp: requests.SyncResponse | None = None
try: try:
resp = get_http_session().get( resp = get_http_session().get(
music_info.download_url, music_info.download_url,
+14 -1
View File
@@ -74,7 +74,20 @@ quote-style = "preserve"
# ============ Pyright:类型检查 ============ # ============ Pyright:类型检查 ============
[tool.pyright] [tool.pyright]
# 全量纳入(Flask 巨石删除后不再有例外清单) # 全量纳入(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"] exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
+6 -59
View File
@@ -723,39 +723,6 @@ _QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat',
'/audio_stream', '/api/fe_error'} '/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: 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() 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) 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: def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
"""批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款; """批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款;
重复调用/重启后调用都只会退一次)""" 重复调用/重启后调用都只会退一次)"""
@@ -1114,6 +1055,12 @@ def _batch_zip_worker(task_id: str) -> None:
_update_progress() _update_progress()
continue continue
mi = result.music_info 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}) base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists})
# 同名去重:歌单里同一首歌出现两次/同名不同版本时, # 同名去重:歌单里同一首歌出现两次/同名不同版本时,
# 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首) # 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首)
+81
View File
@@ -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'))
+1 -1
View File
@@ -31,7 +31,7 @@ async def server():
await runner.setup() await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0) site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start() await site.start()
port = site._server.sockets[0].getsockname()[1] port = runner.addresses[0][1]
try: try:
yield f'http://127.0.0.1:{port}' yield f'http://127.0.0.1:{port}'
finally: finally:
+1
View File
@@ -21,6 +21,7 @@ async def test_write_then_read(temp_db):
rid = await db.execute('INSERT INTO t (v) VALUES (?)', ('hello',)) rid = await db.execute('INSERT INTO t (v) VALUES (?)', ('hello',))
assert rid == 1 assert rid == 1
row = await db.fetch_one('SELECT v FROM t WHERE id=?', (1,)) row = await db.fetch_one('SELECT v FROM t WHERE id=?', (1,))
assert row is not None, '刚写入的行必须能读回'
assert row['v'] == 'hello' assert row['v'] == 'hello'
assert await db.fetch_val('SELECT COUNT(*) FROM t') == 1 assert await db.fetch_val('SELECT COUNT(*) FROM t') == 1
+1
View File
@@ -62,6 +62,7 @@ def _login(env, username):
def _balance(username): def _balance(username):
u = auth.get_user_by_name(username) u = auth.get_user_by_name(username)
assert u is not None, f'用户 {username} 不存在,余额无从校验'
return int(u['duration_seconds'] or 0) return int(u['duration_seconds'] or 0)
+1 -1
View File
@@ -38,7 +38,7 @@ async def server():
await runner.setup() await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0) site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start() await site.start()
port = site._server.sockets[0].getsockname()[1] port = runner.addresses[0][1]
base = f'http://127.0.0.1:{port}' base = f'http://127.0.0.1:{port}'
try: try:
yield base, hits yield base, hits
+3 -1
View File
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""音频流代理测试:签名校验、HEAD、Range 透传、无效 token。""" """音频流代理测试:签名校验、HEAD、Range 透传、无效 token。"""
from typing import Any
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -32,7 +34,7 @@ def client(monkeypatch):
def close(self): def close(self):
self.closed = True 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): async def fake_open(method, url, *, headers=None, timeout=None, **kw):
state['last_range'] = (headers or {}).get('Range') state['last_range'] = (headers or {}).get('Range')
+5 -3
View File
@@ -4,9 +4,11 @@
运行(生产/开发一致): 运行(生产/开发一致):
python web/main.py python web/main.py
迁移期(strangler):默认开启 Flask 兜底——未迁移的路由由遗留 Flask 应用处理, Flask 巨石已删除,本进程是唯一服务端:默认在根路径服务前端(API_PREFIX 留空);
已迁移的 /api/v1 路由走 FastAPI。全部迁移完成后把 FASTAPI_FLASK_FALLBACK 默认值 把 API_PREFIX 设为非空(如 /api/v1)可把全部路由挂到子前缀下,供契约比对脚本使用。
改为 0 即可完全脱离 Flask(届时删除 setdefault 行为)。
数据目录 `data/` 是**相对当前工作目录**解析的(可由 SITE_DATA_DIR 覆盖),
所以务必从项目根启动——否则会在别处新建一份空库。
""" """
import os import os
import sys import sys
+6 -2
View File
@@ -16,6 +16,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.datastructures import UploadFile
import auth_system as auth import auth_system as auth
from web.deps import SiteUser, current_user from web.deps import SiteUser, current_user
@@ -467,7 +468,10 @@ async def backup_upload(admin: AdminUser, request: Request):
try: try:
form = await request.form() form = await request.form()
f = form.get('file') 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('请选择数据库文件') return error('请选择数据库文件')
if not f.filename.endswith('.db'): if not f.filename.endswith('.db'):
return error('仅支持 .db 文件') return error('仅支持 .db 文件')
@@ -479,7 +483,7 @@ async def backup_upload(admin: AdminUser, request: Request):
name = f'{base}-{k}.db' name = f'{base}-{k}.db'
k += 1 k += 1
dest = auth.BACKUP_DIR / name 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) await asyncio.to_thread(dest.write_bytes, content)
with dest.open('rb') as hf: with dest.open('rb') as hf:
header = hf.read(16) header = hf.read(16)
+7
View File
@@ -197,6 +197,13 @@ async def register(request: Request):
await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', str(e)) await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', str(e))
return error(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) # 建立会话(login_seq 递增 + 签发 Cookie)
uid = int(user['id']) uid = int(user['id'])
now = int(time.time()) now = int(time.time())