feat(routes): 迁移音频流代理与结果交付端点(流式)

- web/routers/stream.py:/api/v1/audio_stream 音频范围流代理
  · 签名校验(core.security,密钥同源两栈互认);HEAD 直接 200 不请求上游
  · Range 原样透传上游(206 + Content-Range,拖动进度可用)
  · 编码透明:上游带 Content-Encoding 时不沿用压缩前长度(同 Flask F92)
  · StreamingResponse 16KB 分块,finally 关闭上游(下游中断亦释放)
- core/http_client.py 增 open():返回未关闭流式响应,便于先读状态码再转发
- web/routers/delivery.py:/download/result、/download/batch_zip/result
  · 归属 403 / 进行中 202 / 暂停 200 / 取消 200 / 失败 500 / 缺失 404 分支逐一对齐
  · FileResponse 流式交付 + no-store + X-Download-Filename;清理由监控线程统一回收

验证:pytest 51/51(新增 5 项流代理:无效 token/HEAD/全量/Range 透传/上游关闭);
ruff All checks passed;pyright 0 errors;奇偶校验 26/26;实测 5.2MB 流与
Range 206 正确。
This commit is contained in:
snowy-yang committed 2026-09-26 09:49:22 +08:00
1 parent f588d313f0
commit 0104ae55e5
5 files changed
+326

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""结果交付端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
已迁移:
- 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(与 Flask 分支逐一对应)
- 交付物:FileResponse 流式发送;Cache-Control: no-store;
X-Download-Filename(URL 编码,供前端取回原始文件名)
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
"""
import asyncio
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.response import error
router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)]
async def _request_data(request: Request) -> dict[str, Any]:
data: dict[str, Any] = {}
if request.method == 'GET':
data.update(dict(request.query_params))
return data
try:
body = await request.json()
if isinstance(body, dict):
data.update(body)
except Exception:
pass
if not data:
try:
form = await request.form()
data.update(dict(form.items()))
except Exception:
pass
return data
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 await asyncio.to_thread(os.path.exists, target)
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 await asyncio.to_thread(os.path.exists, zip_path)
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')