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

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