"""内存结果共用的 HTTP 下载响应,适用于图像字节和 JSON。""" import hashlib import secrets from email.utils import formatdate from fastapi import Request from fastapi.responses import FileResponse, Response class ResultFileResponse(FileResponse): """文件响应结束时释放既有下载租约,包括 416 和连接中断路径。""" async def __call__(self, scope, receive, send): """发送文件并保证后台租约释放动作只执行一次。 Args: scope: ASGI 请求上下文。 receive: ASGI 请求消息接收函数。 send: ASGI 响应消息发送函数。 """ release = self.background self.background = None if scope["method"] == "HEAD": # Range 只用于 GET;HEAD 应提供完整结果的长度和校验信息。 scope = {**scope, "headers": [(name, value) for name, value in scope["headers"] if name.lower() != b"range"]} try: await super().__call__(scope, receive, send) finally: # FileResponse 的范围错误会提前返回,不会运行通常的 background。 if release is not None: await release() def result_bytes_response(request: Request, content: bytes, media_type: str, modified_at: float) -> Response: """返回带校验信息、范围读取和 HEAD 支持的不可变结果。 Args: request: 当前 GET 或 HEAD 请求。 content: 已在任务清理锁内读出的完整结果,发送期间不再依赖文件。 media_type: 结果的 MIME 类型。 modified_at: 任务完成时间,作为稳定的 Last-Modified。 Returns: 完整结果、206 范围结果或 416 越界响应;HEAD 始终不含响应体。 """ size = len(content) etag = '"' + hashlib.sha256(content).hexdigest() + '"' modified = formatdate(modified_at, usegmt=True) headers = { "ETag": etag, "Last-Modified": modified, "Accept-Ranges": "bytes", "Cache-Control": "private, max-age=300", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": ( "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified" ), } status_code = 200 range_header = request.headers.get("range") # If-Range 不匹配时发送完整的新结果,避免把两个版本拼成一个文件。 if (request.method == "GET" and range_header and request.headers.get("if-range", etag) in {etag, modified}): try: unit, ranges_text = range_header.split("=", 1) ranges = [] if unit.strip() != "bytes" or len(ranges_text.split(",")) > 100: raise ValueError("Unsupported range") for part in ranges_text.split(","): start_text, end_text = part.strip().split("-", 1) if not start_text: suffix = int(end_text) if suffix <= 0: raise ValueError("Empty suffix") start, end = max(0, size - suffix), size - 1 else: start = int(start_text) end = min(int(end_text), size - 1) if end_text else size - 1 if start < 0 or start > end or start >= size: raise ValueError("Unsatisfiable range") ranges.append((start, end)) except ValueError: headers["Content-Range"] = f"bytes */{size}" return Response(status_code=416, headers=headers) status_code = 206 if len(ranges) == 1: start, end = ranges[0] headers["Content-Range"] = f"bytes {start}-{end}/{size}" content = content[start:end + 1] else: boundary = secrets.token_hex(16) content = b"".join( (f"--{boundary}\r\nContent-Type: {media_type}\r\n" f"Content-Range: bytes {start}-{end}/{size}\r\n\r\n").encode() + content[start:end + 1] + b"\r\n" for start, end in ranges ) + f"--{boundary}--\r\n".encode() media_type = f"multipart/byteranges; boundary={boundary}" headers["Content-Length"] = str(len(content)) return Response(b"" if request.method == "HEAD" else content, status_code=status_code, media_type=media_type, headers=headers)