This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
snowy-yang e8cc7cb34a 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 残留
2026-09-29 15:20:51 +08:00

113 lines
4.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""结果交付端点。
端点:
- POST/GET /api/v1/download/result ↔ Flask /download/result(单曲)
- POST/GET /api/v1/download/batch_zip/result ↔ Flask /download/batch_zip/result(批量 ZIP)
语义对齐要点:
- 任务归属:仅任务本人的站点账号可领取(非本人 403)
- 状态语义:进行中 202 / 暂停 200+提示 / 取消 200+原因 / 失败 500+原因 /
文件缺失 404(与历史分支逐一对应)
- 交付物:FileResponse 流式发送;Cache-Control: no-store;
X-Download-Filename(URL 编码,供前端取回原始文件名)
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
"""
import os
from typing import Annotated, Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK
from web.deps import SiteUser, require_login
from web.request_utils import (
request_data as _request_data,
)
from web.response import error
router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)]
def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
return int(task.get('user_id') or 0) == int(user.id)
def _file_response(target: str, filename: str, mimetype: str) -> FileResponse:
resp = FileResponse(target, media_type=mimetype, filename=filename)
resp.headers['Cache-Control'] = 'no-store'
resp.headers['X-Download-Filename'] = quote(filename, safe='')
return resp
@router.api_route('/download/result', methods=['GET', 'POST'])
async def download_result(user: LoginUser, request: Request):
"""单曲下载结果:任务完成后流式返回文件。"""
data = await _request_data(request)
task_id = str(data.get('task_id') or '').strip()
if not task_id:
return error('缺少 task_id 参数')
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS.get(task_id)
if not task or task.get('type') != 'single':
return error('任务不存在或已过期', 404)
if not _owned_by(task, user):
return error('无权下载该任务', 403)
status = task.get('status')
if status in ('queued', 'running'):
return error('任务还在进行中,请稍后再试', 202)
if status == 'paused':
return error('任务已暂停,请重新打开页面后恢复', 200)
result = task.get('result') or {}
target = result.get('zip_path') or result.get('file_path')
# 微秒级单文件存在性判定,直接同步做(线程调度开销比它本身还大)
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)
if not target_exists:
return error('文件不存在或已被清理', 404)
filename = result.get('filename') or 'download'
assert target is not None
return _file_response(target, filename, result.get('mimetype') or 'application/octet-stream')
@router.api_route('/download/batch_zip/result', methods=['GET', 'POST'])
async def download_batch_zip_result(user: LoginUser, request: Request):
"""批量打包结果:任务完成后流式返回 ZIP。"""
data = await _request_data(request)
task_id = str(data.get('task_id') or '').strip()
if not task_id:
return error('缺少 task_id 参数')
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS.get(task_id)
if not task or task.get('type') != 'batch':
return error('任务不存在或已过期', 404)
if not _owned_by(task, user):
return error('无权下载该任务', 403)
status = task.get('status')
result = task.get('result') or {}
zip_path = result.get('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':
return error('任务已暂停,请重新打开页面后恢复', 200)
# 取消/失败但已有部分成果的任务,成果必须可领取
if status in ('canceled', 'error') and not has_deliverable:
return error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'),
200 if status == 'canceled' else 500)
if not has_deliverable:
return error('打包文件不存在或已被清理', 404)
zip_name = result.get('filename') or '批量下载.zip'
assert zip_path is not None
return _file_response(zip_path, zip_name, 'application/zip')