1 Commits
0.2.5 ... 0.2.6

Author SHA1 Message Date
6b0be35832 接入minio[ci][0.2.6]
All checks were successful
CI / docker-ci (push) Successful in 3m14s
2026-01-16 15:13:57 +08:00
7 changed files with 226 additions and 24 deletions

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'accounts', 'accounts',
'main', 'main',
'elastic', 'elastic',
'minio_storage',
'django_elasticsearch_dsl', 'django_elasticsearch_dsl',
] ]

View File

@@ -328,7 +328,7 @@ function renderTable(data) {
row.innerHTML = ` row.innerHTML = `
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td> <td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
<td> <td>
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'} ${item.image_url ? `<img src="${item.image_url}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="${item.image_url}" />` : '无图片'}
</td> </td>
<td> <td>
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre> <pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
@@ -730,4 +730,4 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -23,6 +23,37 @@ from .es_connect import (
from PIL import Image 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"]) @require_http_methods(["GET", "POST"])
@csrf_exempt @csrf_exempt
def init_index(request): def init_index(request):
@@ -59,7 +90,7 @@ def search(request):
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
results = search_data(query) results = search_data(query)
return JsonResponse({"status": "success", "data": results}) return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -73,7 +104,7 @@ def fuzzy_search(request):
return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400) return JsonResponse({"status": "error", "message": "搜索关键词不能为空"}, status=400)
results = search_by_any_field(keyword) 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: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -82,7 +113,7 @@ def get_all_data(request):
"""获取所有数据""" """获取所有数据"""
try: try:
results = search_all() results = search_all()
return JsonResponse({"status": "success", "data": results}) return JsonResponse({"status": "success", "data": _attach_image_urls(request, results)})
except Exception as e: except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500) return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -176,7 +207,9 @@ def get_data(request, doc_id):
try: try:
result = get_by_id(doc_id) result = get_by_id(doc_id)
if result: 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: else:
return JsonResponse({"status": "error", "message": "数据不存在"}, status=404) return JsonResponse({"status": "error", "message": "数据不存在"}, status=404)
except Exception as e: except Exception as e:
@@ -473,29 +506,45 @@ def confirm(request):
return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400) return JsonResponse({"status": "error", "message": "数据不能为空"}, status=400)
ensure_type_in_list(edited.get("数据类型")) ensure_type_in_list(edited.get("数据类型"))
final_image_rel = image_rel image_ref_to_store = ""
try: temp_files_to_delete = []
if image_rel: if image_rel:
images_dir = os.path.join(settings.MEDIA_ROOT, "images") images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True) os.makedirs(images_dir, exist_ok=True)
src_abs = os.path.join(settings.MEDIA_ROOT, image_rel) src_abs = os.path.join(settings.MEDIA_ROOT, image_rel)
base = os.path.splitext(os.path.basename(image_rel))[0] if not os.path.isfile(src_abs):
webp_name = base + ".webp" return JsonResponse({"status": "error", "message": "图片文件不存在"}, status=400)
webp_abs = os.path.join(images_dir, webp_name)
webp_name = f"{uuid.uuid4().hex}.webp"
webp_abs = os.path.join(images_dir, webp_name)
try:
with Image.open(src_abs) as im: with Image.open(src_abs) as im:
if im.mode in ("RGBA", "LA", "P"): if im.mode in ("RGBA", "LA", "P"):
im = im.convert("RGBA") im = im.convert("RGBA")
else: else:
im = im.convert("RGB") im = im.convert("RGB")
im.save(webp_abs, format="WEBP", quality=80) im.save(webp_abs, format="WEBP", quality=80)
final_image_rel = f"images/{webp_name}" except Exception:
except Exception: try:
final_image_rel = image_rel 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 = { to_store = {
"writer_id": str(request.session.get("user_id")), "writer_id": str(request.session.get("user_id")),
"data": json_to_string(edited), "data": json_to_string(edited),
"image": final_image_rel, "image": image_ref_to_store,
} }
ok = insert_data(to_store) ok = insert_data(to_store)
@@ -503,10 +552,9 @@ def confirm(request):
return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500) return JsonResponse({"status": "error", "message": "写入ES失败"}, status=500)
try: try:
if image_rel and final_image_rel != image_rel: for p in temp_files_to_delete:
orig_abs = os.path.join(settings.MEDIA_ROOT, image_rel) if p and os.path.isfile(p):
if os.path.isfile(orig_abs): os.remove(p)
os.remove(orig_abs)
except Exception: except Exception:
pass pass

View File

@@ -0,0 +1 @@

22
minio_storage/apps.py Normal file
View File

@@ -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}")

View File

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

View File

@@ -7,8 +7,9 @@ requests==2.32.3
openai==1.52.2 openai==1.52.2
httpx==0.27.2 httpx==0.27.2
Pillow==10.4.0 Pillow==10.4.0
minio>=7.2.0,<8
gunicorn==21.2.0 gunicorn==21.2.0
whitenoise==6.6.0 whitenoise==6.6.0
django-browser-reload==1.21.0 django-browser-reload==1.21.0
captcha==0.7.1 captcha==0.7.1
cryptography==46.0.3 cryptography==46.0.3