Membangun Web App E-Commerce dengan Go: #7 - CRUD Produk & Upload Gambar ke Minio

Membangun Web App E-Commerce dengan Go: #7 - CRUD Produk & Upload Gambar ke Minio

Inti dari e-commerce! Bangun fungsionalitas CRUD produk dari nol dan pelajari cara mengintegrasikan Minio untuk mengunggah gambar produk dengan mudah.

Penulis: Novian Hidayat
Tanggal: 1 Juli 2025
Level: advanced

Selamat datang di bagian paling seru dari seri ini! Setelah membangun fondasi autentikasi yang solid, saatnya kita masuk ke jantung dari aplikasi e-commerce: produk.

Di bagian ini, kita akan membangun sistem manajemen produk yang lengkap, termasuk fungsionalitas yang paling ditunggu: upload gambar. Secara spesifik, kita akan:

  1. Membuat tabel products melalui sistem migrasi kita.
  2. Membangun halaman admin untuk menambah dan mengelola produk.
  3. Mengimplementasikan operasi Create dari CRUD untuk produk.
  4. Mengintegrasikan Go dengan Minio untuk menangani upload gambar produk.

Ini adalah bagian yang padat dan sangat memuaskan. Mari kita mulai!

Langkah 1: Skema Database dan Model Produk

Pertama, kita definisikan struktur tabel produk kita.

1. Buat File Migrasi Baru

Gunakan migrate CLI yang sudah kita instal sebelumnya untuk membuat file migrasi baru:

migrate create -ext sql -dir internal/database/migration -seq create_products_table

Buka file ..._create_products_table.up.sql dan tambahkan skema berikut:

CREATE TABLE products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    description TEXT,
    price NUMERIC(10, 2) NOT NULL,
    stock INT NOT NULL,
    image_url VARCHAR(255),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Jangan lupa isi file ..._create_products_table.down.sql untuk rollback:

DROP TABLE IF EXISTS products;

Jalankan migrasi untuk menerapkan skema baru ini ke database Anda:

migrate -path internal/database/migration -database "postgres://admin:secret@127.0.0.1:5432/ecommerce_db?sslmode=disable" -verbose up

2. Buat Model Go untuk Produk

Buat file baru internal/models/product.go dan definisikan struct Product:

// internal/models/product.go
package models

import (
	"time"
	"github.com/google/uuid"
)

type Product struct {
	ID          uuid.UUID `db:"id"`
	Name        string    `db:"name"`
	Description string    `db:"description"`
	Price       float64   `db:"price"`
	Stock       int       `db:"stock"`
	ImageURL    string    `db:"image_url"`
	CreatedAt   time.Time `db:"created_at"`
	UpdatedAt   time.Time `db:"updated_at"`
}

Langkah 2: Menyiapkan Integrasi Minio

Sebelum kita membuat form, mari siapkan koneksi ke Minio.

1. Instal Minio Go SDK

go get github.com/minio/minio-go/v7

Setelah itu, jalankan go mod tidy untuk memastikan semua dependensi sinkron.

2. Buat Modul Koneksi Minio

Buat file baru internal/storage/minio.go:

mkdir -p internal/storage
touch internal/storage/minio.go

Isi dengan kode berikut:

// internal/storage/minio.go
package storage

import (
	"context"
	"log"
	
	"github.com/minio/minio-go/v7"
	"github.com/minio/minio-go/v7/pkg/credentials"
)

var MinioClient *minio.Client

func InitMinio() {
	endpoint := "127.0.0.1:9000"
	accessKeyID := "minioadmin"
	secretAccessKey := "miniosecret"
	useSSL := false

	var err error
	MinioClient, err = minio.New(endpoint, &minio.Options{
		Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
		Secure: useSSL,
	})
	if err != nil {
		log.Fatalf("Gagal terhubung ke Minio: %v", err)
	}

	log.Println("Berhasil terhubung ke Minio!")

	bucketName := "products"
	err = MinioClient.MakeBucket(context.Background(), bucketName, minio.MakeBucketOptions{})
	if err != nil {
		exists, errBucketExists := MinioClient.BucketExists(context.Background(), bucketName)
		if errBucketExists == nil && exists {
			log.Printf("Bucket '%s' sudah ada.\n", bucketName)
		} else {
			log.Fatalf("Gagal membuat bucket: %v", err)
		}
	} else {
		log.Printf("Berhasil membuat bucket '%s'.\n", bucketName)
		policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::` + bucketName + `/*"]}]}`
		err = MinioClient.SetBucketPolicy(context.Background(), bucketName, policy)
		if err != nil {
			log.Fatalf("Gagal mengatur kebijakan bucket: %v", err)
		}
	}
}

Catatan: Kredensial Minio di-hardcode di sini untuk kesederhanaan. Di aplikasi produksi, simpan ini di .env.

Panggil fungsi InitMinio() ini dari cmd/web/main.go:

// cmd/web/main.go
import (
    // ...
    "github.com/kodekilat/go-ecommerce/internal/storage"
)
func main() {
    // ... (koneksi database)
    storage.InitMinio() // Inisialisasi Minio
    // ... (sisa kode)
}

Langkah 3: Membuat Repository Produk

Buat file internal/repository/product_repo.go. Ini akan berisi semua query database yang berhubungan dengan produk.

// internal/repository/product_repo.go
package repository

import (
	"context"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/kodekilat/go-ecommerce/internal/models"
)

type ProductRepository struct {
	DB *pgxpool.Pool
}

func (r *ProductRepository) CreateProduct(product *models.Product) error {
	query := `
		INSERT INTO products (name, description, price, stock, image_url)
		VALUES ($1, $2, $3, $4, $5)
		RETURNING id, created_at, updated_at
	`
	err := r.DB.QueryRow(context.Background(), query,
		product.Name,
		product.Description,
		product.Price,
		product.Stock,
		product.ImageURL,
	).Scan(&product.ID, &product.CreatedAt, &product.UpdatedAt)
	return err
}

Langkah 4: Halaman Admin & Handler Tambah Produk

Sekarang kita akan membuat halaman admin dan menghubungkan semuanya.

1. Buat Template Admin

Buat file web/templates/admin_products.page.html:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>Admin - Manajemen Produk</title></head>
<body>
    <h1>Manajemen Produk</h1>
    <h2>Tambah Produk Baru</h2>
    <form action="/admin/products" method="POST" enctype="multipart/form-data">
        <div><label>Nama Produk:</label><input type="text" name="name" required></div>
        <div><label>Deskripsi:</label><textarea name="description"></textarea></div>
        <div><label>Harga:</label><input type="number" step="0.01" name="price" required></div>
        <div><label>Stok:</label><input type="number" name="stock" required></div>
        <div><label>Gambar Produk:</label><input type="file" name="image" accept="image/*"></div>
        <button type="submit">Simpan Produk</button>
    </form>
    <hr>
    <h2>Daftar Produk</h2>
    <!-- Daftar produk akan ditampilkan di sini nanti -->
</body>
</html>

Perhatikan enctype="multipart/form-data". Ini wajib untuk form yang mengandung upload file.

2. Buat Handler Admin

Buat file baru cmd/web/handler/admin.go:

// cmd/web/handler/admin.go
package handler

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"path/filepath"
	"strconv"

	"github.com/google/uuid"
	"github.com/minio/minio-go/v7"
	"github.com/kodekilat/go-ecommerce/cmd/web/view"
	"github.com/kodekilat/go-ecommerce/internal/models"
	"github.com/kodekilat/go-ecommerce/internal/repository"
	"github.com/kodekilat/go-ecommerce/internal/storage"
)

type AdminHandler struct {
	ProductRepo *repository.ProductRepository
}

func (h *AdminHandler) ShowAdminProducts(w http.ResponseWriter, r *http.Request) {
	view.Render(w, "admin_products.page.html", nil)
}

func (h *AdminHandler) HandleAddProduct(w http.ResponseWriter, r *http.Request) {
	r.ParseMultipartForm(10 << 20) // 10 MB

	name := r.PostFormValue("name")
	description := r.PostFormValue("description")
	price, _ := strconv.ParseFloat(r.PostFormValue("price"), 64)
	stock, _ := strconv.Atoi(r.PostFormValue("stock"))
	
	var imageURL string
	file, header, err := r.FormFile("image")
	if err == nil {
		defer file.Close()
		bucketName := "products"
		objectName := uuid.New().String() + filepath.Ext(header.Filename)

		_, err = storage.MinioClient.PutObject(context.Background(), bucketName, objectName, file, header.Size, minio.PutObjectOptions{ContentType: header.Header.Get("Content-Type")})
		if err != nil {
			http.Error(w, "Gagal mengunggah gambar", http.StatusInternalServerError)
			return
		}
		imageURL = fmt.Sprintf("http://127.0.0.1:9000/%s/%s", bucketName, objectName)
	}

	newProduct := &models.Product{
		Name: name, Description: description, Price: price, Stock: stock, ImageURL: imageURL,
	}

	err = h.ProductRepo.CreateProduct(newProduct)
	if err != nil {
		log.Printf("Gagal menyimpan produk: %v", err)
		http.Error(w, "Gagal menyimpan produk", http.StatusInternalServerError)
		return
	}

	log.Printf("Produk baru berhasil disimpan dengan ID: %s", newProduct.ID)
	http.Redirect(w, r, "/admin/products", http.StatusSeeOther)
}

Catatan: Penanganan error untuk strconv diabaikan untuk keringkasan, tetapi di aplikasi riil Anda harus menanganinya.

Langkah 5: Hubungkan Semuanya (Dependency Injection)

Terakhir, kita perlu membuat instance ProductRepository dan AdminHandler lalu mendaftarkan rutenya.

1. Perbarui main.go

// cmd/web/main.go
func main() {
    // ...
	db, err := database.NewConnection()
    // ...
	storage.InitMinio()

	userRepo := &repository.UserRepository{DB: db}
	productRepo := &repository.ProductRepository{DB: db} // Tambahkan ini

	appRouter := router.New(userRepo, productRepo) // Ubah ini

	log.Println("Memulai server di http://localhost:8080")
	http.ListenAndServe(":8080", appRouter)
}

2. Perbarui router.go

// cmd/web/router/router.go
func New(userRepo *repository.UserRepository, productRepo *repository.ProductRepository) http.Handler { // Ubah signature
	r := chi.NewRouter()
	// ... (middleware)

	authHandler := &handler.AuthHandler{UserRepo: userRepo}
	adminHandler := &handler.AdminHandler{ProductRepo: productRepo} // Tambahkan ini
	
    // ... (rute publik & auth)

	// Grup rute yang memerlukan autentikasi
	r.Group(func(r chi.Router) {
		r.Use(authMiddleware.RequireAuthentication)
		r.Get("/account", handler.ShowAccountPage)

		// Rute admin baru
		r.Get("/admin/products", adminHandler.ShowAdminProducts)
		r.Post("/admin/products", adminHandler.HandleAddProduct)
	})

	return r
}

Jalankan aplikasi Anda, login, dan buka http://localhost:8080/admin/products. Coba tambahkan produk baru dengan gambar. Jika semua berjalan lancar, Anda akan melihat log sukses di terminal dan gambar baru di konsol Minio Anda (http://localhost:9001)!

Penutup

Luar biasa! Ini adalah bagian yang paling padat sejauh ini, tetapi hasilnya sangat nyata. Kita telah berhasil membangun fitur inti dari backend e-commerce mana pun.

Di bagian selanjutnya dan terakhir, kita akan fokus pada sisi pengguna: menampilkan produk-produk ini di halaman utama dan menata semuanya dengan UnoCSS agar terlihat seperti toko online sungguhan. Kita hampir sampai di garis finis!

Untuk referensi, Anda dapat melihat kode final dari tutorial ini di repositori GitHub berikut: Source code lengkap

Diskusi

Tutorial Lainnya