Fix race condition and expired lock validation for showcase kit cart
- Add select_for_update() with transaction.atomic() to prevent race condition when multiple users try to add the same showcase kit simultaneously - Validate cart lock expiration when loading cart from Redis - showcase kits with expired locks are now filtered out instead of being displayed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -227,10 +227,25 @@ def pos_terminal(request):
|
|||||||
# Проверяем что товар существует
|
# Проверяем что товар существует
|
||||||
Product.objects.get(id=item['id'])
|
Product.objects.get(id=item['id'])
|
||||||
cart_data[cart_key] = item
|
cart_data[cart_key] = item
|
||||||
elif item['type'] in ('kit', 'showcase_kit'):
|
elif item['type'] == 'kit':
|
||||||
# Проверяем что комплект существует
|
# Обычный комплект - только проверяем существование
|
||||||
ProductKit.objects.get(id=item['id'])
|
ProductKit.objects.get(id=item['id'])
|
||||||
cart_data[cart_key] = item
|
cart_data[cart_key] = item
|
||||||
|
elif item['type'] == 'showcase_kit':
|
||||||
|
# Витринный комплект - проверяем существование И актуальность блокировки
|
||||||
|
kit = ProductKit.objects.get(id=item['id'])
|
||||||
|
|
||||||
|
# Проверяем, что блокировка всё ещё активна для этого пользователя
|
||||||
|
has_valid_lock = Reservation.objects.filter(
|
||||||
|
product_kit=kit,
|
||||||
|
locked_by_user=request.user,
|
||||||
|
cart_lock_expires_at__gt=timezone.now(),
|
||||||
|
status='reserved'
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if has_valid_lock:
|
||||||
|
cart_data[cart_key] = item
|
||||||
|
# Если блокировка истекла - не добавляем в корзину (товар доступен другим)
|
||||||
except (Product.DoesNotExist, ProductKit.DoesNotExist):
|
except (Product.DoesNotExist, ProductKit.DoesNotExist):
|
||||||
# Товар или комплект удален - пропускаем
|
# Товар или комплект удален - пропускаем
|
||||||
continue
|
continue
|
||||||
@@ -468,43 +483,45 @@ def add_showcase_kit_to_cart(request, kit_id):
|
|||||||
status='active'
|
status='active'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем существующие блокировки
|
# Атомарная проверка и создание блокировки (предотвращает race condition)
|
||||||
existing_locks = Reservation.objects.filter(
|
with transaction.atomic():
|
||||||
product_kit=kit,
|
# Блокируем строки резервов для этого комплекта на уровне БД
|
||||||
cart_lock_expires_at__gt=timezone.now(),
|
reservations = Reservation.objects.select_for_update().filter(
|
||||||
status='reserved'
|
product_kit=kit,
|
||||||
).exclude(
|
status='reserved'
|
||||||
locked_by_user=request.user
|
)
|
||||||
).select_related('locked_by_user')
|
|
||||||
|
|
||||||
if existing_locks.exists():
|
# Проверяем существующие блокировки другими пользователями
|
||||||
lock = existing_locks.first()
|
existing_lock = reservations.filter(
|
||||||
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
|
cart_lock_expires_at__gt=timezone.now()
|
||||||
return JsonResponse({
|
).exclude(
|
||||||
'success': False,
|
locked_by_user=request.user
|
||||||
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". '
|
).select_related('locked_by_user').first()
|
||||||
f'Блокировка истечет через {int(time_left)} мин.'
|
|
||||||
}, status=409) # 409 Conflict
|
|
||||||
|
|
||||||
# Создаем или продлеваем блокировку для текущего пользователя
|
if existing_lock:
|
||||||
lock_expires_at = timezone.now() + timedelta(minutes=30)
|
time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
|
||||||
session_id = request.session.session_key or ''
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Этот букет уже в корзине кассира "{existing_lock.locked_by_user.username}". '
|
||||||
|
f'Блокировка истечет через {int(time_left)} мин.'
|
||||||
|
}, status=409) # 409 Conflict
|
||||||
|
|
||||||
# Обновляем все резервы этого комплекта
|
# Создаем или продлеваем блокировку для текущего пользователя
|
||||||
updated_count = Reservation.objects.filter(
|
lock_expires_at = timezone.now() + timedelta(minutes=30)
|
||||||
product_kit=kit,
|
session_id = request.session.session_key or ''
|
||||||
status='reserved'
|
|
||||||
).update(
|
|
||||||
cart_lock_expires_at=lock_expires_at,
|
|
||||||
locked_by_user=request.user,
|
|
||||||
cart_session_id=session_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if updated_count == 0:
|
# Обновляем все резервы этого комплекта (теперь атомарно!)
|
||||||
return JsonResponse({
|
updated_count = reservations.update(
|
||||||
'success': False,
|
cart_lock_expires_at=lock_expires_at,
|
||||||
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
|
locked_by_user=request.user,
|
||||||
}, status=400)
|
cart_session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_count == 0:
|
||||||
|
return JsonResponse({
|
||||||
|
'success': False,
|
||||||
|
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|||||||
Reference in New Issue
Block a user