Хранение корзины в базе данных

С каталогом товаров мы пока закончили, пора переделать работу с корзиной. В прошлой версии магазина она хранилась в localStorage браузера, теперь же будет храниться в БД.

На публичном сайте пока нет авторизации, да и не каждому магазину она нужна, поэтому доступ к корзине пользователя будет по уникальному идендификатору uuid.

Теперь в localStorage останется только идентификатор, а добавленые товары переедут в базу данных.

Пишем новую миграцию.

Таблицы корзины

Заходим в контейнер PHP и делаем composer db:create Carts

<?php

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class Carts extends Migration
{
    public function up(): void
    {
        $this->schema->create(
            'carts',
            function (Blueprint $table) {
                $table->uuid('id')->primary();
                $table->foreignId('user_id')->nullable()
                    ->constrained('users')->nullOnDelete();
                $table->timestamps();
            }
        );

        $this->schema->create(
            'cart_products',
            function (Blueprint $table) {
                $table->foreignUuid('cart_id')
                    ->constrained('carts')->cascadeOnDelete();
                $table->foreignId('product_id')
                    ->constrained('products')->cascadeOnDelete();
                $table->uuid('product_key');
                $table->unsignedInteger('amount')->default(1);
                $table->json('options')->nullable();
                $table->timestamps();

                $table->primary(['cart_id', 'product_key']);
            }
        );
    }

    public function down(): void
    {
        $this->schema->drop('cart_products');
        $this->schema->drop('carts');
    }
}

Далее создаём модели для этой таблицы, их код я приводить не буду - должно быть уже понятно из схемы.

Корзина может привязываться в юзеру, но это необязательно. К ней же привязываются товары.

product_key - это уникальный ключ товара, который генерируется в зависимости от выбранных опций.

Генерацию выносим в статический метод модели CartProduct:

    public static function generateKey(Product $product, ?array $options = null): string
    {
        $key = (string)$product->id;
        if ($options) {
            $key .= json_encode($options);
        }

        return Uuid::uuid5(Uuid::NAMESPACE_OID, $key);
    }

Использование NAMESPACE_OID гарантирует создание одного и того же uuid для конкретной строки. То есть, ключ товара будет повторяться, в зависимости от его id и указанных опций.

В модели юзера я добавляю связь только с одной корзиной:

    public function cart(): HasOne
    {
        return $this->hasOne(Cart::class);
    }

У пользователя не должно быть больше одной корзины за раз.

Контроллеры корзины

Следуя логике работы приложения, нам нужно два контроллера: для корзины и для её товаров.

Я не хочу создавать корзину для каждого посетителя сайта автоматически, лучше делать это при первом добавлении товара. Так что в контроллере нужно прописать только метод PUT:

<?php

namespace App\Controllers\Web;

// ...

class Cart extends ModelController
{
    protected $model = CartModel::class;

    public function put(): ResponseInterface
    {
        if ($this->user && $this->user->cart) {
            $cart = $this->user->cart;
        } else {
            $cart = new CartModel();
            $cart->user_id = $this->user->id ?? null;
            $cart->save();
        }

        return $this->success($cart->only('id'));
    }

    public function get(): ResponseInterface
    {
        return $this->failure('Method Not Allowed', 405);
    }

    public function patch(): ResponseInterface
    {
        return $this->failure('Method Not Allowed', 405);
    }
}

При попытке создания корзины я проверяю, вдруг это авторизованный юзер, у которого уже есть корзина? Если так - используем её. Если нет, то создаём новую. В любом случае в ответ отдаём уникальный id корзины.

Методы GET и PATCH отключаем за ненадобностью, а DELETE работает по умолчанию из родительского контроллера. При удалении корзины будут удалены и её товары, благодаря внешнему ключу по cart_id.

Добавляем маршрут для контроллера заказов:

$group->any('/cart[/{id}]', App\Controllers\Web\Cart::class);

Контроллер товаров будет чуть сложнее:

<?php

namespace App\Controllers\Web\Cart;

// ...

class Products extends ModelController
{
    protected $model = CartProduct::class;
    protected $primaryKey = ['cart_id', 'product_key'];

    protected ?Cart $cart;

    // При любом обращении к товарам корзины загружаем и саму корзину
    public function checkScope(string $method): ?ResponseInterface
    {
        $id = $this->getProperty('cart_id');
        if (!$id || !$this->cart = Cart::query()->find($id)) {
            return $this->failure('', 404);
        }
        // У корзины нет юзера, а у запроса есть - добавляем корзину этому юзеру
        // Т.е. гость накидал товаров в корзину, а перед заказом авторизовался
        if (!$this->cart->user_id && $this->user) {
            $this->cart->update(['user_id' => $this->user->id]);
        }

        return null;
    }

    // Добавление товара в корзину
    public function put(): ResponseInterface
    {
        $id = $this->getProperty('id');
        // Проверяем наличие товара и статус
        if (!$id || !$product = Product::query()->where('active', true)->find($id)) {
            return $this->failure('Not Found', 404);
        }

        // Генерируем ключ для товара, с учётом опций
        $productKey = CartProduct::generateKey($product, $this->getProperty('options'));
        // Если такой ключ уже есть в корзине - добавляем к нему
        if ($item = $this->cart->cartProducts()->where('product_key', $productKey)->first()) {
            $item->amount += $this->getProperty('amount', 1);
        } else {
            // Товара в корзине нет - создаём
            $item = new CartProduct([
                'cart_id' => $this->cart->id,
                'product_key' => $productKey,
                'product_id' => $product->id,
                'amount' => $this->getProperty('amount', 1),
                'options' => $this->getProperty('options'),
            ]);
        }
        $item->save();

        // Возвращаем список товаров корзины
        return $this->get();
    }

    // Следующие 2 метода работают по умолчанию, меняем только ответ на список товаров корзины
    public function patch(): ResponseInterface
    {
        parent::patch();
        $this->unsetProperty('product_key');

        return $this->get();
    }

    public function delete(): ResponseInterface
    {
        parent::delete();
        $this->unsetProperty('product_key');

        return $this->get();
    }

    // Получаем из корзины только автивные товары
    protected function beforeCount(Builder $c): Builder
    {
        $c->where('cart_id', $this->cart->id);
        $c->whereHas('product', static function (Builder $c) {
            $c->where('active', true);
        });

        return $c;
    }

    // Добираем нужные данные к списку товаров
    protected function afterCount(Builder $c): Builder
    {
        $c->with(
            'product:id,price,weight,uri',
            'product.firstFile',
            'product.translations:product_id,lang,title'
        );

        return $c;
    }

    // Меняем определение первичного ключа, чтобы patch() и delete() правильно работали
    protected function getPrimaryKey(): ?array
    {
        $key = [];
        foreach ($this->primaryKey as $item) {
            if (!$value = $this->getProperty($item)) {
                return null;
            }
            $key[$item] = $value;
        }

        return $key;
    }
}

По умолчанию контроллеры моделей Vesp отдают изменённую модель при вызове PATCH или пустоту при DELETE. Если так и оставить, то мне придётся делать дополнительный запрос в API на загрузку товаров корзины после каждого изменения.

Я считаю это лишним, так что мой контроллер изо всех методов возвращает список товаров корзины.

Заканчиваем работу с бэкендом добавляением второго маршрута:

$group->any('/cart/{cart_id}/products[/{product_key}]', App\Controllers\Web\Cart\Products::class);

Методы хранилища Vuex

В прошлой версии магазина мы вынесли все методы работы с корзиной в центральное хранилище Vuex. Корзина тогда хранилась в localStorage целиком, а сейчас нам нужно делать запросы в API.

Мы будем использовать actions вместо mutations, потому что actions могут быть вызваны асинхронно. Но мутации всё еще будут вызываться внутри действий для изменения state.

Давайте последовательно посмотрим на наш новый site/store/index.js.

В state мы раздельно храним id корзины и её товары.

export const state = () => ({
  cartId: null,
  cartProducts: [],
})

Мутации просто меняют state, без дополнительной логики

export const mutations = {
  cartId(state, payload) {
    state.cartId = payload
  },
  cartProducts(state, payload) {
    state.cartProducts = payload
  },
}

Геттеры возвращают количество товаров в корзине и общую цену этих товаров.

export const getters = {
  cartProducts(state) {
    let amount = 0
    state.cartProducts.forEach((i) => {
      amount += i.amount
    })
    return amount
  },
  cartTotal(state) {
    let total = 0
    state.cartProducts.forEach((i) => {
      total += i.product.price * i.amount
    })
    return total
  },
}

Геттер products я убрал за ненадобностью. Теперь мы можем напрямую обращаться в this.$store.state.cartProducts.

Самое основное находится в actions:

export const actions = {

  // Получение id корзины
  async getCartId({state, commit, dispatch}, create = true) {
    // Если id нет в state
    if (!state.cartId) {
      // Пробуем получить старый id из localStorage
      if (localStorage.getItem('cartId')) {
        commit('cartId', localStorage.getItem('cartId'))
      } else if (create) {
        // Если не указано иное - соаздём новую корзину
        try {
          const {data} = await this.$axios.put('web/cart')
          commit('cartId', data.id)
          dispatch('saveCartId')
        } catch (e) {}
      }
    }
    return state.cartId
  },

  // Загрузка товаров корзины
  async loadCart({dispatch, commit}) {
    // Здесь и далее id корзины получается через общий action
    // В этом случае отключено автосоздание корзины (параметр false)
    const cartId = await dispatch('getCartId', false)
    // запрос на сервер делается только при наличии сохранённого id
    if (cartId) {
      try {
        const {data} = await this.$axios.get('web/cart/' + cartId + '/products')
        commit('cartProducts', data.rows)
      } catch (e) {}
    }
  },

  // Добавление товара в корзину
  async addToCart({commit, dispatch}, item) {
    // Здесь уже корзина будет создана, если её нет
    const cartId = await dispatch('getCartId')
    try {
      const params = {id: item.id, amount: item.amount || 1, options: item.options || null}
      const {data} = await this.$axios.put('web/cart/' + cartId + '/products', params)
      commit('cartProducts', data.rows)
    } catch (e) {}
  },

  // Удаление товара из корзины
  async removeFromCart({state, commit, dispatch}, product) {
    const cartId = await dispatch('getCartId')
    try {
      const {data} = await this.$axios.delete('web/cart/' + cartId + '/products/' + product.product_key)
      commit('cartProducts', data.rows)
    } catch (e) {}
  },

  // Изменение количества товара
  async changeAmount({state, commit, dispatch}, product) {
    const cartId = await dispatch('getCartId')
    // Если количество <= 0, то удаляем товар
    if (product.amount <= 0) {
      return dispatch('removeFromCart', product)
    }
    try {
      const params = {amount: product.amount}
      const {data} = await this.$axios.patch('web/cart/' + cartId + '/products/' + product.product_key, params)
      commit('cartProducts', data.rows)
    } catch (e) {}
  },

  // Удаление корзины
  async deleteCart({state, commit, dispatch}) {
    if (!state.cartId) {
      return
    }
    try {
      await this.$axios.delete('web/cart/' + state.cartId)
      // Удаление товаров и id корзины
      commit('cartId', null)
      commit('cartProducts', [])
      dispatch('saveCartId')
    } catch (e) {}
  },

  // Сохранение id корзины в localStorage
  saveCartId({state}) {
    if (state.cartId) {
      localStorage.setItem('cartId', state.cartId)
    } else {
      localStorage.removeItem('cartId')
    }
  },
}

Id корзины для нас очень важен, потому что из него формируется запрос в API.

При загрузке приложения вызывается loadCart, который поищет старый id козины, и если надёт - загрузит её товары.

Если нет, корзина не будет создана, пока юзер не захочет что-то в неё добваить. Таким образом мы создаём корзины в БД только при реальной необходимости.

Вносим изменения в компонент корзины site/components/cart.vue:

  • computed переменная products() возвращает this.$store.state.cartProducts
  • при выводе товаров используем функции $translate и $price
  • добавляем индикатор загрузки через b-overlay
  • для изменения количества товаров и удаления корзины добавляем новые методы:
  methods: {
    async productMinus(product) {
      this.loading = true
      try {
        await this.$store.dispatch('changeAmount', {...product, amount: product.amount - 1})
      } catch (e) {
      } finally {
        this.loading = false
      }
    },
    async productPlus(product) {
      this.loading = true
      try {
        await this.$store.dispatch('changeAmount', {...product, amount: product.amount + 1})
      } catch (e) {
      } finally {
        this.loading = false
      }
    },
    async cartDelete() {
      this.loading = true
      try {
        await this.$store.dispatch('deleteCart')
      } catch (e) {
      } finally {
        this.loading = false
      }
    },
  },

Асинхронные действия Vuex позволяют нам выводить индикатор загрузки до окончания запроса.

Смотрим на результат (обратите внимание на запросы в API):

При первом запросе на добавление товара создаётся корзина, затем добавляется товар. Добавление товаров идёт через PUT - мы не знаем, какой у них получится product_key на сервере, он сам проверит и прибавит amount, если товар уже в корзине.

При работе из корзины нам уже известны product_key, поэтому мы отправляем PATСH и DELETE по адресу конкретной записи. В ответ всегда приходит список товаров корзины.

Поддержка авторизации

У нас пока нет авторизации на публичном сайте, но она есть в админке - так что мы добавляем @nuxtjs/auth-next в nuxt.config.js. Для API нет никакой разницы, откуда обращается юзер, главное, чтобы был правильный токен.

А токен хранится в localStorage сайта и является общим. То есть, авторизованный админ является авторизованным и на сайте - даже если там нет формы авторизации.

Тут лично у меня вылезла ошибка defu__WEBPACK_IMPORTED_MODULE_3__ is not a function, которую я поправил добавлением transpile: ['defu'] в Config.build после гугления.

Думаю, это связано с серверным рендером на публичном сайте, в отличие от админки.

Перезапускаем контейнер с NodeJS и проверяем авторизацию - всё в порядке:

Если у вас была корзина, то она привяжется к юзеру:

Остаётся только вопрос, а как грузить корзину юзера, если он авторизовался с другого компа и там еще не был сохранён id корзины? А для этого нужно вернуть id корзины сразу в свойствах профиля.

Добавляем выдачу id корзины в профиле пользователя Controllers/User/Profile.php:

    public function get(): ResponseInterface
    {
        if ($this->user) {
            $data = $this->user->toArray();
            $data['scope'] = $this->user->role->scope;
            $data['cart'] = $this->user->cart->id ?? null;

            return $this->success(['user' => $data]);
        }

        return $this->failure('Authentication required', 401);
    }

А теперь обновляем получение id корзины в хранилище Vuex:

  async getCartId({state, commit, dispatch}, create = true) {
    if (!state.cartId) {
      if (this.$auth.loggedIn && this.$auth.user.cart) {
        commit('cartId', this.$auth.user.cart)
      } else if (localStorage.getItem('cartId')) {
        // ...
    }
    return state.cartId
  },

Таким образом, авторизованный юзер сразу загрузит свои товары и сохранит id корзины в state хранилища. Проверяем в анонимном режиме:

Заключение

Использование uuid для запросов в корзину позволяет работать как гостям, так и авторизованным пользователям.

На следующем уроке мы будем оформлять заказ из товаров корзины.

Все изменения в репозитории.

← Предыдущая заметка
Фасетные фильтры
Следующая заметка →
Регистрация с авторизацией и сбросом пароля
Комментарии (0)
bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...