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,
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))
+13 -3
View File
@@ -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', ''),
+6 -3
View File
@@ -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,
+14 -1
View File
@@ -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"
+6 -59
View File
@@ -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 首)
+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()
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:
+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',))
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
+1
View File
@@ -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)
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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')
+5 -3
View File
@@ -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
+6 -2
View File
@@ -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)
+7
View File
@@ -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())