This commit is contained in:
QuangMinh_123
2026-05-27 13:50:27 +07:00
parent 7aebcf9567
commit 2683cdb882
30 changed files with 2091 additions and 17 deletions

View File

@@ -0,0 +1,49 @@
import os
import boto3
from dotenv import load_dotenv
load_dotenv()
def get_s3_client():
"""
Tạo và trả về S3 client dùng để làm việc với MinIO/S3.
MinIO tương thích với S3 API, nên mình dùng boto3 như khi làm với AWS S3.
Hàm này chỉ chịu trách nhiệm tạo kết nối, không xử lý upload hay nghiệp vụ file.
"""
return boto3.client(
"s3",
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
aws_access_key_id=os.getenv("S3_ACCESS_KEY"),
aws_secret_access_key=os.getenv("S3_SECRET_KEY"),
region_name=os.getenv("S3_REGION", "us-east-1"),
)
def get_bucket_name():
"""
Lấy tên bucket từ biến môi trường.
Project này nên dùng một bucket chung, ví dụ: ndms.
Sau đó phân loại file bằng object key:
- device-types/router-uuid.svg
- devices/avatar-uuid.png
"""
return os.getenv("S3_BUCKET_NAME")
def get_public_url():
"""
Lấy public URL của MinIO/S3.
Ví dụ khi chạy local:
S3_PUBLIC_URL=http://localhost:9000
URL cuối cùng sẽ có dạng:
http://localhost:9000/ndms/device-types/router-uuid.svg
"""
return os.getenv("S3_PUBLIC_URL")

View File

@@ -0,0 +1,141 @@
import re
import uuid
from werkzeug.utils import secure_filename
from storage.s3_client import (
get_s3_client,
get_bucket_name,
get_public_url,
)
from storage.validators import validate_image_file
def slugify(text):
"""
Chuyển text thành dạng an toàn để dùng trong tên file.
Ví dụ:
- "Router" -> "router"
- "Core Switch 01" -> "core-switch-01"
- "Thiết bị định tuyến" -> "thi-t-b-nh-tuy-n"
Lưu ý:
Hàm này đơn giản, chưa xử lý tiếng Việt hoàn hảo.
Nếu muốn slug tiếng Việt đẹp hơn, sau này có thể dùng thư viện python-slugify.
"""
if text is None or text.strip() == "":
return "file"
text = text.lower().strip()
# Thay toàn bộ ký tự không phải chữ/số bằng dấu gạch ngang.
text = re.sub(r"[^a-z0-9]+", "-", text)
# Xóa dấu gạch ngang thừa ở đầu/cuối.
text = text.strip("-")
return text or "file"
def build_object_key(folder, business_name, extension):
"""
Tạo object key để lưu file trong bucket.
MinIO/S3 không có folder thật như filesystem.
Folder ở đây chỉ là prefix trong object key.
Ví dụ:
folder = "device-types"
business_name = "Router"
extension = "svg"
Kết quả:
device-types/router-550e8400.svg
Dùng UUID để tránh trùng tên file.
"""
safe_name = slugify(business_name)
unique_id = uuid.uuid4()
return f"{folder}/{safe_name}-{unique_id}.{extension}"
def upload_image(file, folder, business_name): # Hàm Upload dùng chung cho device_types và devices
"""
Upload một file ảnh lên MinIO/S3 và trả về public URL.
Tham số:
- file: file lấy từ request.files
- folder: nhóm lưu trữ, ví dụ "device-types" hoặc "devices"
- business_name: tên nghiệp vụ để đặt tên file đẹp hơn
Ví dụ:
+ DeviceType name = Router
+ Device name = Switch tầng 2
Flow:
1. Validate file
2. Tạo object key
3. Upload file lên bucket
4. Trả về URL public để lưu vào database
"""
extension = validate_image_file(file)
object_key = build_object_key(
folder=folder,
business_name=business_name,
extension=extension,
)
s3_client = get_s3_client()
bucket_name = get_bucket_name()
public_url = get_public_url()
s3_client.upload_fileobj(
file,
bucket_name,
object_key,
ExtraArgs={
"ContentType": file.content_type
},
)
return f"{public_url}/{bucket_name}/{object_key}"
def upload_device_type_icon(file, device_type_name):
"""
Upload icon cho DeviceType.
File sẽ được lưu trong bucket với prefix:
device-types/
Ví dụ URL:
http://localhost:9000/ndms/device-types/router-uuid.svg
"""
return upload_image( # Ở đoạn này có thể trả về một hàm luôn á ? Mà không cần phải gọi hàm đó vào bên trong và truyền tham số à ?
file=file,
folder="device-types",
business_name=device_type_name,
)
def upload_device_avatar(file, device_name):
"""
Upload avatar cho Device.
File sẽ được lưu trong bucket với prefix:
devices/
Ví dụ URL:
http://localhost:9000/ndms/devices/switch-tang-2-uuid.png
"""
return upload_image(
file=file,
folder="devices",
business_name=device_name,
)

View File

@@ -0,0 +1,49 @@
ALLOWED_IMAGE_EXTENSIONS = {"svg", "png", "jpg", "jpeg", "webp"}
MAX_FILE_SIZE_MB = 5
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
def validate_image_file(file):
"""
Validate file ảnh trước khi upload lên MinIO/S3.
Hàm này kiểm tra:
1. Có file được gửi lên không
2. File có tên không
3. File có extension không
4. Extension có hợp lệ không
5. File có vượt quá dung lượng cho phép không
Trả về:
- extension của file nếu hợp lệ
Raise:
- ValueError nếu file không hợp lệ
"""
if file is None:
raise ValueError("No file uploaded")
if file.filename == "":
raise ValueError("Filename is empty")
if "." not in file.filename:
raise ValueError("File must have an extension")
extension = file.filename.rsplit(".", 1)[1].lower()
if extension not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("Invalid image type. Allowed: svg, png, jpg, jpeg, webp")
# Di chuyển con trỏ file tới cuối để đo dung lượng file.
file.seek(0, 2)
file_size = file.tell()
# Đưa con trỏ file về đầu để lát nữa upload không bị mất dữ liệu.
file.seek(0)
if file_size > MAX_FILE_SIZE_BYTES:
raise ValueError(f"File size must be less than {MAX_FILE_SIZE_MB}MB")
return extension