From 6b0be3583291c0c2f8d9342287b690cb903aa394 Mon Sep 17 00:00:00 2001 From: spdis Date: Fri, 16 Jan 2026 15:13:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5minio[ci][0.2.6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Achievement_Inputing/settings.py | 1 + elastic/templates/elastic/manage.html | 4 +- elastic/views.py | 90 +++++++++++++----- minio_storage/__init__.py | 1 + minio_storage/apps.py | 22 +++++ minio_storage/minio_connect.py | 129 ++++++++++++++++++++++++++ requirements.txt | 3 +- 7 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 minio_storage/__init__.py create mode 100644 minio_storage/apps.py create mode 100644 minio_storage/minio_connect.py diff --git a/Achievement_Inputing/settings.py b/Achievement_Inputing/settings.py index d60ffd7..72f8ad2 100644 --- a/Achievement_Inputing/settings.py +++ b/Achievement_Inputing/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'accounts', 'main', 'elastic', + 'minio_storage', 'django_elasticsearch_dsl', ] diff --git a/elastic/templates/elastic/manage.html b/elastic/templates/elastic/manage.html index f7678c8..59041d9 100644 --- a/elastic/templates/elastic/manage.html +++ b/elastic/templates/elastic/manage.html @@ -328,7 +328,7 @@ function renderTable(data) { row.innerHTML = ` ${item._id || item.id || ''} - ${item.image ? `` : '无图片'} + ${item.image_url ? `` : '无图片'}
${escapeHtml(displayData)}
@@ -730,4 +730,4 @@ document.addEventListener('DOMContentLoaded', function() { }); - \ No newline at end of file + diff --git a/elastic/views.py b/elastic/views.py index 871687f..8048e9d 100644 --- a/elastic/views.py +++ b/elastic/views.py @@ -23,6 +23,37 @@ from .es_connect import ( from PIL import Image +def _image_ref_to_url(request, image_ref: str) -> str: + s = str(image_ref or '').strip() + if not s: + return '' + + if not s.startswith('minio:'): + return '' + + object_name = s[len('minio:'):].lstrip('/') + if not object_name: + return '' + + try: + from minio_storage.minio_connect import presigned_get_url + return presigned_get_url(object_name, expires_seconds=8 * 60 * 60) + except Exception: + return '' + + +def _attach_image_urls(request, items): + out = [] + for it in list(items or []): + try: + d = dict(it or {}) + except Exception: + continue + d['image_url'] = _image_ref_to_url(request, d.get('image', '')) + out.append(d) + return out + + @require_http_methods(["GET", "POST"]) @csrf_exempt def init_index(request): @@ -59,7 +90,7 @@ def search(request): return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) results = search_data(query) - return JsonResponse({"status": "success", "data": results}) + return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)}) except Exception as e: return JsonResponse({"status": "error", "message": str(e)}, status=500) @@ -73,7 +104,7 @@ def fuzzy_search(request): return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) results = search_by_any_field(keyword) - return JsonResponse({"status": "success", "data": results}) + return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)}) except Exception as e: return JsonResponse({"status": "error", "message": str(e)}, status=500) @@ -82,7 +113,7 @@ def get_all_data(request): """获取所有数据""" try: results = search_all() - return JsonResponse({"status": "success", "data": results}) + return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)}) except Exception as e: return JsonResponse({"status": "error", "message": str(e)}, status=500) @@ -176,7 +207,9 @@ def get_data(request, doc_id): try: result = get_by_id(doc_id) if result: - return JsonResponse({"status": "success", "data": result}) + wrapped = dict(result) + wrapped['image_url'] = _image_ref_to_url(request, wrapped.get('image', '')) + return JsonResponse({"status": "success", "data": wrapped}) else: return JsonResponse({"status": "error", "message": "数据不存在"}, status=404) except Exception as e: @@ -473,29 +506,45 @@ def confirm(request): return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400) ensure_type_in_list(edited.get("数据类型")) - final_image_rel = image_rel - try: - if image_rel: - images_dir = os.path.join(settings.MEDIA_ROOT, "images") - os.makedirs(images_dir, exist_ok=True) - src_abs = os.path.join(settings.MEDIA_ROOT, image_rel) - base = os.path.splitext(os.path.basename(image_rel))[0] - webp_name = base + ".webp" - webp_abs = os.path.join(images_dir, webp_name) + image_ref_to_store = "" + temp_files_to_delete = [] + if image_rel: + images_dir = os.path.join(settings.MEDIA_ROOT, "images") + os.makedirs(images_dir, exist_ok=True) + src_abs = os.path.join(settings.MEDIA_ROOT, image_rel) + if not os.path.isfile(src_abs): + return JsonResponse({"status": "error", "message": "图片文件不存在"}, status=400) + + webp_name = f"{uuid.uuid4().hex}.webp" + webp_abs = os.path.join(images_dir, webp_name) + try: with Image.open(src_abs) as im: if im.mode in ("RGBA", "LA", "P"): im = im.convert("RGBA") else: im = im.convert("RGB") im.save(webp_abs, format="WEBP", quality=80) - final_image_rel = f"images/{webp_name}" - except Exception: - final_image_rel = image_rel + except Exception: + try: + if os.path.isfile(webp_abs): + os.remove(webp_abs) + except Exception: + pass + return JsonResponse({"status": "error", "message": "图片转换WEBP失败"}, status=500) + + try: + object_name = f"images/{webp_name}" + from minio_storage.minio_connect import upload_file + upload_file(webp_abs, object_name, content_type="image/webp") + image_ref_to_store = f"minio:{object_name}" + temp_files_to_delete.extend([src_abs, webp_abs]) + except Exception as e: + return JsonResponse({"status": "error", "message": f"上传到MinIO失败: {e}"}, status=500) to_store = { "writer_id": str(request.session.get("user_id")), "data": json_to_string(edited), - "image": final_image_rel, + "image": image_ref_to_store, } ok = insert_data(to_store) @@ -503,10 +552,9 @@ def confirm(request): return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500) try: - if image_rel and final_image_rel != image_rel: - orig_abs = os.path.join(settings.MEDIA_ROOT, image_rel) - if os.path.isfile(orig_abs): - os.remove(orig_abs) + for p in temp_files_to_delete: + if p and os.path.isfile(p): + os.remove(p) except Exception: pass diff --git a/minio_storage/__init__.py b/minio_storage/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/minio_storage/__init__.py @@ -0,0 +1 @@ + diff --git a/minio_storage/apps.py b/minio_storage/apps.py new file mode 100644 index 0000000..b8f6d9e --- /dev/null +++ b/minio_storage/apps.py @@ -0,0 +1,22 @@ +from django.apps import AppConfig +import os +import sys + + +class MinioStorageConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'minio_storage' + + def ready(self): + if os.path.basename(sys.argv[0]) == 'manage.py': + if os.environ.get('RUN_MAIN') != 'true': + return + if 'runserver' not in sys.argv: + return + + from .minio_connect import ensure_bucket_exists + try: + ensure_bucket_exists() + except Exception as e: + print(f"❌ MinIO 初始化失败: {e}") + diff --git a/minio_storage/minio_connect.py b/minio_storage/minio_connect.py new file mode 100644 index 0000000..650fddf --- /dev/null +++ b/minio_storage/minio_connect.py @@ -0,0 +1,129 @@ +import os +from datetime import timedelta +import mimetypes +from urllib.parse import urlparse + +from minio import Minio +from minio.error import S3Error + + +def _env_bool(name: str, default: bool = False) -> bool: + v = os.environ.get(name) + if v is None: + return default + return str(v).strip().lower() in {'1', 'true', 'yes', 'y', 'on'} + + +def _normalize_endpoint(minio_url: str): + if not minio_url: + return None, None + + u = str(minio_url).strip() + parsed = urlparse(u) + if parsed.scheme in {'http', 'https'}: + endpoint = parsed.netloc + secure = parsed.scheme == 'https' + else: + endpoint = u + secure = None + + endpoint = endpoint.strip().rstrip('/') + return endpoint, secure + + +def _get_env(*names: str, default: str | None = None) -> str | None: + for n in names: + v = os.environ.get(n) + if v is not None and str(v).strip() != '': + return str(v).strip() + return default + + +def get_minio_client() -> Minio | None: + minio_url = _get_env('MINIO_URL', 'MINIO_ENDPOINT') + access_key = _get_env('MINIO_ACCESS_KEY') + secret_key = _get_env('MINIO_SECRET_KEY') + + if not minio_url or not access_key or not secret_key: + return None + + endpoint, secure_from_url = _normalize_endpoint(minio_url) + if not endpoint: + return None + + secure = _env_bool('MINIO_SECURE', default=secure_from_url if secure_from_url is not None else False) + region = _get_env('MINIO_REGION', default=None) + + return Minio( + endpoint=endpoint, + access_key=access_key, + secret_key=secret_key, + secure=secure, + region=region, + ) + + +def get_bucket_name() -> str: + return _get_env('MINIO_BUCKET', default='achievement') or 'achievement' + + +def ensure_bucket_exists() -> bool: + client = get_minio_client() + bucket = get_bucket_name() + if client is None: + print('ℹ️ MinIO 环境变量未配置,跳过桶检查') + return False + + if not bucket: + print('ℹ️ MINIO_BUCKET 为空,跳过桶检查') + return False + + try: + exists = client.bucket_exists(bucket) + except S3Error as e: + print(f'❌ MinIO 连接失败: {e}') + return False + + if exists: + print(f'ℹ️ MinIO 桶已存在: {bucket}') + return True + + try: + region = _get_env('MINIO_REGION', default=None) + if region: + client.make_bucket(bucket, location=region) + else: + client.make_bucket(bucket) + print(f'✅ MinIO 桶已创建: {bucket}') + return True + except S3Error as e: + print(f'❌ MinIO 创建桶失败: {e}') + return False + + +def upload_file(file_path: str, object_name: str, content_type: str | None = None) -> str: + client = get_minio_client() + if client is None: + raise RuntimeError('MinIO 未配置') + + bucket = get_bucket_name() + ensure_bucket_exists() + + ct = content_type + if not ct: + guessed, _ = mimetypes.guess_type(object_name) + ct = guessed or 'application/octet-stream' + + client.fput_object(bucket, object_name, file_path, content_type=ct) + return object_name + + +def presigned_get_url(object_name: str, expires_seconds: int = 8 * 60 * 60) -> str: + client = get_minio_client() + if client is None: + raise RuntimeError('MinIO 未配置') + + bucket = get_bucket_name() + ensure_bucket_exists() + exp = max(1, int(expires_seconds or 0)) + return client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=exp)) diff --git a/requirements.txt b/requirements.txt index c7af96a..b1a6338 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,9 @@ requests==2.32.3 openai==1.52.2 httpx==0.27.2 Pillow==10.4.0 +minio>=7.2.0,<8 gunicorn==21.2.0 whitenoise==6.6.0 django-browser-reload==1.21.0 captcha==0.7.1 -cryptography==46.0.3 \ No newline at end of file +cryptography==46.0.3