Хранение корзины в базе данных
С каталогом товаров мы пока закончили, пора переделать работу с корзиной. В прошлой версии магазина она хранилась в 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
👍
👎
❤️
🔥
😮
😢
😀
😡
448
05.09.2023, 06:17:30
Комментарии
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Vesp 3.0
101
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Оплата заказа
2
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Обновление проекта
2
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Создание нового проекта
63
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Поездка в Швейцарию
8
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
День рождения 41
6
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Релиз @vesp/nuxt-fontawesome
3
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.
Новая структура таблиц магазина
15
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Начинаем новый курс
4
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!
Запуск в продакшн с помощью Docker
20