diff --git a/deploy/shoppingbasket/development/config.local.yml b/deploy/shoppingbasket/development/config.local.yml index c94dc9a8..71ce5fed 100644 --- a/deploy/shoppingbasket/development/config.local.yml +++ b/deploy/shoppingbasket/development/config.local.yml @@ -5,8 +5,9 @@ redis: db: 0 repo: - kart_key_prefix: "shopping-basket-cart:" - ttl: 3600s + cache_kart_key_prefix: "shopping-basket-cart:user_id" + cache_ttl: 864000s # 24h + mysql_ttl: 604800s # 7d http_server: host: "localhost" diff --git a/domain/shoppingbasket/repository/cache.go b/domain/shoppingbasket/repository/cache.go new file mode 100644 index 00000000..41a8cb6a --- /dev/null +++ b/domain/shoppingbasket/repository/cache.go @@ -0,0 +1,209 @@ +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 +} diff --git a/domain/shoppingbasket/repository/cart.go b/domain/shoppingbasket/repository/cart.go index b007bb60..3e6923ff 100644 --- a/domain/shoppingbasket/repository/cart.go +++ b/domain/shoppingbasket/repository/cart.go @@ -13,17 +13,11 @@ import ( "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"` + CacheKartKeyPrefix string `koanf:"cache_kart_key_prefix"` + CacheTTL time.Duration `koanf:"cache_ttl"` + + MysqlTTL time.Duration `koanf:"mysql_ttl"` } type Repo struct { @@ -39,14 +33,6 @@ func op(s string) string { return fmt.Sprintf("shoppingbasketapp-repository-", s) } -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" @@ -64,7 +50,7 @@ func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) er itemKey: string(itemJson), TotalPriceField: item.Price * types.Price(item.Quantity), CreatedAtField: now, - ExpireAtField: now + r.config.TTL.Nanoseconds(), + ExpireAtField: now + r.config.CacheTTL.Nanoseconds(), }) } else { existsItem, _ := r.client.HGet(ctx, cartKey, itemKey).Result() @@ -79,13 +65,13 @@ func (r Repo) AddItem(ctx context.Context, userID types.ID, item entity.Item) er } r.client.HSet(ctx, cartKey, itemKey, string(itemJson)) - r.client.HSet(ctx, cartKey, ExpireAtField, now+r.config.TTL.Nanoseconds()) + r.client.HSet(ctx, cartKey, ExpireAtField, now+r.config.CacheTTL.Nanoseconds()) if err := r.updateTotalPrice(ctx, cartKey); err != nil { return err } } - r.client.Expire(ctx, cartKey, r.config.TTL) + r.client.Expire(ctx, cartKey, r.config.CacheTTL) return nil } diff --git a/domain/shoppingbasket/repository/constant.go b/domain/shoppingbasket/repository/constant.go index e9ed1943..7f4add13 100644 --- a/domain/shoppingbasket/repository/constant.go +++ b/domain/shoppingbasket/repository/constant.go @@ -16,3 +16,13 @@ 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" +) diff --git a/domain/shoppingbasket/repository/mysql.go b/domain/shoppingbasket/repository/mysql.go index 1a043329..1b2334ce 100644 --- a/domain/shoppingbasket/repository/mysql.go +++ b/domain/shoppingbasket/repository/mysql.go @@ -129,7 +129,7 @@ func (d DB) addToBasket(ctx context.Context, userID types.ID, item entity.Item) TotalPrice: item.Price * float64(item.Quantity), Status: entity.CartStatusActive, CreatedAt: time.Now().UTC(), - ExpireAt: time.Now().Add(d.config.TTL).UTC(), + ExpireAt: time.Now().Add(d.config.MysqlTTL).UTC(), } result, err := tx.ExecContext(ctx, cartQuery, diff --git a/domain/shoppingbasket/repository/redis.go b/domain/shoppingbasket/repository/redis.go deleted file mode 100644 index 50a4378d..00000000 --- a/domain/shoppingbasket/repository/redis.go +++ /dev/null @@ -1 +0,0 @@ -package repository diff --git a/domain/shoppingbasket/service/service.go b/domain/shoppingbasket/service/service.go index e8436e50..51806f15 100644 --- a/domain/shoppingbasket/service/service.go +++ b/domain/shoppingbasket/service/service.go @@ -15,6 +15,7 @@ type Repository interface { DeleteItem(ctx context.Context, cartID, itemID types.ID) error UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error DeleteCart(ctx context.Context, cartID types.ID) error + UpdateStatus(ctx context.Context, cartID types.ID, status entity.CartStatus) error } type Service struct {