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