from __future__ import annotations import base64 from apiflask import APIBlueprint from flask import make_response, request, send_file, url_for from flask_jwt_extended import jwt_required from iti.applications.common.utils import success from iti.applications.common.exceptions.biz_exp import BizException from iti.applications.models import SysFileSchema from iti.applications.service.sys_file import SysFileService from .schemas.file import FileUploadIn bp = APIBlueprint("sys_file", __name__, url_prefix="/file", tag="系统.文件管理") # --------------------------------------------------------------------------- # 普通上传(Uppy XHR / UniApp) # # CORS 说明: # - OPTIONS 预检请求需要手动处理(APIFlask 不自动处理) # - 生产环境建议使用 Flask-CORS 扩展统一配置 CORS # - 或在 Nginx/网关层处理 CORS 头部 # --------------------------------------------------------------------------- @bp.route("/upload", methods=["OPTIONS"]) def upload_options(): """处理 CORS 预检请求""" response = make_response("", 204) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" response.headers["Access-Control-Expose-Headers"] = "Content-Length" return response @bp.post("/upload") @jwt_required(optional=True) @bp.input(FileUploadIn, location="form_and_files") @bp.output(SysFileSchema) def upload_file(form_and_files_data): """普通文件上传(Uppy XHR / UniApp) 符合APIFlask推荐的文件上传方式,使用 @bp.input 装饰器声明输入。 参考:https://zh.apiflask.com/request/#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0 """ file = form_and_files_data["file"] directory_id = form_and_files_data.get("directory_id") storage_type = form_and_files_data.get("storage_type") # 提取额外的metadata字段(任何不在FileUploadIn schema中定义的字段) # 支持前端传入 width、height 等扩展属性 schema_fields = {"file", "directory_id", "directoryId", "storage_type", "storageType"} metadata = {} # 从表单数据和查询参数中提取额外字段 form_data = request.form.to_dict() query_data = request.args.to_dict() combined = {**query_data, **form_data} for key, value in combined.items(): # 跳过schema中已定义的字段 if key not in schema_fields: metadata[key] = value file_record = SysFileService.upload_file( file, directory_id=directory_id, metadata=metadata, storage_type=storage_type, ) return success(file_record) # --------------------------------------------------------------------------- # TUS 协议 # 注意:TUS协议接口未使用框架统一返回结构,原因如下: # 1. TUS 1.0.0 协议有严格的响应头要求(Tus-Resumable, Upload-Offset, Location等) # 2. 部分请求需要返回空响应体(HEAD, OPTIONS) # 3. 响应状态码有特殊含义(201创建, 204无内容, 409冲突等) # 4. 需要精确控制响应格式以兼容 Uppy 等客户端 # --------------------------------------------------------------------------- @bp.route("/upload/tus", methods=["OPTIONS"]) def tus_options(): response = make_response("", 204) response.headers.update( { "Tus-Resumable": "1.0.0", "Tus-Version": "1.0.0", "Tus-Extension": "creation,termination,creation-with-upload", "Tus-Max-Size": str(100 * 1024 * 1024 * 1024), "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, HEAD, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Upload-Offset, Upload-Length, Tus-Resumable, Upload-Metadata, Content-Type, Authorization", "Access-Control-Expose-Headers": "Upload-Offset, Location, Upload-Length, Tus-Resumable", } ) return response @bp.post("/upload/tus") @jwt_required(optional=True) def tus_create_upload(): if request.headers.get("Tus-Resumable") != "1.0.0": return _tus_error("不支持的 TUS 版本", 412) upload_length = request.headers.get("Upload-Length") if not upload_length: return _tus_error("缺少 Upload-Length 头", 400) metadata = _parse_tus_metadata(request.headers.get("Upload-Metadata")) filename = metadata.pop("filename", "unknown") directory_id = metadata.pop("directoryId", None) or metadata.pop( "directory_id", None ) file_hash = metadata.pop("fileHash", None) or metadata.pop("file_hash", None) storage_type = metadata.pop("storageType", None) or metadata.pop( "storage_type", None ) result = SysFileService.init_tus_upload( filename=filename, file_size=int(upload_length), file_hash=file_hash, directory_id=directory_id, metadata=metadata, storage_type=storage_type, ) if result.get("instant_upload"): schema = SysFileSchema() data = schema.dump(result["file"]) response = make_response({"success": True, "data": data}, 201) response.headers["Tus-Resumable"] = "1.0.0" response.headers["Access-Control-Allow-Origin"] = "*" return response upload_id = result["upload_id"] location = url_for("sys_file.tus_upload_chunk", upload_id=upload_id, _external=True) response = make_response("", 201) response.headers.update( { "Location": location, "Tus-Resumable": "1.0.0", "Upload-Offset": "0", "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Location, Upload-Offset", } ) return response @bp.route("/upload/tus/", methods=["HEAD"]) @jwt_required(optional=True) def tus_get_offset(upload_id: str): if request.headers.get("Tus-Resumable") != "1.0.0": return _tus_error("不支持的 TUS 版本", 412) try: progress = SysFileService.get_tus_upload_progress(upload_id) except BizException as exc: return _tus_error(str(exc), getattr(exc, "code", 404)) response = make_response("", 200) response.headers.update( { "Upload-Offset": str(progress["offset"]), "Upload-Length": str(progress["total_size"]), "Tus-Resumable": "1.0.0", "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Upload-Offset, Upload-Length", } ) return response @bp.patch("/upload/tus/") @jwt_required(optional=True) def tus_upload_chunk(upload_id: str): if request.headers.get("Tus-Resumable") != "1.0.0": return _tus_error("不支持的 TUS 版本", 412) upload_offset = request.headers.get("Upload-Offset") if upload_offset is None: return _tus_error("缺少 Upload-Offset 头", 400) chunk_data = request.get_data() try: result = SysFileService.upload_tus_chunk( upload_id, int(upload_offset), chunk_data ) except BizException as exc: return _tus_error(str(exc), getattr(exc, "code", 500)) response_headers = { "Upload-Offset": str(result["new_offset"]), "Tus-Resumable": "1.0.0", "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Upload-Offset", } if result.get("completed"): schema = SysFileSchema() data = schema.dump(result["file"]) response = make_response({"success": True, "data": data}, 200) response.headers.update(response_headers) return response response = make_response("", 204) response.headers.update(response_headers) return response @bp.delete("/upload/tus/") @jwt_required(optional=True) def tus_delete_upload(upload_id: str): if request.headers.get("Tus-Resumable") != "1.0.0": return _tus_error("不支持的 TUS 版本", 412) try: SysFileService.abort_tus_upload(upload_id) except BizException as exc: return _tus_error(str(exc), getattr(exc, "code", 404)) response = make_response("", 204) response.headers["Tus-Resumable"] = "1.0.0" response.headers["Access-Control-Allow-Origin"] = "*" return response # --------------------------------------------------------------------------- # 文件访问 # --------------------------------------------------------------------------- @bp.get("/") @jwt_required(optional=True) @bp.output(SysFileSchema) def get_file(file_id: str): """获取文件详情""" file_obj = SysFileService.get_file_by_id(file_id) return success(file_obj) @bp.get("//download") @jwt_required(optional=True) def download_file(file_id: str): """下载文件""" file_obj = SysFileService.get_file_by_id(file_id) file_stream = SysFileService.download_file(file_id) return send_file( file_stream, mimetype=file_obj.mime_type or "application/octet-stream", as_attachment=True, download_name=file_obj.filename, ) @bp.get("//preview") @jwt_required(optional=True) def preview_file(file_id: str): """预览文件""" file_obj = SysFileService.get_file_by_id(file_id) # OSS文件直接重定向到OSS URL if file_obj.storage_type != "local": oss_url = SysFileService.get_file_url(file_id, expires=3600) from flask import redirect return redirect(oss_url) # 本地文件直接返回 file_stream = SysFileService.download_file(file_id) return send_file( file_stream, mimetype=file_obj.mime_type or "application/octet-stream", as_attachment=False, # 预览模式 ) @bp.get("//thumbnail") @jwt_required(optional=True) def thumbnail_file(file_id: str): """获取缩略图""" from flask import request # 获取缩略图参数(参考阿里云OSS风格) width = request.args.get("w", type=int) or 200 height = request.args.get("h", type=int) or 200 mode = request.args.get("mode", "fit") # fit/fill/pad thumbnail_stream = SysFileService.get_thumbnail( file_id, width=width, height=height, mode=mode ) return send_file( thumbnail_stream, mimetype="image/jpeg", as_attachment=False, ) # --------------------------------------------------------------------------- # 工具函数 # --------------------------------------------------------------------------- def _parse_tus_metadata(metadata_header: str) -> dict: if not metadata_header: return {} result = {} for item in metadata_header.split(","): if " " not in item: continue key, value = item.split(" ", 1) try: decoded = base64.b64decode(value).decode("utf-8") except Exception: decoded = value result[key] = decoded return result def _tus_error(message: str, status: int): response = make_response({"error": message}, status) response.headers["Tus-Resumable"] = "1.0.0" response.headers["Access-Control-Allow-Origin"] = "*" return response