# -*- 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')