forked from amir_tavakolian/niki
Compare commits
10 Commits
develop
...
feat/shopp
| Author | SHA1 | Date |
|---|---|---|
|
|
017459d6a2 | |
|
|
5f57c9aa44 | |
|
|
ef60a6516d | |
|
|
12af65bcd4 | |
|
|
7df63bd36f | |
|
|
f5a2a31dd0 | |
|
|
d400991b13 | |
|
|
6ca9fd1645 | |
|
|
dbe9cb7df8 | |
|
|
4ae65e95e1 |
|
|
@ -3,9 +3,9 @@ package main
|
|||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
purchaseMysql "git.gocasts.ir/ebhomengo/niki/domain/purchase/repository/mysql"
|
||||
purchaseMysql "git.gocasts.ir/ebhomengo/niki/domain/order/repository/mysql"
|
||||
order "git.gocasts.ir/ebhomengo/niki/domain/order/service"
|
||||
"git.gocasts.ir/ebhomengo/niki/purchaseapp/delivery/http"
|
||||
"git.gocasts.ir/ebhomengo/niki/purchaseapp/service/order"
|
||||
"git.gocasts.ir/ebhomengo/niki/repository/migrator"
|
||||
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1 +1,52 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/database/migrator"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/logger"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var up bool
|
||||
var down bool
|
||||
|
||||
var migrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Run database migrations",
|
||||
Long: `This command runs the database migrations for the shoppingbasket service.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
migrate()
|
||||
},
|
||||
}
|
||||
|
||||
func migrate() {
|
||||
var cfg = loadAppConfig()
|
||||
|
||||
logger.Init(cfg.Logger)
|
||||
l := logger.L()
|
||||
|
||||
migrationCfg := migrator.Config{
|
||||
MysqlConfig: cfg.Mysql,
|
||||
MigrationPath: cfg.PathOfMigration,
|
||||
MigrationDBName: "gorp_migrations",
|
||||
}
|
||||
|
||||
if migrateUp || migrateDown {
|
||||
mgr := migrator.New(migrationCfg)
|
||||
if migrateUp {
|
||||
l.Info("Running migrations up...")
|
||||
mgr.Up()
|
||||
l.Info("Migrations up completed.")
|
||||
}
|
||||
if migrateDown {
|
||||
l.Info("Running migrations down...")
|
||||
mgr.Down()
|
||||
l.Info("Migrations down completed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateCmd.Flags().BoolVar(&up, "up", false, "Run migrations up")
|
||||
migrateCmd.Flags().BoolVar(&down, "down", false, "Run migrations down")
|
||||
RootCmd.AddCommand(migrateCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func loadAppConfig() shoppingbasketapp.Config {
|
|||
if _, err := os.Stat(defaultConfig); err == nil {
|
||||
yamlPath = defaultConfig
|
||||
} else {
|
||||
yamlPath = filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.local.yml")
|
||||
yamlPath = filepath.Join(projectRoot, "deploy", "shoppingbasket", "development", "config.yml")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ func loadAppConfig() shoppingbasketapp.Config {
|
|||
}
|
||||
|
||||
if err := cfgloader.Load(options, &cfg); err != nil {
|
||||
log.Fatalf("Failed to load benefactor config: %v", err)
|
||||
log.Fatalf("Failed to load shoppingbasket config: %v", err)
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
|
|
|||
|
|
@ -5,27 +5,32 @@ import (
|
|||
"fmt"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/logger"
|
||||
"git.gocasts.ir/ebhomengo/niki/shoppingbasketapp"
|
||||
"github.com/labstack/gommon/log"
|
||||
"github.com/spf13/cobra"
|
||||
"log"
|
||||
)
|
||||
|
||||
var migrateUp bool
|
||||
var migrateDown bool
|
||||
|
||||
var ServeCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start shoppingbasket service",
|
||||
Long: `This command starts the main shoppingbasket service.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
serve()
|
||||
},
|
||||
}
|
||||
|
||||
func serve() {
|
||||
var cfg = loadAppConfig()
|
||||
var cfg shoppingbasketapp.Config = loadAppConfig()
|
||||
|
||||
logger.Init(cfg.Logger)
|
||||
l := logger.L()
|
||||
|
||||
l.Info("Starting shoppingbasket service...")
|
||||
|
||||
migrate()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -39,5 +44,8 @@ func serve() {
|
|||
}
|
||||
|
||||
func init() {
|
||||
ServeCmd.Flags().BoolVar(&migrateUp, "migrate-up", false, "Run migrations up before starting the server")
|
||||
ServeCmd.Flags().BoolVar(&migrateDown, "migrate-down", false, "Run migrations down before starting the server")
|
||||
ServeCmd.MarkFlagsMutuallyExclusive("migrate-up", "migrate-down")
|
||||
RootCmd.AddCommand(ServeCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,17 @@ redis:
|
|||
password: ""
|
||||
db: 0
|
||||
|
||||
mysql:
|
||||
port: 3306
|
||||
host: localhost
|
||||
db_name: niki_db
|
||||
username: niki
|
||||
password: nikiappt0lk2o20
|
||||
|
||||
repo:
|
||||
kart_key_prefix: "shopping-basket-cart:"
|
||||
ttl: 3600s
|
||||
cache_kart_key_prefix: "shopping-basket-cart:user_id"
|
||||
cache_ttl: 2h # 24h
|
||||
mysql_ttl: 168h # 7d
|
||||
|
||||
http_server:
|
||||
host: "localhost"
|
||||
|
|
@ -16,10 +24,12 @@ http_server:
|
|||
allow_origins:
|
||||
- "*"
|
||||
|
||||
path_of_migration: "./domain/shoppingbasket/repository/migration"
|
||||
|
||||
|
||||
logger:
|
||||
level: "debug" # Can be `debug`, `info`, `warn`, `error`
|
||||
file_path: "logs/shoppingbasketapp/service.log"
|
||||
file_path: "./logs/service.log"
|
||||
use_local_time: true
|
||||
file_max_size_in_mb: 10
|
||||
file_max_age_in_days: 7
|
||||
|
|
@ -2,20 +2,46 @@ package entity
|
|||
|
||||
import (
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CartStatus string
|
||||
|
||||
type Item struct {
|
||||
ID types.ID
|
||||
CartID types.ID
|
||||
ProductID types.ID
|
||||
UserID types.ID
|
||||
Quantity int
|
||||
Price types.Price
|
||||
Price float64
|
||||
Name string
|
||||
AddedAt int64
|
||||
AddedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Cart struct {
|
||||
ID types.ID
|
||||
UserID types.ID
|
||||
Items []Item
|
||||
TotalPrice types.Price
|
||||
ExpireAt int64
|
||||
CreatedAt int64
|
||||
Status CartStatus
|
||||
TotalPrice float64
|
||||
ExpireAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
CartStatusActive CartStatus = "active"
|
||||
CartStatusExpired CartStatus = "expired"
|
||||
CartStatusCheckedOut CartStatus = "checked_out"
|
||||
)
|
||||
|
||||
func (c CartStatus) IsValid() bool {
|
||||
switch c {
|
||||
case CartStatusExpired, CartStatusActive, CartStatusCheckedOut:
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CacheKartKeyPrefix string `koanf:"cache_kart_key_prefix"`
|
||||
CacheTTL time.Duration `koanf:"cache_ttl"`
|
||||
|
||||
MysqlTTL time.Duration `koanf:"mysql_ttl"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db DB
|
||||
cache Cache
|
||||
}
|
||||
|
||||
func New(db DB, cache Cache) Repo {
|
||||
return Repo{db: db, cache: cache}
|
||||
}
|
||||
|
||||
func op(s string) string {
|
||||
return fmt.Sprintf("shoppingbasketapp-repository-", s)
|
||||
}
|
||||
|
||||
func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) error {
|
||||
if err := r.db.addToBasket(ctx, userID, item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cart, err := r.db.getCart(ctx, nil, FindCartByUserIDQuery, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.cache.upsertCart(ctx, cart)
|
||||
}
|
||||
|
||||
func (r Repo) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
|
||||
cart, err := r.cache.getCart(ctx, userID)
|
||||
if err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
|
||||
if cart.ID < 1 || len(cart.Items) < 1 {
|
||||
c, err := r.db.getCart(ctx, nil, FindCartByUserIDQuery, userID)
|
||||
if err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
|
||||
if c.ID < 1 {
|
||||
return entity.Cart{}, nil
|
||||
}
|
||||
|
||||
if err := r.cache.upsertCart(ctx, c); err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
return cart, nil
|
||||
}
|
||||
|
||||
func (r Repo) UpdateQuantity(ctx context.Context, cartID, itemID types.ID, quantity int) error {
|
||||
|
||||
cart, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, item := range cart.Items {
|
||||
if item.ID == itemID {
|
||||
cart.Items[i].Quantity = quantity
|
||||
item.Quantity = quantity
|
||||
if err := r.db.updateQuantity(ctx, nil, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r.cache.upsertCart(ctx, cart)
|
||||
}
|
||||
|
||||
func (r Repo) UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error {
|
||||
if err := r.db.updateCartStatus(ctx, nil, cartID, status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cart, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.cache.upsertCart(ctx, cart)
|
||||
}
|
||||
|
||||
func (r Repo) DeleteItem(ctx context.Context, cartID, itemID types.ID) error {
|
||||
if err := r.db.deleteItem(ctx, nil, cartID, itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := r.db.getCart(ctx, nil, FindCartByIDQuery, cartID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.cache.upsertCart(ctx, c)
|
||||
}
|
||||
|
||||
func (r Repo) DeleteCart(ctx context.Context, cartID, userID types.ID) error {
|
||||
|
||||
if err := r.db.deleteCart(ctx, cartID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.cache.deleteCart(ctx, userID)
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
adapter "git.gocasts.ir/ebhomengo/niki/adapter/redis"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
|
||||
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
client *adapter.Adapter
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewCache(client *adapter.Adapter, cfg Config) Cache {
|
||||
return Cache{client: client, config: cfg}
|
||||
}
|
||||
|
||||
func (c Cache) cartKey(userID types.ID) string {
|
||||
return fmt.Sprintf("%s:%d", c.config.CacheKartKeyPrefix, userID)
|
||||
}
|
||||
|
||||
func (c Cache) itemKey(itemID types.ID) string {
|
||||
return fmt.Sprintf("item:%d", itemID)
|
||||
}
|
||||
|
||||
func (c Cache) kartIDsKey(userID types.ID) string {
|
||||
return fmt.Sprintf("cart:%d:items", userID)
|
||||
}
|
||||
|
||||
func (c Cache) upsertCart(ctx context.Context, cart entity.Cart) error {
|
||||
cartKey := c.cartKey(cart.UserID)
|
||||
cartIDsKey := c.kartIDsKey(cart.UserID)
|
||||
|
||||
pipe := c.client.Client().Pipeline()
|
||||
pipe.HSet(ctx, cartKey, map[string]interface{}{
|
||||
IDField: cart.ID,
|
||||
UserIDField: cart.UserID,
|
||||
StatusField: cart.Status,
|
||||
TotalPriceField: cart.TotalPrice,
|
||||
ExpireAtField: cart.ExpireAt,
|
||||
CreatedAtField: cart.CreatedAt,
|
||||
UpdatedAtField: cart.UpdatedAt,
|
||||
})
|
||||
|
||||
pipe.Expire(ctx, cartKey, c.config.CacheTTL)
|
||||
|
||||
pipe.Del(ctx, cartIDsKey)
|
||||
|
||||
for _, i := range cart.Items {
|
||||
|
||||
pipe.SAdd(ctx, cartIDsKey, i.ID)
|
||||
|
||||
itemKey := c.itemKey(i.ID)
|
||||
jsonItem, err := json.Marshal(i)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("cache.upsertCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
pipe.Set(ctx, itemKey, string(jsonItem), c.config.CacheTTL)
|
||||
}
|
||||
|
||||
pipe.Expire(ctx, cartIDsKey, c.config.CacheTTL)
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("cache.upsertCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Cache) getCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
|
||||
cartKey := c.cartKey(userID)
|
||||
cartIDsKey := c.kartIDsKey(userID)
|
||||
|
||||
pipe := c.client.Client().Pipeline()
|
||||
|
||||
resCart := pipe.HGetAll(ctx, cartKey)
|
||||
resIDs := pipe.SMembers(ctx, cartIDsKey)
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
result := resCart.Val()
|
||||
|
||||
if len(result) < 1 {
|
||||
return entity.Cart{}, nil
|
||||
}
|
||||
|
||||
cart, err := parseCartFromRedis(result)
|
||||
if err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
|
||||
if cart.ID < 1 {
|
||||
return entity.Cart{}, nil
|
||||
}
|
||||
|
||||
ids := resIDs.Val()
|
||||
|
||||
if len(ids) < 1 {
|
||||
return cart, nil
|
||||
}
|
||||
|
||||
itemPipe := c.client.Client().Pipeline()
|
||||
|
||||
itemResults := make([]*redis.StringCmd, len(ids))
|
||||
|
||||
for i, idStr := range ids {
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
itemKey := c.itemKey(types.ID(id))
|
||||
cmd := itemPipe.Get(ctx, itemKey)
|
||||
itemResults[i] = cmd
|
||||
}
|
||||
|
||||
if _, err := itemPipe.Exec(ctx); err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
for _, cmd := range itemResults {
|
||||
if err := cmd.Err(); err != nil {
|
||||
if err == redis.Nil {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
var item entity.Item
|
||||
|
||||
if err := json.Unmarshal([]byte(cmd.Val()), &item); err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
cart.Items = append(cart.Items, item)
|
||||
}
|
||||
|
||||
return cart, nil
|
||||
}
|
||||
|
||||
func parseCartFromRedis(result map[string]string) (entity.Cart, error) {
|
||||
if len(result) == 0 {
|
||||
return entity.Cart{}, nil
|
||||
}
|
||||
|
||||
var cart entity.Cart
|
||||
|
||||
cart.Items = []entity.Item{}
|
||||
|
||||
for key, val := range result {
|
||||
switch key {
|
||||
case IDField:
|
||||
id, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
cart.ID = types.ID(id)
|
||||
case UserIDField:
|
||||
uid, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
cart.UserID = types.ID(uid)
|
||||
case StatusField:
|
||||
cart.Status = entity.CartStatus(val)
|
||||
case TotalPriceField:
|
||||
price, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
cart.TotalPrice = price
|
||||
case ExpireAtField, CreatedAtField, UpdatedAtField:
|
||||
t, err := time.Parse(time.RFC3339, val)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("cache.getCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
switch key {
|
||||
case ExpireAtField:
|
||||
cart.ExpireAt = t
|
||||
case CreatedAtField:
|
||||
cart.CreatedAt = t
|
||||
case UpdatedAtField:
|
||||
cart.UpdatedAt = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cart, nil
|
||||
}
|
||||
|
||||
func (c Cache) deleteCart(ctx context.Context, userID types.ID) error {
|
||||
cartKey := c.cartKey(userID)
|
||||
cartIDsKey := c.kartIDsKey(userID)
|
||||
pipe := c.client.Client().Pipeline()
|
||||
|
||||
pipe.Del(ctx, cartKey)
|
||||
pipe.Del(ctx, cartIDsKey)
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("cache-deleteCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
|
||||
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
FieldNumber = 5
|
||||
UserIDField = "user_id"
|
||||
CreatedAtField = "created_at"
|
||||
ExpireAtField = "expire_at"
|
||||
TotalPriceField = "total_price"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
KartKeyPrefix string `koanf:"kart_key_prefix"`
|
||||
TTL time.Duration `koanf:"ttl"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
client *redis.Client
|
||||
config Config
|
||||
}
|
||||
|
||||
func New(client *redis.Client, cfg Config) Repo {
|
||||
return Repo{client: client, config: cfg}
|
||||
}
|
||||
|
||||
func (r Repo) cartKey(userID types.ID) string {
|
||||
return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID)
|
||||
}
|
||||
|
||||
func (r Repo) itemKey(productID types.ID) string {
|
||||
return fmt.Sprintf("item:%d", productID)
|
||||
}
|
||||
|
||||
func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) error {
|
||||
const op = "shoppingbasketapp.repository.AddItem"
|
||||
|
||||
cartKey := r.cartKey(userID)
|
||||
itemKey := r.itemKey(item.ProductID)
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
itemJson, _ := json.Marshal(item)
|
||||
|
||||
exists, _ := r.client.Exists(ctx, cartKey).Result()
|
||||
|
||||
if exists == 0 {
|
||||
r.client.HSet(ctx, cartKey, map[string]interface{}{
|
||||
UserIDField: userID,
|
||||
itemKey: string(itemJson),
|
||||
TotalPriceField: item.Price * types.Price(item.Quantity),
|
||||
CreatedAtField: now,
|
||||
ExpireAtField: now + r.config.TTL.Nanoseconds(),
|
||||
})
|
||||
} else {
|
||||
existsItem, _ := r.client.HGet(ctx, cartKey, itemKey).Result()
|
||||
if existsItem != "" {
|
||||
var i entity.Item
|
||||
if err := json.Unmarshal([]byte(existsItem), &i); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
item.Quantity += i.Quantity
|
||||
itemJson, _ = json.Marshal(item)
|
||||
}
|
||||
|
||||
r.client.HSet(ctx, cartKey, itemKey, string(itemJson))
|
||||
r.client.HSet(ctx, cartKey, ExpireAtField, now+r.config.TTL.Nanoseconds())
|
||||
if err := r.updateTotalPrice(ctx, cartKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r.client.Expire(ctx, cartKey, r.config.TTL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsInt(s string) int64 {
|
||||
i, _ := strconv.ParseInt(s, 10, 64)
|
||||
return i
|
||||
}
|
||||
|
||||
func (r Repo) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) {
|
||||
const op = "shoppingbasketapp.repository.GetCart"
|
||||
cartKey := r.cartKey(userID)
|
||||
|
||||
exists, err := r.client.Exists(ctx, cartKey).Result()
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if exists == 0 {
|
||||
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket")
|
||||
}
|
||||
|
||||
allCart, err := r.client.HGetAll(ctx, cartKey).Result()
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
c := entity.Cart{Items: []entity.Item{}}
|
||||
|
||||
for field, value := range allCart {
|
||||
if strings.HasPrefix(field, "item:") {
|
||||
var i entity.Item
|
||||
if err := json.Unmarshal([]byte(value), &i); err != nil {
|
||||
return entity.Cart{}, richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
c.Items = append(c.Items, i)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
switch field {
|
||||
case UserIDField:
|
||||
c.UserID = types.ID(parsInt(value))
|
||||
case TotalPriceField:
|
||||
c.TotalPrice = types.Price(parsInt(value))
|
||||
case CreatedAtField:
|
||||
c.CreatedAt = parsInt(value)
|
||||
case ExpireAtField:
|
||||
c.ExpireAt = parsInt(value)
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r Repo) DeleteItem(ctx context.Context, userID, productID types.ID) error {
|
||||
const op = "shoppingbasketapp.repository.DeleteItem"
|
||||
cartKey := r.cartKey(userID)
|
||||
itemKey := r.itemKey(productID)
|
||||
|
||||
if err := r.existsCart(ctx, cartKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.existsItem(ctx, cartKey, itemKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.client.HDel(ctx, cartKey, itemKey).Err(); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
num, err := r.client.HLen(ctx, cartKey).Result()
|
||||
if err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if num < FieldNumber {
|
||||
return r.DeleteCart(ctx, userID)
|
||||
}
|
||||
|
||||
return r.updateTotalPrice(ctx, cartKey)
|
||||
}
|
||||
|
||||
func (r Repo) UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error {
|
||||
const op = "shoppingbasketapp.repository.UpdateQuantity"
|
||||
cartKey := r.cartKey(userID)
|
||||
itemKey := r.itemKey(productID)
|
||||
|
||||
if err := r.existsCart(ctx, cartKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.existsItem(ctx, cartKey, itemKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := r.client.HGet(ctx, cartKey, itemKey).Result()
|
||||
if err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
var item entity.Item
|
||||
if err := json.Unmarshal([]byte(data), &item); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
item.Quantity = quantity
|
||||
|
||||
j, _ := json.Marshal(item)
|
||||
|
||||
if err := r.client.HSet(ctx, cartKey, itemKey, string(j)).Err(); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
return r.updateTotalPrice(ctx, cartKey)
|
||||
}
|
||||
|
||||
func (r Repo) DeleteCart(ctx context.Context, userID types.ID) error {
|
||||
const op = "shoppingbasketapp.repository.DeleteCart"
|
||||
cartKey := r.cartKey(userID)
|
||||
|
||||
if err := r.existsCart(ctx, cartKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.client.Del(ctx, cartKey).Err(); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Repo) updateTotalPrice(ctx context.Context, cartKey string) error {
|
||||
const op = "shoppingbasketapp.repository.updateTotalPrice"
|
||||
|
||||
allFields, err := r.client.HGetAll(ctx, cartKey).Result()
|
||||
if err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
var total types.Price
|
||||
|
||||
for field, value := range allFields {
|
||||
if strings.HasPrefix(field, "item:") {
|
||||
var item entity.Item
|
||||
|
||||
if err := json.Unmarshal([]byte(value), &item); err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
total += item.Price * types.Price(item.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
return r.client.HSet(ctx, cartKey, TotalPriceField, int64(total)).Err()
|
||||
}
|
||||
|
||||
func (r Repo) existsCart(ctx context.Context, cartKey string) error {
|
||||
const op = "shoppingbasketapp.repository.existsCart"
|
||||
|
||||
exists, err := r.client.Exists(ctx, cartKey).Result()
|
||||
if err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if exists == 0 {
|
||||
return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found shopping basket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Repo) existsItem(ctx context.Context, cartKey, itemKey string) error {
|
||||
const op = "shoppingbasketapp.repository.existsCart"
|
||||
|
||||
exists, err := r.client.HExists(ctx, cartKey, itemKey).Result()
|
||||
if err != nil {
|
||||
return richerror.New(op).WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return richerror.New(op).WithKind(richerror.KindNotFound).WithMessage("not found product form shopping basket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package repository
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
FindCartByUserIDQuery = `SELECT id, user_id, total_price, status, expire_at, created_at
|
||||
FROM carts
|
||||
WHERE user_id = ? AND status = 'active' AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
FindCartByIDQuery = `SELECT id, user_id, total_price, status, expire_at, created_at
|
||||
FROM carts
|
||||
WHERE id = ? AND status = 'active' AND deleted_at IS NULL`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotExistsCart = errors.New("not exists cart")
|
||||
ErrExpiredCart = errors.New("expired cart")
|
||||
)
|
||||
|
||||
const (
|
||||
IDField = "id"
|
||||
UserIDField = "user_id"
|
||||
StatusField = "status"
|
||||
TotalPriceField = "total_price"
|
||||
CreatedAtField = "created_at"
|
||||
ExpireAtField = "expire_at"
|
||||
UpdatedAtField = "updated_at"
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- +migrate Up
|
||||
CREATE TABLE `carts` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`total_price` DECIMAL(10,2) DEFAULT 0,
|
||||
`status` ENUM('active', 'expired', 'checked_out') NOT NULL DEFAULT 'active',
|
||||
`expire_at` TIMESTAMP NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`deleted_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX `idx_carts_user_id` ON `carts`(`user_id`);
|
||||
CREATE INDEX `idx_carts_id` ON `carts`(`id`);
|
||||
|
||||
-- +migrate Down
|
||||
DROP INDEX `idx_carts_user_id` ON `carts`;
|
||||
DROP INDEX `idx_carts_id` ON `carts`;
|
||||
DROP TABLE `carts`;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- +migrate Up
|
||||
CREATE TABLE `items` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`cart_id` BIGINT UNSIGNED NOT NULL,
|
||||
`product_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`quantity` INT NOT NULL DEFAULT 1,
|
||||
`price` DECIMAL(10,2) NOT NULL,
|
||||
`name` VARCHAR(256) NOT NULL ,
|
||||
`added_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`deleted_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`cart_id`) REFERENCES `carts`(`id`)
|
||||
);
|
||||
|
||||
CREATE INDEX `idx_items_cart_id` ON `items`(`cart_id`);
|
||||
|
||||
-- +migrate Down
|
||||
DROP INDEX `idx_items_cart_id` ON `items`;
|
||||
DROP TABLE `items`;
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
|
||||
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
db *mysql.DB
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewDB(db *mysql.DB, cfg Config) DB {
|
||||
return DB{db: db, config: cfg}
|
||||
}
|
||||
|
||||
func (d DB) getCart(ctx context.Context, tx *sql.Tx, query string, by types.ID) (entity.Cart, error) {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-getCart")
|
||||
if err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
var c entity.Cart
|
||||
|
||||
err = t.QueryRowContext(ctx, query, by).
|
||||
Scan(&c.ID, &c.UserID, &c.TotalPrice, &c.Status, &c.ExpireAt, &c.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindNotFound).
|
||||
WithErr(ErrNotExistsCart)
|
||||
}
|
||||
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
|
||||
}
|
||||
|
||||
if c.ExpireAt.Before(time.Now().UTC()) {
|
||||
if err := d.updateCartStatus(ctx, t, c.ID, entity.CartStatusExpired); err != nil {
|
||||
return entity.Cart{}, err
|
||||
}
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindInvalid).WithErr(ErrExpiredCart)
|
||||
}
|
||||
|
||||
itemsQuery := `SELECT id, cart_id, user_id, product_id, quantity, price, name, added_at, updated_at
|
||||
FROM items
|
||||
WHERE cart_id=? AND deleted_at IS NULL
|
||||
ORDER BY added_at`
|
||||
|
||||
rows, err := t.QueryContext(ctx, itemsQuery, c.ID)
|
||||
if err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]entity.Item, 0)
|
||||
for rows.Next() {
|
||||
var i entity.Item
|
||||
if err := rows.Scan(&i.ID, &i.CartID, &i.UserID, &i.ProductID,
|
||||
&i.Quantity, &i.Price, &i.Name, &i.AddedAt, &i.UpdatedAt); err != nil {
|
||||
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(fmt.Errorf("error scan rows: %w", err))
|
||||
}
|
||||
|
||||
items = append(items, i)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
c.Items = items
|
||||
|
||||
if ok {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return entity.Cart{}, richerror.New(richerror.Op(op("mysql-getCart"))).
|
||||
WithKind(richerror.
|
||||
KindUnexpected).WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d DB) addToBasket(ctx context.Context, userID types.ID, item entity.Item) error {
|
||||
tx, err := d.db.Conn().BeginTx(ctx, &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
})
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
c, err := d.getCart(ctx, tx, FindCartByUserIDQuery, userID)
|
||||
if err != nil {
|
||||
if err.Error() == ErrExpiredCart.Error() || err.Error() == ErrNotExistsCart.Error() {
|
||||
cartQuery := `INSERT INTO carts user_id, total_price, status, expire_at, created_at
|
||||
VALUES (?,?,?,?)`
|
||||
|
||||
newCart := entity.Cart{
|
||||
UserID: userID,
|
||||
TotalPrice: item.Price * float64(item.Quantity),
|
||||
Status: entity.CartStatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpireAt: time.Now().Add(d.config.MysqlTTL).UTC(),
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, cartQuery,
|
||||
newCart.UserID, newCart.TotalPrice, newCart.Status, newCart.ExpireAt, newCart.CreatedAt)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
cartID, _ := result.LastInsertId()
|
||||
item.CartID = types.ID(cartID)
|
||||
|
||||
if err := d.addItem(ctx, tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.updateTotalPrice(ctx, tx, types.ID(cartID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
isNew := true
|
||||
if c.ID > 0 {
|
||||
for _, i := range c.Items {
|
||||
if i.ProductID == item.ProductID {
|
||||
item.Quantity = i.Quantity + item.Quantity
|
||||
if err := d.updateQuantity(ctx, tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isNew = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isNew {
|
||||
err := d.addItem(ctx, tx, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.updateTotalPrice(ctx, tx, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addToBasket"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) addItem(ctx context.Context, tx *sql.Tx, item entity.Item) error {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-addItem")
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addItem"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
query := `INSERT INTO items cart_id, user_id, product_id, quantity, price, name, added_at
|
||||
VALUES (?,?,?,?,?,?,?)`
|
||||
|
||||
_, err = t.ExecContext(ctx, query,
|
||||
item.CartID, item.UserID, item.ProductID, item.Quantity, item.Price, item.Name, item.AddedAt)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addItem"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-addItem"))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) updateQuantity(ctx context.Context, tx *sql.Tx, item entity.Item) error {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateQuantity")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
query := `UPDATE items
|
||||
SET
|
||||
quantity = ?
|
||||
WHERE id = ?`
|
||||
|
||||
_, err = t.ExecContext(ctx, query, item.Quantity, item.ID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateQuantity"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if err := t.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateQuantity"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) updateTotalPrice(ctx context.Context, tx *sql.Tx, cartID types.ID) error {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateTotalPrice")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
c, err := d.getCart(ctx, t, FindCartByIDQuery, cartID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
|
||||
for _, i := range c.Items {
|
||||
totalPrice += i.Price * float64(i.Quantity)
|
||||
}
|
||||
|
||||
query := `UPDATE carts
|
||||
SET
|
||||
total_price = ?
|
||||
WHERE cart_id=?`
|
||||
|
||||
_, err = tx.ExecContext(ctx, query, totalPrice, c.ID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateTotalPrice"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateTotalPrice"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) deleteItem(ctx context.Context, tx *sql.Tx, cartID, itemID types.ID) error {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-deleteItem")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
query := `UPDATE items
|
||||
SET
|
||||
deleted_at = ?
|
||||
WHERE cart_id = ? AND id = ? AND deleted_at IS NULL`
|
||||
deleteTime := time.Now().UTC()
|
||||
_, err = t.ExecContext(ctx, query, deleteTime, cartID, itemID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-deleteItem"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if err := d.updateTotalPrice(ctx, t, cartID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok {
|
||||
if err := t.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-deleteItem"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) deleteCart(ctx context.Context, cartID, userID types.ID) error {
|
||||
itemQuery := `UPDATE items
|
||||
SET
|
||||
deleted_at = ?
|
||||
WHERE cart_id = ? AND user_id = ? AND deleted_at IS NULL`
|
||||
|
||||
deleteItemTime := time.Now().UTC()
|
||||
|
||||
_, err := d.db.Conn().ExecContext(ctx, itemQuery, deleteItemTime, cartID, userID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-deleteCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
cartQuery := `UPDATE carts
|
||||
SET
|
||||
deleted_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`
|
||||
|
||||
deleteCartTime := time.Now().UTC()
|
||||
|
||||
_, err = d.db.Conn().ExecContext(ctx, cartQuery, deleteCartTime, cartID, userID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-deleteCart"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) updateCartStatus(ctx context.Context, tx *sql.Tx, cartID types.ID, status entity.CartStatus) error {
|
||||
t, ok, err := d.getLocalTX(ctx, tx, "mysql-updateCartStatus")
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
defer func() {
|
||||
_ = t.Rollback()
|
||||
}()
|
||||
}
|
||||
|
||||
query := `UPDATE carts
|
||||
SET
|
||||
status = ?
|
||||
WHERE id = ? AND status = active AND deleted_at IS NULL`
|
||||
|
||||
_, err = t.ExecContext(ctx, query, status, cartID)
|
||||
if err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if err := t.Commit(); err != nil {
|
||||
return richerror.New(richerror.Op(op("mysql-updateCartStatus"))).
|
||||
WithKind(richerror.KindUnexpected).WithErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DB) getLocalTX(ctx context.Context, tx *sql.Tx, p string) (*sql.Tx, bool, error) {
|
||||
var err error
|
||||
var ok bool
|
||||
if tx == nil {
|
||||
tx, err = d.db.Conn().BeginTx(ctx, &sql.TxOptions{
|
||||
Isolation: sql.LevelReadCommitted,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, richerror.New(richerror.Op(op(fmt.Sprintf("%s", p)))).
|
||||
WithKind(richerror.KindUnexpected).
|
||||
WithErr(err)
|
||||
}
|
||||
|
||||
ok = true
|
||||
}
|
||||
|
||||
return tx, ok, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package scheduler
|
||||
|
||||
// TODO: implement cron job or scheduler
|
||||
// To check the expiration time in the MYSQL database and change the shopping cart status to expired
|
||||
// and clear the cart from the cache
|
||||
|
|
@ -3,31 +3,39 @@ package service
|
|||
import (
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/entity"
|
||||
"git.gocasts.ir/ebhomengo/niki/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AddToCartRequest struct {
|
||||
UserID types.ID `json:"user_id"`
|
||||
ProductID types.ID `json:"product_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price types.Price `json:"price"`
|
||||
Price float64 `json:"price"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type GetCartResponse struct {
|
||||
ID types.ID `json:"id"`
|
||||
UserID types.ID `json:"user_id"`
|
||||
Items []entity.Item `json:"items"`
|
||||
TotalPrice types.Price `json:"total_price"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpireAt int64 `json:"expire_at"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
}
|
||||
|
||||
type RemoveFromCartRequest struct {
|
||||
UserID types.ID `json:"user_id"`
|
||||
ProductID types.ID `json:"product_id"`
|
||||
CartID types.ID `json:"cart_id"`
|
||||
ItemID types.ID `json:"item_id"`
|
||||
}
|
||||
|
||||
type UpdateQuantityRequest struct {
|
||||
UserID types.ID `json:"user_id"`
|
||||
ProductID types.ID `json:"product_id"`
|
||||
CartID types.ID `json:"cart_id"`
|
||||
ItemID types.ID `json:"item_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type UpdateCartStatusRequest struct {
|
||||
CartID types.ID `json:"cart_id"`
|
||||
Status entity.CartStatus `json:"status"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import (
|
|||
type Repository interface {
|
||||
AddItem(ctx context.Context, userID types.ID, item entity.Item) error
|
||||
GetCart(ctx context.Context, userID types.ID) (entity.Cart, error)
|
||||
DeleteItem(ctx context.Context, userID, productID types.ID) error
|
||||
UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error
|
||||
DeleteCart(ctx context.Context, userID types.ID) error
|
||||
DeleteItem(ctx context.Context, cartID, itemID types.ID) error
|
||||
UpdateQuantity(ctx context.Context, cartID, itemID types.ID, quantity int) error
|
||||
DeleteCart(ctx context.Context, cartID, userID types.ID) error
|
||||
UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
|
@ -39,7 +40,7 @@ func (s Service) AddToBasket(ctx context.Context, req AddToCartRequest) error {
|
|||
Quantity: req.Quantity,
|
||||
Price: req.Price,
|
||||
Name: req.Name,
|
||||
AddedAt: time.Now().UnixNano(),
|
||||
AddedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -57,9 +58,11 @@ func (s Service) GetCart(ctx context.Context, userID types.ID) (GetCartResponse,
|
|||
}
|
||||
|
||||
return GetCartResponse{
|
||||
ID: res.ID,
|
||||
UserID: res.UserID,
|
||||
Items: res.Items,
|
||||
TotalPrice: res.TotalPrice,
|
||||
Status: string(res.Status),
|
||||
CreatedAt: res.CreatedAt,
|
||||
ExpireAt: res.ExpireAt,
|
||||
}, nil
|
||||
|
|
@ -73,7 +76,7 @@ func (s Service) RemoveFromCart(ctx context.Context, req RemoveFromCartRequest)
|
|||
return err
|
||||
}
|
||||
|
||||
return s.repo.DeleteItem(ctx, req.UserID, req.ProductID)
|
||||
return s.repo.DeleteItem(ctx, req.CartID, req.ItemID)
|
||||
}
|
||||
|
||||
func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest) error {
|
||||
|
|
@ -85,13 +88,13 @@ func (s Service) UpdateQuantity(ctx context.Context, req UpdateQuantityRequest)
|
|||
}
|
||||
|
||||
if req.Quantity == 0 {
|
||||
return s.repo.DeleteItem(ctx, req.UserID, req.ProductID)
|
||||
return s.repo.DeleteItem(ctx, req.CartID, req.ItemID)
|
||||
}
|
||||
|
||||
return s.repo.UpdateQuantity(ctx, req.UserID, req.ProductID, req.Quantity)
|
||||
return s.repo.UpdateQuantity(ctx, req.CartID, req.ItemID, req.Quantity)
|
||||
}
|
||||
|
||||
func (s Service) ClearCart(ctx context.Context, userID types.ID) error {
|
||||
func (s Service) ClearCart(ctx context.Context, cartID, userID types.ID) error {
|
||||
const op = "shoppingbaskerapp.service.ClearCart"
|
||||
|
||||
if userID < 1 {
|
||||
|
|
@ -100,5 +103,14 @@ func (s Service) ClearCart(ctx context.Context, userID types.ID) error {
|
|||
WithMessage("invalid user id")
|
||||
}
|
||||
|
||||
return s.repo.DeleteCart(ctx, userID)
|
||||
return s.repo.DeleteCart(ctx, cartID, userID)
|
||||
}
|
||||
|
||||
func (s Service) UpdateCartStatus(ctx context.Context, req UpdateCartStatusRequest) error {
|
||||
const op = "shoppingbaskerapp.service.ClearCart"
|
||||
if !req.Status.IsValid() {
|
||||
return richerror.New(op).WithKind(richerror.KindInvalid).WithMessage("invalid status")
|
||||
}
|
||||
|
||||
return s.repo.UpdateStatus(ctx, req.CartID, req.Status)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ func (v Validate) ValidateRemoveFromCart(req RemoveFromCartRequest) error {
|
|||
const op = "shoppingbasketapp.service.ValidateRemoveFromCart"
|
||||
|
||||
if err := validation.ValidateStruct(&req,
|
||||
validation.Field(&req.UserID, validation.Required),
|
||||
validation.Field(&req.ProductID, validation.Required)); err != nil {
|
||||
validation.Field(&req.CartID, validation.Required),
|
||||
validation.Field(&req.ItemID, validation.Required)); err != nil {
|
||||
|
||||
fieldErrs := make(map[string]interface{})
|
||||
vErr, ok := err.(validation.Errors)
|
||||
|
|
@ -70,8 +70,8 @@ func (v Validate) ValidateUpdateQuantity(req UpdateQuantityRequest) error {
|
|||
const op = "shoppingbasketapp.service.ValidateUpdateQuantity"
|
||||
|
||||
if err := validation.ValidateStruct(&req,
|
||||
validation.Field(&req.UserID, validation.Required),
|
||||
validation.Field(&req.ProductID, validation.Required),
|
||||
validation.Field(&req.CartID, validation.Required),
|
||||
validation.Field(&req.ItemID, validation.Required),
|
||||
validation.Field(&req.Quantity, validation.Required, validation.Min(int(1)))); err != nil {
|
||||
|
||||
fieldErrs := make(map[string]interface{})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"git.gocasts.ir/ebhomengo/niki/adapter/redis"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/service"
|
||||
mysql2 "git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/httpserver"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/logger"
|
||||
"git.gocasts.ir/ebhomengo/niki/shoppingbasketapp/delivery/http"
|
||||
|
|
@ -27,7 +28,12 @@ type Application struct {
|
|||
func Setup(ctx context.Context, cfg Config) (Application, error) {
|
||||
|
||||
adapter := redis.New(cfg.Redis)
|
||||
repo := repository.New(adapter.Client(), cfg.Repo)
|
||||
cache := repository.NewCache(adapter, cfg.Repo)
|
||||
|
||||
mysql := mysql2.New(cfg.Mysql)
|
||||
db := repository.NewDB(mysql, cfg.Repo)
|
||||
|
||||
repo := repository.New(db, cache)
|
||||
|
||||
validator := service.NewValidate()
|
||||
svc := service.New(validator, repo)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ package shoppingbasketapp
|
|||
import (
|
||||
"git.gocasts.ir/ebhomengo/niki/adapter/redis"
|
||||
"git.gocasts.ir/ebhomengo/niki/domain/shoppingbasket/repository"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/database/mysql"
|
||||
"git.gocasts.ir/ebhomengo/niki/pkg/httpserver"
|
||||
logger "git.gocasts.ir/ebhomengo/niki/pkg/logger"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Redis redis.Config `koanf:"redis" json:"redis"`
|
||||
Mysql mysql.Config `koanf:"mysql" json:"mysql"`
|
||||
Repo repository.Config `koanf:"repo" json:"repo"`
|
||||
HTTPServer httpserver.Config `koanf:"http_server" json:"http_server"`
|
||||
Logger logger.Config `koanf:"logger" json:"logger"`
|
||||
PathOfMigration string `koanf:"path_of_migration" json:"path_of_migration"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package consumer
|
||||
|
||||
// TODO: implement subscriber or consumer
|
||||
// When the shopping cart is successfully paid
|
||||
// the service waits for a message in the broker to change the cart status to paid and clear it from the cache.
|
||||
|
|
@ -52,7 +52,21 @@ func (h Handler) GetCart(c echo.Context) error {
|
|||
func (h Handler) RemoveCart(c echo.Context) error {
|
||||
claims := claim.GetClaimsFromEchoContext(c)
|
||||
|
||||
if err := h.svc.ClearCart(c.Request().Context(), types.ID(claims.UserID)); err != nil {
|
||||
cartIDStr := c.Param("cart_id")
|
||||
if cartIDStr == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "cart id required",
|
||||
})
|
||||
}
|
||||
|
||||
cartID, err := strconv.Atoi(cartIDStr)
|
||||
if err != nil || cartID < 1 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid cart id",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.svc.ClearCart(c.Request().Context(), types.ID(cartID), types.ID(claims.UserID)); err != nil {
|
||||
msg, code := httpmsg.Error(err)
|
||||
return c.JSON(code, msg)
|
||||
}
|
||||
|
|
@ -61,10 +75,28 @@ func (h Handler) RemoveCart(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (h Handler) RemoveItem(c echo.Context) error {
|
||||
claims := claim.GetClaimsFromEchoContext(c)
|
||||
p := c.Param("productID")
|
||||
cartIDStr := c.Param("cart_id")
|
||||
itemIDStr := c.Param("item_id")
|
||||
|
||||
pID, err := strconv.Atoi(p)
|
||||
if cartIDStr == "" {
|
||||
return c.JSON(http.StatusBadRequest, echo.Map{
|
||||
"error": "cart id required",
|
||||
})
|
||||
}
|
||||
|
||||
cID, err := strconv.Atoi(cartIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid product id",
|
||||
})
|
||||
}
|
||||
|
||||
if itemIDStr == "" {
|
||||
return c.JSON(http.StatusBadRequest, echo.Map{
|
||||
"error": "item id required",
|
||||
})
|
||||
}
|
||||
iID, err := strconv.Atoi(itemIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid product id",
|
||||
|
|
@ -73,8 +105,8 @@ func (h Handler) RemoveItem(c echo.Context) error {
|
|||
|
||||
var req service.RemoveFromCartRequest
|
||||
|
||||
req.UserID = types.ID(claims.UserID)
|
||||
req.ProductID = types.ID(pID)
|
||||
req.CartID = types.ID(cID)
|
||||
req.ItemID = types.ID(iID)
|
||||
|
||||
if err := h.svc.RemoveFromCart(c.Request().Context(), req); err != nil {
|
||||
msg, code := httpmsg.Error(err)
|
||||
|
|
@ -85,28 +117,12 @@ func (h Handler) RemoveItem(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (h Handler) UpdateQuantity(c echo.Context) error {
|
||||
claims := claim.GetClaimsFromEchoContext(c)
|
||||
p := c.Param("productID")
|
||||
|
||||
pID, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid product id",
|
||||
})
|
||||
}
|
||||
|
||||
qStr := c.Param("quantity")
|
||||
q, err := strconv.Atoi(qStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid quantity",
|
||||
})
|
||||
}
|
||||
|
||||
var req service.UpdateQuantityRequest
|
||||
req.UserID = types.ID(claims.UserID)
|
||||
req.ProductID = types.ID(pID)
|
||||
req.Quantity = q
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateQuantity(c.Request().Context(), req); err != nil {
|
||||
msg, code := httpmsg.Error(err)
|
||||
|
|
@ -115,3 +131,19 @@ func (h Handler) UpdateQuantity(c echo.Context) error {
|
|||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h Handler) UpdateCartStatus(c echo.Context) error {
|
||||
var req service.UpdateCartStatusRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateCartStatus(c.Request().Context(), req); err != nil {
|
||||
msg, code := httpmsg.Error(err)
|
||||
return c.JSON(code, msg)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,13 @@ func (s Server) registerRoutes() {
|
|||
|
||||
router.GET("shoppingbasket/health-check", s.healthCheck)
|
||||
|
||||
r := router.Group("shoppingbasket/cart") // Authentication is required
|
||||
r := router.Group("shoppingbasket/carts") // Authentication is required
|
||||
|
||||
r.GET("/", s.handler.GetCart)
|
||||
r.DELETE("/", s.handler.RemoveCart)
|
||||
r.DELETE("/:cart_id", s.handler.RemoveCart)
|
||||
r.PUT("/status", s.handler.UpdateCartStatus)
|
||||
|
||||
r.POST("/items", s.handler.AddToBasket)
|
||||
r.DELETE("/items/:productID", s.handler.RemoveItem)
|
||||
r.PUT("/items/:productID/:quantity", s.handler.UpdateQuantity)
|
||||
r.DELETE("/items/:cart_id/:item_id", s.handler.RemoveItem)
|
||||
r.PUT("/items/item-quantity", s.handler.UpdateQuantity)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ github.com/knadh/koanf/parsers/yaml
|
|||
github.com/knadh/koanf/providers/env
|
||||
github.com/knadh/koanf/providers/file
|
||||
github.com/knadh/koanf/providers/structs
|
||||
# github.com/knadh/koanf/v2 v2.3.0
|
||||
## explicit; go 1.23.0
|
||||
# github.com/labstack/echo-jwt/v4 v4.4.0
|
||||
## explicit; go 1.24.0
|
||||
github.com/labstack/echo-jwt/v4
|
||||
|
|
|
|||
Loading…
Reference in New Issue