model_train_dm/backend/apps/common/views/minio_storage.py

856 lines
29 KiB
Python
Raw Normal View History

2026-07-27 17:51:49 +08:00
"""
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, "文件复制失败")