hearts: zero hearts really stops play

- The zero-hearts check moved inside the answer transaction: two wrong
  answers sent at once with one heart left were both accepted (reproduced
  with two threads).
- Finishing a lesson or an island without hearts and without having
  answered its questions is now 403 no_hearts; before, quiz/complete let
  a student move on island after island with zero hearts. Losing the last
  heart on the last question still finishes the island.
- A heart that came back while waiting is now saved before answering, so
  the reply no longer says 0 hearts left (the app then showed the
  no-hearts page to someone who had a heart).
- The 403 also carries fullRefillAt for the waiting screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:31:44 +03:30
parent 0f0b779a4e
commit 4700ccbf91
2 changed files with 207 additions and 20 deletions

View File

@@ -192,6 +192,42 @@ def _record_streak(user_id):
_FA_DIGITS = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
def no_hearts_response(hearts) -> Response:
"""۴۰۳ِ «قلب تمام شد»؛ فرانت با code=no_hearts صفحه‌ی انتظار را با شمارشِ معکوس نشان می‌دهد."""
return Response(
{
'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.',
'code': 'no_hearts',
'nextRefillAt': hearts.next_refill_at,
'fullRefillAt': hearts.full_refill_at,
},
status=status.HTTP_403_FORBIDDEN,
)
def blocked_without_hearts(user_id, answered: int, needed: int) -> Response | None:
"""
پایانِ درس یا جزیره برای کسی که قلب ندارد و سوال‌هایش را جواب نداده: ۴۰۳.
جواب دادن بی‌قلب ممکن نیست (QuestionAnswerView)، ولی تا ۲۰۲۶-۱۰-۰۲ پایانِ آزمون
(`quiz/complete`) و پایانِ درس هیچ شرطی نداشتند، پس با قلبِ صفر هم می‌شد جزیره پشتِ جزیره
جلو رفت. کسی که آخرین قلبش را روی آخرین سوال از دست داده همه را جواب داده و رد نمی‌شود.
"""
if not settings.HEARTS_ENFORCED or answered >= needed or has_unlimited_hearts(user_id):
return None
hearts = UserHearts.objects.filter(user_id=user_id).first()
if hearts is None:
return None
if hearts.apply_regen():
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
return no_hearts_response(hearts) if hearts.current_hearts <= 0 else None
def _answered(user_id, **lookup) -> int:
"""چند سوالِ متفاوت از این درس/جزیره را این کاربر تا حالا جواب داده."""
return UserAnswer.objects.filter(user_id=user_id, **lookup).values('question_id').distinct().count()
def _completion_body(title, stars, xp):
"""
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که
@@ -455,26 +491,20 @@ class QuestionAnswerView(APIView):
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
# خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد.
# قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود»
# (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ
# مستقیمِ API دور زده می‌شد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی
# برای این ۴۰۳ هنوز صفحه‌ی «قلب‌ها تمام شد» ندارد.
if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id):
hearts = UserHearts.objects.filter(user_id=user_id).first()
if hearts is not None:
hearts.apply_regen()
if hearts.current_hearts <= 0:
return Response(
{
'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.',
'code': 'no_hearts',
'nextRefillAt': hearts.next_refill_at,
},
status=status.HTTP_403_FORBIDDEN,
)
# قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو)، مگر قلبِ نامحدود (اشتراک یا کارتِ قلبِ بسته).
# بدونِ این، «قلبِ نامحدود» فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ مستقیمِ API دور زده
# می‌شد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند: تا ۲۰۲۶-۱۰-۰۲ بیرونش بود و
# دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (فقط خوانده شد، بازتولید نه).
unlimited = has_unlimited_hearts(user_id)
try:
with transaction.atomic():
hearts = None
if settings.HEARTS_ENFORCED and not unlimited:
hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first()
if hearts is not None and hearts.apply_regen():
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
if hearts is not None and hearts.current_hearts <= 0:
return no_hearts_response(hearts)
UserAnswer.objects.create(
user_id=user_id,
question=question,
@@ -487,12 +517,12 @@ class QuestionAnswerView(APIView):
UserHearts.objects.get_or_create(user_id=user_id)
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
hearts.apply_regen()
if not has_unlimited_hearts(user_id):
if not unlimited:
hearts.lose_heart()
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
hearts_remaining = hearts.current_hearts
else:
hearts = UserHearts.objects.filter(user_id=user_id).first()
hearts = hearts or UserHearts.objects.filter(user_id=user_id).first()
hearts_remaining = hearts.current_hearts if hearts else 5
except IntegrityError:
if not idempotency_key:
@@ -602,6 +632,12 @@ class LessonCompleteView(APIView):
}
return Response(LessonCompleteResponseSerializer(data).data)
blocked = blocked_without_hearts(
user_id, _answered(user_id, question__lesson=lesson), actual_question_count,
)
if blocked is not None:
return blocked
# atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه
# (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته.
@@ -936,6 +972,12 @@ class NodeQuizCompleteView(APIView):
if existing is not None:
return respond(existing, UserStats.objects.filter(user_id=user_id).first())
blocked = blocked_without_hearts(
user_id, _answered(user_id, question__lesson__node_id=node_id), total_questions,
)
if blocked is not None:
return blocked
try:
with transaction.atomic():
UserStats.objects.get_or_create(user_id=user_id)