refactor: 本地文件 IO 归位——重活留线程、微秒级直接同步,移除零使用依赖 aiofiles

为什么:文件 IO 这条线上 to_thread 用得过宽。凡是「单文件 exists / unlink / stat」
这类微秒级元数据操作,线程调度开销(几十微秒起)比操作本身还大,套 to_thread 只是
给同步调用加了层异步外壳,不产生任何并发收益;真正需要卸载的是整库复制、ZIP 整段
拷贝、SQLite 探测这类活。本批按这个口径把两端都摆正,并顺手修掉一处真实缺陷。

改动:
- web/routers/admin_site.py 备份上传:原 `content = await f.read()` 会把整库一次性
  读进内存(端点对外无大小上限,等于给了上传方一个内存放大器),落盘后还要把文件
  重新打开读一次头。改为 _save_upload_stream 按 1 MiB 逐块搬运——内存占用恒为一块,
  文件头在写第一块时就地截获(省掉那趟回读)。仍属阻塞 IO,故保留在 to_thread 里。
- web/routers/admin_site.py backup_list:撤掉仅为一次 stat 而拆出的嵌套函数 +
  to_thread,直接在事件循环里做完。
- web/routers/admin.py:save_cookie / logout / _get_cookie_info 四处 to_thread 改
  直接调用——它们读写 1KB 级的 cookie.txt,全程不碰网络。
- music_api/qr_client.py:logout() 的 print 改 logger。该方法既被 CLI 也被 admin
  端点调用,在 web 语境里 print 会把内容直接喷到服务端 stdout,绕过 logging 配置、
  无法被收集,也污染容器日志。
- auth_system/backup.py:三处单文件 unlink(占位文件回滚、删备份、裁剪 pre_restore_)
  改直接同步;把只为配合 to_thread 而拆出的 _backup_path_sync 并回 backup_path。
  保留在线程里的:整库 copy2 / os.replace、glob + N 次 stat、SQLite 探测、以及持
  跨线程锁的 _reserve_backup_path(内联会让事件循环等一把别的线程可能持有的锁)。
- web/routers/delivery.py:两处 os.path.exists 改直接同步(带 ASYNC240 行内豁免,
  与 core/db.py、auth_system/db.py 同一写法),顺带删掉随之未使用的 asyncio 导入。
- pyproject.toml / uv.lock:移除 aiofiles。它全仓零引用,且底层同样是 to_thread——
  对 1KB 的 cookie 文件不会带来任何真异步收益。

验证:
- uv run ruff check .  → All checks passed!
- uv run pyright       → 0 errors, 0 warnings, 0 informations
- uv run pytest        → 392 项,0 失败 / 0 error / 0 skipped
  (含 test_admin_backup_upload 四条:接受真实 SQLite / 缺字段拒 / 后缀拒 /
   后缀对但内容非库必须拒且不留半成品)
- data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
This commit is contained in:
snowy-yang committed 2026-09-29 15:20:51 +08:00
1 parent 046225e38d
commit e8cc7cb34a
7 files changed
+76 -53

No files matched your search

+10 -8
View File
@@ -11,8 +11,10 @@
鉴权:管理员密码闸(文案"需要管理员权限,请使用管理员账号登录"),
与 auth_system.admin_required 的文案不同,故本模块用本地 admin 闸保持逐字对齐。
说明:QRLoginManager 已异步化(直接 await);QRLoginClient 的 save_cookie/logout/
_get_cookie_info 仍为同步文件操作,经 asyncio.to_thread 执行。
说明:QRLoginManager 已异步化(直接 await)。QRLoginClient 的 save_cookie/logout/
_get_cookie_info 是**微秒级本地文件操作**——读写 1KB 级的 cookie.txt、os.replace、
unlink,全程不碰网络;对它们走 to_thread,线程调度开销比操作本身还大,且并不产生
任何并发收益。故直接同步调用(to_thread 在这里只是给同步 API 套了层异步外壳)。
"""
import asyncio
import logging
@@ -111,12 +113,12 @@ async def admin_qr_check(_admin: AdminUser, request: Request):
cookie = f'MUSIC_U={musu_u};os=pc;appver=8.9.70;'
if cookie_dict.get('__csrf'):
cookie += f"__csrf={cookie_dict['__csrf']};"
saved = await asyncio.to_thread(client.save_cookie, cookie)
saved = client.save_cookie(cookie)
if not saved:
return error('登录成功但 Cookie 保存失败', 500)
_reset_caches()
logger.info('二维码登录成功,Cookie 已自动保存')
info = await asyncio.to_thread(client._get_cookie_info)
info = client._get_cookie_info()
return success({'status': 'success', 'cookie_info': info}, '登录成功,Cookie 已保存')
if code == 801:
return success({'status': 'waiting'}, '等待扫码')
@@ -133,7 +135,7 @@ async def admin_cookie_status(_admin: AdminUser, request: Request):
await ratelimit.guard(request, bucket='free', limit=30, window=60)
try:
client = _admin_client()
info = await asyncio.to_thread(client._get_cookie_info)
info = client._get_cookie_info()
return success(info, '获取 Cookie 状态成功')
except Exception:
return error('服务暂时不可用,请稍后再试', 500)
@@ -145,15 +147,15 @@ async def admin_cookie_delete(_admin: AdminUser, request: Request):
await ratelimit.guard(request, bucket='free', limit=30, window=60)
try:
client = _admin_client()
ok = await asyncio.to_thread(client.logout)
ok = client.logout()
if not ok:
info = await asyncio.to_thread(client._get_cookie_info)
info = client._get_cookie_info()
return error(
f"Cookie 清除失败(文件仍存在:{info.get('file_exists')},"
f"可能被占用或为挂载点,请手动删除)", 500)
_reset_caches()
logger.info('管理员已清除 Cookie')
info = await asyncio.to_thread(client._get_cookie_info)
info = client._get_cookie_info()
return success(info, 'Cookie 已清除')
except Exception:
return error('服务暂时不可用,请稍后再试', 500)
+37 -15
View File
@@ -3,16 +3,20 @@
鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。
说明:这些端点多为对 auth_system 的薄封装。auth_system 已迁移为**原生异步**
(aiosqlite / aiosmtplib 语义下的 to_thread 卸载),故直接 await;仅 PBKDF2 校验
(CPU 密集)与本地文件 stat 仍经 asyncio.to_thread 卸载。操作者名取当前管理员用户名。
说明:这些端点多为对 auth_system 的薄封装。auth_system 已迁移为**原生异步**,
故直接 await。PBKDF2 校验属 CPU 密集且 CPython 在迭代里**释放 GIL**,是这里唯一
真正值得占用线程的活(唯有 to_thread 下它才是真并行)。本地文件操作的归位口径是
「重活留线程、微秒级直接同步」:备份上传是整库体量,按块流式落盘、仍走 to_thread;
stat / unlink 这类元数据操作,线程调度开销比操作本身还大,直接在事件循环里做完。
操作者名取当前管理员用户名。
"""
import asyncio
import math
import re
import secrets
import time
from typing import Annotated, Any
from pathlib import Path
from typing import Annotated, Any, BinaryIO
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
@@ -28,6 +32,29 @@ router = APIRouter()
_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'}
_EMAIL_RE = re.compile(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$')
# 备份上传的搬运块大小。上传的是**整库备份**,原实现 `content = await f.read()`
# 会把整库一次性读进内存(本端点对外没有大小上限),等于给上传方一个内存放大器;
# 改成逐块搬运后,内存占用恒为一块。
_UPLOAD_CHUNK = 1 << 20
_SQLITE_MAGIC = b'SQLite format 3'
def _save_upload_stream(src: BinaryIO, dest: Path) -> bytes:
"""把上传流按块写进 dest,并返回文件头(前 16 字节)。
**阻塞文件 IO**:只应由 asyncio.to_thread 调用。文件头在写第一块时就地截获,
省掉「落盘后再把文件重新打开读一次」的那趟回读。
"""
src.seek(0)
header = b''
with dest.open('wb') as out:
while chunk := src.read(_UPLOAD_CHUNK):
if len(header) < 16:
header += chunk[:16 - len(header)]
out.write(chunk)
return header
async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser:
if user is None or not user.is_admin:
@@ -442,12 +469,9 @@ async def backup_create(admin: AdminUser):
async def backup_list(_admin: AdminUser):
backups = await auth.list_backups()
interval = await auth.get_setting('backup_interval_hours', '0')
def _size() -> int:
return auth.db.DB_PATH.stat().st_size if auth.db.DB_PATH.exists() else 0
return success({'backups': backups, 'interval_hours': interval,
'db_size': await asyncio.to_thread(_size)})
# 主库文件的 stat:微秒级元数据操作,线程调度开销比它本身还大,直接同步做
db_size = auth.db.DB_PATH.stat().st_size if auth.db.DB_PATH.exists() else 0
return success({'backups': backups, 'interval_hours': interval, 'db_size': db_size})
@router.get('/admin/site/backup/download')
@@ -482,11 +506,9 @@ async def backup_upload(admin: AdminUser, request: Request):
name = f'{base}-{k}.db'
k += 1
dest = auth.db.BACKUP_DIR / name
content = await f.read()
await asyncio.to_thread(dest.write_bytes, content)
with dest.open('rb') as hf:
header = hf.read(16)
if not header.startswith(b'SQLite format 3'):
# 流式落盘:整库体量逐块搬运,文件头随第一块一起截获(不再回读一遍)
header = await asyncio.to_thread(_save_upload_stream, f.file, dest)
if not header.startswith(_SQLITE_MAGIC):
dest.unlink()
return error('不是有效的 SQLite 数据库文件')
await _log(admin, '上传数据库', dest.name)
+4 -3
View File
@@ -13,7 +13,6 @@
X-Download-Filename(URL 编码,供前端取回原始文件名)
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
"""
import asyncio
import os
from typing import Annotated, Any
from urllib.parse import quote
@@ -67,7 +66,8 @@ async def download_result(user: LoginUser, request: Request):
result = task.get('result') or {}
target = result.get('zip_path') or result.get('file_path')
target_exists = bool(target) and await asyncio.to_thread(os.path.exists, target)
# 微秒级单文件存在性判定,直接同步做(线程调度开销比它本身还大)
target_exists = bool(target) and os.path.exists(target) # noqa: ASYNC240
if status in ('canceled', 'error') and not target_exists:
return error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'),
200 if status == 'canceled' else 500)
@@ -95,7 +95,8 @@ async def download_batch_zip_result(user: LoginUser, request: Request):
status = task.get('status')
result = task.get('result') or {}
zip_path = result.get('zip_path')
has_deliverable = bool(zip_path) and await asyncio.to_thread(os.path.exists, zip_path)
# 同上:微秒级单文件存在性判定
has_deliverable = bool(zip_path) and os.path.exists(zip_path) # noqa: ASYNC240
if status in ('queued', 'running'):
return error('任务还在进行中,请稍后再试', 202)
if status == 'paused':