From b4faafac1ec648464bef25149ac75d67e8f27df7 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Wed, 15 Apr 2026 09:25:35 +0700 Subject: [PATCH 1/6] feat: Implement domain for product service with caching, error handling, and full-text search capabilities --- .../db/004_create_search_index.up.sql | 42 ++++ services/product-service/go.mod | 9 + services/product-service/go.sum | 6 + .../product-service/internal/domain/cache.go | 18 ++ .../product-service/internal/domain/errors.go | 25 +++ .../product-service/internal/domain/events.go | 13 ++ .../internal/domain/product.go | 198 ++++++++++++++++++ .../internal/domain/repository.go | 35 ++++ 8 files changed, 346 insertions(+) create mode 100644 services/product-service/db/004_create_search_index.up.sql create mode 100644 services/product-service/go.mod create mode 100644 services/product-service/go.sum create mode 100644 services/product-service/internal/domain/cache.go create mode 100644 services/product-service/internal/domain/errors.go create mode 100644 services/product-service/internal/domain/events.go create mode 100644 services/product-service/internal/domain/product.go create mode 100644 services/product-service/internal/domain/repository.go diff --git a/services/product-service/db/004_create_search_index.up.sql b/services/product-service/db/004_create_search_index.up.sql new file mode 100644 index 0000000..28953ae --- /dev/null +++ b/services/product-service/db/004_create_search_index.up.sql @@ -0,0 +1,42 @@ +-- Migration: 004_create_search_index +-- Purpose: Setup PostgreSQL full-text search with tsvector and automatic triggers + +-- Add search_vector column if it doesn't exist (GORM may have created it) +ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector; + +-- Create GIN index for full-text search +CREATE INDEX IF NOT EXISTS idx_products_search ON products USING GIN(search_vector); + +-- Create function to update search_vector on product changes +CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := to_tsvector('english', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.description, '')); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Drop existing trigger if it exists +DROP TRIGGER IF EXISTS products_search_vector_update ON products; + +-- Create trigger to auto-populate search_vector on INSERT/UPDATE +CREATE TRIGGER products_search_vector_update + BEFORE INSERT OR UPDATE ON products + FOR EACH ROW + EXECUTE FUNCTION products_search_vector_trigger(); + +-- Populate search_vector for existing records +UPDATE products +SET search_vector = to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')) +WHERE search_vector IS NULL OR search_vector = ''; + +-- Add index on category_id for faster filtering +CREATE INDEX IF NOT EXISTS idx_products_category_id ON products(category_id); + +-- Add index on price for range queries +CREATE INDEX IF NOT EXISTS idx_products_price ON products(price); + +-- Add index on is_active for filtering +CREATE INDEX IF NOT EXISTS idx_products_is_active ON products(is_active); + +-- Add index on created_at for sorting +CREATE INDEX IF NOT EXISTS idx_products_created_at ON products(created_at DESC); diff --git a/services/product-service/go.mod b/services/product-service/go.mod new file mode 100644 index 0000000..3673997 --- /dev/null +++ b/services/product-service/go.mod @@ -0,0 +1,9 @@ +module auron/product-service + +go 1.25.8 + +require ( + github.com/google/uuid v1.6.0 // indirect + google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/services/product-service/go.sum b/services/product-service/go.sum new file mode 100644 index 0000000..f342c22 --- /dev/null +++ b/services/product-service/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= +google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/services/product-service/internal/domain/cache.go b/services/product-service/internal/domain/cache.go new file mode 100644 index 0000000..9ca989c --- /dev/null +++ b/services/product-service/internal/domain/cache.go @@ -0,0 +1,18 @@ +package domain + +import "context" + +type ProductCache interface { + // product detail cache + GetProduct(ctx context.Context, id string) (*Product, error) + SetProduct(ctx context.Context, product *Product) error + DeleteProduct(ctx context.Context, id string) error + + // product list cache (paginated result) + GetProductList(ctx context.Context, cacheKey string) (*ProductListResponse, error) + SetProductList(ctx context.Context, cacheKey string, response *ProductListResponse) error + InvalidateProductList(ctx context.Context) error + + // utility + ClearAll(ctx context.Context) error +} diff --git a/services/product-service/internal/domain/errors.go b/services/product-service/internal/domain/errors.go new file mode 100644 index 0000000..8aa0a0b --- /dev/null +++ b/services/product-service/internal/domain/errors.go @@ -0,0 +1,25 @@ +package domain + +import "errors" + +var ( + // product errors + ErrProductNotFound = errors.New("product not found") + ErrInvalidProductID = errors.New("invalid product ID") + ErrProductAlreadyExists = errors.New("product with this name already exists") + + // category errors + ErrCategoryNotFound = errors.New("category not found") + ErrCategorySlugExists = errors.New("category with this slug already exists") + ErrInvalidCategoryID = errors.New("invalid category ID") + + // inventory errors + ErrInvalidSortParam = errors.New("invalid sort parameter. allowed price_asc, price_desc, newest, name_asc, name_desc") + ErrInvalidPageParam = errors.New("page must be >= 1") + ErrInvalidLimitParam = errors.New("limit must be >= 1 and <= 100") + ErrPriceMustBePositive = errors.New("price must be a positive number") + + //generic + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) \ No newline at end of file diff --git a/services/product-service/internal/domain/events.go b/services/product-service/internal/domain/events.go new file mode 100644 index 0000000..33621a4 --- /dev/null +++ b/services/product-service/internal/domain/events.go @@ -0,0 +1,13 @@ +package domain + +import "context" + +type EventPublisher interface { + Publish(ctx context.Context, topic string, payload any) error +} + +const ( + TopicProductCreated = "product.created" + TopicProductUpdated = "product.updated" + TopicProductDeleted = "product.deleted" +) diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go new file mode 100644 index 0000000..f1242fd --- /dev/null +++ b/services/product-service/internal/domain/product.go @@ -0,0 +1,198 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "google.golang.org/genproto/googleapis/type/decimal" +) + +// ============================================================ +// CONSTANTS +// ============================================================ + +const ( + SortPriceAsc = "price_asc" + SortPriceDesc = "price_desc" + SortNewest = "newest" + SortNameAsc = "name_asc" + SortNameDesc = "name_desc" +) + +var ValidSorts = map[string]bool{ + SortPriceAsc: true, + SortPriceDesc: true, + SortNewest: true, + SortNameAsc: true, + SortNameDesc: true, +} + +// ============================================================ +// ENTITIES +// ============================================================ + +type Category struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + Slug string `json:"slug" gorm:"type:varchar(255);not null;uniqueIndex"` + ParentID *uuid.UUID `json:"parent_id,omitempty" gorm:"type:uuid;index"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` +} + +func (Category) TableName() string { + return "categories" +} + +type Product struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` + Name string `json:"name" gorm:"type:varchar(500);not null"` + Description string `json:"description" gorm:"type:text"` + Price decimal.Decimal `json:"price" gorm:"type:decimal(12,2);not null;index"` + ImageURL string `json:"image_url" gorm:"type:text"` + SearchVector string `json:"-" gorm:"type:tsvector;index:idx_products_search,type:GIN"` + IsActive bool `json:"is_active" gorm:"not null;default:true;index"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` + Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID;references:ID"` +} + +func (Product) TableName() string { + return "products" +} + +// BeforeSave is a GORM hook that populates the search_vector before insert/update. +// This enables PostgreSQL full-text search via tsvector. +func (p *Product) BeforeSave() error { + // Build tsvector from name and description for full-text search + // Format: 'word':position,'word2':position2 (PostgreSQL tsvector format) + // We use a simplified approach — PostgreSQL will parse this properly via triggers + p.SearchVector = p.buildSearchVector() + return nil +} + +// buildSearchVector creates a searchable text vector from product fields. +// This is a Go-side fallback; PostgreSQL triggers should handle the actual tsvector generation. +func (p *Product) buildSearchVector() string { + // Return plain text — PostgreSQL's to_tsvector() will convert this properly + // when using triggers. This is only for GORM AutoMigrate compatibility. + return p.Name + " " + p.Description +} + +type Inventory struct { + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` + TotalQuantity int `json:"total_quantity" gorm:"not null;default:0"` + ReservedQuantity int `json:"reserved_quantity" gorm:"not null;default:0"` + Version int `json:"version" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (Inventory) TableName() string { + return "inventory" +} + +// AvailableQuantity returns the stock available for purchase. +func (i *Inventory) AvailableQuantity() int { + return i.TotalQuantity - i.ReservedQuantity +} + +// ============================================================ +// DTOs — REQUESTS +// ============================================================ + +type CategoryRequest struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug" binding:"required"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` +} + +type ProductRequest struct { + CategoryID uuid.UUID `json:"category_id" binding:"required"` + Name string `json:"name" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Price decimal.Decimal `json:"price" binding:"required,gt=0"` + ImageURL string `json:"image_url" binding:"omitempty,url"` + IsActive *bool `json:"is_active"` +} + +// ============================================================ +// DTOs — RESPONSES +// ============================================================ + +type CategoryResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ProductResponse struct { + ID uuid.UUID `json:"id"` + CategoryID uuid.UUID `json:"category_id"` + Name string `json:"name"` + Description string `json:"description"` + Price decimal.Decimal `json:"price"` + ImageURL string `json:"image_url,omitempty"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Category *Category `json:"category,omitempty"` +} + +type InventoryResponse struct { + ProductID uuid.UUID `json:"product_id"` + TotalQuantity int `json:"total_quantity"` + ReservedQuantity int `json:"reserved_quantity"` + AvailableQuantity int `json:"available_quantity"` + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ============================================================ +// HELPER FUNCTIONS +// ============================================================ + +// ToResponse converts a Product entity to its response DTO. +func (p *Product) ToResponse() *ProductResponse { + resp := &ProductResponse{ + ID: p.ID, + CategoryID: p.CategoryID, + Name: p.Name, + Description: p.Description, + Price: p.Price, + ImageURL: p.ImageURL, + IsActive: p.IsActive, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + + if p.Category != nil { + resp.Category = p.Category + } + + return resp +} + +// ToResponse converts a Category entity to its response DTO. +func (c *Category) ToResponse() *CategoryResponse { + return &CategoryResponse{ + ID: c.ID, + Name: c.Name, + Slug: c.Slug, + ParentID: c.ParentID, + CreatedAt: c.CreatedAt, + } +} + +// ToResponse converts an Inventory entity to its response DTO. +func (i *Inventory) ToResponse() *InventoryResponse { + return &InventoryResponse{ + ProductID: i.ProductID, + TotalQuantity: i.TotalQuantity, + ReservedQuantity: i.ReservedQuantity, + AvailableQuantity: i.AvailableQuantity(), + Version: i.Version, + UpdatedAt: i.UpdatedAt, + } +} diff --git a/services/product-service/internal/domain/repository.go b/services/product-service/internal/domain/repository.go new file mode 100644 index 0000000..74462d3 --- /dev/null +++ b/services/product-service/internal/domain/repository.go @@ -0,0 +1,35 @@ +package domain + +import "github.com/google/uuid" + +type ProductFilter struct { + Q string + CategoryID *uuid.UUID + MinPrice *float64 + MaxPrice *float64 + Sort string + Page int + Limit int +} + +type ProductListResponse struct { + Product []Product + Total int64 + Page int + Limit int +} + +type ProductRepository interface { + // product operations + GetProducts(filter ProductFilter) (ProductListResponse, error) + GetProductByID(id uuid.UUID) (*Product, error) + CreateProduct(product *Product) (*Product, error) + UpdateProduct(product *Product) (*Product, error) + DeleteProduct(id uuid.UUID) error + + // category operations + GetCategories() ([]Category, error) + GetCategoryByID(id uuid.UUID) (*Category, error) + GetCategoryBySlug(slug string) (*Category, error) + CreateCategory(category *Category) (*Category, error) +} From 9d98d849df8e51a6ab6bf4a1327e31b84161a7c3 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Wed, 15 Apr 2026 10:07:26 +0700 Subject: [PATCH 2/6] feat: Add product repository implementation with full text search, filtering, sorting, and pagination --- services/product-service/go.mod | 4 + services/product-service/go.sum | 8 + .../internal/domain/repository.go | 4 +- .../internal/repository/product_repository.go | 157 ++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 services/product-service/internal/repository/product_repository.go diff --git a/services/product-service/go.mod b/services/product-service/go.mod index 3673997..0a14259 100644 --- a/services/product-service/go.mod +++ b/services/product-service/go.mod @@ -4,6 +4,10 @@ go 1.25.8 require ( github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.11 // indirect + gorm.io/gorm v1.31.1 // indirect ) diff --git a/services/product-service/go.sum b/services/product-service/go.sum index f342c22..7edc16d 100644 --- a/services/product-service/go.sum +++ b/services/product-service/go.sum @@ -1,6 +1,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/services/product-service/internal/domain/repository.go b/services/product-service/internal/domain/repository.go index 74462d3..1e71ae8 100644 --- a/services/product-service/internal/domain/repository.go +++ b/services/product-service/internal/domain/repository.go @@ -13,7 +13,7 @@ type ProductFilter struct { } type ProductListResponse struct { - Product []Product + Products []Product Total int64 Page int Limit int @@ -21,7 +21,7 @@ type ProductListResponse struct { type ProductRepository interface { // product operations - GetProducts(filter ProductFilter) (ProductListResponse, error) + GetProducts(filter ProductFilter) (*ProductListResponse, error) GetProductByID(id uuid.UUID) (*Product, error) CreateProduct(product *Product) (*Product, error) UpdateProduct(product *Product) (*Product, error) diff --git a/services/product-service/internal/repository/product_repository.go b/services/product-service/internal/repository/product_repository.go new file mode 100644 index 0000000..e222d9b --- /dev/null +++ b/services/product-service/internal/repository/product_repository.go @@ -0,0 +1,157 @@ +package repository + +import ( + // "auron/product-service/internal/domain" + + "auron/product-service/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ProductRepository struct { + db *gorm.DB +} + +func NewProductRepository(db *gorm.DB) domain.ProductRepository { + return &ProductRepository{db: db} +} + +func (r *ProductRepository) GetProducts(filter domain.ProductFilter) (*domain.ProductListResponse, error) { + query := r.db.Model(&domain.Product{}).Where("is_active = ?", true) + + // filter by category + if filter.CategoryID != nil { + query = query.Where("category_id = ?", *filter.CategoryID) + } + + // price range filters + if filter.MinPrice != nil { + query = query.Where("price >= ?", *filter.MinPrice) + } + if filter.MaxPrice != nil { + query = query.Where("price <= ?", *filter.MaxPrice) + } + + // full text search + if filter.Q != "" { + query = query.Where("search_vector @@ plainto_tsquery('english', ?)", &filter.Q) + } + + // apply sorting + query = r.applySort(query, filter.Sort) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + // apply pagination + offset := (filter.Page - 1) * filter.Limit + query = query.Offset(offset).Limit(filter.Limit) + + // execite query with category preload + var products []domain.Product + if err := query.Preload("Category").Find(&products).Error; err != nil { + return nil, err + } + + return &domain.ProductListResponse{ + Products: products, + Total: total, + Page: filter.Page, + Limit: filter.Limit, + }, nil +} + +func (r *ProductRepository) GetProductByID(id uuid.UUID) (*domain.Product, error) { + var product domain.Product + if err := r.db.Preload("Category").First(&product, "id = ? AND is_active = ?", id, true).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrProductNotFound + } + return nil, err + } + return &product, nil +} + +func (r *ProductRepository) CreateProduct(product *domain.Product) (*domain.Product, error) { + if err := r.db.Create(product).Error; err != nil { + return nil, err + } + return product, nil +} + +func (r *ProductRepository) UpdateProduct(product *domain.Product) (*domain.Product, error) { + if err := r.db.Save(product).Error; err != nil { + return nil, err + } + return product, nil +} + +func (r *ProductRepository) DeleteProduct(id uuid.UUID) error { + if err := r.db.Where("id = ?", id).Delete(&domain.Product{}).Error; err != nil { + return err + } + return nil +} + +func (r *ProductRepository) GetCategories() ([]domain.Category, error) { + var categories []domain.Category + if err := r.db.Find(&categories).Error; err != nil { + return nil, err + } + return categories, nil +} + +func (r *ProductRepository) GetCategoryByID(id uuid.UUID) (*domain.Category, error) { + var category domain.Category + if err := r.db.First(&category, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrCategoryNotFound + } + return nil, err + } + return &category, nil +} + +func (r *ProductRepository) GetCategoryBySlug(slug string) (*domain.Category, error) { + var category domain.Category + if err := r.db.First(&category, "slug = ?", slug).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrCategoryNotFound + } + return nil, err + } + return &category, nil +} + +func (r *ProductRepository) CreateCategory(category *domain.Category) (*domain.Category, error) { + if err := r.db.Create(category).Error; err != nil { + return nil, err + } + return category, nil +} + +func (r *ProductRepository) applySort(query *gorm.DB, sort string) *gorm.DB { + switch sort { + case "price_asc": + return query.Order("price ASC") + + case "price_desc": + return query.Order("price DESC") + + case "newest": + return query.Order("created_at DESC") + + case "name_asc": + return query.Order("name ASC") + + case "name_desc": + return query.Order("name DESC") + + default: + return query.Order("created_at DESC") // default newest first + } + +} From e05aae1bb2e3baf3b4d8f67ac2c65e8f5ad731b4 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Wed, 15 Apr 2026 10:30:02 +0700 Subject: [PATCH 3/6] feat: Implement product caching with Redis for improved performance and data retrieval --- services/product-service/go.mod | 4 + services/product-service/go.sum | 8 ++ .../internal/cache/product_cache.go | 120 ++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 services/product-service/internal/cache/product_cache.go diff --git a/services/product-service/go.mod b/services/product-service/go.mod index 0a14259..5e8e0bf 100644 --- a/services/product-service/go.mod +++ b/services/product-service/go.mod @@ -3,9 +3,13 @@ module auron/product-service go 1.25.8 require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/services/product-service/go.sum b/services/product-service/go.sum index 7edc16d..2c6cfac 100644 --- a/services/product-service/go.sum +++ b/services/product-service/go.sum @@ -1,9 +1,17 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= diff --git a/services/product-service/internal/cache/product_cache.go b/services/product-service/internal/cache/product_cache.go new file mode 100644 index 0000000..87d37b6 --- /dev/null +++ b/services/product-service/internal/cache/product_cache.go @@ -0,0 +1,120 @@ +package cache + +import ( + "auron/product-service/internal/domain" + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + ProductDetailPrefix = "product:" + ProductListPrefix = "product:list" + cacheTTL = 5 * time.Minute +) + +type ProductCache struct { + redis *redis.Client +} + +func NewProductCache(redisClient *redis.Client) domain.ProductCache { + return &ProductCache{redis: redisClient} +} + +func (c *ProductCache) GetProduct(ctx context.Context, id string) (*domain.Product, error) { + key := ProductDetailPrefix + id + + cached, err := c.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var product domain.Product + if err := json.Unmarshal([]byte(cached), &product); err != nil { + return nil, err + } + + return &product, nil +} + +func (c *ProductCache) SetProduct(ctx context.Context, product *domain.Product) error { + key := ProductDetailPrefix + product.ID.String() + data, err := json.Marshal(product) + if err != nil { + return err + } + + return c.redis.Set(ctx, key, data, cacheTTL).Err() +} + +func (c *ProductCache) DeleteProduct(ctx context.Context, id string) error { + key := ProductDetailPrefix + id + return c.redis.Del(ctx, key).Err() +} + +func (c *ProductCache) GetProductList(ctx context.Context, cacheKey string) (*domain.ProductListResponse, error) { + cached, err := c.redis.Get(ctx, cacheKey).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var response domain.ProductListResponse + if err := json.Unmarshal([]byte(cached), &response); err != nil { + return nil, err + } + + return &response, nil +} + +func (c *ProductCache) SetProductList(ctx context.Context, cacheKey string, response *domain.ProductListResponse) error { + data, err := json.Marshal(response) + if err != nil { + return err + } + + return c.redis.Set(ctx, cacheKey, data, cacheTTL).Err() +} + +func (c *ProductCache) InvalidateProductList(ctx context.Context) error { + var cursor uint64 + pattern := ProductListPrefix + "*" + + for { + keys, nextCursor, err := c.redis.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + return err + } + + if len(keys) > 0 { + if err := c.redis.Del(ctx, keys...).Err(); err != nil { + return err + } + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + return nil +} + +func (c *ProductCache) ClearAll(ctx context.Context) error { + return c.redis.FlushDB(ctx).Err() +} + +func GenerateCacheKey(filter domain.ProductFilter) string { + // create a has from filter params for list caching + hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", filter.Q, filter.CategoryID.String(), fmt.Sprintf("%.2f", *filter.MinPrice), fmt.Sprintf("%.2f", *filter.MaxPrice), filter.Page, filter.Limit) + + return ProductListPrefix + hash +} From c49eda0ad961207c7011d670fbd3d0178a6ae3fa Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 10:28:01 +0700 Subject: [PATCH 4/6] fix: Align JWT algorithm to HS256 and wire gateway auth middleware - Replace RSA/RS256 JWT validation in api-gateway with HS256 HMAC to match tokens issued by user-service; token claims now read user UUID from the standard "sub" field instead of the missing "user_id" field - Add JWT_SECRET env var to api-gateway config, docker-compose, and .env files; remove defunct JWT_PUBLIC_KEY / PEM file loading - Wire RequireAuth and RequireRole("admin") into all protected routes: logout, /users/*, /cart/*, /orders/*, payments GET, inventory (admin), and product/category write endpoints (admin); public GETs unchanged - Stripe webhook route kept unauthenticated (Stripe signs its own payload) - Add FIXES_PLAN.md with implementation plan for remaining tasks Co-Authored-By: Claude Sonnet 4.6 --- FIXES_PLAN.md | 450 ++++++++++++++++++++++++ docker-compose.yml | 1 + services/api-gateway/.env.example | 2 +- services/api-gateway/config/config.go | 4 +- services/api-gateway/middleware/auth.go | 162 +++------ services/api-gateway/routes/router.go | 67 ++-- 6 files changed, 540 insertions(+), 146 deletions(-) create mode 100644 FIXES_PLAN.md diff --git a/FIXES_PLAN.md b/FIXES_PLAN.md new file mode 100644 index 0000000..20d0407 --- /dev/null +++ b/FIXES_PLAN.md @@ -0,0 +1,450 @@ +# Auron — Fixes & Product Service Completion Plan + +> **Scope:** JWT algorithm fix · Gateway auth wiring · Product service layer · Product service entry point · Cache key nil panic +> **Branch:** `feature/product-service` +> **Order:** Tasks must be done in sequence — each builds on the previous. + +--- + +## Table of Contents + +1. [Task 1 — Fix JWT Algorithm Mismatch](#task-1--fix-jwt-algorithm-mismatch) +2. [Task 2 — Wire Auth Middleware in Gateway](#task-2--wire-auth-middleware-in-gateway) +3. [Task 3 — Implement Product Service Layer](#task-3--implement-product-service-layer) +4. [Task 4 — Add Product Service Entry Point](#task-4--add-product-service-entry-point) +5. [Task 5 — Fix GenerateCacheKey Nil Panic](#task-5--fix-generatecachekey-nil-panic) +6. [Verification Checklist](#verification-checklist) + +--- + +## Task 1 — Fix JWT Algorithm Mismatch + +### Problem + +The user service signs tokens with **HS256** but the API Gateway validates expecting **RS256 (RSA)**. Every token the user-service issues fails gateway validation — auth is completely broken end-to-end. + +| File | Algorithm | Secret Source | +|---|---|---| +| `services/user-service/internal/service/user_service.go:369` | HS256 | `JWT_SECRET` env var | +| `services/user-service/internal/middleware/auth_middleware.go:39` | HS256 | `JWT_SECRET` env var | +| `services/api-gateway/middleware/auth.go:94` | **RS256** | PEM file at `JWT_PUBLIC_KEY` path | + +**Decision: standardize on HS256.** Both user-service files already use it. RS256 is architecturally better for multi-service trust but adds operational complexity (key file management). Can be upgraded later. + +### Files to Change + +#### `services/api-gateway/middleware/auth.go` + +- Replace `type JWTMiddleware struct { publicKey *rsa.PublicKey }` with `type JWTMiddleware struct { secret []byte }` +- Replace `NewJWTMiddleware(keyPath string)` (reads PEM file) with `NewJWTMiddleware(secret string)` (takes raw string) +- In `Auth()` and `RequireAuth()`: change the `jwt.ParseWithClaims` key func to check for `*jwt.SigningMethodHMAC` and return `j.secret` +- Update the `Claims` struct: the user-service puts the user UUID in `"sub"`, not `"user_id"`. Change `UserID string json:"user_id"` → `Sub string json:"sub"` and set `c.Set(UserIDKey, claims.Sub)` inside the middleware +- Remove now-unused imports: `crypto/rsa`, `encoding/pem`, `io/ioutil` + +#### `services/api-gateway/config/config.go` + +- Replace `JWTPublicKeyPath string` (env: `JWT_PUBLIC_KEY`) with `JWTSecret string` (env: `JWT_SECRET`, no default — must be set explicitly) + +#### `services/api-gateway/main.go` + +- Replace `middleware.NewJWTMiddleware(cfg.JWTPublicKeyPath)` with `middleware.NewJWTMiddleware(cfg.JWTSecret)` + +#### `services/api-gateway/.env` and `.env.example` + +- Remove `JWT_PUBLIC_KEY=...` +- Add `JWT_SECRET=` + +#### `docker-compose.yml` + +- Add `JWT_SECRET` to the `api-gateway` environment block, sourced from the root `.env` so both containers share the identical value + +--- + +## Task 2 — Wire Auth Middleware in Gateway + +### Problem + +`JWTMiddleware.RequireAuth()` and `RequireRole()` exist but are **never called** in `services/api-gateway/routes/router.go`. All routes — including write endpoints, user profile, cart, and orders — are fully unprotected. + +### Route Protection Matrix + +| Route | Methods | Auth | Role | +|---|---|---|---| +| `/api/auth/logout` | POST | Yes | Any | +| `/api/users/*` | All | Yes | Any | +| `/api/products` | GET | No | — | +| `/api/products/:id` | GET | No | — | +| `/api/products` | POST | Yes | `admin` | +| `/api/products/:id` | PUT, DELETE | Yes | `admin` | +| `/api/categories` | GET | No | — | +| `/api/categories` | POST | Yes | `admin` | +| `/api/cart/*` | All | Yes | Any | +| `/api/orders/*` | All | Yes | Any | +| `/api/payments/:id` | GET | Yes | Any | +| `/api/payments/webhook/stripe` | POST | **No** | — (Stripe signs its own payload) | +| `/api/inventory/*` | All | Yes | `admin` | + +### Files to Change + +#### `services/api-gateway/routes/router.go` + +At the top of `Setup()`, instantiate the middleware using the secret from config (after Task 1 lands): + +```go +jwtMiddleware, err := middleware.NewJWTMiddleware(cfg.JWTSecret) +if err != nil { + return fmt.Errorf("create jwt middleware: %w", err) +} +requireAuth := jwtMiddleware.RequireAuth() +requireAdmin := gin.HandlersChain{jwtMiddleware.RequireAuth(), jwtMiddleware.RequireRole("admin")} +``` + +Then apply per group: + +- `auth` group: add `requireAuth` to the `authProtected` sub-group (logout route) +- `users` group: add `requireAuth` to the group-level `Use()` +- `products` write routes: change the three write routes to use `requireAdmin` handlers prepended +- `categories` POST: add `requireAdmin` +- `cart` group: add `requireAuth` to group-level `Use()` +- `orders` group: add `requireAuth` to group-level `Use()` +- `payments.GET("/:id")`: add `requireAuth` inline on that route only +- `inventory` group: add `requireAdmin` to group-level `Use()` + +> **Note:** `proxy.go` already forwards `X-User-ID`, `X-User-Email`, `X-User-Role` headers downstream once the context keys are set by the middleware — no changes needed there. + +--- + +## Task 3 — Implement Product Service Layer + +### Problem + +Every method in `services/product-service/internal/service/product_service.go` returns `nil, nil` — the file is entirely stubs. Additionally, the concrete method signatures don't match the `domain.ProductService` interface (wrong argument types, missing `context.Context`), so it won't compile. + +### Sub-task 3.0 — Fix Interface Signature Mismatch First + +`domain.ProductService` interface (`internal/domain/service.go`) uses `context.Context` + `uuid.UUID`: + +```go +GetProductByID(ctx context.Context, id uuid.UUID) (*Product, error) +``` + +But the concrete struct uses bare `string` with no context: + +```go +GetProductByID(id string) (*Product, error) // ← won't satisfy interface +``` + +**Fix:** Update every method signature in `product_service.go` to match `domain.ProductService` exactly — add `ctx context.Context` as first param and use `uuid.UUID` (not `string`) for IDs. + +### Sub-task 3.1 — Read Methods (cache-aside pattern) + +**`GetProductByID(ctx, id)`** +1. `cache.GetProduct(ctx, id.String())` +2. On cache miss → `repo.GetProductByID(id)` +3. `cache.SetProduct(ctx, product)` — log error, don't fail +4. Return product + +**`GetProducts(ctx, filter)`** +1. Validate filter (page ≥ 1, limit 1–100, sort in `ValidSorts`) +2. Build cache key via `GenerateCacheKey(filter)` (fixed in Task 5) +3. `cache.GetProductList(ctx, cacheKey)` +4. On cache miss → `repo.GetProducts(filter)` +5. `cache.SetProductList(ctx, cacheKey, result)` — log error, don't fail +6. Return result + +**`GetCategories(ctx)`, `GetCategoryByID(ctx, id)`, `GetCategoryBySlug(ctx, slug)`** +- Direct repo calls — no caching needed at this stage + +### Sub-task 3.2 — Write Methods (invalidate cache + publish event) + +**`CreateProduct(ctx, req)`** +1. Verify category exists: `repo.GetCategoryByID(req.CategoryID)` → `ErrCategoryNotFound` if missing +2. Build `domain.Product` from request; set `ID = uuid.New()`, timestamps +3. `repo.CreateProduct(&product)` +4. `cache.SetProduct(ctx, product)` + `cache.InvalidateProductList(ctx)` +5. `publisher.Publish(ctx, TopicProductCreated, product)` — log error, don't fail request +6. Return product + +**`UpdateProduct(ctx, id, req)`** +1. Fetch existing: `repo.GetProductByID(id)` → propagate `ErrProductNotFound` +2. If `CategoryID` changed, verify new category exists +3. Apply fields from req, update `UpdatedAt` +4. `repo.UpdateProduct(&product)` +5. `cache.SetProduct(ctx, product)` + `cache.InvalidateProductList(ctx)` +6. `publisher.Publish(ctx, TopicProductUpdated, product)` +7. Return product + +**`DeleteProduct(ctx, id)`** +1. Verify exists: `repo.GetProductByID(id)` +2. `repo.DeleteProduct(id)` +3. `cache.DeleteProduct(ctx, id.String())` + `cache.InvalidateProductList(ctx)` +4. `publisher.Publish(ctx, TopicProductDeleted, gin.H{"product_id": id})` +5. Return nil + +**`CreateCategory(ctx, req)`** +1. Check slug uniqueness: `repo.GetCategoryBySlug(req.Slug)` → if found, return `ErrCategorySlugExists` +2. Build `domain.Category`; set `ID = uuid.New()` +3. `repo.CreateCategory(&category)` +4. Return category + +--- + +## Task 4 — Add Product Service Entry Point + +### Problem + +The product service has no `main.go`, no `Dockerfile`, no config loader, no HTTP handler, no route layer. It cannot be built or run. + +### Files to Create + +``` +services/product-service/ +├── main.go +├── Dockerfile +├── .env +├── .env.example +├── cmd/ +│ ├── config.go +│ ├── dotenv.go +│ ├── infrastructure.go +│ ├── kafka.go +│ ├── run.go +│ └── server.go +└── internal/ + ├── handler/ + │ └── product_handler.go + └── route/ + └── product_route.go +``` + +### `cmd/config.go` + +```go +type Config struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string +} +``` + +Env vars: `PORT` (default `"8082"`), `DATABASE_URL` (required), `REDIS_URL` (default `"redis://localhost:6379/0"`), `KAFKA_BROKERS` (default `"localhost:9092"`). + +### `cmd/dotenv.go` + +Same pattern as user-service: silently skip if `.env` is absent. + +### `cmd/infrastructure.go` + +- **DB:** GORM + `gorm.io/driver/postgres`. Run `AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{})`. Also run the tsvector trigger SQL from `db/004_create_search_index.up.sql` via `db.Exec(...)` after AutoMigrate. +- **Redis:** `redis.ParseURL(cfg.RedisURL)` → `redis.NewClient(opt)` +- **Kafka:** One `kafka.Writer` per topic (`product.created`, `product.updated`, `product.deleted`) — same pattern as `services/user-service/cmd/kafka.go` + +### `cmd/run.go` + +Wire the dependency graph in order: + +``` +config → db, redis, kafka +db → NewProductRepository(db) +redis → NewProductCache(redisClient) +kafka → NewKafkaPublisher(writers) +repo + cache + publisher → NewProductService(...) +service → NewProductHandler(service) +handler → RegisterProductRoutes(router, handler) +``` + +Register graceful shutdown (close DB, Redis, Kafka writers on SIGINT/SIGTERM). + +### `cmd/server.go` + +Same pattern as user-service `cmd/server.go`: +- `gin.SetMode(gin.ReleaseMode)` +- `gin.New()` + `gin.Logger()` + `gin.Recovery()` +- `GET /health` → `{"status":"healthy","service":"product-service"}` +- `GET /metrics` → stub (Prometheus later) +- Call `route.RegisterProductRoutes(router, h)` + +### `main.go` + +```go +package main + +import "auron/product-service/cmd" + +func main() { cmd.Run() } +``` + +### `internal/handler/product_handler.go` + +One handler per endpoint. Keep handlers thin — only HTTP binding and error mapping, no business logic. + +| Handler | HTTP | Domain call | +|---|---|---| +| `GetProducts` | `GET /products` | `service.GetProducts(ctx, filter)` | +| `GetProductByID` | `GET /products/:id` | `service.GetProductByID(ctx, id)` | +| `CreateProduct` | `POST /products` | `service.CreateProduct(ctx, req)` | +| `UpdateProduct` | `PUT /products/:id` | `service.UpdateProduct(ctx, id, req)` | +| `DeleteProduct` | `DELETE /products/:id` | `service.DeleteProduct(ctx, id)` | +| `GetCategories` | `GET /categories` | `service.GetCategories(ctx)` | +| `CreateCategory` | `POST /categories` | `service.CreateCategory(ctx, req)` | + +**`GetProducts` query param parsing:** + +| Param | Type | Default | Validation | +|---|---|---|---| +| `q` | string | `""` | none | +| `category_id` | UUID string | nil | `uuid.Parse` → 400 on invalid | +| `min_price` | float64 | nil | `strconv.ParseFloat` → 400 on invalid | +| `max_price` | float64 | nil | same | +| `sort` | string | `"newest"` | validated in service layer | +| `page` | int | 1 | validated in service layer | +| `limit` | int | 20 | validated in service layer | + +**`handleServiceError` mapping:** + +| Domain Error | HTTP Status | +|---|---| +| `ErrProductNotFound`, `ErrCategoryNotFound` | 404 | +| `ErrCategorySlugExists`, `ErrProductAlreadyExists` | 409 | +| `ErrInvalidSortParam`, `ErrInvalidPageParam`, `ErrInvalidLimitParam`, `ErrPriceMustBePositive` | 400 | +| `ErrUnauthorized` | 401 | +| `ErrForbidden` | 403 | +| everything else | 500 | + +### `internal/route/product_route.go` + +```go +func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { + api := router.Group("/") + api.GET("/products", h.GetProducts) + api.GET("/products/:id", h.GetProductByID) + api.POST("/products", h.CreateProduct) + api.PUT("/products/:id", h.UpdateProduct) + api.DELETE("/products/:id",h.DeleteProduct) + api.GET("/categories", h.GetCategories) + api.POST("/categories", h.CreateCategory) +} +``` + +Auth enforcement lives at the **gateway** (Task 2). The product service trusts `X-User-Role` injected by the gateway. + +### `Dockerfile` + +Multi-stage build identical to `services/user-service/Dockerfile`, changing: +- Binary output name: `/product-service` +- `EXPOSE 8082` +- `CMD ["./product-service"]` + +### `.env` / `.env.example` + +```env +PORT=8082 +DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable +REDIS_URL=redis://localhost:6380/0 +KAFKA_BROKERS=localhost:9092 +``` + +### `go.mod` — missing dependencies to add + +``` +github.com/gin-gonic/gin +gorm.io/driver/postgres +github.com/segmentio/kafka-go +``` + +Run `go mod tidy` after editing. + +--- + +## Task 5 — Fix GenerateCacheKey Nil Panic + +### Problem + +`services/product-service/internal/cache/product_cache.go:117` unconditionally dereferences three optional pointer fields: + +```go +// Current code — panics when any filter field is nil +hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", + filter.Q, + filter.CategoryID.String(), // nil pointer panic + fmt.Sprintf("%.2f", *filter.MinPrice), // nil pointer panic + fmt.Sprintf("%.2f", *filter.MaxPrice), // nil pointer panic + filter.Page, + filter.Limit, +) +``` + +Every `GET /products` request without all three filters panics and crashes the service. + +### Fix + +Replace with nil-safe guards before formatting: + +```go +func GenerateCacheKey(filter domain.ProductFilter) string { + categoryID := "" + if filter.CategoryID != nil { + categoryID = filter.CategoryID.String() + } + + minPrice := "0.00" + if filter.MinPrice != nil { + minPrice = fmt.Sprintf("%.2f", *filter.MinPrice) + } + + maxPrice := "0.00" + if filter.MaxPrice != nil { + maxPrice = fmt.Sprintf("%.2f", *filter.MaxPrice) + } + + key := fmt.Sprintf("%s_%s_%s_%s_%d_%d", + filter.Q, categoryID, minPrice, maxPrice, + filter.Page, filter.Limit, + ) + return ProductListPrefix + ":" + key +} +``` + +The `InvalidateProductList` scan pattern `"product:list*"` still matches after adding the `:` separator — no other changes needed. + +--- + +## Verification Checklist + +### After Task 1 +- [ ] `go build ./...` passes inside `services/api-gateway` +- [ ] `NewJWTMiddleware` no longer reads any file +- [ ] `JWT_SECRET` present in `api-gateway/.env` matching `user-service/.env` +- [ ] `docker-compose.yml` passes `JWT_SECRET` to `api-gateway` + +### After Task 2 +- [ ] `POST /api/products` without token → `401` +- [ ] `GET /api/products` without token → `200` +- [ ] `POST /api/payments/webhook/stripe` without token → `200` (not 401) +- [ ] `GET /api/users/me` without token → `401` +- [ ] Login via user-service → use returned token → `GET /api/users/me` → `200` + +### After Task 3 +- [ ] `go build ./...` passes inside `services/product-service` +- [ ] `ProductService` struct satisfies `domain.ProductService` interface (compiler verifies) +- [ ] Cache miss path calls repository +- [ ] Cache hit path does NOT call repository +- [ ] Write methods publish to Kafka; if Kafka is unavailable, request still succeeds + +### After Task 4 +- [ ] `go build ./...` passes inside `services/product-service` +- [ ] `docker build -t product-service .` succeeds +- [ ] `GET /health` → `200 {"status":"healthy"}` +- [ ] `GET /products` → `200` with paginated list +- [ ] `POST /products` with valid body → `201` with created product +- [ ] `GET /products?q=laptop` → FTS results +- [ ] `GET /products?sort=invalid` → `400` +- [ ] `GET /products/:nonexistent-id` → `404` + +### After Task 5 +- [ ] `GET /products` (no filters) does not panic +- [ ] `GET /products?category_id=` does not panic +- [ ] `GET /products?min_price=10` without `max_price` does not panic +- [ ] Two requests with different filters produce different cache keys +- [ ] Two requests with identical filters produce the same cache key diff --git a/docker-compose.yml b/docker-compose.yml index 3bf6488..da1df40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: - ./services/api-gateway/.env environment: - PORT=8080 + - JWT_SECRET=${JWT_SECRET} - USER_SERVICE_URL=http://user-service:8081 - PRODUCT_SERVICE_URL=http://product-service:8082 - ORDER_SERVICE_URL=http://order-service:8083 diff --git a/services/api-gateway/.env.example b/services/api-gateway/.env.example index 0c80c47..da65b31 100644 --- a/services/api-gateway/.env.example +++ b/services/api-gateway/.env.example @@ -20,6 +20,6 @@ SERVICE_URLS= # SERVICE_URL_NOTIFICATION=http://localhost:8086 REDIS_URL=redis://localhost:6379/0 -JWT_PUBLIC_KEY=/run/secrets/jwt-public-key +JWT_SECRET=your-secret-here # must match JWT_SECRET in user-service RATE_LIMIT_REQUESTS=100 RATE_LIMIT_WINDOW=1m diff --git a/services/api-gateway/config/config.go b/services/api-gateway/config/config.go index f7ce785..1ee080c 100644 --- a/services/api-gateway/config/config.go +++ b/services/api-gateway/config/config.go @@ -21,7 +21,7 @@ type Config struct { Port string ServiceURLs map[string]string RedisURL string - JWTPublicKeyPath string + JWTSecret string RateLimitRequests int RateLimitWindow time.Duration } @@ -65,7 +65,7 @@ func Load() *Config { Port: getEnv("PORT", "8080"), ServiceURLs: serviceURLs, RedisURL: getEnv("REDIS_URL", "redis://localhost:6379/0"), - JWTPublicKeyPath: getEnv("JWT_PUBLIC_KEY", "/run/secrets/jwt-public-key"), + JWTSecret: getEnv("JWT_SECRET", ""), RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100), RateLimitWindow: getEnvDuration("RATE_LIMIT_WINDOW", time.Minute), } diff --git a/services/api-gateway/middleware/auth.go b/services/api-gateway/middleware/auth.go index f3ec627..8eb5c81 100644 --- a/services/api-gateway/middleware/auth.go +++ b/services/api-gateway/middleware/auth.go @@ -1,11 +1,7 @@ package middleware import ( - "crypto/rsa" - "encoding/pem" - "errors" "fmt" - "io/ioutil" "net/http" "strings" @@ -14,115 +10,59 @@ import ( ) const ( - // AuthorizationHeader is the header name for authorization AuthorizationHeader = "Authorization" - // UserIDKey is the context key for user ID - UserIDKey = "user_id" - // UserEmailKey is the context key for user email - UserEmailKey = "user_email" - // UserRoleKey is the context key for user role - UserRoleKey = "user_role" + UserIDKey = "user_id" + UserEmailKey = "user_email" + UserRoleKey = "user_role" ) -// Claims represents JWT claims +// Claims represents JWT claims produced by user-service (HS256). +// The user UUID lives in the standard "sub" field (RegisteredClaims.Subject). type Claims struct { jwt.RegisteredClaims - UserID string `json:"user_id"` - Email string `json:"email"` - Role string `json:"role"` + Email string `json:"email"` + Role string `json:"role"` } -// JWTMiddleware handles JWT validation +// JWTMiddleware validates HS256 tokens signed with a shared secret. type JWTMiddleware struct { - publicKey *rsa.PublicKey + secret []byte } -// NewJWTMiddleware creates a new JWT middleware -func NewJWTMiddleware(keyPath string) (*JWTMiddleware, error) { - keyData, err := ioutil.ReadFile(keyPath) - if err != nil { - return nil, fmt.Errorf("failed to read JWT public key: %w", err) - } - - block, _ := pem.Decode(keyData) - if block == nil || block.Type != "PUBLIC KEY" { - return nil, errors.New("failed to parse PEM block containing public key") +// NewJWTMiddleware creates a JWT middleware from the shared HMAC secret. +func NewJWTMiddleware(secret string) (*JWTMiddleware, error) { + if secret == "" { + return nil, fmt.Errorf("JWT_SECRET must not be empty") } + return &JWTMiddleware{secret: []byte(secret)}, nil +} - pubKey, err := jwt.ParseRSAPublicKeyFromPEM(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse RSA public key: %w", err) +// parseToken validates the token string and returns its claims. +func (j *JWTMiddleware) parseToken(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return j.secret, nil + }) + if err != nil || !token.Valid { + return nil, fmt.Errorf("invalid or expired token") } - - return &JWTMiddleware{ - publicKey: pubKey, - }, nil + return claims, nil } -// Auth returns an authentication middleware +// Auth returns a middleware that validates the JWT and populates context keys. +// Proceeds even if validation fails — use RequireAuth to block unauthenticated requests. func (j *JWTMiddleware) Auth() gin.HandlerFunc { - return func(c *gin.Context) { - authHeader := c.GetHeader(AuthorizationHeader) - if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "Authorization header is required", - }, - }) - return - } - - // Extract token from "Bearer " - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "Invalid authorization header format", - }, - }) - return - } - - tokenString := parts[1] - - // Parse and validate token - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return j.publicKey, nil - }) - - if err != nil || !token.Valid { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "Invalid or expired token", - }, - }) - return - } - - // Set claims in context - c.Set(UserIDKey, claims.UserID) - c.Set(UserEmailKey, claims.Email) - c.Set(UserRoleKey, claims.Role) - - c.Next() - } + return j.RequireAuth() } -// RequireAuth returns a middleware that requires authentication +// RequireAuth returns a middleware that rejects requests without a valid JWT. func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader(AuthorizationHeader) - if authHeader == "" { + tokenString := extractBearerToken(c.GetHeader(AuthorizationHeader)) + if tokenString == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "success": false, "error": gin.H{ @@ -133,29 +73,8 @@ func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc { return } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "Invalid authorization header format", - }, - }) - return - } - - tokenString := parts[1] - - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return j.publicKey, nil - }) - - if err != nil || !token.Valid { + claims, err := j.parseToken(tokenString) + if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "success": false, "error": gin.H{ @@ -166,7 +85,8 @@ func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc { return } - c.Set(UserIDKey, claims.UserID) + // Subject holds the user UUID ("sub" claim set by user-service) + c.Set(UserIDKey, claims.Subject) c.Set(UserEmailKey, claims.Email) c.Set(UserRoleKey, claims.Role) @@ -207,6 +127,14 @@ func (j *JWTMiddleware) RequireRole(roles ...string) gin.HandlerFunc { } } +func extractBearerToken(header string) string { + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + // GetUserID returns the user ID from the context func GetUserID(c *gin.Context) string { if userID, exists := c.Get(UserIDKey); exists { diff --git a/services/api-gateway/routes/router.go b/services/api-gateway/routes/router.go index f38c0d1..efcf93b 100644 --- a/services/api-gateway/routes/router.go +++ b/services/api-gateway/routes/router.go @@ -17,6 +17,15 @@ func Setup(router *gin.Engine, cfg *config.Config) error { if err != nil { return fmt.Errorf("create proxy handler: %w", err) } + + // Create JWT middleware (HS256, shared secret with user-service) + jwtMiddleware, err := middleware.NewJWTMiddleware(cfg.JWTSecret) + if err != nil { + return fmt.Errorf("create jwt middleware: %w", err) + } + requireAuth := jwtMiddleware.RequireAuth() + requireRole := jwtMiddleware.RequireRole("admin") + toUserAuthService := proxyHandler.ProxyToWithStrip(config.ServiceUser, "/api/auth") toUserAuthLegacyService := proxyHandler.ProxyToWithStrip(config.ServiceUser, "/api") toUserService := proxyHandler.ProxyToWithStrip(config.ServiceUser, "/api/users") @@ -36,18 +45,17 @@ func Setup(router *gin.Engine, cfg *config.Config) error { }) }) - // Auth routes (no auth required) + // Auth routes — public (register, login, refresh) auth := api.Group("/auth") auth.Use(middleware.RequestID()) - auth.Use(middleware.RateLimit(middleware.NewInMemoryRateLimiter(20, time.Minute))) // 20 req/min for auth + auth.Use(middleware.RateLimit(middleware.NewInMemoryRateLimiter(20, time.Minute))) { auth.POST("/register", toUserAuthService) auth.POST("/login", toUserAuthService) auth.POST("/refresh", toUserAuthService) } - // Backward-compatible auth aliases: - // /api/login, /api/register, /api/refresh + // Backward-compatible auth aliases api.POST("/register", toUserAuthLegacyService) api.POST("/register/", toUserAuthLegacyService) api.POST("/login", toUserAuthLegacyService) @@ -55,21 +63,22 @@ func Setup(router *gin.Engine, cfg *config.Config) error { api.POST("/refresh", toUserAuthLegacyService) api.POST("/refresh/", toUserAuthLegacyService) - // Protected auth routes + // Protected auth routes (logout requires a valid token) authProtected := auth.Group("") authProtected.Use(middleware.RequestID()) - // Note: Some auth routes need token validation, handled by user service + authProtected.Use(requireAuth) { authProtected.POST("/logout", toUserAuthService) } // Backward-compatible logout alias - api.POST("/logout", toUserAuthLegacyService) - api.POST("/logout/", toUserAuthLegacyService) + api.POST("/logout", requireAuth, toUserAuthLegacyService) + api.POST("/logout/", requireAuth, toUserAuthLegacyService) - // User routes (auth required) + // User routes — auth required users := api.Group("/users") users.Use(middleware.RequestID()) + users.Use(requireAuth) { users.GET("/me", toUserService) users.PUT("/me", toUserService) @@ -79,31 +88,37 @@ func Setup(router *gin.Engine, cfg *config.Config) error { users.DELETE("/me/addresses/:id", toUserService) } - // Product routes (public read, admin write) + // Product routes — public reads, admin writes products := api.Group("/products") products.Use(middleware.RequestID()) { - // Public: read operations products.GET("", toProductService) products.GET("/:id", toProductService) - - // Protected: admin only - products.POST("", toProductService) - products.PUT("/:id", toProductService) - products.DELETE("/:id", toProductService) + } + adminProducts := products.Group("") + adminProducts.Use(requireAuth, requireRole) + { + adminProducts.POST("", toProductService) + adminProducts.PUT("/:id", toProductService) + adminProducts.DELETE("/:id", toProductService) } - // Category routes + // Category routes — public read, admin write categories := api.Group("/categories") categories.Use(middleware.RequestID()) { categories.GET("", toProductService) - categories.POST("", toProductService) + } + adminCategories := categories.Group("") + adminCategories.Use(requireAuth, requireRole) + { + adminCategories.POST("", toProductService) } - // Cart routes (auth required) + // Cart routes — auth required cart := api.Group("/cart") cart.Use(middleware.RequestID()) + cart.Use(requireAuth) { cart.GET("", toOrderService) cart.POST("/items", toOrderService) @@ -111,9 +126,10 @@ func Setup(router *gin.Engine, cfg *config.Config) error { cart.DELETE("/items/:id", toOrderService) } - // Order routes (auth required) + // Order routes — auth required orders := api.Group("/orders") orders.Use(middleware.RequestID()) + orders.Use(requireAuth) { orders.GET("", toOrderService) orders.POST("", toOrderService) @@ -121,25 +137,24 @@ func Setup(router *gin.Engine, cfg *config.Config) error { orders.PUT("/:id/cancel", toOrderService) } - // Payment routes (auth required) + // Payment routes — GET requires auth; Stripe webhook is public (Stripe signs its own payload) payments := api.Group("/payments") payments.Use(middleware.RequestID()) { - payments.GET("/:id", toPaymentService) - // Stripe webhook - no auth, handled by payment service + payments.GET("/:id", requireAuth, toPaymentService) payments.POST("/webhook/stripe", toPaymentService) } - // Inventory routes (admin only) + // Inventory routes — admin only inventory := api.Group("/inventory") inventory.Use(middleware.RequestID()) + inventory.Use(requireAuth, requireRole) { inventory.GET("/:product_id", toInventoryService) inventory.PUT("/:product_id", toInventoryService) } - // Generic service route for future services. - // Example: /api/services/notification/health -> SERVICE_URL_NOTIFICATION + // Generic escape hatch: /api/services/:service/*path -> SERVICE_URL_ api.Any("/services/:service/*proxyPath", proxyHandler.ProxyByPathParam("service")) return nil From 3a0c50d1438a138d8a92670c8fe53e1c2aba5f7a Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 10:38:07 +0700 Subject: [PATCH 5/6] feat: Implement product service layer and entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 — Service layer: - Replace all stub methods in product_service.go with real implementations matching the domain.ProductService interface (context.Context + uuid.UUID) - Cache-aside pattern for GetProductByID and GetProducts (Redis → DB fallback) - Write methods (Create/Update/Delete) invalidate list cache and evict detail cache; Kafka events published async in goroutine so they never block HTTP - CreateProduct and UpdateProduct verify the target category exists first - CreateCategory guards slug uniqueness before inserting - normalizeFilter sets page/limit defaults and validates sort param - buildListCacheKey is nil-safe for CategoryID, MinPrice, MaxPrice pointers Task 4 — Entry point: - Add main.go, cmd/{config,dotenv,infrastructure,kafka,run,server}.go - Wire dependency graph: DB → repo, Redis → cache, Kafka → publisher → service → handler - infrastructure.go runs AutoMigrate for Category/Product/Inventory then applies the tsvector trigger SQL from db/004_create_search_index.up.sql via db.Exec - kafka.go creates one kafka.Writer per product topic; ensureTopics on startup - Graceful shutdown closes DB, Redis, and Kafka writers on SIGINT/SIGTERM - Add internal/events/kafka_publisher.go (mirrors user-service pattern) - Add internal/handler/product_handler.go with productBody DTO using float64 for price (clean JSON API) converted to genproto Decimal on service call - Add internal/route/product_route.go registering all product + category routes - Add Dockerfile (multi-stage golang:1.25-alpine → alpine:3.18, port 8082) - Add .env.example; pull in gin, gorm/driver/postgres, kafka-go dependencies Task 5 — Fix GenerateCacheKey nil panic: - Guard filter.CategoryID, MinPrice, MaxPrice pointer dereferences - Fix InvalidateProductList scan pattern to "product:list:*" matching new key format Co-Authored-By: Claude Sonnet 4.6 --- services/product-service/.env.example | 6 + services/product-service/Dockerfile | 23 ++ services/product-service/cmd/config.go | 41 +++ services/product-service/cmd/dotenv.go | 42 +++ .../product-service/cmd/infrastructure.go | 102 +++++++ services/product-service/cmd/kafka.go | 103 +++++++ services/product-service/cmd/run.go | 69 +++++ services/product-service/cmd/server.go | 34 +++ services/product-service/go.mod | 30 ++ services/product-service/go.sum | 127 +++++++++ .../internal/cache/product_cache.go | 22 +- .../internal/domain/service.go | 22 ++ .../internal/events/kafka_publisher.go | 51 ++++ .../internal/handler/product_handler.go | 262 ++++++++++++++++++ .../internal/route/product_route.go | 20 ++ .../internal/service/product_service.go | 249 +++++++++++++++++ services/product-service/main.go | 7 + 17 files changed, 1205 insertions(+), 5 deletions(-) create mode 100644 services/product-service/.env.example create mode 100644 services/product-service/Dockerfile create mode 100644 services/product-service/cmd/config.go create mode 100644 services/product-service/cmd/dotenv.go create mode 100644 services/product-service/cmd/infrastructure.go create mode 100644 services/product-service/cmd/kafka.go create mode 100644 services/product-service/cmd/run.go create mode 100644 services/product-service/cmd/server.go create mode 100644 services/product-service/internal/domain/service.go create mode 100644 services/product-service/internal/events/kafka_publisher.go create mode 100644 services/product-service/internal/handler/product_handler.go create mode 100644 services/product-service/internal/route/product_route.go create mode 100644 services/product-service/internal/service/product_service.go create mode 100644 services/product-service/main.go diff --git a/services/product-service/.env.example b/services/product-service/.env.example new file mode 100644 index 0000000..52cf8f5 --- /dev/null +++ b/services/product-service/.env.example @@ -0,0 +1,6 @@ +# Product Service +PORT=8082 +DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable +REDIS_URL=redis://localhost:6379/0 +KAFKA_BROKERS=localhost:9092 +GORM_LOG_LEVEL=warn diff --git a/services/product-service/Dockerfile b/services/product-service/Dockerfile new file mode 100644 index 0000000..14ac5e7 --- /dev/null +++ b/services/product-service/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /product-service . + +FROM alpine:3.18 + +RUN apk add --no-cache ca-certificates curl + +WORKDIR /app + +COPY --from=builder /product-service . + +EXPOSE 8082 + +CMD ["./product-service"] diff --git a/services/product-service/cmd/config.go b/services/product-service/cmd/config.go new file mode 100644 index 0000000..a822096 --- /dev/null +++ b/services/product-service/cmd/config.go @@ -0,0 +1,41 @@ +package cmd + +import "os" + +type appConfig struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string +} + +func loadConfig() appConfig { + loadDotEnvFile(".env") + + port := os.Getenv("PORT") + if port == "" { + port = "8082" + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable" + } + + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + redisURL = "redis://localhost:6379/0" + } + + kafkaBrokers := os.Getenv("KAFKA_BROKERS") + if kafkaBrokers == "" { + kafkaBrokers = "localhost:9092" + } + + return appConfig{ + Port: port, + DatabaseURL: databaseURL, + RedisURL: redisURL, + KafkaBrokers: kafkaBrokers, + } +} diff --git a/services/product-service/cmd/dotenv.go b/services/product-service/cmd/dotenv.go new file mode 100644 index 0000000..3c652db --- /dev/null +++ b/services/product-service/cmd/dotenv.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "bufio" + "os" + "strings" +) + +func loadDotEnvFile(path string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"'`) + + if key == "" { + continue + } + + if _, exists := os.LookupEnv(key); exists { + continue + } + + _ = os.Setenv(key, value) + } +} diff --git a/services/product-service/cmd/infrastructure.go b/services/product-service/cmd/infrastructure.go new file mode 100644 index 0000000..33514de --- /dev/null +++ b/services/product-service/cmd/infrastructure.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "auron/product-service/internal/domain" + "context" + "os" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupDatabase(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(resolveGormLogLevel()), + }) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + return db, nil +} + +func runMigrations(db *gorm.DB) error { + if err := db.AutoMigrate(&domain.Category{}, &domain.Product{}, &domain.Inventory{}); err != nil { + return err + } + + // Apply tsvector trigger for full-text search (idempotent raw SQL) + return applySearchIndex(db) +} + +func applySearchIndex(db *gorm.DB) error { + statements := []string{ + `ALTER TABLE products ADD COLUMN IF NOT EXISTS search_vector tsvector`, + `CREATE INDEX IF NOT EXISTS idx_products_search ON products USING GIN(search_vector)`, + `CREATE OR REPLACE FUNCTION products_search_vector_trigger() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := to_tsvector('english', COALESCE(NEW.name, '') || ' ' || COALESCE(NEW.description, '')); + RETURN NEW; + END; + $$ LANGUAGE plpgsql`, + `DROP TRIGGER IF EXISTS products_search_vector_update ON products`, + `CREATE TRIGGER products_search_vector_update + BEFORE INSERT OR UPDATE ON products + FOR EACH ROW EXECUTE FUNCTION products_search_vector_trigger()`, + `UPDATE products + SET search_vector = to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, '')) + WHERE search_vector IS NULL`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + return err + } + } + return nil +} + +func setupRedis(redisURL string) (*redis.Client, error) { + opt, err := redis.ParseURL(redisURL) + if err != nil { + return nil, err + } + + client := redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, err + } + + return client, nil +} + +func resolveGormLogLevel() logger.LogLevel { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GORM_LOG_LEVEL"))) { + case "silent": + return logger.Silent + case "error": + return logger.Error + case "warn", "warning": + return logger.Warn + case "info": + return logger.Info + default: + return logger.Warn + } +} diff --git a/services/product-service/cmd/kafka.go b/services/product-service/cmd/kafka.go new file mode 100644 index 0000000..f581cb5 --- /dev/null +++ b/services/product-service/cmd/kafka.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "auron/product-service/internal/domain" + "auron/product-service/internal/events" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/segmentio/kafka-go" +) + +var productTopics = []string{ + domain.TopicProductCreated, + domain.TopicProductUpdated, + domain.TopicProductDeleted, +} + +func setupKafkaPublisher(kafkaBrokers string) domain.EventPublisher { + brokers := parseBrokers(kafkaBrokers) + + ensureTopics(brokers, productTopics) + + writers := make(map[string]*kafka.Writer, len(productTopics)) + for _, topic := range productTopics { + writers[topic] = &kafka.Writer{ + Addr: kafka.TCP(brokers...), + Topic: topic, + Balancer: &kafka.LeastBytes{}, + RequiredAcks: kafka.RequireOne, + BatchTimeout: 10 * time.Millisecond, + } + } + + return events.NewKafkaPublisher(writers) +} + +func parseBrokers(kafkaBrokers string) []string { + parts := strings.Split(kafkaBrokers, ",") + brokers := make([]string, 0, len(parts)) + for _, b := range parts { + if trimmed := strings.TrimSpace(b); trimmed != "" { + brokers = append(brokers, trimmed) + } + } + if len(brokers) == 0 { + return []string{"localhost:9092"} + } + return brokers +} + +func ensureTopics(brokers []string, topics []string) { + if len(brokers) == 0 || len(topics) == 0 { + return + } + + conn, err := kafka.Dial("tcp", brokers[0]) + if err != nil { + slog.Warn("kafka topic init skipped: cannot connect", "broker", brokers[0], "error", err) + return + } + defer conn.Close() + + controller, err := conn.Controller() + if err != nil { + slog.Warn("kafka topic init skipped: cannot get controller", "error", err) + return + } + + controllerConn, err := kafka.Dial("tcp", controller.Host+":"+strconv.Itoa(controller.Port)) + if err != nil { + slog.Warn("kafka topic init skipped: cannot connect to controller", "error", err) + return + } + defer controllerConn.Close() + + configs := make([]kafka.TopicConfig, 0, len(topics)) + for _, topic := range topics { + configs = append(configs, kafka.TopicConfig{ + Topic: topic, + NumPartitions: 3, + ReplicationFactor: 1, + }) + } + + if err := controllerConn.CreateTopics(configs...); err != nil { + slog.Warn("kafka topic init failed", "topics", topics, "error", err) + return + } + + slog.Info("kafka topics ensured", "topics", topics) +} + +func closeKafkaPublisher(publisher domain.EventPublisher) { + closer, ok := publisher.(interface{ Close() error }) + if !ok { + return + } + if err := closer.Close(); err != nil { + slog.Warn("failed to close kafka publisher", "error", err) + } +} diff --git a/services/product-service/cmd/run.go b/services/product-service/cmd/run.go new file mode 100644 index 0000000..241be65 --- /dev/null +++ b/services/product-service/cmd/run.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "auron/product-service/internal/cache" + "auron/product-service/internal/domain" + "auron/product-service/internal/handler" + "auron/product-service/internal/repository" + "auron/product-service/internal/service" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +func Run() { + cfg := loadConfig() + + db, err := setupDatabase(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + + if err := runMigrations(db); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + log.Println("database migrations completed") + + redisClient, err := setupRedis(cfg.RedisURL) + if err != nil { + log.Fatalf("failed to connect to Redis: %v", err) + } + + publisher := setupKafkaPublisher(cfg.KafkaBrokers) + + repo := repository.NewProductRepository(db) + productCache := cache.NewProductCache(redisClient) + svc := service.NewProductService(repo, productCache, publisher) + h := handler.NewProductHandler(svc) + + router := setupRouter(h) + registerGracefulShutdown(db, redisClient, publisher) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("starting product-service on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} + +func registerGracefulShutdown(db *gorm.DB, redisClient *redis.Client, publisher domain.EventPublisher) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + fmt.Println("\nshutting down product-service...") + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + _ = redisClient.Close() + closeKafkaPublisher(publisher) + os.Exit(0) + }() +} diff --git a/services/product-service/cmd/server.go b/services/product-service/cmd/server.go new file mode 100644 index 0000000..2ba447a --- /dev/null +++ b/services/product-service/cmd/server.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "time" + + "auron/product-service/internal/handler" + "auron/product-service/internal/route" + + "github.com/gin-gonic/gin" +) + +func setupRouter(h *handler.ProductHandler) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "healthy", + "service": "product-service", + "timestamp": time.Now().UTC(), + }) + }) + + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "# Prometheus metrics endpoint\n") + }) + + route.RegisterProductRoutes(router, h) + + return router +} diff --git a/services/product-service/go.mod b/services/product-service/go.mod index 5e8e0bf..3c6d975 100644 --- a/services/product-service/go.mod +++ b/services/product-service/go.mod @@ -3,15 +3,45 @@ module auron/product-service go 1.25.8 require ( + github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.15.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/segmentio/kafka-go v0.4.47 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.5.4 // indirect gorm.io/gorm v1.31.1 // indirect ) diff --git a/services/product-service/go.sum b/services/product-service/go.sum index 2c6cfac..532b2a4 100644 --- a/services/product-service/go.sum +++ b/services/product-service/go.sum @@ -1,22 +1,149 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= +github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/services/product-service/internal/cache/product_cache.go b/services/product-service/internal/cache/product_cache.go index 87d37b6..0f8f9e6 100644 --- a/services/product-service/internal/cache/product_cache.go +++ b/services/product-service/internal/cache/product_cache.go @@ -86,7 +86,7 @@ func (c *ProductCache) SetProductList(ctx context.Context, cacheKey string, resp func (c *ProductCache) InvalidateProductList(ctx context.Context) error { var cursor uint64 - pattern := ProductListPrefix + "*" + pattern := ProductListPrefix + ":*" for { keys, nextCursor, err := c.redis.Scan(ctx, cursor, pattern, 100).Result() @@ -113,8 +113,20 @@ func (c *ProductCache) ClearAll(ctx context.Context) error { } func GenerateCacheKey(filter domain.ProductFilter) string { - // create a has from filter params for list caching - hash := fmt.Sprintf("%s_%s_%s_%s_%d_%d", filter.Q, filter.CategoryID.String(), fmt.Sprintf("%.2f", *filter.MinPrice), fmt.Sprintf("%.2f", *filter.MaxPrice), filter.Page, filter.Limit) - - return ProductListPrefix + hash + categoryID := "" + if filter.CategoryID != nil { + categoryID = filter.CategoryID.String() + } + minPrice := "0.00" + if filter.MinPrice != nil { + minPrice = fmt.Sprintf("%.2f", *filter.MinPrice) + } + maxPrice := "0.00" + if filter.MaxPrice != nil { + maxPrice = fmt.Sprintf("%.2f", *filter.MaxPrice) + } + return fmt.Sprintf("%s:%s_%s_%s_%s_%d_%d", + ProductListPrefix, filter.Q, categoryID, minPrice, maxPrice, + filter.Page, filter.Limit, + ) } diff --git a/services/product-service/internal/domain/service.go b/services/product-service/internal/domain/service.go new file mode 100644 index 0000000..6c91f4d --- /dev/null +++ b/services/product-service/internal/domain/service.go @@ -0,0 +1,22 @@ +package domain + +import ( + "context" + "github.com/google/uuid" +) + +// ProductService defines the business logic for products and categories. +type ProductService interface { + // Product operations + GetProducts(ctx context.Context, filter ProductFilter) (*ProductListResponse, error) + GetProductByID(ctx context.Context, id uuid.UUID) (*Product, error) + CreateProduct(ctx context.Context, req ProductRequest) (*Product, error) + UpdateProduct(ctx context.Context, id uuid.UUID, req ProductRequest) (*Product, error) + DeleteProduct(ctx context.Context, id uuid.UUID) error + + // Category operations + GetCategories(ctx context.Context) ([]Category, error) + GetCategoryByID(ctx context.Context, id uuid.UUID) (*Category, error) + GetCategoryBySlug(ctx context.Context, slug string) (*Category, error) + CreateCategory(ctx context.Context, req CategoryRequest) (*Category, error) +} diff --git a/services/product-service/internal/events/kafka_publisher.go b/services/product-service/internal/events/kafka_publisher.go new file mode 100644 index 0000000..f05e160 --- /dev/null +++ b/services/product-service/internal/events/kafka_publisher.go @@ -0,0 +1,51 @@ +package events + +import ( + "auron/product-service/internal/domain" + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/segmentio/kafka-go" +) + +type kafkaPublisher struct { + writers map[string]*kafka.Writer +} + +func NewKafkaPublisher(writers map[string]*kafka.Writer) domain.EventPublisher { + return &kafkaPublisher{writers: writers} +} + +func (p *kafkaPublisher) Publish(ctx context.Context, topic string, payload any) error { + writer, ok := p.writers[topic] + if !ok { + return fmt.Errorf("publisher: no writer registered for topic %q", topic) + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("publisher: marshal payload: %w", err) + } + + if err := writer.WriteMessages(ctx, kafka.Message{Value: data}); err != nil { + return fmt.Errorf("publisher: write to topic %q: %w", topic, err) + } + + slog.Debug("event published", slog.String("topic", topic)) + return nil +} + +func (p *kafkaPublisher) Close() error { + var closeErr error + for _, writer := range p.writers { + if writer == nil { + continue + } + if err := writer.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/services/product-service/internal/handler/product_handler.go b/services/product-service/internal/handler/product_handler.go new file mode 100644 index 0000000..06fd138 --- /dev/null +++ b/services/product-service/internal/handler/product_handler.go @@ -0,0 +1,262 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "auron/product-service/internal/domain" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + googledecimal "google.golang.org/genproto/googleapis/type/decimal" +) + +type ProductHandler struct { + service domain.ProductService +} + +func NewProductHandler(service domain.ProductService) *ProductHandler { + return &ProductHandler{service: service} +} + +// ── Product handlers ────────────────────────────────────────────────────────── + +func (h *ProductHandler) GetProducts(c *gin.Context) { + filter, err := parseFilter(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + result, err := h.service.GetProducts(c.Request.Context(), filter) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result.Products, + "meta": gin.H{ + "page": result.Page, + "limit": result.Limit, + "total": result.Total, + }, + }) +} + +func (h *ProductHandler) GetProductByID(c *gin.Context) { + id, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + product, err := h.service.GetProductByID(c.Request.Context(), id) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": product.ToResponse()}) +} + +func (h *ProductHandler) CreateProduct(c *gin.Context) { + var body productBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + req, err := body.toDomain() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + product, err := h.service.CreateProduct(c.Request.Context(), req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": true, "data": product.ToResponse()}) +} + +func (h *ProductHandler) UpdateProduct(c *gin.Context) { + id, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + var body productBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + req, err := body.toDomain() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + product, err := h.service.UpdateProduct(c.Request.Context(), id, req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": product.ToResponse()}) +} + +func (h *ProductHandler) DeleteProduct(c *gin.Context) { + id, err := parseUUID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + if err := h.service.DeleteProduct(c.Request.Context(), id); err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "product deleted"}) +} + +// ── Category handlers ───────────────────────────────────────────────────────── + +func (h *ProductHandler) GetCategories(c *gin.Context) { + categories, err := h.service.GetCategories(c.Request.Context()) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": categories}) +} + +func (h *ProductHandler) CreateCategory(c *gin.Context) { + var req domain.CategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + category, err := h.service.CreateCategory(c.Request.Context(), req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": true, "data": category.ToResponse()}) +} + +// ── Input DTO ───────────────────────────────────────────────────────────────── + +// productBody is the HTTP request body for product create/update. +// Uses float64 for price so clients send {"price": 99.99} instead of the protobuf-wrapped form. +type productBody struct { + CategoryID string `json:"category_id" binding:"required"` + Name string `json:"name" binding:"required,max=500"` + Description string `json:"description" binding:"required"` + Price float64 `json:"price" binding:"required,gt=0"` + ImageURL string `json:"image_url" binding:"omitempty,url"` + IsActive *bool `json:"is_active"` +} + +func (b *productBody) toDomain() (domain.ProductRequest, error) { + categoryID, err := uuid.Parse(b.CategoryID) + if err != nil { + return domain.ProductRequest{}, fmt.Errorf("invalid category_id: %w", err) + } + + return domain.ProductRequest{ + CategoryID: categoryID, + Name: b.Name, + Description: b.Description, + Price: googledecimal.Decimal{Value: strconv.FormatFloat(b.Price, 'f', -1, 64)}, + ImageURL: b.ImageURL, + IsActive: b.IsActive, + }, nil +} + +// ── Error mapping ───────────────────────────────────────────────────────────── + +func (h *ProductHandler) handleError(c *gin.Context, err error) { + switch { + case errors.Is(err, domain.ErrProductNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrCategoryNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrCategorySlugExists), errors.Is(err, domain.ErrProductAlreadyExists): + c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrInvalidSortParam), + errors.Is(err, domain.ErrInvalidPageParam), + errors.Is(err, domain.ErrInvalidLimitParam), + errors.Is(err, domain.ErrPriceMustBePositive): + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrUnauthorized): + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrForbidden): + c.JSON(http.StatusForbidden, gin.H{"success": false, "error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"}) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func parseUUID(raw string) (uuid.UUID, error) { + return uuid.Parse(raw) +} + +func parseFilter(c *gin.Context) (domain.ProductFilter, error) { + filter := domain.ProductFilter{ + Q: c.Query("q"), + Sort: c.Query("sort"), + Page: parseIntQuery(c.Query("page"), 1), + Limit: parseIntQuery(c.Query("limit"), 20), + } + + if raw := c.Query("category_id"); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + return filter, fmt.Errorf("invalid category_id: must be a valid UUID") + } + filter.CategoryID = &id + } + + if raw := c.Query("min_price"); raw != "" { + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return filter, fmt.Errorf("invalid min_price: must be a number") + } + filter.MinPrice = &v + } + + if raw := c.Query("max_price"); raw != "" { + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return filter, fmt.Errorf("invalid max_price: must be a number") + } + filter.MaxPrice = &v + } + + return filter, nil +} + +func parseIntQuery(raw string, defaultVal int) int { + if raw == "" { + return defaultVal + } + v, err := strconv.Atoi(raw) + if err != nil || v < 1 { + return defaultVal + } + return v +} diff --git a/services/product-service/internal/route/product_route.go b/services/product-service/internal/route/product_route.go new file mode 100644 index 0000000..0d75b13 --- /dev/null +++ b/services/product-service/internal/route/product_route.go @@ -0,0 +1,20 @@ +package route + +import ( + "auron/product-service/internal/handler" + + "github.com/gin-gonic/gin" +) + +func RegisterProductRoutes(router *gin.Engine, h *handler.ProductHandler) { + api := router.Group("/") + + api.GET("/products", h.GetProducts) + api.GET("/products/:id", h.GetProductByID) + api.POST("/products", h.CreateProduct) + api.PUT("/products/:id", h.UpdateProduct) + api.DELETE("/products/:id", h.DeleteProduct) + + api.GET("/categories", h.GetCategories) + api.POST("/categories", h.CreateCategory) +} diff --git a/services/product-service/internal/service/product_service.go b/services/product-service/internal/service/product_service.go new file mode 100644 index 0000000..2e40143 --- /dev/null +++ b/services/product-service/internal/service/product_service.go @@ -0,0 +1,249 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "auron/product-service/internal/domain" + + "github.com/google/uuid" +) + +type ProductService struct { + repository domain.ProductRepository + cache domain.ProductCache + publisher domain.EventPublisher +} + +func NewProductService(repo domain.ProductRepository, cache domain.ProductCache, publisher domain.EventPublisher) domain.ProductService { + return &ProductService{ + repository: repo, + cache: cache, + publisher: publisher, + } +} + +// ── Read methods ────────────────────────────────────────────────────────────── + +func (s *ProductService) GetProducts(ctx context.Context, filter domain.ProductFilter) (*domain.ProductListResponse, error) { + if err := normalizeFilter(&filter); err != nil { + return nil, err + } + + cacheKey := buildListCacheKey(filter) + if cached, err := s.cache.GetProductList(ctx, cacheKey); err == nil && cached != nil { + return cached, nil + } + + result, err := s.repository.GetProducts(filter) + if err != nil { + return nil, err + } + + if err := s.cache.SetProductList(ctx, cacheKey, result); err != nil { + slog.Warn("failed to cache product list", "error", err) + } + + return result, nil +} + +func (s *ProductService) GetProductByID(ctx context.Context, id uuid.UUID) (*domain.Product, error) { + if cached, err := s.cache.GetProduct(ctx, id.String()); err == nil && cached != nil { + return cached, nil + } + + product, err := s.repository.GetProductByID(id) + if err != nil { + return nil, err + } + + if err := s.cache.SetProduct(ctx, product); err != nil { + slog.Warn("failed to cache product", "product_id", id, "error", err) + } + + return product, nil +} + +func (s *ProductService) GetCategories(ctx context.Context) ([]domain.Category, error) { + return s.repository.GetCategories() +} + +func (s *ProductService) GetCategoryByID(ctx context.Context, id uuid.UUID) (*domain.Category, error) { + return s.repository.GetCategoryByID(id) +} + +func (s *ProductService) GetCategoryBySlug(ctx context.Context, slug string) (*domain.Category, error) { + return s.repository.GetCategoryBySlug(slug) +} + +// ── Write methods ───────────────────────────────────────────────────────────── + +func (s *ProductService) CreateProduct(ctx context.Context, req domain.ProductRequest) (*domain.Product, error) { + if _, err := s.repository.GetCategoryByID(req.CategoryID); err != nil { + return nil, domain.ErrCategoryNotFound + } + + now := time.Now() + product := &domain.Product{ + ID: uuid.New(), + CategoryID: req.CategoryID, + Name: req.Name, + Description: req.Description, + Price: req.Price, + ImageURL: req.ImageURL, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } + if req.IsActive != nil { + product.IsActive = *req.IsActive + } + + created, err := s.repository.CreateProduct(product) + if err != nil { + return nil, err + } + + if err := s.cache.SetProduct(ctx, created); err != nil { + slog.Warn("failed to cache new product", "product_id", created.ID, "error", err) + } + if err := s.cache.InvalidateProductList(ctx); err != nil { + slog.Warn("failed to invalidate product list cache", "error", err) + } + + go func() { + if err := s.publisher.Publish(context.Background(), domain.TopicProductCreated, created); err != nil { + slog.Warn("failed to publish product.created", "product_id", created.ID, "error", err) + } + }() + + return created, nil +} + +func (s *ProductService) UpdateProduct(ctx context.Context, id uuid.UUID, req domain.ProductRequest) (*domain.Product, error) { + existing, err := s.repository.GetProductByID(id) + if err != nil { + return nil, err + } + + if req.CategoryID != existing.CategoryID { + if _, err := s.repository.GetCategoryByID(req.CategoryID); err != nil { + return nil, domain.ErrCategoryNotFound + } + } + + existing.CategoryID = req.CategoryID + existing.Name = req.Name + existing.Description = req.Description + existing.Price = req.Price + existing.ImageURL = req.ImageURL + existing.UpdatedAt = time.Now() + if req.IsActive != nil { + existing.IsActive = *req.IsActive + } + + updated, err := s.repository.UpdateProduct(existing) + if err != nil { + return nil, err + } + + if err := s.cache.SetProduct(ctx, updated); err != nil { + slog.Warn("failed to update cached product", "product_id", id, "error", err) + } + if err := s.cache.InvalidateProductList(ctx); err != nil { + slog.Warn("failed to invalidate product list cache", "error", err) + } + + go func() { + if err := s.publisher.Publish(context.Background(), domain.TopicProductUpdated, updated); err != nil { + slog.Warn("failed to publish product.updated", "product_id", id, "error", err) + } + }() + + return updated, nil +} + +func (s *ProductService) DeleteProduct(ctx context.Context, id uuid.UUID) error { + if _, err := s.repository.GetProductByID(id); err != nil { + return err + } + + if err := s.repository.DeleteProduct(id); err != nil { + return err + } + + if err := s.cache.DeleteProduct(ctx, id.String()); err != nil { + slog.Warn("failed to evict product from cache", "product_id", id, "error", err) + } + if err := s.cache.InvalidateProductList(ctx); err != nil { + slog.Warn("failed to invalidate product list cache", "error", err) + } + + go func() { + payload := map[string]string{"product_id": id.String()} + if err := s.publisher.Publish(context.Background(), domain.TopicProductDeleted, payload); err != nil { + slog.Warn("failed to publish product.deleted", "product_id", id, "error", err) + } + }() + + return nil +} + +func (s *ProductService) CreateCategory(ctx context.Context, req domain.CategoryRequest) (*domain.Category, error) { + if existing, _ := s.repository.GetCategoryBySlug(req.Slug); existing != nil { + return nil, domain.ErrCategorySlugExists + } + + category := &domain.Category{ + ID: uuid.New(), + Name: req.Name, + Slug: req.Slug, + ParentID: req.ParentID, + CreatedAt: time.Now(), + } + + return s.repository.CreateCategory(category) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func normalizeFilter(f *domain.ProductFilter) error { + if f.Page < 1 { + f.Page = 1 + } + if f.Limit < 1 { + f.Limit = 20 + } + if f.Limit > 100 { + f.Limit = 100 + } + if f.Sort == "" { + f.Sort = domain.SortNewest + } + if !domain.ValidSorts[f.Sort] { + return domain.ErrInvalidSortParam + } + return nil +} + +// buildListCacheKey produces a deterministic cache key for a product list query. +// All pointer fields are nil-safe. +func buildListCacheKey(f domain.ProductFilter) string { + categoryID := "" + if f.CategoryID != nil { + categoryID = f.CategoryID.String() + } + minPrice := "0.00" + if f.MinPrice != nil { + minPrice = fmt.Sprintf("%.2f", *f.MinPrice) + } + maxPrice := "0.00" + if f.MaxPrice != nil { + maxPrice = fmt.Sprintf("%.2f", *f.MaxPrice) + } + return fmt.Sprintf("product:list:%s_%s_%s_%s_%d_%d", + f.Q, categoryID, minPrice, maxPrice, f.Page, f.Limit, + ) +} \ No newline at end of file diff --git a/services/product-service/main.go b/services/product-service/main.go new file mode 100644 index 0000000..a22d6b4 --- /dev/null +++ b/services/product-service/main.go @@ -0,0 +1,7 @@ +package main + +import "auron/product-service/cmd" + +func main() { + cmd.Run() +} From 78508fbf51ece7aa04adde5b4cf807bbc3a55b9f Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 10:45:29 +0700 Subject: [PATCH 6/6] fix: Resolve runtime blockers in product-service before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace genproto decimal.Decimal with float64 for Product.Price, ProductRequest.Price, and ProductResponse.Price — genproto's Decimal does not implement sql.Scanner/driver.Valuer so GORM could not read or write price values from PostgreSQL at runtime - Remove BeforeSave GORM hook and buildSearchVector helper from Product; the PostgreSQL trigger installed by applySearchIndex handles tsvector population correctly — the Go-side hook was redundant and wrote plain text into a tsvector column which risks type errors on some PG versions - Remove genproto decimal import from handler; simplify toDomain() to assign b.Price (float64) directly now that domain type matches - Add REDIS_URL and KAFKA_BROKERS to product-service in docker-compose.yml; without REDIS_URL the service panicked at startup before serving requests - Remove stale commented-out duplicate import in product_repository.go Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 ++ .../internal/domain/product.go | 25 +++---------------- .../internal/handler/product_handler.go | 4 +-- .../internal/repository/product_repository.go | 2 -- 4 files changed, 6 insertions(+), 27 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index da1df40..7b22dc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,8 @@ services: environment: - PORT=8082 - DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - KAFKA_BROKERS=kafka:29092 depends_on: products-db: condition: service_healthy diff --git a/services/product-service/internal/domain/product.go b/services/product-service/internal/domain/product.go index f1242fd..9dc7295 100644 --- a/services/product-service/internal/domain/product.go +++ b/services/product-service/internal/domain/product.go @@ -4,7 +4,6 @@ import ( "time" "github.com/google/uuid" - "google.golang.org/genproto/googleapis/type/decimal" ) // ============================================================ @@ -48,7 +47,7 @@ type Product struct { CategoryID uuid.UUID `json:"category_id" gorm:"type:uuid;not null;index"` Name string `json:"name" gorm:"type:varchar(500);not null"` Description string `json:"description" gorm:"type:text"` - Price decimal.Decimal `json:"price" gorm:"type:decimal(12,2);not null;index"` + Price float64 `json:"price" gorm:"type:decimal(12,2);not null;index"` ImageURL string `json:"image_url" gorm:"type:text"` SearchVector string `json:"-" gorm:"type:tsvector;index:idx_products_search,type:GIN"` IsActive bool `json:"is_active" gorm:"not null;default:true;index"` @@ -61,24 +60,6 @@ func (Product) TableName() string { return "products" } -// BeforeSave is a GORM hook that populates the search_vector before insert/update. -// This enables PostgreSQL full-text search via tsvector. -func (p *Product) BeforeSave() error { - // Build tsvector from name and description for full-text search - // Format: 'word':position,'word2':position2 (PostgreSQL tsvector format) - // We use a simplified approach — PostgreSQL will parse this properly via triggers - p.SearchVector = p.buildSearchVector() - return nil -} - -// buildSearchVector creates a searchable text vector from product fields. -// This is a Go-side fallback; PostgreSQL triggers should handle the actual tsvector generation. -func (p *Product) buildSearchVector() string { - // Return plain text — PostgreSQL's to_tsvector() will convert this properly - // when using triggers. This is only for GORM AutoMigrate compatibility. - return p.Name + " " + p.Description -} - type Inventory struct { ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` TotalQuantity int `json:"total_quantity" gorm:"not null;default:0"` @@ -110,7 +91,7 @@ type ProductRequest struct { CategoryID uuid.UUID `json:"category_id" binding:"required"` Name string `json:"name" binding:"required,max=500"` Description string `json:"description" binding:"required"` - Price decimal.Decimal `json:"price" binding:"required,gt=0"` + Price float64 `json:"price" binding:"required,gt=0"` ImageURL string `json:"image_url" binding:"omitempty,url"` IsActive *bool `json:"is_active"` } @@ -132,7 +113,7 @@ type ProductResponse struct { CategoryID uuid.UUID `json:"category_id"` Name string `json:"name"` Description string `json:"description"` - Price decimal.Decimal `json:"price"` + Price float64 `json:"price"` ImageURL string `json:"image_url,omitempty"` IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` diff --git a/services/product-service/internal/handler/product_handler.go b/services/product-service/internal/handler/product_handler.go index 06fd138..c8c6f16 100644 --- a/services/product-service/internal/handler/product_handler.go +++ b/services/product-service/internal/handler/product_handler.go @@ -10,7 +10,6 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - googledecimal "google.golang.org/genproto/googleapis/type/decimal" ) type ProductHandler struct { @@ -159,7 +158,6 @@ func (h *ProductHandler) CreateCategory(c *gin.Context) { // ── Input DTO ───────────────────────────────────────────────────────────────── // productBody is the HTTP request body for product create/update. -// Uses float64 for price so clients send {"price": 99.99} instead of the protobuf-wrapped form. type productBody struct { CategoryID string `json:"category_id" binding:"required"` Name string `json:"name" binding:"required,max=500"` @@ -179,7 +177,7 @@ func (b *productBody) toDomain() (domain.ProductRequest, error) { CategoryID: categoryID, Name: b.Name, Description: b.Description, - Price: googledecimal.Decimal{Value: strconv.FormatFloat(b.Price, 'f', -1, 64)}, + Price: b.Price, ImageURL: b.ImageURL, IsActive: b.IsActive, }, nil diff --git a/services/product-service/internal/repository/product_repository.go b/services/product-service/internal/repository/product_repository.go index e222d9b..e13d92f 100644 --- a/services/product-service/internal/repository/product_repository.go +++ b/services/product-service/internal/repository/product_repository.go @@ -1,8 +1,6 @@ package repository import ( - // "auron/product-service/internal/domain" - "auron/product-service/internal/domain" "github.com/google/uuid"