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,21 +483,26 @@ def add_showcase_kit_to_cart(request, kit_id):
status='active' status='active'
) )
# Проверяем существующие блокировки # Атомарная проверка и создание блокировки (предотвращает race condition)
existing_locks = Reservation.objects.filter( with transaction.atomic():
# Блокируем строки резервов для этого комплекта на уровне БД
reservations = Reservation.objects.select_for_update().filter(
product_kit=kit, product_kit=kit,
cart_lock_expires_at__gt=timezone.now(),
status='reserved' status='reserved'
)
# Проверяем существующие блокировки другими пользователями
existing_lock = reservations.filter(
cart_lock_expires_at__gt=timezone.now()
).exclude( ).exclude(
locked_by_user=request.user locked_by_user=request.user
).select_related('locked_by_user') ).select_related('locked_by_user').first()
if existing_locks.exists(): if existing_lock:
lock = existing_locks.first() time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". ' 'error': f'Этот букет уже в корзине кассира "{existing_lock.locked_by_user.username}". '
f'Блокировка истечет через {int(time_left)} мин.' f'Блокировка истечет через {int(time_left)} мин.'
}, status=409) # 409 Conflict }, status=409) # 409 Conflict
@@ -490,11 +510,8 @@ def add_showcase_kit_to_cart(request, kit_id):
lock_expires_at = timezone.now() + timedelta(minutes=30) lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or '' session_id = request.session.session_key or ''
# Обновляем все резервы этого комплекта # Обновляем все резервы этого комплекта (теперь атомарно!)
updated_count = Reservation.objects.filter( updated_count = reservations.update(
product_kit=kit,
status='reserved'
).update(
cart_lock_expires_at=lock_expires_at, cart_lock_expires_at=lock_expires_at,
locked_by_user=request.user, locked_by_user=request.user,
cart_session_id=session_id cart_session_id=session_id