added mysql db

This commit is contained in:
mzfarshad 2026-04-28 20:54:47 +03:30
parent 4ae65e95e1
commit dbe9cb7df8
7 changed files with 457 additions and 19 deletions

View File

@ -2,20 +2,36 @@ package entity
import ( import (
"git.gocasts.ir/ebhomengo/niki/types" "git.gocasts.ir/ebhomengo/niki/types"
"time"
) )
type CartStatus string
type Item struct { type Item struct {
ID types.ID
CartID types.ID
ProductID types.ID ProductID types.ID
UserID types.ID
Quantity int Quantity int
Price types.Price Price float64
Name string Name string
AddedAt int64 AddedAt time.Time
UpdatedAt time.Time
} }
type Cart struct { type Cart struct {
ID types.ID
UserID types.ID UserID types.ID
Items []Item Items []Item
TotalPrice types.Price Status CartStatus
ExpireAt int64 TotalPrice float64
CreatedAt int64 ExpireAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }
const (
CartStatusActive CartStatus = "active"
CartStatusExpired CartStatus = "expired"
CartStatusCheckedOut CartStatus = "checked_out"
)

View File

@ -35,6 +35,10 @@ func New(client *redis.Client, cfg Config) Repo {
return Repo{client: client, config: cfg} return Repo{client: client, config: cfg}
} }
func op(s string) string {
return fmt.Sprintf("shoppingbasketapp-repository-", s)
}
func (r Repo) cartKey(userID types.ID) string { func (r Repo) cartKey(userID types.ID) string {
return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID) return r.config.KartKeyPrefix + fmt.Sprintf("%d", userID)
} }

View File

@ -0,0 +1,18 @@
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`
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")
)

View File

@ -1,15 +1,19 @@
-- +migrate Up -- +migrate Up
CREATE TABLE `carts` ( CREATE TABLE `carts` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL,
`total_price` DECIMAL(10,2) DEFAULT 0, `total_price` DECIMAL(10,2) DEFAULT 0,
`expire_at` TIMESTAMP NULL, `status` ENUM('active', 'expired', 'checked_out') NOT NULL DEFAULT 'active',
`expire_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `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 `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_user_id` ON `carts`(`user_id`);
CREATE INDEX `idx_carts_id` ON `carts`(`id`);
-- +migrate Down -- +migrate Down
DROP INDEX `idx_carts_user_id` ON `carts`; DROP INDEX `idx_carts_user_id` ON `carts`;
DROP INDEX `idx_carts_id` ON `carts`;
DROP TABLE `carts`; DROP TABLE `carts`;

View File

@ -1,13 +1,16 @@
-- +migrate Up -- +migrate Up
CREATE TABLE `items` ( CREATE TABLE `items` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
`cart_id` INT NOT NULL, `cart_id` BIGINT UNSIGNED NOT NULL,
`product_id` INT NOT NULL, `product_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`quantity` INT NOT NULL DEFAULT 1, `quantity` INT NOT NULL DEFAULT 1,
`price` DECIMAL(10,2) NOT NULL, `price` DECIMAL(10,2) NOT NULL,
`name` VARCHAR(256), `name` VARCHAR(256) NOT NULL ,
`added_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `added_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`cart_id`) REFERENCES `cart`(`id`) `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`); CREATE INDEX `idx_items_cart_id` ON `items`(`cart_id`);

View File

@ -1,15 +1,408 @@
package repository package repository
import querier "git.gocasts.ir/ebhomengo/niki/pkg/query_transaction/sql" 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 { type DB struct {
db *querier.SQLDB db *mysql.DB
config Config
} }
func NewDB(db *querier.SQLDB) DB { func NewDB(db *mysql.DB) DB {
return DB{db: db} return DB{db: db}
} }
func (d DB) addToBasket() { 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.TTL).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, productID 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 product_id = ? AND deleted_at IS NULL`
deleteTime := time.Now().UTC()
_, err = t.ExecContext(ctx, query, deleteTime, cartID, productID)
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 {
query := `UPDATE items
SET
deleted_at = ?
WHERE cart_id = ? AND user_id = ? AND deleted_at IS NULL`
deleteTime := time.Now().UTC()
_, err := d.db.Conn().ExecContext(ctx, query, deleteTime, 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 cart_id = ? 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
} }

View File

@ -12,9 +12,9 @@ import (
type Repository interface { type Repository interface {
AddItem(ctx context.Context, userID types.ID, item entity.Item) error AddItem(ctx context.Context, userID types.ID, item entity.Item) error
GetCart(ctx context.Context, userID types.ID) (entity.Cart, error) GetCart(ctx context.Context, userID types.ID) (entity.Cart, error)
DeleteItem(ctx context.Context, userID, productID types.ID) error DeleteItem(ctx context.Context, cartID, itemID types.ID) error
UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error UpdateQuantity(ctx context.Context, userID, productID types.ID, quantity int) error
DeleteCart(ctx context.Context, userID types.ID) error DeleteCart(ctx context.Context, cartID types.ID) error
} }
type Service struct { type Service struct {