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

+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 首)