""" 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, "文件复制失败")