0

Media Manager: Xây dựng "Google Drive" nội bộ với tính năng Nén ảnh và Watermark

Trong các dự án nhỏ, dev thường chỉ upload ảnh rồi lưu link vào bảng bài viết. Nhưng ở các hệ thống chuyên nghiệp, bạn cần một Media Manager độc lập. Tại sao? Vì một tấm ảnh có thể được dùng cho cả bài viết, sản phẩm, và banner. Nếu mỗi nơi bạn lại upload một bản copy, server sẽ sớm "nổ tung" vì dữ liệu rác.

Hôm nay, anh em mình sẽ xây dựng một "Google Drive thu nhỏ". Chúng ta không chỉ upload file mà còn tích hợp Intervention Image để tự động nén ảnh và đóng dấu bản quyền (Watermark) ngay khi vừa tải lên.

Lời mở đầu: Tại sao phải "xử lý" trước khi lưu?

Một tấm ảnh chụp từ iPhone có dung lượng 5-10MB. Nếu bạn cho phép user upload trực tiếp lên web mà không nén, băng thông của bạn sẽ bị đốt sạch và tốc độ load trang sẽ chậm như rùa.

Một hệ thống Media chuyên nghiệp cần:

  1. Normalization: Đổi tên file về chuẩn slug (không dấu, không khoảng trắng).
  2. Compression: Giảm dung lượng ảnh xuống mức tối ưu (thường là 80% chất lượng) mà mắt thường không nhận ra.
  3. Watermarking: Đóng dấu logo để tránh bị đối thủ "cướp" ảnh.
  4. Folder Logic: Phân loại file theo thư mục để dễ quản lý.

Bước 1: Khởi tạo và Cài đặt "Phòng thí nghiệm"

Tạo dự án mới:

laravel new enterprise-media
cd enterprise-media
php artisan storage:link

Cài đặt thư viện xử lý ảnh hàng đầu cho Laravel:

composer require intervention/image

Bước 2: Thiết kế Database & Model

Chúng ta cần một bảng để quản lý thông tin file, kích thước, và thư mục.

php artisan make:model Media -m

File Migration:

// database/migrations/xxxx_create_media_table.php
public function up(): void
{
    Schema::create('media', function (Blueprint $table) {
        $table->id();
        $table->string('name'); // Tên hiển thị
        $table->string('file_name'); // Tên file thực tế trên disk
        $table->string('mime_type'); // image/jpeg, application/pdf...
        $table->string('path'); // Đường dẫn thư mục
        $table->unsignedBigInteger('size'); // Dung lượng file (bytes)
        $table->string('disk')->default('public');
        $table->timestamps();
    });
}

Bước 3: MediaService - Bộ não xử lý File "Hạng nặng"

Đây là nơi chúng ta thực hiện phép thuật: Nén ảnh và chèn Watermark.

// app/Services/MediaService.php
namespace App\Services;

use App\Models\Media;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\ImageManagerStatic as Image;

class MediaService
{
    public function upload(UploadedFile $file, string $folder = 'uploads'): Media
    {
        $extension = $file->getClientOriginalExtension();
        $name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $fileName = Str::slug($name) . '-' . time() . '.' . $extension;
        $fullPath = $folder . '/' . $fileName;

        // Xử lý nâng cao nếu là FILE ẢNH
        if (str_starts_with($file->getMimeType(), 'image/')) {
            $this->processImage($file, $fullPath);
        } else {
            // File tài liệu (PDF, Word) thì chỉ lưu thuần túy
            Storage::disk('public')->putFileAs($folder, $file, $fileName);
        }

        return Media::create([
            'name' => $name,
            'file_name' => $fileName,
            'mime_type' => $file->getMimeType(),
            'path' => $folder,
            'size' => $file->getSize(),
        ]);
    }

    /**
     * Nén ảnh và Đóng dấu bản quyền
     */
    private function processImage($file, $fullPath)
    {
        $img = Image::make($file);

        // 1. Tự động Resize nếu ảnh quá lớn (giữ tỉ lệ)
        $img->resize(1920, null, function ($constraint) {
            $constraint->aspectRatio();
            $constraint->upsize();
        });

        // 2. Chèn Watermark (Giả sử bạn có file logo.png trong public)
        // $img->insert(public_path('watermark.png'), 'bottom-right', 10, 10);
        
        // Hoặc chèn Text Watermark đơn giản
        $img->text('© 2026 Enterprise System', $img->width() - 10, $img->height() - 10, function($font) {
            $font->size(24);
            $font->color('#ffffff');
            $font->align('right');
            $font->valign('bottom');
        });

        // 3. Nén chất lượng xuống 80% để tối ưu dung lượng và lưu
        $img->save(storage_path('app/public/' . $fullPath), 80);
    }
}

Bước 4: Controller Điều khiển "Thư viện"

Chúng ta sẽ làm các tính năng: Liệt kê file theo folder, Upload và Đổi tên.

// app/Http/Controllers/Api/MediaController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Media;
use App\Services\MediaService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class MediaController extends Controller
{
    protected MediaService $mediaService;

    public function __construct(MediaService $mediaService)
    {
        $this->mediaService = $mediaService;
    }

    /**
     * Lấy danh sách file theo thư mục
     */
    public function index(Request $request)
    {
        $folder = $request->query('folder', 'uploads');
        $files = Media::where('path', $folder)->latest()->get();

        return response()->json([
            'success' => true,
            'data' => $files->map(fn($file) => [
                'id' => $file->id,
                'name' => $file->name,
                'url' => asset('storage/' . $file->path . '/' . $file->file_name),
                'size' => round($file->size / 1024, 2) . ' KB',
                'type' => $file->mime_type
            ])
        ]);
    }

    /**
     * Upload file mới
     */
    public function store(Request $request)
    {
        $request->validate([
            'file' => 'required|file|max:10240', // Max 10MB
            'folder' => 'nullable|string'
        ]);

        $folder = $request->input('folder', 'uploads');
        $media = $this->mediaService->upload($request->file('file'), $folder);

        return response()->json(['success' => true, 'data' => $media], 201);
    }

    /**
     * Đổi tên file (Rename)
     */
    public function update(Request $request, Media $media)
    {
        $request->validate(['name' => 'required|string']);
        $media->update(['name' => $request->name]);

        return response()->json(['success' => true, 'message' => 'Đã đổi tên file.']);
    }

    /**
     * Xóa file
     */
    public function destroy(Media $media)
    {
        Storage::disk('public')->delete($media->path . '/' . $media->file_name);
        $media->delete();

        return response()->json(['success' => true, 'message' => 'Đã xóa file vĩnh viễn.']);
    }
}

Routes (routes/api.php):

use App\Http\Controllers\Api\MediaController;

Route::apiResource('media', MediaController::class);

Bước 5: Thử lửa với Postman

Bật server: php artisan serve

Kịch bản 1: Upload ảnh gốc dung lượng lớn

  • Method: POST
  • URL: http://127.0.0.1:8000/api/media
  • Body (form-data): * Key: file (chọn 1 tấm ảnh 5MB)
  • Key: folder (giá trị: products)

Kết quả: Hệ thống trả về 201. Nếu bạn vào storage/app/public/products, tấm ảnh đã được đổi tên sạch sẽ. Hãy kiểm tra dung lượng, nó có thể đã giảm từ 5MB xuống còn vài trăm KB mà chất lượng vẫn rất nét!

Kịch bản 2: Kiểm tra Watermark

Mở đường link URL trả về trong JSON. Bạn sẽ thấy dòng chữ © 2026 Enterprise System nằm ở góc dưới bên phải tấm ảnh. Chúc mừng, bạn đã bảo vệ được tài sản số của công ty.

Kịch bản 3: Phân loại thư mục

  • Method: GET
  • URL: http://127.0.0.1:8000/api/media?folder=products
  • Kết quả: Chỉ hiện ra các file nằm trong thư mục sản phẩm.

Tổng kết

Xây dựng một Media Manager chuyên nghiệp không chỉ là "quăng file lên ổ cứng". Đó là sự kết hợp giữa:

  1. Storage Abstraction: Dùng Facade để sẵn sàng chuyển sang Amazon S3 bất cứ lúc nào.
  2. Image Processing: Tối ưu hiệu năng hiển thị cho người dùng cuối bằng cách nén ảnh.
  3. Metadata Management: Lưu thông tin file vào Database để dễ dàng tìm kiếm và tái sử dụng.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí