|
|
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
|