Archived
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:
1 parent
8446166120
commit
838d635bc5
14 files changed
+152
-78
No files matched your search
+7
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 首)
|
||||
|
||||
@@ -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'))
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user