856 lines
29 KiB
Python
856 lines
29 KiB
Python
|
|
"""
|
|||
|
|
MinIO 文件存储 REST API 接口
|
|||
|
|
提供标准的文件上传、下载、删除、预览、Bucket 管理等操作
|
|||
|
|
"""
|
|||
|
|
import base64
|
|||
|
|
import mimetypes
|
|||
|
|
import os
|
|||
|
|
import posixpath
|
|||
|
|
import zipfile
|
|||
|
|
from pathlib import Path
|
|||
|
|
from tempfile import NamedTemporaryFile
|
|||
|
|
from urllib.parse import quote
|
|||
|
|
|
|||
|
|
from django.conf import settings
|
|||
|
|
from django.http import Http404, StreamingHttpResponse
|
|||
|
|
from minio.error import S3Error
|
|||
|
|
from rest_framework.decorators import api_view, parser_classes, permission_classes
|
|||
|
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
|||
|
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
|||
|
|
from rest_framework.response import Response
|
|||
|
|
|
|||
|
|
from apps.common.minio_client import ensure_bucket, minio_storage
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import cv2
|
|||
|
|
except Exception:
|
|||
|
|
cv2 = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
DEFAULT_BUCKET = settings.MINIO_DEFAULT_BUCKET
|
|||
|
|
IMAGE_EXTENSIONS = {
|
|||
|
|
".jpg",
|
|||
|
|
".jpeg",
|
|||
|
|
".png",
|
|||
|
|
".bmp",
|
|||
|
|
".gif",
|
|||
|
|
".webp",
|
|||
|
|
".tif",
|
|||
|
|
".tiff",
|
|||
|
|
}
|
|||
|
|
VIDEO_EXTENSIONS = {
|
|||
|
|
".mp4",
|
|||
|
|
".avi",
|
|||
|
|
".mov",
|
|||
|
|
".mkv",
|
|||
|
|
".wmv",
|
|||
|
|
".flv",
|
|||
|
|
".mpeg",
|
|||
|
|
".mpg",
|
|||
|
|
".webm",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_expires(value) -> int:
|
|||
|
|
"""标准化预签名过期时间,限制在 1 到 7 天之间。"""
|
|||
|
|
try:
|
|||
|
|
expires = int(value)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
raise ValueError("expires 必须是正整数")
|
|||
|
|
|
|||
|
|
if expires <= 0:
|
|||
|
|
raise ValueError("expires 必须大于 0")
|
|||
|
|
return min(expires, 7 * 24 * 3600)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _stream_object(response, chunk_size: int = 1024 * 1024):
|
|||
|
|
"""以分块方式转发 MinIO 对象流,避免整文件读入内存。"""
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
chunk = response.read(chunk_size)
|
|||
|
|
if not chunk:
|
|||
|
|
break
|
|||
|
|
yield chunk
|
|||
|
|
finally:
|
|||
|
|
response.close()
|
|||
|
|
response.release_conn()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _file_content_type(filename: str, fallback: str = "application/octet-stream") -> str:
|
|||
|
|
return mimetypes.guess_type(filename)[0] or fallback
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_bool(value, default: bool = False) -> bool:
|
|||
|
|
if value is None:
|
|||
|
|
return default
|
|||
|
|
if isinstance(value, bool):
|
|||
|
|
return value
|
|||
|
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_int(value, field_name: str, default=None, minimum: int | None = None) -> int:
|
|||
|
|
if value in (None, ""):
|
|||
|
|
if default is None:
|
|||
|
|
raise ValueError(f"{field_name} 不能为空")
|
|||
|
|
result = default
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
result = int(value)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
raise ValueError(f"{field_name} 必须是整数")
|
|||
|
|
|
|||
|
|
if minimum is not None and result < minimum:
|
|||
|
|
raise ValueError(f"{field_name} 必须大于等于 {minimum}")
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_join_prefix(prefix: str, name: str) -> str:
|
|||
|
|
prefix = (prefix or "").strip().strip("/")
|
|||
|
|
name = name.strip().lstrip("/")
|
|||
|
|
return f"{prefix}/{name}" if prefix else name
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_zip_object_name(prefix: str, member_name: str) -> str:
|
|||
|
|
normalized = member_name.replace("\\", "/")
|
|||
|
|
safe_parts = []
|
|||
|
|
for part in normalized.split("/"):
|
|||
|
|
if not part or part in {".", ".."}:
|
|||
|
|
continue
|
|||
|
|
safe_parts.append(part)
|
|||
|
|
if not safe_parts:
|
|||
|
|
raise ValueError("ZIP 中存在非法文件路径")
|
|||
|
|
return _safe_join_prefix(prefix, "/".join(safe_parts))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iter_zip_upload_items(uploaded_file, prefix: str, allowed_extensions: set[str] | None = None):
|
|||
|
|
with zipfile.ZipFile(uploaded_file) as zip_file:
|
|||
|
|
for member in zip_file.infolist():
|
|||
|
|
if member.is_dir():
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
filename = Path(member.filename).name
|
|||
|
|
if not filename or filename.startswith("."):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
ext = Path(filename).suffix.lower()
|
|||
|
|
if allowed_extensions and ext not in allowed_extensions:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
object_name = _safe_zip_object_name(prefix, member.filename)
|
|||
|
|
with zip_file.open(member, "r") as file_obj:
|
|||
|
|
yield {
|
|||
|
|
"member_name": member.filename,
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"size": member.file_size,
|
|||
|
|
"content_type": _file_content_type(filename),
|
|||
|
|
"stream": file_obj,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _map_s3_error(exc: S3Error, default_message: str = "MinIO 操作失败"):
|
|||
|
|
"""将常见 MinIO 错误映射为更友好的 HTTP 响应。"""
|
|||
|
|
error_code = getattr(exc, "code", "") or ""
|
|||
|
|
status_code = 500
|
|||
|
|
message = default_message
|
|||
|
|
|
|||
|
|
if error_code in {"NoSuchBucket"}:
|
|||
|
|
status_code = 404
|
|||
|
|
message = "Bucket 不存在"
|
|||
|
|
elif error_code in {"NoSuchKey", "NoSuchObject", "NoSuchVersion"}:
|
|||
|
|
status_code = 404
|
|||
|
|
message = "文件不存在"
|
|||
|
|
elif error_code in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
|
|||
|
|
status_code = 409
|
|||
|
|
message = "Bucket 已存在"
|
|||
|
|
elif error_code in {"BucketNotEmpty"}:
|
|||
|
|
status_code = 409
|
|||
|
|
message = "Bucket 非空,无法删除"
|
|||
|
|
elif error_code in {"InvalidBucketName"}:
|
|||
|
|
status_code = 400
|
|||
|
|
message = "Bucket 名称不合法"
|
|||
|
|
elif error_code in {"AccessDenied"}:
|
|||
|
|
status_code = 403
|
|||
|
|
message = "访问被拒绝"
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{"code": status_code, "msg": message, "detail": str(exc)},
|
|||
|
|
status=status_code,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Bucket 管理 ──────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_bucket_create(request):
|
|||
|
|
"""创建 Bucket"""
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
try:
|
|||
|
|
created = minio_storage.create_bucket(bucket_name)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "Bucket 创建失败")
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 200,
|
|||
|
|
"data": {"bucket_name": bucket_name, "created": created},
|
|||
|
|
"msg": "Bucket 已存在" if not created else "Bucket 创建成功",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_bucket_list(request):
|
|||
|
|
"""列出所有 Bucket"""
|
|||
|
|
try:
|
|||
|
|
buckets = minio_storage.list_buckets()
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "Bucket 列表获取失败")
|
|||
|
|
return Response({"code": 200, "data": buckets, "msg": "获取成功"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["DELETE"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_bucket_delete(request):
|
|||
|
|
"""删除 Bucket"""
|
|||
|
|
bucket_name = request.data.get("bucket_name", "")
|
|||
|
|
if not bucket_name:
|
|||
|
|
return Response({"code": 400, "msg": "bucket_name 不能为空"}, status=400)
|
|||
|
|
try:
|
|||
|
|
removed = minio_storage.remove_bucket(bucket_name)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "Bucket 删除失败")
|
|||
|
|
if removed:
|
|||
|
|
return Response({"code": 200, "data": {"bucket_name": bucket_name}, "msg": "Bucket 已删除"})
|
|||
|
|
return Response({"code": 404, "msg": "Bucket 不存在"}, status=404)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 文件上传 ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([MultiPartParser, FormParser])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_upload(request):
|
|||
|
|
"""上传单个文件到 MinIO
|
|||
|
|
|
|||
|
|
POST /server/minio/file/upload/
|
|||
|
|
FormData:
|
|||
|
|
file: 文件
|
|||
|
|
bucket_name: Bucket 名称(可选,默认 DEFAULT_BUCKET)
|
|||
|
|
prefix: 存储路径前缀(可选)
|
|||
|
|
object_name: 指定对象名(可选,不传则自动生成)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
{object_name, bucket_name, presigned_url, size, content_type}
|
|||
|
|
"""
|
|||
|
|
uploaded_file = request.FILES.get("file")
|
|||
|
|
if not uploaded_file:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 file 字段"}, status=400)
|
|||
|
|
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.data.get("prefix", "")
|
|||
|
|
custom_name = request.data.get("object_name", "")
|
|||
|
|
|
|||
|
|
if custom_name:
|
|||
|
|
object_name = custom_name
|
|||
|
|
else:
|
|||
|
|
object_name = minio_storage.generate_object_name(uploaded_file.name, prefix)
|
|||
|
|
|
|||
|
|
content_type = uploaded_file.content_type or _file_content_type(uploaded_file.name)
|
|||
|
|
try:
|
|||
|
|
minio_storage.upload_stream(
|
|||
|
|
bucket_name,
|
|||
|
|
object_name,
|
|||
|
|
uploaded_file.file,
|
|||
|
|
uploaded_file.size,
|
|||
|
|
content_type=content_type,
|
|||
|
|
)
|
|||
|
|
presigned_url = minio_storage.presign_get(
|
|||
|
|
bucket_name, object_name, settings.MINIO_PRESIGN_EXPIRES
|
|||
|
|
)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "文件上传失败")
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 200,
|
|||
|
|
"data": {
|
|||
|
|
"bucket_name": bucket_name,
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"original_name": uploaded_file.name,
|
|||
|
|
"size": uploaded_file.size,
|
|||
|
|
"content_type": content_type,
|
|||
|
|
"presigned_url": presigned_url,
|
|||
|
|
},
|
|||
|
|
"msg": "上传成功",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([MultiPartParser, FormParser])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_batch_upload(request):
|
|||
|
|
"""批量上传文件
|
|||
|
|
|
|||
|
|
POST /server/minio/files/upload/
|
|||
|
|
FormData:
|
|||
|
|
files: 多文件
|
|||
|
|
bucket_name: Bucket 名称(可选)
|
|||
|
|
prefix: 存储路径前缀(可选)
|
|||
|
|
"""
|
|||
|
|
files = request.FILES.getlist("files")
|
|||
|
|
if not files:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 files 字段"}, status=400)
|
|||
|
|
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.data.get("prefix", "")
|
|||
|
|
|
|||
|
|
results = []
|
|||
|
|
try:
|
|||
|
|
for f in files:
|
|||
|
|
object_name = minio_storage.generate_object_name(f.name, prefix)
|
|||
|
|
content_type = f.content_type or _file_content_type(f.name)
|
|||
|
|
minio_storage.upload_stream(
|
|||
|
|
bucket_name,
|
|||
|
|
object_name,
|
|||
|
|
f.file,
|
|||
|
|
f.size,
|
|||
|
|
content_type=content_type,
|
|||
|
|
)
|
|||
|
|
presigned_url = minio_storage.presign_get(
|
|||
|
|
bucket_name, object_name, settings.MINIO_PRESIGN_EXPIRES
|
|||
|
|
)
|
|||
|
|
results.append(
|
|||
|
|
{
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"original_name": f.name,
|
|||
|
|
"size": f.size,
|
|||
|
|
"content_type": content_type,
|
|||
|
|
"presigned_url": presigned_url,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "批量上传失败")
|
|||
|
|
|
|||
|
|
return Response({"code": 200, "data": results, "msg": f"全部上传成功,共 {len(results)} 个文件"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([MultiPartParser, FormParser])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_zip_upload_extract(request):
|
|||
|
|
"""上传 ZIP 文件并自动解压批量入库。"""
|
|||
|
|
uploaded_file = request.FILES.get("file")
|
|||
|
|
if not uploaded_file:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 file 字段"}, status=400)
|
|||
|
|
|
|||
|
|
if Path(uploaded_file.name).suffix.lower() != ".zip":
|
|||
|
|
return Response({"code": 400, "msg": "仅支持上传 zip 文件"}, status=400)
|
|||
|
|
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.data.get("prefix", "")
|
|||
|
|
images_only = _normalize_bool(request.data.get("images_only"), default=False)
|
|||
|
|
|
|||
|
|
uploaded = []
|
|||
|
|
skipped = []
|
|||
|
|
try:
|
|||
|
|
for item in _iter_zip_upload_items(
|
|||
|
|
uploaded_file,
|
|||
|
|
prefix,
|
|||
|
|
allowed_extensions=IMAGE_EXTENSIONS if images_only else None,
|
|||
|
|
):
|
|||
|
|
minio_storage.upload_stream(
|
|||
|
|
bucket_name=bucket_name,
|
|||
|
|
object_name=item["object_name"],
|
|||
|
|
data=item["stream"],
|
|||
|
|
length=item["size"],
|
|||
|
|
content_type=item["content_type"],
|
|||
|
|
)
|
|||
|
|
uploaded.append(
|
|||
|
|
{
|
|||
|
|
"source_name": item["member_name"],
|
|||
|
|
"object_name": item["object_name"],
|
|||
|
|
"size": item["size"],
|
|||
|
|
"content_type": item["content_type"],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
except zipfile.BadZipFile:
|
|||
|
|
return Response({"code": 400, "msg": "zip 文件格式无效或已损坏"}, status=400)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
return Response({"code": 400, "msg": str(exc)}, status=400)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "ZIP 解压入库失败")
|
|||
|
|
|
|||
|
|
if images_only:
|
|||
|
|
try:
|
|||
|
|
with zipfile.ZipFile(uploaded_file) as zip_file:
|
|||
|
|
for member in zip_file.infolist():
|
|||
|
|
if member.is_dir():
|
|||
|
|
continue
|
|||
|
|
filename = Path(member.filename).name
|
|||
|
|
if not filename or filename.startswith("."):
|
|||
|
|
continue
|
|||
|
|
if Path(filename).suffix.lower() not in IMAGE_EXTENSIONS:
|
|||
|
|
skipped.append(member.filename)
|
|||
|
|
except zipfile.BadZipFile:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 200,
|
|||
|
|
"data": {
|
|||
|
|
"bucket_name": bucket_name,
|
|||
|
|
"zip_name": uploaded_file.name,
|
|||
|
|
"uploaded_count": len(uploaded),
|
|||
|
|
"uploaded": uploaded,
|
|||
|
|
"skipped": skipped,
|
|||
|
|
},
|
|||
|
|
"msg": f"ZIP 解压入库成功,共上传 {len(uploaded)} 个文件",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([MultiPartParser, FormParser])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_video_extract_frames(request):
|
|||
|
|
"""上传视频文件并按规则抽帧后存储到 MinIO。"""
|
|||
|
|
if cv2 is None:
|
|||
|
|
return Response({"code": 500, "msg": "opencv-python 未安装,无法处理视频"}, status=500)
|
|||
|
|
|
|||
|
|
uploaded_file = request.FILES.get("file")
|
|||
|
|
if not uploaded_file:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 file 字段"}, status=400)
|
|||
|
|
|
|||
|
|
video_ext = Path(uploaded_file.name).suffix.lower()
|
|||
|
|
if video_ext not in VIDEO_EXTENSIONS:
|
|||
|
|
return Response({"code": 400, "msg": "不支持的视频格式"}, status=400)
|
|||
|
|
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.data.get("prefix", "")
|
|||
|
|
image_format = (request.data.get("image_format", "jpg") or "jpg").lower().strip(".")
|
|||
|
|
if image_format not in {"jpg", "jpeg", "png"}:
|
|||
|
|
return Response({"code": 400, "msg": "image_format 仅支持 jpg/jpeg/png"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
interval_seconds = _normalize_int(
|
|||
|
|
request.data.get("interval_seconds"),
|
|||
|
|
"interval_seconds",
|
|||
|
|
default=1,
|
|||
|
|
minimum=1,
|
|||
|
|
)
|
|||
|
|
start_second = _normalize_int(
|
|||
|
|
request.data.get("start_second"),
|
|||
|
|
"start_second",
|
|||
|
|
default=0,
|
|||
|
|
minimum=0,
|
|||
|
|
)
|
|||
|
|
max_frames = _normalize_int(
|
|||
|
|
request.data.get("max_frames"),
|
|||
|
|
"max_frames",
|
|||
|
|
default=100,
|
|||
|
|
minimum=1,
|
|||
|
|
)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
return Response({"code": 400, "msg": str(exc)}, status=400)
|
|||
|
|
|
|||
|
|
end_second_raw = request.data.get("end_second")
|
|||
|
|
try:
|
|||
|
|
end_second = (
|
|||
|
|
_normalize_int(end_second_raw, "end_second", minimum=0) if end_second_raw not in (None, "") else None
|
|||
|
|
)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
return Response({"code": 400, "msg": str(exc)}, status=400)
|
|||
|
|
|
|||
|
|
if end_second is not None and end_second < start_second:
|
|||
|
|
return Response({"code": 400, "msg": "end_second 不能小于 start_second"}, status=400)
|
|||
|
|
|
|||
|
|
temp_path = None
|
|||
|
|
cap = None
|
|||
|
|
try:
|
|||
|
|
with NamedTemporaryFile(delete=False, suffix=video_ext) as temp_file:
|
|||
|
|
for chunk in uploaded_file.chunks():
|
|||
|
|
temp_file.write(chunk)
|
|||
|
|
temp_path = temp_file.name
|
|||
|
|
|
|||
|
|
cap = cv2.VideoCapture(temp_path)
|
|||
|
|
if not cap.isOpened():
|
|||
|
|
return Response({"code": 400, "msg": "视频文件无法打开"}, status=400)
|
|||
|
|
|
|||
|
|
fps = cap.get(cv2.CAP_PROP_FPS) or 0
|
|||
|
|
if fps <= 0:
|
|||
|
|
fps = 25.0
|
|||
|
|
|
|||
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
|||
|
|
duration_seconds = total_frames / fps if total_frames > 0 else 0
|
|||
|
|
|
|||
|
|
step_frames = max(int(round(interval_seconds * fps)), 1)
|
|||
|
|
start_frame = int(round(start_second * fps))
|
|||
|
|
end_frame = int(round(end_second * fps)) if end_second is not None else total_frames
|
|||
|
|
|
|||
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|||
|
|
current_frame = start_frame
|
|||
|
|
extracted = []
|
|||
|
|
video_stem = Path(uploaded_file.name).stem
|
|||
|
|
frame_prefix = _safe_join_prefix(prefix, f"{video_stem}_frames")
|
|||
|
|
|
|||
|
|
while len(extracted) < max_frames:
|
|||
|
|
ok, frame = cap.read()
|
|||
|
|
if not ok:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if end_frame and current_frame > end_frame:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if (current_frame - start_frame) % step_frames == 0:
|
|||
|
|
suffix = "jpg" if image_format == "jpeg" else image_format
|
|||
|
|
encode_ext = ".jpg" if image_format in {"jpg", "jpeg"} else ".png"
|
|||
|
|
ok_encode, encoded = cv2.imencode(encode_ext, frame)
|
|||
|
|
if ok_encode:
|
|||
|
|
frame_time = current_frame / fps
|
|||
|
|
object_name = _safe_join_prefix(
|
|||
|
|
frame_prefix,
|
|||
|
|
f"frame_{len(extracted) + 1:05d}_{int(round(frame_time * 1000))}ms.{suffix}",
|
|||
|
|
)
|
|||
|
|
minio_storage.upload_bytes(
|
|||
|
|
bucket_name=bucket_name,
|
|||
|
|
object_name=object_name,
|
|||
|
|
data=encoded.tobytes(),
|
|||
|
|
content_type=_file_content_type(f"frame.{suffix}"),
|
|||
|
|
)
|
|||
|
|
extracted.append(
|
|||
|
|
{
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"frame_index": current_frame,
|
|||
|
|
"time_second": round(frame_time, 3),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
current_frame += 1
|
|||
|
|
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "视频抽帧入库失败")
|
|||
|
|
finally:
|
|||
|
|
if cap is not None:
|
|||
|
|
cap.release()
|
|||
|
|
if temp_path and os.path.exists(temp_path):
|
|||
|
|
os.unlink(temp_path)
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 200,
|
|||
|
|
"data": {
|
|||
|
|
"bucket_name": bucket_name,
|
|||
|
|
"video_name": uploaded_file.name,
|
|||
|
|
"fps": round(fps, 3),
|
|||
|
|
"duration_seconds": round(duration_seconds, 3),
|
|||
|
|
"interval_seconds": interval_seconds,
|
|||
|
|
"start_second": start_second,
|
|||
|
|
"end_second": end_second,
|
|||
|
|
"max_frames": max_frames,
|
|||
|
|
"extracted_count": len(extracted),
|
|||
|
|
"extracted": extracted,
|
|||
|
|
},
|
|||
|
|
"msg": f"视频抽帧入库成功,共生成 {len(extracted)} 张图片",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@parser_classes([JSONParser])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_upload_bytes(request):
|
|||
|
|
"""上传 base64 编码的文件内容
|
|||
|
|
|
|||
|
|
POST /server/minio/file/upload-base64/
|
|||
|
|
Body:
|
|||
|
|
{
|
|||
|
|
"file_base64": "base64编码的文件内容",
|
|||
|
|
"filename": "example.jpg",
|
|||
|
|
"bucket_name": "ai-trainprediction", // 可选
|
|||
|
|
"prefix": "images" // 可选
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
file_base64 = request.data.get("file_base64", "")
|
|||
|
|
filename = request.data.get("filename", "file.bin")
|
|||
|
|
if not file_base64:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 file_base64 字段"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
file_data = base64.b64decode(file_base64)
|
|||
|
|
except Exception:
|
|||
|
|
return Response({"code": 400, "msg": "file_base64 解码失败"}, status=400)
|
|||
|
|
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.data.get("prefix", "")
|
|||
|
|
object_name = minio_storage.generate_object_name(filename, prefix)
|
|||
|
|
|
|||
|
|
content_type = _file_content_type(filename)
|
|||
|
|
try:
|
|||
|
|
minio_storage.upload_bytes(bucket_name, object_name, file_data, content_type=content_type)
|
|||
|
|
presigned_url = minio_storage.presign_get(
|
|||
|
|
bucket_name, object_name, settings.MINIO_PRESIGN_EXPIRES
|
|||
|
|
)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "Base64 文件上传失败")
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 200,
|
|||
|
|
"data": {
|
|||
|
|
"bucket_name": bucket_name,
|
|||
|
|
"object_name": object_name,
|
|||
|
|
"original_name": filename,
|
|||
|
|
"size": len(file_data),
|
|||
|
|
"content_type": content_type,
|
|||
|
|
"presigned_url": presigned_url,
|
|||
|
|
},
|
|||
|
|
"msg": "上传成功",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 文件下载 ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_download(request):
|
|||
|
|
"""下载文件(返回文件流)
|
|||
|
|
|
|||
|
|
GET /server/minio/file/download/?bucket_name=xxx&object_name=xxx
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.GET.get("object_name", "")
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
filename = os.path.basename(object_name)
|
|||
|
|
try:
|
|||
|
|
info = minio_storage.get_object_info(bucket_name, object_name)
|
|||
|
|
obj = minio_storage.get_object(bucket_name, object_name)
|
|||
|
|
except S3Error:
|
|||
|
|
raise Http404("文件不存在")
|
|||
|
|
|
|||
|
|
response = StreamingHttpResponse(
|
|||
|
|
_stream_object(obj),
|
|||
|
|
content_type=info["content_type"] or _file_content_type(filename),
|
|||
|
|
)
|
|||
|
|
response["Content-Disposition"] = (
|
|||
|
|
f'attachment; filename="{quote(filename)}"; '
|
|||
|
|
f"filename*=UTF-8''{quote(filename)}"
|
|||
|
|
)
|
|||
|
|
response["Content-Length"] = str(info["size"])
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_presign(request):
|
|||
|
|
"""获取文件预签名下载 URL
|
|||
|
|
|
|||
|
|
GET /server/minio/file/presign/?bucket_name=xxx&object_name=xxx&expires=3600
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.GET.get("object_name", "")
|
|||
|
|
try:
|
|||
|
|
expires = _normalize_expires(
|
|||
|
|
request.GET.get("expires", settings.MINIO_PRESIGN_EXPIRES)
|
|||
|
|
)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
return Response({"code": 400, "msg": str(exc)}, status=400)
|
|||
|
|
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
if not minio_storage.object_exists(bucket_name, object_name):
|
|||
|
|
return Response({"code": 404, "msg": "文件不存在"}, status=404)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
url = minio_storage.presign_get(bucket_name, object_name, expires)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "预签名下载地址生成失败")
|
|||
|
|
return Response({"code": 200, "data": {"presigned_url": url, "expires_in": expires}, "msg": "获取成功"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_presign_upload(request):
|
|||
|
|
"""获取文件预签名上传 URL"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.GET.get("object_name", "")
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
expires = _normalize_expires(
|
|||
|
|
request.GET.get("expires", settings.MINIO_PRESIGN_EXPIRES)
|
|||
|
|
)
|
|||
|
|
ensure_bucket(minio_storage, bucket_name)
|
|||
|
|
url = minio_storage.presign_upload(bucket_name, object_name, expires)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
return Response({"code": 400, "msg": str(exc)}, status=400)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "预签名上传地址生成失败")
|
|||
|
|
|
|||
|
|
return Response(
|
|||
|
|
{"code": 200, "data": {"presigned_url": url, "expires_in": expires}, "msg": "获取成功"}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_preview(request):
|
|||
|
|
"""预览文件(返回文件流用于浏览器直接显示)
|
|||
|
|
|
|||
|
|
GET /server/minio/file/preview/?bucket_name=xxx&object_name=xxx
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.GET.get("object_name", "")
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
filename = os.path.basename(object_name)
|
|||
|
|
try:
|
|||
|
|
info = minio_storage.get_object_info(bucket_name, object_name)
|
|||
|
|
obj = minio_storage.get_object(bucket_name, object_name)
|
|||
|
|
except S3Error:
|
|||
|
|
raise Http404("文件不存在")
|
|||
|
|
|
|||
|
|
response = StreamingHttpResponse(
|
|||
|
|
_stream_object(obj),
|
|||
|
|
content_type=info["content_type"] or _file_content_type(filename),
|
|||
|
|
)
|
|||
|
|
response["Content-Length"] = str(info["size"])
|
|||
|
|
response["Content-Disposition"] = f'inline; filename="{quote(filename)}"'
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 文件信息 & 列表 ──────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_info(request):
|
|||
|
|
"""获取文件元信息
|
|||
|
|
|
|||
|
|
GET /server/minio/file/info/?bucket_name=xxx&object_name=xxx
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.GET.get("object_name", "")
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
info = minio_storage.get_object_info(bucket_name, object_name)
|
|||
|
|
presigned_url = minio_storage.presign_get(
|
|||
|
|
bucket_name, object_name, settings.MINIO_PRESIGN_EXPIRES
|
|||
|
|
)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "文件信息获取失败")
|
|||
|
|
info["presigned_url"] = presigned_url
|
|||
|
|
return Response({"code": 200, "data": info, "msg": "获取成功"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["GET"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_list(request):
|
|||
|
|
"""列出文件
|
|||
|
|
|
|||
|
|
GET /server/minio/files/?bucket_name=xxx&prefix=xxx
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.GET.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
prefix = request.GET.get("prefix", "")
|
|||
|
|
try:
|
|||
|
|
objects = minio_storage.list_objects(bucket_name, prefix=prefix)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "文件列表获取失败")
|
|||
|
|
return Response({"code": 200, "data": objects, "msg": f"共 {len(objects)} 个对象"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 文件删除 ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["DELETE"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_delete(request):
|
|||
|
|
"""删除单个文件
|
|||
|
|
|
|||
|
|
DELETE /server/minio/file/delete/
|
|||
|
|
Body: {"bucket_name": "xxx", "object_name": "xxx"}
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_name = request.data.get("object_name", "")
|
|||
|
|
if not object_name:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_name"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
minio_storage.delete_object(bucket_name, object_name)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "文件删除失败")
|
|||
|
|
return Response({"code": 200, "data": {"object_name": object_name}, "msg": "删除成功"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["DELETE"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_batch_delete(request):
|
|||
|
|
"""批量删除文件
|
|||
|
|
|
|||
|
|
DELETE /server/minio/files/delete/
|
|||
|
|
Body: {"bucket_name": "xxx", "object_names": ["a.jpg", "b.png"]}
|
|||
|
|
"""
|
|||
|
|
bucket_name = request.data.get("bucket_name", DEFAULT_BUCKET)
|
|||
|
|
object_names = request.data.get("object_names", [])
|
|||
|
|
if not object_names:
|
|||
|
|
return Response({"code": 400, "msg": "缺少 object_names"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
errors = minio_storage.delete_objects(bucket_name, object_names)
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "批量删除失败")
|
|||
|
|
if errors:
|
|||
|
|
return Response(
|
|||
|
|
{
|
|||
|
|
"code": 207,
|
|||
|
|
"data": {"deleted": len(object_names) - len(errors), "errors": errors},
|
|||
|
|
"msg": f"部分删除,{len(errors)} 个失败",
|
|||
|
|
},
|
|||
|
|
status=207,
|
|||
|
|
)
|
|||
|
|
return Response(
|
|||
|
|
{"code": 200, "data": {"deleted": len(object_names)}, "msg": "全部删除成功"}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 文件复制 ─────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@api_view(["POST"])
|
|||
|
|
@permission_classes([IsAuthenticated])
|
|||
|
|
def minio_file_copy(request):
|
|||
|
|
"""复制文件
|
|||
|
|
|
|||
|
|
POST /server/minio/file/copy/
|
|||
|
|
Body: {
|
|||
|
|
"src_bucket": "xxx",
|
|||
|
|
"src_object": "a.jpg",
|
|||
|
|
"dst_bucket": "yyy",
|
|||
|
|
"dst_object": "b.jpg"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
src_bucket = request.data.get("src_bucket", DEFAULT_BUCKET)
|
|||
|
|
src_object = request.data.get("src_object", "")
|
|||
|
|
dst_bucket = request.data.get("dst_bucket", DEFAULT_BUCKET)
|
|||
|
|
dst_object = request.data.get("dst_object", "")
|
|||
|
|
|
|||
|
|
if not src_object or not dst_object:
|
|||
|
|
return Response({"code": 400, "msg": "src_object 和 dst_object 不能为空"}, status=400)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = minio_storage.copy_object(src_bucket, src_object, dst_bucket, dst_object)
|
|||
|
|
return Response({"code": 200, "data": {"object_name": result}, "msg": "复制成功"})
|
|||
|
|
except S3Error as exc:
|
|||
|
|
return _map_s3_error(exc, "文件复制失败")
|