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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user