From e87c21bb75019cefa52086ed366e51dcca26b967 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 10:59:42 +0700 Subject: [PATCH 1/4] feat: Implement order service domain, migrations, and repository layers - Domain: Cart, CartItem, Order, OrderItem entities with GORM tags and ToResponse helpers - Domain interfaces: CartRepository, OrderRepository, CartService, OrderService, CartCache, OrderCache, EventPublisher, ProductClient - DB migrations: 001_create_carts.up.sql, 002_create_orders.up.sql with indexes and CHECK constraints - Repository: CartRepository and OrderRepository backed by GORM; CreateOrder uses explicit transaction to insert order then items atomically Co-Authored-By: Claude Sonnet 4.6 --- services/order-service/IMPLEMENTATION_PLAN.md | 361 ++++++++++++++++++ .../order-service/db/001_create_carts.up.sql | 27 ++ .../order-service/db/002_create_orders.up.sql | 37 ++ services/order-service/go.mod | 14 + services/order-service/go.sum | 10 + .../order-service/internal/domain/cache.go | 20 + .../order-service/internal/domain/cart.go | 106 +++++ .../order-service/internal/domain/client.go | 20 + .../order-service/internal/domain/errors.go | 27 ++ .../order-service/internal/domain/events.go | 13 + .../order-service/internal/domain/order.go | 138 +++++++ .../internal/domain/repository.go | 22 ++ .../order-service/internal/domain/service.go | 23 ++ .../internal/repository/cart_repository.go | 67 ++++ .../internal/repository/order_repository.go | 71 ++++ 15 files changed, 956 insertions(+) create mode 100644 services/order-service/IMPLEMENTATION_PLAN.md create mode 100644 services/order-service/db/001_create_carts.up.sql create mode 100644 services/order-service/db/002_create_orders.up.sql create mode 100644 services/order-service/go.mod create mode 100644 services/order-service/go.sum create mode 100644 services/order-service/internal/domain/cache.go create mode 100644 services/order-service/internal/domain/cart.go create mode 100644 services/order-service/internal/domain/client.go create mode 100644 services/order-service/internal/domain/errors.go create mode 100644 services/order-service/internal/domain/events.go create mode 100644 services/order-service/internal/domain/order.go create mode 100644 services/order-service/internal/domain/repository.go create mode 100644 services/order-service/internal/domain/service.go create mode 100644 services/order-service/internal/repository/cart_repository.go create mode 100644 services/order-service/internal/repository/order_repository.go diff --git a/services/order-service/IMPLEMENTATION_PLAN.md b/services/order-service/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..c5d9d0c --- /dev/null +++ b/services/order-service/IMPLEMENTATION_PLAN.md @@ -0,0 +1,361 @@ +# Order Service — Implementation Plan + +**Branch:** `feature/order-service` +**Port:** 8083 +**Database:** `orders_db` (PostgreSQL, orders-db:5434) +**Stack:** Go 1.25 · Gin · GORM · Redis · Kafka + +--- + +## Overview + +The Order Service owns two resources: + +| Resource | Responsibility | +|---|---| +| **Cart** | Per-user in-flight basket; items with product snapshots | +| **Order** | Confirmed purchase; immutable after creation | + +Gateway already routes these endpoints to port 8083: + +``` +Cart: GET /api/cart + POST /api/cart/items + PUT /api/cart/items/:id + DELETE /api/cart/items/:id + +Orders: GET /api/orders + POST /api/orders + GET /api/orders/:id + PUT /api/orders/:id/cancel +``` + +All routes require `Authorization: Bearer `. The gateway injects `X-User-ID` and `X-User-Role` headers; the service trusts these. + +--- + +## Domain Model + +### Entities + +``` +Cart + id uuid PK + user_id uuid UNIQUE (one cart per user) + created_at timestamp + updated_at timestamp + +CartItem + id uuid PK + cart_id uuid FK → carts + product_id uuid + product_name varchar (snapshot at time of add) + price float64 (snapshot at time of add) + quantity int + created_at timestamp + updated_at timestamp + +Order + id uuid PK + user_id uuid + status varchar (pending|confirmed|processing|shipped|delivered|cancelled) + total_amount float64 + shipping_name varchar (snapshot) + shipping_address varchar (snapshot) + created_at timestamp + updated_at timestamp + +OrderItem + id uuid PK + order_id uuid FK → orders + product_id uuid + product_name varchar (snapshot) + price float64 (snapshot) + quantity int + subtotal float64 (price × quantity, stored for history) + created_at timestamp +``` + +Price and product name are **snapshotted** on cart-add and order-create so historical orders are never affected by product edits. + +### Order Status Flow + +``` +pending → confirmed → processing → shipped → delivered + ↘ cancelled (any stage before shipped) +``` + +--- + +## External Dependencies + +The service calls **Product Service** (HTTP) to: +1. Validate a product exists and is active before adding to cart +2. Get the current price and name for the snapshot + +This is modelled as a `ProductClient` interface in the domain layer so the concrete HTTP implementation stays outside the domain. + +--- + +## Redis Cache Strategy + +| Key pattern | TTL | Evicted when | +|---|---|---| +| `cart:` | 24 h | item added/updated/removed, cart cleared | +| `order:` | 1 h | order status changes | +| `orders:user::page::limit:` | 5 min | new order created, order cancelled | + +--- + +## Kafka Events + +| Topic | Published when | +|---|---| +| `order.created` | Order confirmed from cart | +| `order.updated` | Status changes | +| `order.cancelled` | Order cancelled | + +Inventory Service and Notification Service consume these topics. + +--- + +## Implementation Tasks + +### Task 1 — Domain Layer + +**Files to create:** + +- `internal/domain/cart.go` — Cart, CartItem entities + TableName +- `internal/domain/order.go` — Order, OrderItem, OrderStatus entities + TableName +- `internal/domain/errors.go` — sentinel errors (ErrCartNotFound, ErrCartItemNotFound, ErrOrderNotFound, ErrOrderNotCancellable, ErrProductNotFound, ErrProductInactive, ErrInsufficientStock, ErrCartEmpty, ErrUnauthorized, ErrForbidden) +- `internal/domain/repository.go` — CartRepository + OrderRepository interfaces +- `internal/domain/service.go` — CartService + OrderService interfaces +- `internal/domain/cache.go` — CartCache + OrderCache interfaces +- `internal/domain/events.go` — EventPublisher interface + topic constants +- `internal/domain/client.go` — ProductClient interface (`GetProduct(id uuid.UUID) (*ProductSnapshot, error)`) + +Key interface signatures: + +```go +// CartService +GetCart(ctx, userID uuid.UUID) (*Cart, error) +AddItem(ctx, userID uuid.UUID, req AddItemRequest) (*Cart, error) +UpdateItem(ctx, userID, itemID uuid.UUID, qty int) (*Cart, error) +RemoveItem(ctx, userID, itemID uuid.UUID) error + +// OrderService +GetOrders(ctx, userID uuid.UUID, page, limit int) (*OrderListResponse, error) +CreateOrder(ctx, userID uuid.UUID, req CreateOrderRequest) (*Order, error) +GetOrderByID(ctx, userID, orderID uuid.UUID) (*Order, error) +CancelOrder(ctx, userID, orderID uuid.UUID) (*Order, error) +``` + +--- + +### Task 2 — Database Migrations + +**Files to create:** + +- `db/001_create_carts.up.sql` — `carts` + `cart_items` tables +- `db/002_create_orders.up.sql` — `orders` + `order_items` tables + +GORM AutoMigrate will handle the actual schema apply at startup (same pattern as product-service). The SQL files serve as documentation / manual fallback. + +--- + +### Task 3 — Repository Layer + +**Files to create:** + +- `internal/repository/cart_repository.go` — implements `domain.CartRepository` + - `GetCartByUserID(userID)` — preloads CartItems + - `GetCartItemByID(cartID, itemID)` — single item lookup + - `UpsertCart(cart)` — create or save + - `UpsertCartItem(item)` — create or save + - `DeleteCartItem(cartID, itemID)` — hard delete + - `ClearCart(cartID)` — delete all items (after order created) + +- `internal/repository/order_repository.go` — implements `domain.OrderRepository` + - `GetOrdersByUserID(userID, offset, limit)` — preloads OrderItems + - `GetOrderByID(orderID)` — preloads OrderItems + - `CreateOrder(order)` — creates order + items in a single transaction + - `UpdateOrderStatus(orderID, status)` — targeted update + +--- + +### Task 4 — Cache Layer + +**Files to create:** + +- `internal/cache/cart_cache.go` + - `GetCart(ctx, userID) (*domain.Cart, error)` + - `SetCart(ctx, cart) error` + - `InvalidateCart(ctx, userID) error` + +- `internal/cache/order_cache.go` + - `GetOrder(ctx, orderID) (*domain.Order, error)` + - `SetOrder(ctx, order) error` + - `InvalidateOrder(ctx, orderID) error` + - `GetOrderList(ctx, userID, page, limit) (*domain.OrderListResponse, error)` + - `SetOrderList(ctx, userID, page, limit, resp) error` + - `InvalidateOrderList(ctx, userID) error` — scans `orders:user::*` + +--- + +### Task 5 — Kafka Publisher + +**File to create:** + +- `internal/events/kafka_publisher.go` — implements `domain.EventPublisher` + - One `kafka.Writer` per topic (same pattern as product-service) + - JSON-serialises the payload, publishes with context + key = order ID + +--- + +### Task 6 — Product HTTP Client + +**File to create:** + +- `internal/client/product_client.go` — implements `domain.ProductClient` + - `GET {PRODUCT_SERVICE_URL}/products/{id}` + - Returns `domain.ProductSnapshot{ID, Name, Price, IsActive}` + - Returns `domain.ErrProductNotFound` on 404, `domain.ErrProductInactive` if `is_active == false` + - 5-second timeout + +--- + +### Task 7 — Service Layer + +**Files to create:** + +- `internal/service/cart_service.go` — implements `domain.CartService` + - `AddItem`: validate product via ProductClient → snapshot price/name → upsert cart + item → invalidate cache + - `UpdateItem`: validate item belongs to user's cart → update qty → invalidate cache + - `RemoveItem`: validate ownership → delete item → invalidate cache + - `GetCart`: cache-aside (cache → DB) + +- `internal/service/order_service.go` — implements `domain.OrderService` + - `CreateOrder`: load cart → validate not empty → build Order + OrderItems from cart snapshots → DB create in transaction → clear cart → cache order → invalidate order list → publish `order.created` + - `CancelOrder`: validate order belongs to user + status allows cancellation → update status → cache → publish `order.cancelled` + - `GetOrderByID`: cache-aside + - `GetOrders`: cache-aside (list cache, short TTL) + +--- + +### Task 8 — Handler + Route Layers + +**Files to create:** + +- `internal/handler/cart_handler.go` + - Reads `X-User-ID` header (set by gateway) to identify the caller + - `GetCart`, `AddItem`, `UpdateItem`, `RemoveItem` + +- `internal/handler/order_handler.go` + - `GetOrders`, `CreateOrder`, `GetOrderByID`, `CancelOrder` + - Request body for CreateOrder: `{ shipping_name, shipping_address }` + +- `internal/route/order_route.go` + - Registers all 8 routes on the Gin engine + +--- + +### Task 9 — cmd Bootstrap + +**Files to create** (same structure as product-service): + +- `cmd/config.go` — `appConfig` struct; loads PORT, DATABASE_URL, REDIS_URL, KAFKA_BROKERS, PRODUCT_SERVICE_URL from env +- `cmd/dotenv.go` — silent `.env` loader +- `cmd/infrastructure.go` — GORM setup + AutoMigrate (Cart, CartItem, Order, OrderItem) + Redis setup +- `cmd/kafka.go` — creates `kafka.Writer` per topic (`order.created`, `order.updated`, `order.cancelled`), `ensureTopics`, `closeKafkaPublisher` +- `cmd/server.go` — Gin engine, `/health`, `/metrics`, calls `RegisterOrderRoutes` +- `cmd/run.go` — wires full dependency graph (repo → cache → client → publisher → service → handler → routes), registers graceful shutdown (DB, Redis, Kafka) + +--- + +### Task 10 — Entry Point + Dockerfile + +**Files to create:** + +- `main.go` — calls `cmd.Run()` +- `Dockerfile` — multi-stage build (golang:1.25-alpine builder → alpine:3.18 runtime), port 8083 +- `.env` — local dev values +- `.env.example` + +--- + +### Task 11 — docker-compose Update + +Add missing env vars to the `order-service` block in `docker-compose.yml`: + +```yaml +- REDIS_URL=redis://redis:6379/0 +- KAFKA_BROKERS=kafka:29092 +- PRODUCT_SERVICE_URL=http://product-service:8082 +``` + +Also add `depends_on: redis` and `depends_on: kafka` conditions. + +--- + +## File Map + +``` +services/order-service/ +├── main.go +├── Dockerfile +├── .env +├── .env.example +├── go.mod +├── go.sum +├── cmd/ +│ ├── config.go +│ ├── dotenv.go +│ ├── infrastructure.go +│ ├── kafka.go +│ ├── run.go +│ └── server.go +├── db/ +│ ├── 001_create_carts.up.sql +│ └── 002_create_orders.up.sql +└── internal/ + ├── cache/ + │ ├── cart_cache.go + │ └── order_cache.go + ├── client/ + │ └── product_client.go + ├── domain/ + │ ├── cart.go + │ ├── order.go + │ ├── errors.go + │ ├── repository.go + │ ├── service.go + │ ├── cache.go + │ ├── events.go + │ └── client.go + ├── events/ + │ └── kafka_publisher.go + ├── handler/ + │ ├── cart_handler.go + │ └── order_handler.go + ├── repository/ + │ ├── cart_repository.go + │ └── order_repository.go + ├── route/ + │ └── order_route.go + └── service/ + ├── cart_service.go + └── order_service.go +``` + +--- + +## Key Decisions + +| Decision | Rationale | +|---|---| +| Price snapshot on cart-add | Historical orders stay accurate when product prices change | +| ProductClient interface in domain | Keeps domain testable; HTTP impl detail lives in `internal/client` | +| Cart cleared after order creation | Cart is single-use; users start a new one after checkout | +| No cart service auth check | Gateway already enforces auth; service trusts `X-User-ID` header | +| Kafka publish is async goroutine | Kafka unavailability never blocks HTTP response (same pattern as product-service) | +| `float64` for price | Consistent with product-service; avoids genproto/decimal GORM incompatibility | diff --git a/services/order-service/db/001_create_carts.up.sql b/services/order-service/db/001_create_carts.up.sql new file mode 100644 index 0000000..64667c3 --- /dev/null +++ b/services/order-service/db/001_create_carts.up.sql @@ -0,0 +1,27 @@ +-- Migration: 001_create_carts +-- Purpose: Create carts and cart_items tables + +CREATE TABLE IF NOT EXISTS carts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_carts_user_id UNIQUE (user_id) +); + +CREATE INDEX IF NOT EXISTS idx_carts_user_id ON carts(user_id); + +CREATE TABLE IF NOT EXISTS cart_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cart_id UUID NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + product_id UUID NOT NULL, + product_name VARCHAR(500) NOT NULL, + price DECIMAL(12, 2) NOT NULL, + quantity INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_cart_items_quantity CHECK (quantity >= 1) +); + +CREATE INDEX IF NOT EXISTS idx_cart_items_cart_id ON cart_items(cart_id); +CREATE INDEX IF NOT EXISTS idx_cart_items_product_id ON cart_items(product_id); diff --git a/services/order-service/db/002_create_orders.up.sql b/services/order-service/db/002_create_orders.up.sql new file mode 100644 index 0000000..b23ff85 --- /dev/null +++ b/services/order-service/db/002_create_orders.up.sql @@ -0,0 +1,37 @@ +-- Migration: 002_create_orders +-- Purpose: Create orders and order_items tables + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + total_amount DECIMAL(12, 2) NOT NULL, + shipping_name VARCHAR(255) NOT NULL, + shipping_address TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_orders_status CHECK ( + status IN ('pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled') + ), + CONSTRAINT chk_orders_total_amount CHECK (total_amount >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at DESC); + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL, + product_name VARCHAR(500) NOT NULL, + price DECIMAL(12, 2) NOT NULL, + quantity INT NOT NULL, + subtotal DECIMAL(12, 2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_order_items_quantity CHECK (quantity >= 1), + CONSTRAINT chk_order_items_subtotal CHECK (subtotal >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id); diff --git a/services/order-service/go.mod b/services/order-service/go.mod new file mode 100644 index 0000000..752918e --- /dev/null +++ b/services/order-service/go.mod @@ -0,0 +1,14 @@ +module auron/order-service + +go 1.25.8 + +require ( + github.com/google/uuid v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/services/order-service/go.sum b/services/order-service/go.sum new file mode 100644 index 0000000..f5ab7ba --- /dev/null +++ b/services/order-service/go.sum @@ -0,0 +1,10 @@ +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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/services/order-service/internal/domain/cache.go b/services/order-service/internal/domain/cache.go new file mode 100644 index 0000000..7109398 --- /dev/null +++ b/services/order-service/internal/domain/cache.go @@ -0,0 +1,20 @@ +package domain + +import "context" + +// CartCache defines Redis cache operations for carts. +type CartCache interface { + GetCart(ctx context.Context, userID string) (*Cart, error) + SetCart(ctx context.Context, cart *Cart) error + InvalidateCart(ctx context.Context, userID string) error +} + +// OrderCache defines Redis cache operations for orders. +type OrderCache interface { + GetOrder(ctx context.Context, orderID string) (*Order, error) + SetOrder(ctx context.Context, order *Order) error + InvalidateOrder(ctx context.Context, orderID string) error + GetOrderList(ctx context.Context, cacheKey string) (*OrderListResponse, error) + SetOrderList(ctx context.Context, cacheKey string, resp *OrderListResponse) error + InvalidateOrderList(ctx context.Context, userID string) error +} diff --git a/services/order-service/internal/domain/cart.go b/services/order-service/internal/domain/cart.go new file mode 100644 index 0000000..f5131bd --- /dev/null +++ b/services/order-service/internal/domain/cart.go @@ -0,0 +1,106 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================ +// ENTITIES +// ============================================================ + +type Cart struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex"` + Items []CartItem `json:"items,omitempty" gorm:"foreignKey:CartID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (Cart) TableName() string { + return "carts" +} + +type CartItem struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + CartID uuid.UUID `json:"cart_id" gorm:"type:uuid;not null;index"` + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null"` + ProductName string `json:"product_name" gorm:"type:varchar(500);not null"` + Price float64 `json:"price" gorm:"type:decimal(12,2);not null"` + Quantity int `json:"quantity" gorm:"not null;default:1"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (CartItem) TableName() string { + return "cart_items" +} + +// ============================================================ +// DTOs — REQUESTS +// ============================================================ + +type AddItemRequest struct { + ProductID uuid.UUID `json:"product_id" binding:"required"` + Quantity int `json:"quantity" binding:"required,min=1"` +} + +// ============================================================ +// DTOs — RESPONSES +// ============================================================ + +type CartItemResponse struct { + ID uuid.UUID `json:"id"` + CartID uuid.UUID `json:"cart_id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + Subtotal float64 `json:"subtotal"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CartResponse struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Items []CartItemResponse `json:"items"` + Total float64 `json:"total"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ============================================================ +// HELPER FUNCTIONS +// ============================================================ + +func (c *Cart) ToResponse() *CartResponse { + resp := &CartResponse{ + ID: c.ID, + UserID: c.UserID, + Items: make([]CartItemResponse, 0, len(c.Items)), + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + + var total float64 + for _, item := range c.Items { + subtotal := item.Price * float64(item.Quantity) + total += subtotal + resp.Items = append(resp.Items, CartItemResponse{ + ID: item.ID, + CartID: item.CartID, + ProductID: item.ProductID, + ProductName: item.ProductName, + Price: item.Price, + Quantity: item.Quantity, + Subtotal: subtotal, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.Total = total + + return resp +} diff --git a/services/order-service/internal/domain/client.go b/services/order-service/internal/domain/client.go new file mode 100644 index 0000000..41fa6a0 --- /dev/null +++ b/services/order-service/internal/domain/client.go @@ -0,0 +1,20 @@ +package domain + +import ( + "context" + + "github.com/google/uuid" +) + +// ProductSnapshot holds the product fields snapshotted at the time an item is added to a cart. +type ProductSnapshot struct { + ID uuid.UUID + Name string + Price float64 + IsActive bool +} + +// ProductClient fetches product data from the product-service. +type ProductClient interface { + GetProduct(ctx context.Context, id uuid.UUID) (*ProductSnapshot, error) +} diff --git a/services/order-service/internal/domain/errors.go b/services/order-service/internal/domain/errors.go new file mode 100644 index 0000000..88ddba8 --- /dev/null +++ b/services/order-service/internal/domain/errors.go @@ -0,0 +1,27 @@ +package domain + +import "errors" + +var ( + // cart errors + ErrCartNotFound = errors.New("cart not found") + ErrCartItemNotFound = errors.New("cart item not found") + ErrCartEmpty = errors.New("cart is empty") + + // order errors + ErrOrderNotFound = errors.New("order not found") + ErrOrderNotCancellable = errors.New("order cannot be cancelled at its current status") + + // product errors + ErrProductNotFound = errors.New("product not found") + ErrProductInactive = errors.New("product is no longer available") + + // validation errors + ErrInvalidQuantity = errors.New("quantity must be at least 1") + ErrInvalidPageParam = errors.New("page must be >= 1") + ErrInvalidLimitParam = errors.New("limit must be >= 1 and <= 100") + + // generic + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) diff --git a/services/order-service/internal/domain/events.go b/services/order-service/internal/domain/events.go new file mode 100644 index 0000000..acd1ac9 --- /dev/null +++ b/services/order-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 ( + TopicOrderCreated = "order.created" + TopicOrderUpdated = "order.updated" + TopicOrderCancelled = "order.cancelled" +) diff --git a/services/order-service/internal/domain/order.go b/services/order-service/internal/domain/order.go new file mode 100644 index 0000000..fc8f642 --- /dev/null +++ b/services/order-service/internal/domain/order.go @@ -0,0 +1,138 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================ +// CONSTANTS +// ============================================================ + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusConfirmed OrderStatus = "confirmed" + OrderStatusProcessing OrderStatus = "processing" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" + OrderStatusCancelled OrderStatus = "cancelled" +) + +// cancellable returns true if the order can still be cancelled. +func (s OrderStatus) Cancellable() bool { + return s == OrderStatusPending || s == OrderStatusConfirmed || s == OrderStatusProcessing +} + +// ============================================================ +// ENTITIES +// ============================================================ + +type Order struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;index"` + Status OrderStatus `json:"status" gorm:"type:varchar(50);not null;default:'pending';index"` + TotalAmount float64 `json:"total_amount" gorm:"type:decimal(12,2);not null"` + ShippingName string `json:"shipping_name" gorm:"type:varchar(255);not null"` + ShippingAddress string `json:"shipping_address" gorm:"type:text;not null"` + Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (Order) TableName() string { + return "orders" +} + +type OrderItem struct { + ID uuid.UUID `json:"id" gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + OrderID uuid.UUID `json:"order_id" gorm:"type:uuid;not null;index"` + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;not null"` + ProductName string `json:"product_name" gorm:"type:varchar(500);not null"` + Price float64 `json:"price" gorm:"type:decimal(12,2);not null"` + Quantity int `json:"quantity" gorm:"not null"` + Subtotal float64 `json:"subtotal" gorm:"type:decimal(12,2);not null"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` +} + +func (OrderItem) TableName() string { + return "order_items" +} + +// ============================================================ +// DTOs — REQUESTS +// ============================================================ + +type CreateOrderRequest struct { + ShippingName string `json:"shipping_name" binding:"required"` + ShippingAddress string `json:"shipping_address" binding:"required"` +} + +// ============================================================ +// DTOs — RESPONSES +// ============================================================ + +type OrderItemResponse struct { + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + Price float64 `json:"price"` + Quantity int `json:"quantity"` + Subtotal float64 `json:"subtotal"` + CreatedAt time.Time `json:"created_at"` +} + +type OrderResponse struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Status OrderStatus `json:"status"` + TotalAmount float64 `json:"total_amount"` + ShippingName string `json:"shipping_name"` + ShippingAddress string `json:"shipping_address"` + Items []OrderItemResponse `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OrderListResponse struct { + Orders []Order `json:"orders"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// ============================================================ +// HELPER FUNCTIONS +// ============================================================ + +func (o *Order) ToResponse() *OrderResponse { + resp := &OrderResponse{ + ID: o.ID, + UserID: o.UserID, + Status: o.Status, + TotalAmount: o.TotalAmount, + ShippingName: o.ShippingName, + ShippingAddress: o.ShippingAddress, + Items: make([]OrderItemResponse, 0, len(o.Items)), + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + } + + for _, item := range o.Items { + resp.Items = append(resp.Items, OrderItemResponse{ + ID: item.ID, + OrderID: item.OrderID, + ProductID: item.ProductID, + ProductName: item.ProductName, + Price: item.Price, + Quantity: item.Quantity, + Subtotal: item.Subtotal, + CreatedAt: item.CreatedAt, + }) + } + + return resp +} diff --git a/services/order-service/internal/domain/repository.go b/services/order-service/internal/domain/repository.go new file mode 100644 index 0000000..68e1f42 --- /dev/null +++ b/services/order-service/internal/domain/repository.go @@ -0,0 +1,22 @@ +package domain + +import "github.com/google/uuid" + +// CartRepository defines persistence operations for carts and their items. +type CartRepository interface { + GetCartByUserID(userID uuid.UUID) (*Cart, error) + CreateCart(cart *Cart) (*Cart, error) + GetCartItemByID(itemID uuid.UUID) (*CartItem, error) + CreateCartItem(item *CartItem) (*CartItem, error) + UpdateCartItem(item *CartItem) (*CartItem, error) + DeleteCartItem(itemID uuid.UUID) error + ClearCart(cartID uuid.UUID) error +} + +// OrderRepository defines persistence operations for orders. +type OrderRepository interface { + GetOrdersByUserID(userID uuid.UUID, offset, limit int) ([]Order, int64, error) + GetOrderByID(orderID uuid.UUID) (*Order, error) + CreateOrder(order *Order) (*Order, error) + UpdateOrderStatus(orderID uuid.UUID, status OrderStatus) (*Order, error) +} diff --git a/services/order-service/internal/domain/service.go b/services/order-service/internal/domain/service.go new file mode 100644 index 0000000..70e822f --- /dev/null +++ b/services/order-service/internal/domain/service.go @@ -0,0 +1,23 @@ +package domain + +import ( + "context" + + "github.com/google/uuid" +) + +// CartService defines the business logic for cart operations. +type CartService interface { + GetCart(ctx context.Context, userID uuid.UUID) (*Cart, error) + AddItem(ctx context.Context, userID uuid.UUID, req AddItemRequest) (*Cart, error) + UpdateItem(ctx context.Context, userID, itemID uuid.UUID, quantity int) (*Cart, error) + RemoveItem(ctx context.Context, userID, itemID uuid.UUID) error +} + +// OrderService defines the business logic for order operations. +type OrderService interface { + GetOrders(ctx context.Context, userID uuid.UUID, page, limit int) (*OrderListResponse, error) + CreateOrder(ctx context.Context, userID uuid.UUID, req CreateOrderRequest) (*Order, error) + GetOrderByID(ctx context.Context, userID, orderID uuid.UUID) (*Order, error) + CancelOrder(ctx context.Context, userID, orderID uuid.UUID) (*Order, error) +} diff --git a/services/order-service/internal/repository/cart_repository.go b/services/order-service/internal/repository/cart_repository.go new file mode 100644 index 0000000..d5ea0d4 --- /dev/null +++ b/services/order-service/internal/repository/cart_repository.go @@ -0,0 +1,67 @@ +package repository + +import ( + "auron/order-service/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CartRepository struct { + db *gorm.DB +} + +func NewCartRepository(db *gorm.DB) domain.CartRepository { + return &CartRepository{db: db} +} + +func (r *CartRepository) GetCartByUserID(userID uuid.UUID) (*domain.Cart, error) { + var cart domain.Cart + if err := r.db.Preload("Items").First(&cart, "user_id = ?", userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrCartNotFound + } + return nil, err + } + return &cart, nil +} + +func (r *CartRepository) CreateCart(cart *domain.Cart) (*domain.Cart, error) { + if err := r.db.Create(cart).Error; err != nil { + return nil, err + } + return cart, nil +} + +func (r *CartRepository) GetCartItemByID(itemID uuid.UUID) (*domain.CartItem, error) { + var item domain.CartItem + if err := r.db.First(&item, "id = ?", itemID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrCartItemNotFound + } + return nil, err + } + return &item, nil +} + +func (r *CartRepository) CreateCartItem(item *domain.CartItem) (*domain.CartItem, error) { + if err := r.db.Create(item).Error; err != nil { + return nil, err + } + return item, nil +} + +func (r *CartRepository) UpdateCartItem(item *domain.CartItem) (*domain.CartItem, error) { + if err := r.db.Save(item).Error; err != nil { + return nil, err + } + return item, nil +} + +func (r *CartRepository) DeleteCartItem(itemID uuid.UUID) error { + return r.db.Delete(&domain.CartItem{}, "id = ?", itemID).Error +} + +func (r *CartRepository) ClearCart(cartID uuid.UUID) error { + return r.db.Delete(&domain.CartItem{}, "cart_id = ?", cartID).Error +} diff --git a/services/order-service/internal/repository/order_repository.go b/services/order-service/internal/repository/order_repository.go new file mode 100644 index 0000000..3c1b13a --- /dev/null +++ b/services/order-service/internal/repository/order_repository.go @@ -0,0 +1,71 @@ +package repository + +import ( + "auron/order-service/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type OrderRepository struct { + db *gorm.DB +} + +func NewOrderRepository(db *gorm.DB) domain.OrderRepository { + return &OrderRepository{db: db} +} + +func (r *OrderRepository) GetOrdersByUserID(userID uuid.UUID, offset, limit int) ([]domain.Order, int64, error) { + var total int64 + if err := r.db.Model(&domain.Order{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { + return nil, 0, err + } + + var orders []domain.Order + if err := r.db.Preload("Items"). + Where("user_id = ?", userID). + Order("created_at DESC"). + Offset(offset).Limit(limit). + Find(&orders).Error; err != nil { + return nil, 0, err + } + + return orders, total, nil +} + +func (r *OrderRepository) GetOrderByID(orderID uuid.UUID) (*domain.Order, error) { + var order domain.Order + if err := r.db.Preload("Items").First(&order, "id = ?", orderID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrOrderNotFound + } + return nil, err + } + return &order, nil +} + +func (r *OrderRepository) CreateOrder(order *domain.Order) (*domain.Order, error) { + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Omit("Items").Create(order).Error; err != nil { + return err + } + for i := range order.Items { + order.Items[i].OrderID = order.ID + if err := tx.Create(&order.Items[i]).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return order, nil +} + +func (r *OrderRepository) UpdateOrderStatus(orderID uuid.UUID, status domain.OrderStatus) (*domain.Order, error) { + if err := r.db.Model(&domain.Order{}).Where("id = ?", orderID).Update("status", status).Error; err != nil { + return nil, err + } + return r.GetOrderByID(orderID) +} From 415e723d187aba96c26a19a67105ae9b03c2fa53 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 11:07:18 +0700 Subject: [PATCH 2/4] feat: Implement order service cache, events, client, service, and handler layers - Cache: CartCache (24h TTL) and OrderCache (1h detail / 5min list TTL) backed by Redis; InvalidateOrderList scans by user prefix - Events: Kafka publisher for order.created, order.updated, order.cancelled topics - Client: HTTP ProductClient fetches product snapshot from product-service; returns ErrProductInactive if product is disabled - Service: CartService auto-creates cart on first access, merges quantities on duplicate add, enforces item ownership on update/remove; OrderService builds order from cart snapshots in a single transaction, clears cart after checkout, publishes events asynchronously - Handler + Route: 8 endpoints (GET/POST/PUT/DELETE cart, GET/POST/GET/PUT orders); user identity read from X-User-ID header injected by gateway Co-Authored-By: Claude Sonnet 4.6 --- services/order-service/go.mod | 37 +++- services/order-service/go.sum | 115 +++++++++- .../internal/cache/cart_cache.go | 56 +++++ .../internal/cache/order_cache.go | 114 ++++++++++ .../internal/client/product_client.go | 79 +++++++ .../internal/events/kafka_publisher.go | 52 +++++ .../internal/handler/cart_handler.go | 153 +++++++++++++ .../internal/handler/order_handler.go | 129 +++++++++++ .../internal/route/order_route.go | 21 ++ .../internal/service/cart_service.go | 176 +++++++++++++++ .../internal/service/order_service.go | 206 ++++++++++++++++++ 11 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 services/order-service/internal/cache/cart_cache.go create mode 100644 services/order-service/internal/cache/order_cache.go create mode 100644 services/order-service/internal/client/product_client.go create mode 100644 services/order-service/internal/events/kafka_publisher.go create mode 100644 services/order-service/internal/handler/cart_handler.go create mode 100644 services/order-service/internal/handler/order_handler.go create mode 100644 services/order-service/internal/route/order_route.go create mode 100644 services/order-service/internal/service/cart_service.go create mode 100644 services/order-service/internal/service/order_service.go diff --git a/services/order-service/go.mod b/services/order-service/go.mod index 752918e..b5dbcde 100644 --- a/services/order-service/go.mod +++ b/services/order-service/go.mod @@ -3,12 +3,47 @@ module auron/order-service go 1.25.8 require ( + github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.19.0 + github.com/segmentio/kafka-go v0.4.51 gorm.io/gorm v1.31.1 ) require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // 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.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - golang.org/x/text v0.20.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // 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.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/services/order-service/go.sum b/services/order-service/go.sum index f5ab7ba..ca6bbb9 100644 --- a/services/order-service/go.sum +++ b/services/order-service/go.sum @@ -1,10 +1,121 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +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.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/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.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= +github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/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/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/services/order-service/internal/cache/cart_cache.go b/services/order-service/internal/cache/cart_cache.go new file mode 100644 index 0000000..225af74 --- /dev/null +++ b/services/order-service/internal/cache/cart_cache.go @@ -0,0 +1,56 @@ +package cache + +import ( + "context" + "encoding/json" + "time" + + "auron/order-service/internal/domain" + + "github.com/redis/go-redis/v9" +) + +const ( + CartPrefix = "cart:" + cartTTL = 24 * time.Hour +) + +type CartCache struct { + redis *redis.Client +} + +func NewCartCache(redisClient *redis.Client) domain.CartCache { + return &CartCache{redis: redisClient} +} + +func (c *CartCache) GetCart(ctx context.Context, userID string) (*domain.Cart, error) { + key := CartPrefix + userID + + cached, err := c.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var cart domain.Cart + if err := json.Unmarshal([]byte(cached), &cart); err != nil { + return nil, err + } + + return &cart, nil +} + +func (c *CartCache) SetCart(ctx context.Context, cart *domain.Cart) error { + key := CartPrefix + cart.UserID.String() + data, err := json.Marshal(cart) + if err != nil { + return err + } + return c.redis.Set(ctx, key, data, cartTTL).Err() +} + +func (c *CartCache) InvalidateCart(ctx context.Context, userID string) error { + return c.redis.Del(ctx, CartPrefix+userID).Err() +} diff --git a/services/order-service/internal/cache/order_cache.go b/services/order-service/internal/cache/order_cache.go new file mode 100644 index 0000000..06255c3 --- /dev/null +++ b/services/order-service/internal/cache/order_cache.go @@ -0,0 +1,114 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "auron/order-service/internal/domain" + + "github.com/redis/go-redis/v9" +) + +const ( + OrderPrefix = "order:" + OrderListPrefix = "orders:user:" + orderTTL = time.Hour + orderListTTL = 5 * time.Minute +) + +type OrderCache struct { + redis *redis.Client +} + +func NewOrderCache(redisClient *redis.Client) domain.OrderCache { + return &OrderCache{redis: redisClient} +} + +func (c *OrderCache) GetOrder(ctx context.Context, orderID string) (*domain.Order, error) { + key := OrderPrefix + orderID + + cached, err := c.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var order domain.Order + if err := json.Unmarshal([]byte(cached), &order); err != nil { + return nil, err + } + + return &order, nil +} + +func (c *OrderCache) SetOrder(ctx context.Context, order *domain.Order) error { + key := OrderPrefix + order.ID.String() + data, err := json.Marshal(order) + if err != nil { + return err + } + return c.redis.Set(ctx, key, data, orderTTL).Err() +} + +func (c *OrderCache) InvalidateOrder(ctx context.Context, orderID string) error { + return c.redis.Del(ctx, OrderPrefix+orderID).Err() +} + +func (c *OrderCache) GetOrderList(ctx context.Context, cacheKey string) (*domain.OrderListResponse, error) { + cached, err := c.redis.Get(ctx, cacheKey).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var resp domain.OrderListResponse + if err := json.Unmarshal([]byte(cached), &resp); err != nil { + return nil, err + } + + return &resp, nil +} + +func (c *OrderCache) SetOrderList(ctx context.Context, cacheKey string, resp *domain.OrderListResponse) error { + data, err := json.Marshal(resp) + if err != nil { + return err + } + return c.redis.Set(ctx, cacheKey, data, orderListTTL).Err() +} + +// InvalidateOrderList scans and deletes all list cache keys for the given user. +func (c *OrderCache) InvalidateOrderList(ctx context.Context, userID string) error { + pattern := OrderListPrefix + userID + ":*" + var cursor uint64 + + 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 +} + +// BuildOrderListCacheKey produces a deterministic key for an order list query. +func BuildOrderListCacheKey(userID string, page, limit int) string { + return fmt.Sprintf("%s%s:page:%d:limit:%d", OrderListPrefix, userID, page, limit) +} diff --git a/services/order-service/internal/client/product_client.go b/services/order-service/internal/client/product_client.go new file mode 100644 index 0000000..12394c5 --- /dev/null +++ b/services/order-service/internal/client/product_client.go @@ -0,0 +1,79 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "auron/order-service/internal/domain" + + "github.com/google/uuid" +) + +type httpProductClient struct { + baseURL string + httpClient *http.Client +} + +func NewProductClient(baseURL string) domain.ProductClient { + return &httpProductClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +func (c *httpProductClient) GetProduct(ctx context.Context, id uuid.UUID) (*domain.ProductSnapshot, error) { + url := fmt.Sprintf("%s/products/%s", c.baseURL, id.String()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("product client: create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("product client: do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, domain.ErrProductNotFound + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("product client: unexpected status %d", resp.StatusCode) + } + + // product-service response envelope: {"success": true, "data": {...}} + var envelope struct { + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + IsActive bool `json:"is_active"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("product client: decode response: %w", err) + } + + productID, err := uuid.Parse(envelope.Data.ID) + if err != nil { + return nil, fmt.Errorf("product client: parse product id: %w", err) + } + + if !envelope.Data.IsActive { + return nil, domain.ErrProductInactive + } + + return &domain.ProductSnapshot{ + ID: productID, + Name: envelope.Data.Name, + Price: envelope.Data.Price, + IsActive: envelope.Data.IsActive, + }, nil +} diff --git a/services/order-service/internal/events/kafka_publisher.go b/services/order-service/internal/events/kafka_publisher.go new file mode 100644 index 0000000..d421cb2 --- /dev/null +++ b/services/order-service/internal/events/kafka_publisher.go @@ -0,0 +1,52 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "auron/order-service/internal/domain" + + "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/order-service/internal/handler/cart_handler.go b/services/order-service/internal/handler/cart_handler.go new file mode 100644 index 0000000..748b624 --- /dev/null +++ b/services/order-service/internal/handler/cart_handler.go @@ -0,0 +1,153 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "auron/order-service/internal/domain" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type CartHandler struct { + service domain.CartService +} + +func NewCartHandler(service domain.CartService) *CartHandler { + return &CartHandler{service: service} +} + +func (h *CartHandler) GetCart(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + cart, err := h.service.GetCart(c.Request.Context(), userID) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": cart.ToResponse()}) +} + +func (h *CartHandler) AddItem(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + var req domain.AddItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + cart, err := h.service.AddItem(c.Request.Context(), userID, req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": cart.ToResponse()}) +} + +func (h *CartHandler) UpdateItem(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + itemID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid item id"}) + return + } + + var body struct { + Quantity int `json:"quantity" binding:"required,min=1"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + cart, err := h.service.UpdateItem(c.Request.Context(), userID, itemID, body.Quantity) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": cart.ToResponse()}) +} + +func (h *CartHandler) RemoveItem(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + itemID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid item id"}) + return + } + + if err := h.service.RemoveItem(c.Request.Context(), userID, itemID); err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "item removed from cart"}) +} + +func (h *CartHandler) handleError(c *gin.Context, err error) { + switch { + case errors.Is(err, domain.ErrCartNotFound), errors.Is(err, domain.ErrCartItemNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrProductNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrProductInactive): + c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrInvalidQuantity): + 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"}) + } +} + +// getUserID reads the user UUID from the X-User-ID header set by the gateway. +func getUserID(c *gin.Context) (uuid.UUID, bool) { + raw := c.GetHeader("X-User-ID") + if raw == "" { + return uuid.Nil, false + } + id, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, false + } + return id, true +} + +// parseIntQuery parses a query param as int, returning defaultVal on empty/error. +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/order-service/internal/handler/order_handler.go b/services/order-service/internal/handler/order_handler.go new file mode 100644 index 0000000..fbd76d2 --- /dev/null +++ b/services/order-service/internal/handler/order_handler.go @@ -0,0 +1,129 @@ +package handler + +import ( + "errors" + "net/http" + + "auron/order-service/internal/domain" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type OrderHandler struct { + service domain.OrderService +} + +func NewOrderHandler(service domain.OrderService) *OrderHandler { + return &OrderHandler{service: service} +} + +func (h *OrderHandler) GetOrders(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + page := parseIntQuery(c.Query("page"), 1) + limit := parseIntQuery(c.Query("limit"), 10) + + result, err := h.service.GetOrders(c.Request.Context(), userID, page, limit) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result.Orders, + "meta": gin.H{ + "page": result.Page, + "limit": result.Limit, + "total": result.Total, + }, + }) +} + +func (h *OrderHandler) CreateOrder(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + var req domain.CreateOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + order, err := h.service.CreateOrder(c.Request.Context(), userID, req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusCreated, gin.H{"success": true, "data": order.ToResponse()}) +} + +func (h *OrderHandler) GetOrderByID(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + orderID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid order id"}) + return + } + + order, err := h.service.GetOrderByID(c.Request.Context(), userID, orderID) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": order.ToResponse()}) +} + +func (h *OrderHandler) CancelOrder(c *gin.Context) { + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": domain.ErrUnauthorized.Error()}) + return + } + + orderID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid order id"}) + return + } + + order, err := h.service.CancelOrder(c.Request.Context(), userID, orderID) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": order.ToResponse()}) +} + +func (h *OrderHandler) handleError(c *gin.Context, err error) { + switch { + case errors.Is(err, domain.ErrOrderNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrCartEmpty): + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrOrderNotCancellable): + c.JSON(http.StatusConflict, 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"}) + } +} diff --git a/services/order-service/internal/route/order_route.go b/services/order-service/internal/route/order_route.go new file mode 100644 index 0000000..8cb2920 --- /dev/null +++ b/services/order-service/internal/route/order_route.go @@ -0,0 +1,21 @@ +package route + +import ( + "auron/order-service/internal/handler" + + "github.com/gin-gonic/gin" +) + +func RegisterOrderRoutes(router *gin.Engine, cartHandler *handler.CartHandler, orderHandler *handler.OrderHandler) { + api := router.Group("/") + + api.GET("/cart", cartHandler.GetCart) + api.POST("/cart/items", cartHandler.AddItem) + api.PUT("/cart/items/:id", cartHandler.UpdateItem) + api.DELETE("/cart/items/:id", cartHandler.RemoveItem) + + api.GET("/orders", orderHandler.GetOrders) + api.POST("/orders", orderHandler.CreateOrder) + api.GET("/orders/:id", orderHandler.GetOrderByID) + api.PUT("/orders/:id/cancel", orderHandler.CancelOrder) +} diff --git a/services/order-service/internal/service/cart_service.go b/services/order-service/internal/service/cart_service.go new file mode 100644 index 0000000..45e2b6b --- /dev/null +++ b/services/order-service/internal/service/cart_service.go @@ -0,0 +1,176 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "time" + + "auron/order-service/internal/domain" + + "github.com/google/uuid" +) + +type CartService struct { + cartRepo domain.CartRepository + cartCache domain.CartCache + productClient domain.ProductClient +} + +func NewCartService( + cartRepo domain.CartRepository, + cartCache domain.CartCache, + productClient domain.ProductClient, +) domain.CartService { + return &CartService{ + cartRepo: cartRepo, + cartCache: cartCache, + productClient: productClient, + } +} + +func (s *CartService) GetCart(ctx context.Context, userID uuid.UUID) (*domain.Cart, error) { + if cached, err := s.cartCache.GetCart(ctx, userID.String()); err == nil && cached != nil { + return cached, nil + } + + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + if errors.Is(err, domain.ErrCartNotFound) { + return s.createEmptyCart(ctx, userID) + } + return nil, err + } + + if err := s.cartCache.SetCart(ctx, cart); err != nil { + slog.Warn("failed to cache cart", "user_id", userID, "error", err) + } + return cart, nil +} + +func (s *CartService) AddItem(ctx context.Context, userID uuid.UUID, req domain.AddItemRequest) (*domain.Cart, error) { + snapshot, err := s.productClient.GetProduct(ctx, req.ProductID) + if err != nil { + return nil, err + } + + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + if errors.Is(err, domain.ErrCartNotFound) { + cart, err = s.createEmptyCart(ctx, userID) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + + // if product already in cart, merge quantities and refresh price + for _, item := range cart.Items { + if item.ProductID == req.ProductID { + item.Quantity += req.Quantity + item.Price = snapshot.Price + item.UpdatedAt = time.Now() + if _, err := s.cartRepo.UpdateCartItem(&item); err != nil { + return nil, err + } + return s.reloadCart(ctx, userID) + } + } + + now := time.Now() + newItem := &domain.CartItem{ + ID: uuid.New(), + CartID: cart.ID, + ProductID: snapshot.ID, + ProductName: snapshot.Name, + Price: snapshot.Price, + Quantity: req.Quantity, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := s.cartRepo.CreateCartItem(newItem); err != nil { + return nil, err + } + + return s.reloadCart(ctx, userID) +} + +func (s *CartService) UpdateItem(ctx context.Context, userID, itemID uuid.UUID, quantity int) (*domain.Cart, error) { + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + return nil, err + } + + item, err := s.cartRepo.GetCartItemByID(itemID) + if err != nil { + return nil, err + } + + if item.CartID != cart.ID { + return nil, domain.ErrForbidden + } + + item.Quantity = quantity + item.UpdatedAt = time.Now() + if _, err := s.cartRepo.UpdateCartItem(item); err != nil { + return nil, err + } + + return s.reloadCart(ctx, userID) +} + +func (s *CartService) RemoveItem(ctx context.Context, userID, itemID uuid.UUID) error { + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + return err + } + + item, err := s.cartRepo.GetCartItemByID(itemID) + if err != nil { + return err + } + + if item.CartID != cart.ID { + return domain.ErrForbidden + } + + if err := s.cartRepo.DeleteCartItem(itemID); err != nil { + return err + } + + if err := s.cartCache.InvalidateCart(ctx, userID.String()); err != nil { + slog.Warn("failed to invalidate cart cache", "user_id", userID, "error", err) + } + return nil +} + +// createEmptyCart inserts a new cart for the user and returns it. +func (s *CartService) createEmptyCart(ctx context.Context, userID uuid.UUID) (*domain.Cart, error) { + cart := &domain.Cart{ + ID: uuid.New(), + UserID: userID, + Items: []domain.CartItem{}, + } + created, err := s.cartRepo.CreateCart(cart) + if err != nil { + return nil, err + } + if err := s.cartCache.SetCart(ctx, created); err != nil { + slog.Warn("failed to cache new cart", "user_id", userID, "error", err) + } + return created, nil +} + +// reloadCart re-fetches the cart from DB and updates the cache. +func (s *CartService) reloadCart(ctx context.Context, userID uuid.UUID) (*domain.Cart, error) { + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + return nil, err + } + if err := s.cartCache.SetCart(ctx, cart); err != nil { + slog.Warn("failed to cache cart", "user_id", userID, "error", err) + } + return cart, nil +} diff --git a/services/order-service/internal/service/order_service.go b/services/order-service/internal/service/order_service.go new file mode 100644 index 0000000..4b3bb42 --- /dev/null +++ b/services/order-service/internal/service/order_service.go @@ -0,0 +1,206 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "auron/order-service/internal/domain" + + "github.com/google/uuid" +) + +type OrderService struct { + orderRepo domain.OrderRepository + cartRepo domain.CartRepository + orderCache domain.OrderCache + cartCache domain.CartCache + publisher domain.EventPublisher +} + +func NewOrderService( + orderRepo domain.OrderRepository, + cartRepo domain.CartRepository, + orderCache domain.OrderCache, + cartCache domain.CartCache, + publisher domain.EventPublisher, +) domain.OrderService { + return &OrderService{ + orderRepo: orderRepo, + cartRepo: cartRepo, + orderCache: orderCache, + cartCache: cartCache, + publisher: publisher, + } +} + +func (s *OrderService) GetOrders(ctx context.Context, userID uuid.UUID, page, limit int) (*domain.OrderListResponse, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + + cacheKey := buildOrderListCacheKey(userID.String(), page, limit) + if cached, err := s.orderCache.GetOrderList(ctx, cacheKey); err == nil && cached != nil { + return cached, nil + } + + offset := (page - 1) * limit + orders, total, err := s.orderRepo.GetOrdersByUserID(userID, offset, limit) + if err != nil { + return nil, err + } + + resp := &domain.OrderListResponse{ + Orders: orders, + Total: total, + Page: page, + Limit: limit, + } + + if err := s.orderCache.SetOrderList(ctx, cacheKey, resp); err != nil { + slog.Warn("failed to cache order list", "user_id", userID, "error", err) + } + return resp, nil +} + +func (s *OrderService) CreateOrder(ctx context.Context, userID uuid.UUID, req domain.CreateOrderRequest) (*domain.Order, error) { + cart, err := s.cartRepo.GetCartByUserID(userID) + if err != nil { + if errors.Is(err, domain.ErrCartNotFound) { + return nil, domain.ErrCartEmpty + } + return nil, err + } + + if len(cart.Items) == 0 { + return nil, domain.ErrCartEmpty + } + + var orderItems []domain.OrderItem + var total float64 + now := time.Now() + + for _, item := range cart.Items { + subtotal := item.Price * float64(item.Quantity) + total += subtotal + orderItems = append(orderItems, domain.OrderItem{ + ID: uuid.New(), + ProductID: item.ProductID, + ProductName: item.ProductName, + Price: item.Price, + Quantity: item.Quantity, + Subtotal: subtotal, + CreatedAt: now, + }) + } + + order := &domain.Order{ + ID: uuid.New(), + UserID: userID, + Status: domain.OrderStatusPending, + TotalAmount: total, + ShippingName: req.ShippingName, + ShippingAddress: req.ShippingAddress, + Items: orderItems, + CreatedAt: now, + UpdatedAt: now, + } + + created, err := s.orderRepo.CreateOrder(order) + if err != nil { + return nil, err + } + + if err := s.cartRepo.ClearCart(cart.ID); err != nil { + slog.Warn("failed to clear cart after order creation", "cart_id", cart.ID, "error", err) + } + if err := s.cartCache.InvalidateCart(ctx, userID.String()); err != nil { + slog.Warn("failed to invalidate cart cache", "user_id", userID, "error", err) + } + + if err := s.orderCache.SetOrder(ctx, created); err != nil { + slog.Warn("failed to cache new order", "order_id", created.ID, "error", err) + } + if err := s.orderCache.InvalidateOrderList(ctx, userID.String()); err != nil { + slog.Warn("failed to invalidate order list cache", "user_id", userID, "error", err) + } + + go func() { + if err := s.publisher.Publish(context.Background(), domain.TopicOrderCreated, created); err != nil { + slog.Warn("failed to publish order.created", "order_id", created.ID, "error", err) + } + }() + + return created, nil +} + +func (s *OrderService) GetOrderByID(ctx context.Context, userID, orderID uuid.UUID) (*domain.Order, error) { + if cached, err := s.orderCache.GetOrder(ctx, orderID.String()); err == nil && cached != nil { + if cached.UserID != userID { + return nil, domain.ErrForbidden + } + return cached, nil + } + + order, err := s.orderRepo.GetOrderByID(orderID) + if err != nil { + return nil, err + } + + if order.UserID != userID { + return nil, domain.ErrForbidden + } + + if err := s.orderCache.SetOrder(ctx, order); err != nil { + slog.Warn("failed to cache order", "order_id", orderID, "error", err) + } + return order, nil +} + +func (s *OrderService) CancelOrder(ctx context.Context, userID, orderID uuid.UUID) (*domain.Order, error) { + order, err := s.orderRepo.GetOrderByID(orderID) + if err != nil { + return nil, err + } + + if order.UserID != userID { + return nil, domain.ErrForbidden + } + + if !order.Status.Cancellable() { + return nil, domain.ErrOrderNotCancellable + } + + cancelled, err := s.orderRepo.UpdateOrderStatus(orderID, domain.OrderStatusCancelled) + if err != nil { + return nil, err + } + + if err := s.orderCache.SetOrder(ctx, cancelled); err != nil { + slog.Warn("failed to update cached order", "order_id", orderID, "error", err) + } + if err := s.orderCache.InvalidateOrderList(ctx, userID.String()); err != nil { + slog.Warn("failed to invalidate order list cache", "user_id", userID, "error", err) + } + + go func() { + if err := s.publisher.Publish(context.Background(), domain.TopicOrderCancelled, cancelled); err != nil { + slog.Warn("failed to publish order.cancelled", "order_id", orderID, "error", err) + } + }() + + return cancelled, nil +} + +// buildOrderListCacheKey produces a deterministic cache key for a paginated order list. +func buildOrderListCacheKey(userID string, page, limit int) string { + return fmt.Sprintf("orders:user:%s:page:%d:limit:%d", userID, page, limit) +} From 916115f6276846906c3dbe4496923640a13c202d Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 11:10:30 +0700 Subject: [PATCH 3/4] feat: Implement order service bootstrap, entry point, Dockerfile, and docker-compose wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd: config (PORT/DATABASE_URL/REDIS_URL/KAFKA_BROKERS/PRODUCT_SERVICE_URL), dotenv loader, GORM+Redis infrastructure setup, Kafka publisher bootstrap with ensureTopics, dependency wiring in Run(), graceful shutdown (DB/Redis/Kafka) - main.go: single-line entry point calling cmd.Run() - Dockerfile: multi-stage build (golang:1.25-alpine → alpine:3.18), port 8083 - .env / .env.example: local dev defaults - docker-compose: added REDIS_URL, KAFKA_BROKERS, PRODUCT_SERVICE_URL to order-service environment Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 3 + services/order-service/.env.example | 7 ++ services/order-service/Dockerfile | 23 ++++ services/order-service/cmd/config.go | 48 +++++++++ services/order-service/cmd/dotenv.go | 42 ++++++++ services/order-service/cmd/infrastructure.go | 76 ++++++++++++++ services/order-service/cmd/kafka.go | 104 +++++++++++++++++++ services/order-service/cmd/run.go | 77 ++++++++++++++ services/order-service/cmd/server.go | 34 ++++++ services/order-service/go.mod | 6 ++ services/order-service/go.sum | 13 +++ services/order-service/main.go | 7 ++ 12 files changed, 440 insertions(+) create mode 100644 services/order-service/.env.example create mode 100644 services/order-service/Dockerfile create mode 100644 services/order-service/cmd/config.go create mode 100644 services/order-service/cmd/dotenv.go create mode 100644 services/order-service/cmd/infrastructure.go create mode 100644 services/order-service/cmd/kafka.go create mode 100644 services/order-service/cmd/run.go create mode 100644 services/order-service/cmd/server.go create mode 100644 services/order-service/main.go diff --git a/docker-compose.yml b/docker-compose.yml index 7b22dc5..ff5682d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,9 @@ services: environment: - PORT=8083 - DATABASE_URL=postgres://auron:auron_pass@orders-db:5434/orders_db?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - KAFKA_BROKERS=kafka:29092 + - PRODUCT_SERVICE_URL=http://product-service:8082 depends_on: orders-db: condition: service_healthy diff --git a/services/order-service/.env.example b/services/order-service/.env.example new file mode 100644 index 0000000..1acf747 --- /dev/null +++ b/services/order-service/.env.example @@ -0,0 +1,7 @@ +# Order Service +PORT=8083 +DATABASE_URL=postgres://auron:auron_pass@localhost:5434/orders_db?sslmode=disable +REDIS_URL=redis://localhost:6379/0 +KAFKA_BROKERS=localhost:9092 +PRODUCT_SERVICE_URL=http://localhost:8082 +GORM_LOG_LEVEL=warn diff --git a/services/order-service/Dockerfile b/services/order-service/Dockerfile new file mode 100644 index 0000000..0b2d54d --- /dev/null +++ b/services/order-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 /order-service . + +FROM alpine:3.18 + +RUN apk add --no-cache ca-certificates curl + +WORKDIR /app + +COPY --from=builder /order-service . + +EXPOSE 8083 + +CMD ["./order-service"] diff --git a/services/order-service/cmd/config.go b/services/order-service/cmd/config.go new file mode 100644 index 0000000..cd18210 --- /dev/null +++ b/services/order-service/cmd/config.go @@ -0,0 +1,48 @@ +package cmd + +import "os" + +type appConfig struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string + ProductServiceURL string +} + +func loadConfig() appConfig { + loadDotEnvFile(".env") + + port := os.Getenv("PORT") + if port == "" { + port = "8083" + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://auron:auron_pass@localhost:5434/orders_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" + } + + productServiceURL := os.Getenv("PRODUCT_SERVICE_URL") + if productServiceURL == "" { + productServiceURL = "http://localhost:8082" + } + + return appConfig{ + Port: port, + DatabaseURL: databaseURL, + RedisURL: redisURL, + KafkaBrokers: kafkaBrokers, + ProductServiceURL: productServiceURL, + } +} diff --git a/services/order-service/cmd/dotenv.go b/services/order-service/cmd/dotenv.go new file mode 100644 index 0000000..3c652db --- /dev/null +++ b/services/order-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/order-service/cmd/infrastructure.go b/services/order-service/cmd/infrastructure.go new file mode 100644 index 0000000..b7efd94 --- /dev/null +++ b/services/order-service/cmd/infrastructure.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "os" + "strings" + "time" + + "auron/order-service/internal/domain" + + "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 { + return db.AutoMigrate( + &domain.Cart{}, + &domain.CartItem{}, + &domain.Order{}, + &domain.OrderItem{}, + ) +} + +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/order-service/cmd/kafka.go b/services/order-service/cmd/kafka.go new file mode 100644 index 0000000..4581d37 --- /dev/null +++ b/services/order-service/cmd/kafka.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "log/slog" + "strconv" + "strings" + "time" + + "auron/order-service/internal/domain" + "auron/order-service/internal/events" + + "github.com/segmentio/kafka-go" +) + +var orderTopics = []string{ + domain.TopicOrderCreated, + domain.TopicOrderUpdated, + domain.TopicOrderCancelled, +} + +func setupKafkaPublisher(kafkaBrokers string) domain.EventPublisher { + brokers := parseBrokers(kafkaBrokers) + + ensureTopics(brokers, orderTopics) + + writers := make(map[string]*kafka.Writer, len(orderTopics)) + for _, topic := range orderTopics { + 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/order-service/cmd/run.go b/services/order-service/cmd/run.go new file mode 100644 index 0000000..4a45896 --- /dev/null +++ b/services/order-service/cmd/run.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "auron/order-service/internal/cache" + "auron/order-service/internal/client" + "auron/order-service/internal/domain" + "auron/order-service/internal/handler" + "auron/order-service/internal/repository" + "auron/order-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) + + cartRepo := repository.NewCartRepository(db) + orderRepo := repository.NewOrderRepository(db) + cartCache := cache.NewCartCache(redisClient) + orderCache := cache.NewOrderCache(redisClient) + productClient := client.NewProductClient(cfg.ProductServiceURL) + + cartSvc := service.NewCartService(cartRepo, cartCache, productClient) + orderSvc := service.NewOrderService(orderRepo, cartRepo, orderCache, cartCache, publisher) + + cartHandler := handler.NewCartHandler(cartSvc) + orderHandler := handler.NewOrderHandler(orderSvc) + + router := setupRouter(cartHandler, orderHandler) + registerGracefulShutdown(db, redisClient, publisher) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("starting order-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 order-service...") + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + _ = redisClient.Close() + closeKafkaPublisher(publisher) + os.Exit(0) + }() +} diff --git a/services/order-service/cmd/server.go b/services/order-service/cmd/server.go new file mode 100644 index 0000000..1086ef4 --- /dev/null +++ b/services/order-service/cmd/server.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "time" + + "auron/order-service/internal/handler" + "auron/order-service/internal/route" + + "github.com/gin-gonic/gin" +) + +func setupRouter(cartHandler *handler.CartHandler, orderHandler *handler.OrderHandler) *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": "order-service", + "timestamp": time.Now().UTC(), + }) + }) + + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "# Prometheus metrics endpoint\n") + }) + + route.RegisterOrderRoutes(router, cartHandler, orderHandler) + + return router +} diff --git a/services/order-service/go.mod b/services/order-service/go.mod index b5dbcde..49cc0c9 100644 --- a/services/order-service/go.mod +++ b/services/order-service/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.19.0 github.com/segmentio/kafka-go v0.4.51 + gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -23,6 +24,10 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // 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 @@ -43,6 +48,7 @@ require ( golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/services/order-service/go.sum b/services/order-service/go.sum index ca6bbb9..0e9a0de 100644 --- a/services/order-service/go.sum +++ b/services/order-service/go.sum @@ -38,6 +38,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX 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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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= @@ -76,6 +84,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -106,6 +115,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -117,5 +128,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/services/order-service/main.go b/services/order-service/main.go new file mode 100644 index 0000000..d50326b --- /dev/null +++ b/services/order-service/main.go @@ -0,0 +1,7 @@ +package main + +import "auron/order-service/cmd" + +func main() { + cmd.Run() +} From 64a6a1bbcf078bc6088f4eb796aaa0234ed35265 Mon Sep 17 00:00:00 2001 From: rezadrian01 Date: Thu, 28 May 2026 11:15:16 +0700 Subject: [PATCH 4/4] fix: Correct DATABASE_URL ports in docker-compose for inter-container networking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Docker Compose, service-to-service communication uses the container's internal port (5432 for PostgreSQL), not the host-side mapped port. The ports directive (e.g. "5433:5432") only affects host access — all services in the same network must connect on 5432. Affected services: product-service, order-service, payment-service, inventory-service Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ff5682d..90a3105 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,7 +88,7 @@ services: - "8082:8082" environment: - PORT=8082 - - DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable + - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable - REDIS_URL=redis://redis:6379/0 - KAFKA_BROKERS=kafka:29092 depends_on: @@ -112,7 +112,7 @@ services: - "8083:8083" environment: - PORT=8083 - - DATABASE_URL=postgres://auron:auron_pass@orders-db:5434/orders_db?sslmode=disable + - DATABASE_URL=postgres://auron:auron_pass@orders-db:5432/orders_db?sslmode=disable - REDIS_URL=redis://redis:6379/0 - KAFKA_BROKERS=kafka:29092 - PRODUCT_SERVICE_URL=http://product-service:8082 @@ -137,7 +137,7 @@ services: - "8084:8084" environment: - PORT=8084 - - DATABASE_URL=postgres://auron:auron_pass@payments-db:5435/payments_db?sslmode=disable + - DATABASE_URL=postgres://auron:auron_pass@payments-db:5432/payments_db?sslmode=disable depends_on: payments-db: condition: service_healthy @@ -159,7 +159,7 @@ services: - "8085:8085" environment: - PORT=8085 - - DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable + - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable depends_on: products-db: condition: service_healthy