You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iTi-Flask/iti/applications/routes/sys/file.py

333 lines
11 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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/<string:upload_id>", 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/<string:upload_id>")
@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/<string:upload_id>")
@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("/<string:file_id>")
@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("/<string:file_id>/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("/<string:file_id>/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("/<string:file_id>/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