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:
2025-11-22 19:00:48 +03:00
parent c856d5d50a
commit 38ee2f5df7

View File

@@ -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,