From 821311f4ad09228fce2e515019406ab706cfcfd6 Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 03:07:38 +0800 Subject: [PATCH 1/2] feat: complete realtime challenge matchmaking --- backend/contest/admin.py | 23 +- backend/contest/consumers.py | 15 +- ...mematch_matchmaking_lookup_idx_and_more.py | 36 ++ backend/contest/models.py | 19 +- backend/contest/services.py | 316 ++++++++++++++++-- backend/contest/test_consumers.py | 87 +++++ backend/contest/test_realtime_api.py | 96 ++++++ backend/contest/test_services.py | 159 +++++++++ backend/contest/urls.py | 12 +- backend/contest/views.py | 34 +- requirements-dev.txt | 1 + 11 files changed, 767 insertions(+), 31 deletions(-) create mode 100644 backend/contest/migrations/0004_remove_realtimematch_matchmaking_lookup_idx_and_more.py create mode 100644 backend/contest/test_consumers.py create mode 100644 backend/contest/test_realtime_api.py diff --git a/backend/contest/admin.py b/backend/contest/admin.py index 140d575..07da68a 100644 --- a/backend/contest/admin.py +++ b/backend/contest/admin.py @@ -62,11 +62,32 @@ class QuestionVersionAdmin(admin.ModelAdmin): admin.site.register(ContestAnswer) -admin.site.register(RealtimeMatch) admin.site.register(RatingHistory) admin.site.register(LeaderboardSnapshot) +@admin.register(RealtimeMatch) +class RealtimeMatchAdmin(admin.ModelAdmin): + list_display = ( + "id", + "contest", + "match_type", + "challenge_code", + "player_one", + "player_two", + "status", + "created_at", + ) + list_filter = ("match_type", "status", "contest__track") + search_fields = ( + "challenge_code", + "player_one__username", + "player_two__username", + ) + readonly_fields = ("created_at", "started_at", "completed_at") + ordering = ("-created_at",) + + @admin.register(MathGameAttempt) class MathGameAttemptAdmin(admin.ModelAdmin): list_display = ( diff --git a/backend/contest/consumers.py b/backend/contest/consumers.py index 9f0f26a..ff522d2 100644 --- a/backend/contest/consumers.py +++ b/backend/contest/consumers.py @@ -26,7 +26,11 @@ class MatchConsumer(AsyncJsonWebsocketConsumer): await self.send_json({"type": "pong"}) return if event_type == "progress": - answered_count = max(0, min(int(content.get("answered_count", 0)), 100)) + try: + answered_count = max(0, min(int(content.get("answered_count", 0)), 100)) + except (TypeError, ValueError): + await self.send_json({"type": "error", "message": "答题进度无效"}) + return await self.channel_layer.group_send( self.group_name, { @@ -45,6 +49,15 @@ class MatchConsumer(AsyncJsonWebsocketConsumer): } ) + async def match_state(self, event): + await self.send_json( + { + "type": "state", + "reason": event["reason"], + "match_id": str(self.match_id), + } + ) + @database_sync_to_async def _is_participant(self, user_id): return RealtimeMatch.objects.filter(id=self.match_id).filter( diff --git a/backend/contest/migrations/0004_remove_realtimematch_matchmaking_lookup_idx_and_more.py b/backend/contest/migrations/0004_remove_realtimematch_matchmaking_lookup_idx_and_more.py new file mode 100644 index 0000000..16c4ec8 --- /dev/null +++ b/backend/contest/migrations/0004_remove_realtimematch_matchmaking_lookup_idx_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.23 on 2026-08-08 18:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contest', '0003_mathgameattempt_and_more'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='realtimematch', + name='matchmaking_lookup_idx', + ), + migrations.AddField( + model_name='realtimematch', + name='challenge_code', + field=models.CharField(blank=True, max_length=8, null=True, unique=True), + ), + migrations.AddField( + model_name='realtimematch', + name='expires_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='realtimematch', + name='match_type', + field=models.CharField(choices=[('random', '随机匹配'), ('challenge', '联机码约战')], default='random', max_length=16), + ), + migrations.AddIndex( + model_name='realtimematch', + index=models.Index(fields=['contest', 'match_type', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'), + ), + ] diff --git a/backend/contest/models.py b/backend/contest/models.py index 9ac860e..8fd20d2 100644 --- a/backend/contest/models.py +++ b/backend/contest/models.py @@ -77,6 +77,10 @@ class ContestQuestion(models.Model): class RealtimeMatch(models.Model): + class MatchType(models.TextChoices): + RANDOM = "random", "随机匹配" + CHALLENGE = "challenge", "联机码约战" + class Status(models.TextChoices): WAITING = "waiting", "等待对手" ACTIVE = "active", "进行中" @@ -85,6 +89,12 @@ class RealtimeMatch(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) contest = models.ForeignKey(Contest, on_delete=models.PROTECT) + match_type = models.CharField( + max_length=16, + choices=MatchType.choices, + default=MatchType.RANDOM, + ) + challenge_code = models.CharField(max_length=8, unique=True, null=True, blank=True) player_one = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one" ) @@ -106,13 +116,20 @@ class RealtimeMatch(models.Model): related_name="won_matches", ) created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(null=True, blank=True) started_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) class Meta: indexes = [ models.Index( - fields=("contest", "status", "player_one_rating", "created_at"), + fields=( + "contest", + "match_type", + "status", + "player_one_rating", + "created_at", + ), name="matchmaking_lookup_idx", ) ] diff --git a/backend/contest/services.py b/backend/contest/services.py index 02073fc..b9c06d7 100644 --- a/backend/contest/services.py +++ b/backend/contest/services.py @@ -1,5 +1,9 @@ +import secrets +from datetime import timedelta from decimal import Decimal +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from django.db import transaction from django.db.models import Q from django.utils import timezone @@ -16,6 +20,93 @@ from .models import ( RealtimeMatch, ) +CHALLENGE_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +WAITING_MATCH_TTL = timedelta(minutes=10) + + +def _broadcast_match(match_id, reason): + channel_layer = get_channel_layer() + if channel_layer is None: + return + async_to_sync(channel_layer.group_send)( + f"match_{match_id}", + { + "type": "match.state", + "reason": reason, + }, + ) + + +def notify_match_on_commit(match_id, reason): + transaction.on_commit(lambda: _broadcast_match(match_id, reason)) + + +def _new_challenge_code(): + for _ in range(20): + code = "".join(secrets.choice(CHALLENGE_CODE_ALPHABET) for _ in range(6)) + if not RealtimeMatch.objects.filter(challenge_code=code).exists(): + return code + raise ValidationError("暂时无法生成联机码,请稍后重试") + + +def _validate_realtime_contest(contest): + if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED: + raise ValidationError("实时比赛不可用") + + +def _cancel_expired_waiting_matches(): + RealtimeMatch.objects.filter(status=RealtimeMatch.Status.WAITING).filter( + Q(expires_at__isnull=True) | Q(expires_at__lte=timezone.now()) + ).update(status=RealtimeMatch.Status.CANCELLED) + + +def _active_match_for(user): + return ( + RealtimeMatch.objects.filter( + Q(player_one=user) | Q(player_two=user), + status=RealtimeMatch.Status.ACTIVE, + ) + .select_related("contest", "player_one", "player_two") + .first() + ) + + +def _cancel_other_waiting_matches(user, match_type): + matches = list( + RealtimeMatch.objects.filter( + player_one=user, + status=RealtimeMatch.Status.WAITING, + ) + .exclude(match_type=match_type) + .values_list("id", flat=True) + ) + if matches: + RealtimeMatch.objects.filter(id__in=matches).update( + status=RealtimeMatch.Status.CANCELLED + ) + for match_id in matches: + notify_match_on_commit(match_id, "cancelled") + + +def _activate_match(match, user): + now = timezone.now() + match.player_two = user + match.player_two_rating = user.rating + match.status = RealtimeMatch.Status.ACTIVE + match.started_at = now + match.save( + update_fields=["player_two", "player_two_rating", "status", "started_at"] + ) + ContestAttempt.objects.bulk_create( + [ + ContestAttempt(contest=match.contest, user=match.player_one, match=match), + ContestAttempt(contest=match.contest, user=user, match=match), + ] + ) + match.attempts.update(started_at=now) + notify_match_on_commit(match.id, "matched") + return match + def normalize_answer(value): text = str(value).strip().lower().replace(" ", "") @@ -35,12 +126,12 @@ def attempt_payload(attempt, include_results=False): "metadata": item.question_version.metadata, "points": item.points, } - if include_results and item.id in answers: - answer = answers[item.id] + if include_results: + answer = answers.get(item.id) question.update( { - "submitted_answer": answer.submitted_answer, - "is_correct": answer.is_correct, + "submitted_answer": answer.submitted_answer if answer else "", + "is_correct": answer.is_correct if answer else False, "correct_answer": item.question_version.answer, "explanation": item.question_version.explanation, } @@ -48,6 +139,7 @@ def attempt_payload(attempt, include_results=False): questions.append(question) return { "attempt_id": attempt.id, + "match_id": attempt.match_id, "contest": attempt.contest.title, "kind": attempt.contest.kind, "status": attempt.status, @@ -90,7 +182,10 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key): ) if attempt.status != ContestAttempt.Status.ACTIVE: if submission_key and attempt.submission_key == submission_key: - return attempt_payload(attempt, include_results=True) + include_results = not attempt.match_id or ( + attempt.match.status == RealtimeMatch.Status.COMPLETED + ) + return attempt_payload(attempt, include_results=include_results) raise ValidationError("该答题记录已经结算") if not submission_key: raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"}) @@ -153,20 +248,34 @@ def submit_attempt(user, attempt_id, raw_answers, submission_key): }, ) if attempt.match_id: - finalize_match(attempt.match_id) + match = finalize_match(attempt.match_id) + if match.status != RealtimeMatch.Status.COMPLETED: + notify_match_on_commit(attempt.match_id, "submitted") + attempt.match.refresh_from_db() + return attempt_payload( + attempt, + include_results=attempt.match.status == RealtimeMatch.Status.COMPLETED, + ) return attempt_payload(attempt, include_results=True) @transaction.atomic def find_match(user, contest): - if contest.kind != Contest.Kind.REALTIME or contest.status != Contest.Status.PUBLISHED: - raise ValidationError("实时比赛不可用") + _validate_realtime_contest(contest) + _cancel_expired_waiting_matches() + active = _active_match_for(user) + if active: + if active.contest_id == contest.id: + return active + raise ValidationError("你已有一场进行中的实时比赛") + _cancel_other_waiting_matches(user, RealtimeMatch.MatchType.RANDOM) existing = ( RealtimeMatch.objects.filter( Q(player_one=user) | Q(player_two=user), contest=contest, + match_type=RealtimeMatch.MatchType.RANDOM, status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE], - ) + ).filter(Q(status=RealtimeMatch.Status.ACTIVE) | Q(expires_at__gt=timezone.now())) .order_by("-created_at") .first() ) @@ -177,7 +286,9 @@ def find_match(user, contest): RealtimeMatch.objects.select_for_update(skip_locked=True) .filter( contest=contest, + match_type=RealtimeMatch.MatchType.RANDOM, status=RealtimeMatch.Status.WAITING, + expires_at__gt=timezone.now(), player_one_rating__gte=max(0, user.rating - 300), player_one_rating__lte=user.rating + 300, ) @@ -188,42 +299,198 @@ def find_match(user, contest): if waiting is None: return RealtimeMatch.objects.create( contest=contest, + match_type=RealtimeMatch.MatchType.RANDOM, player_one=user, player_one_rating=user.rating, + expires_at=timezone.now() + WAITING_MATCH_TTL, ) - waiting.player_two = user - waiting.player_two_rating = user.rating - waiting.status = RealtimeMatch.Status.ACTIVE - waiting.started_at = timezone.now() - waiting.save( - update_fields=["player_two", "player_two_rating", "status", "started_at"] + return _activate_match(waiting, user) + + +@transaction.atomic +def create_challenge(user, contest): + _validate_realtime_contest(contest) + _cancel_expired_waiting_matches() + active = _active_match_for(user) + if active: + raise ValidationError("你已有一场进行中的实时比赛") + _cancel_other_waiting_matches(user, RealtimeMatch.MatchType.CHALLENGE) + existing = ( + RealtimeMatch.objects.filter( + player_one=user, + contest=contest, + match_type=RealtimeMatch.MatchType.CHALLENGE, + status=RealtimeMatch.Status.WAITING, + expires_at__gt=timezone.now(), + ) + .order_by("-created_at") + .first() ) - ContestAttempt.objects.bulk_create( - [ - ContestAttempt(contest=contest, user=waiting.player_one, match=waiting), - ContestAttempt(contest=contest, user=user, match=waiting), - ] + if existing: + return existing + return RealtimeMatch.objects.create( + contest=contest, + match_type=RealtimeMatch.MatchType.CHALLENGE, + challenge_code=_new_challenge_code(), + player_one=user, + player_one_rating=user.rating, + expires_at=timezone.now() + WAITING_MATCH_TTL, ) - return waiting + + +@transaction.atomic +def join_challenge(user, challenge_code): + _cancel_expired_waiting_matches() + code = str(challenge_code or "").strip().upper() + if len(code) != 6 or any(character not in CHALLENGE_CODE_ALPHABET for character in code): + raise ValidationError({"challenge_code": "联机码应为 6 位大写字母或数字"}) + try: + match = ( + RealtimeMatch.objects.select_for_update() + .select_related("contest", "player_one") + .get( + challenge_code=code, + match_type=RealtimeMatch.MatchType.CHALLENGE, + ) + ) + except RealtimeMatch.DoesNotExist as exc: + raise ValidationError({"challenge_code": "联机码不存在"}) from exc + if match.player_one_id == user.id: + raise ValidationError({"challenge_code": "不能加入自己创建的约战"}) + if match.status != RealtimeMatch.Status.WAITING or ( + match.expires_at and match.expires_at <= timezone.now() + ): + raise ValidationError({"challenge_code": "联机码已失效或已被使用"}) + active = _active_match_for(user) + if active and active.id != match.id: + raise ValidationError("你已有一场进行中的实时比赛") + own_waiting_ids = list( + RealtimeMatch.objects.filter( + player_one=user, + status=RealtimeMatch.Status.WAITING, + ) + .exclude(id=match.id) + .values_list("id", flat=True) + ) + if own_waiting_ids: + RealtimeMatch.objects.filter(id__in=own_waiting_ids).update( + status=RealtimeMatch.Status.CANCELLED + ) + for match_id in own_waiting_ids: + notify_match_on_commit(match_id, "cancelled") + return _activate_match(match, user) + + +@transaction.atomic +def cancel_waiting_match(user, match_id): + match = RealtimeMatch.objects.select_for_update().get(id=match_id) + if match.player_one_id != user.id: + raise ValidationError("只有创建者可以取消等待") + if match.status != RealtimeMatch.Status.WAITING: + raise ValidationError("只能取消等待中的比赛") + match.status = RealtimeMatch.Status.CANCELLED + match.save(update_fields=["status"]) + notify_match_on_commit(match.id, "cancelled") + return match def match_payload(match, user): - attempt = match.attempts.filter(user=user).first() + reveal_results = match.status == RealtimeMatch.Status.COMPLETED + attempts = { + attempt.user_id: attempt + for attempt in match.attempts.select_related("user", "contest").all() + } + attempt = attempts.get(user.id) opponent = match.player_two if match.player_one_id == user.id else match.player_one + opponent_attempt = attempts.get(opponent.id) if opponent else None + rating_change = ( + match.rating_changes.filter(user=user).values("delta", "rating_after").first() + if reveal_results + else None + ) return { "match_id": match.id, + "match_type": match.match_type, + "is_owner": match.player_one_id == user.id, + "challenge_code": ( + match.challenge_code + if match.match_type == RealtimeMatch.MatchType.CHALLENGE + and match.status == RealtimeMatch.Status.WAITING + else None + ), "status": match.status, + "contest": match.contest.title, + "duration_seconds": match.contest.duration_seconds, + "expires_at": match.expires_at, + "started_at": match.started_at, "opponent": ( - {"nickname": opponent.nickname, "rating": opponent.rating} + { + "nickname": opponent.nickname, + "rating": opponent.rating, + "status": opponent_attempt.status if opponent_attempt else None, + "score": opponent_attempt.score if reveal_results and opponent_attempt else None, + "correct_count": ( + opponent_attempt.correct_count + if reveal_results and opponent_attempt + else None + ), + } if opponent else None ), - "attempt": attempt_payload(attempt) if attempt else None, + "attempt": ( + attempt_payload(attempt, include_results=reveal_results) + if attempt + else None + ), + "result": ( + { + "winner": ( + "draw" + if match.winner_id is None + else "self" + if match.winner_id == user.id + else "opponent" + ), + "rating_delta": rating_change["delta"] if rating_change else 0, + "rating_after": rating_change["rating_after"] if rating_change else user.rating, + } + if reveal_results + else None + ), "websocket_path": f"/ws/v1/contest/matches/{match.id}/", } +@transaction.atomic +def refresh_match_state(match_id): + match = ( + RealtimeMatch.objects.select_for_update() + .select_related("contest", "player_one", "player_two") + .get(id=match_id) + ) + if ( + match.status == RealtimeMatch.Status.WAITING + and match.expires_at + and match.expires_at <= timezone.now() + ): + match.status = RealtimeMatch.Status.CANCELLED + match.save(update_fields=["status"]) + notify_match_on_commit(match.id, "expired") + elif match.status == RealtimeMatch.Status.ACTIVE and match.started_at: + deadline = match.started_at + timedelta(seconds=match.contest.duration_seconds) + if timezone.now() >= deadline: + elapsed_ms = match.contest.duration_seconds * 1000 + match.attempts.filter(status=ContestAttempt.Status.ACTIVE).update( + status=ContestAttempt.Status.EXPIRED, + duration_ms=elapsed_ms, + submitted_at=timezone.now(), + ) + match = finalize_match(match.id) + return match + + def _elo_delta(rating, opponent_rating, score, k=32): expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400)) return round(k * (score - expected)) @@ -277,4 +544,5 @@ def finalize_match(match_id): match.status = RealtimeMatch.Status.COMPLETED match.completed_at = timezone.now() match.save(update_fields=["winner", "status", "completed_at"]) + notify_match_on_commit(match.id, "completed") return match diff --git a/backend/contest/test_consumers.py b/backend/contest/test_consumers.py new file mode 100644 index 0000000..2bee226 --- /dev/null +++ b/backend/contest/test_consumers.py @@ -0,0 +1,87 @@ +import pytest +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from channels.routing import URLRouter +from channels.testing import WebsocketCommunicator +from django.urls import path + +from accounts.models import User +from contest.consumers import MatchConsumer +from contest.models import Contest, Question, RealtimeMatch + + +@pytest.mark.django_db(transaction=True) +def test_match_consumer_双方连接并同步答题进度(): + first = User.objects.create_user( + username="socket_player_one", + password="StrongPass_2026", + nickname="WS 玩家一", + ) + second = User.objects.create_user( + username="socket_player_two", + password="StrongPass_2026", + nickname="WS 玩家二", + ) + outsider = User.objects.create_user( + username="socket_outsider", + password="StrongPass_2026", + nickname="WS 局外人", + ) + contest = Contest.objects.create( + slug="socket-contest", + title="WebSocket 联机赛", + kind=Contest.Kind.REALTIME, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + ) + match = RealtimeMatch.objects.create( + contest=contest, + player_one=first, + player_two=second, + player_one_rating=first.rating, + player_two_rating=second.rating, + status=RealtimeMatch.Status.ACTIVE, + ) + application = URLRouter( + [ + path( + "ws/test//", + MatchConsumer.as_asgi(), + ) + ] + ) + + async def scenario(): + outsider_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/") + outsider_socket.scope["user"] = outsider + outsider_connected, close_code = await outsider_socket.connect() + assert not outsider_connected + assert close_code == 4403 + + first_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/") + second_socket = WebsocketCommunicator(application, f"/ws/test/{match.id}/") + first_socket.scope["user"] = first + second_socket.scope["user"] = second + first_connected, _ = await first_socket.connect() + second_connected, _ = await second_socket.connect() + assert first_connected and second_connected + assert (await first_socket.receive_json_from())["type"] == "connected" + assert (await second_socket.receive_json_from())["type"] == "connected" + + await first_socket.send_json_to({"type": "progress", "answered_count": 3}) + first_progress = await first_socket.receive_json_from() + second_progress = await second_socket.receive_json_from() + assert first_progress["answered_count"] == 3 + assert second_progress["answered_count"] == 3 + assert second_progress["user_id"] == str(first.id) + + await get_channel_layer().group_send( + f"match_{match.id}", + {"type": "match.state", "reason": "completed"}, + ) + assert (await first_socket.receive_json_from())["reason"] == "completed" + assert (await second_socket.receive_json_from())["reason"] == "completed" + await first_socket.disconnect() + await second_socket.disconnect() + + async_to_sync(scenario)() diff --git a/backend/contest/test_realtime_api.py b/backend/contest/test_realtime_api.py new file mode 100644 index 0000000..9f72438 --- /dev/null +++ b/backend/contest/test_realtime_api.py @@ -0,0 +1,96 @@ +import pytest +from django.test import Client + +from accounts.models import User +from contest.models import Contest, ContestQuestion, Question, QuestionVersion + + +@pytest.fixture +def realtime_api_setup(db): + question = Question.objects.create( + slug="realtime-api-question", + track=Question.Track.STANDARD, + ) + version = QuestionVersion.objects.create( + question=question, + version=1, + prompt="18 + 24", + answer="42", + explanation="18 + 24 = 42", + ) + contest = Contest.objects.create( + slug="realtime-api", + title="API 联机赛", + kind=Contest.Kind.REALTIME, + track=Question.Track.STANDARD, + status=Contest.Status.PUBLISHED, + duration_seconds=60, + ) + ContestQuestion.objects.create( + contest=contest, + question_version=version, + order=1, + points=100, + ) + first = User.objects.create_user( + username="api_player_one", + password="StrongPass_2026", + nickname="API 玩家一", + ) + second = User.objects.create_user( + username="api_player_two", + password="StrongPass_2026", + nickname="API 玩家二", + ) + first_client = Client() + second_client = Client() + first_client.force_login(first) + second_client.force_login(second) + return contest, first_client, second_client + + +@pytest.mark.django_db +def test_challenge_api_创建加入状态提交形成完整闭环(realtime_api_setup): + contest, first_client, second_client = realtime_api_setup + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {}, + content_type="application/json", + ) + code = created.json()["challenge_code"] + + joined = second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": code.lower()}, + content_type="application/json", + ) + match_id = joined.json()["match_id"] + first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/") + + assert created.status_code == 201 + assert joined.status_code == 200 + assert joined.json()["status"] == "active" + assert first_state.json()["opponent"]["nickname"] == "API 玩家二" + + first_attempt = first_state.json()["attempt"]["attempt_id"] + second_attempt = joined.json()["attempt"]["attempt_id"] + first_submit = first_client.post( + f"/api/v1/contests/attempts/{first_attempt}/submit/", + {"answers": [{"order": 1, "answer": "42"}]}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="api-submit-one", + ) + second_submit = second_client.post( + f"/api/v1/contests/attempts/{second_attempt}/submit/", + {"answers": [{"order": 1, "answer": "0"}]}, + content_type="application/json", + HTTP_IDEMPOTENCY_KEY="api-submit-two", + ) + + assert first_submit.json()["status"] == "active" + assert "correct_answer" not in first_submit.json()["attempt"]["questions"][0] + assert second_submit.json()["status"] == "completed" + assert second_submit.json()["result"]["winner"] == "opponent" + final_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json() + assert final_state["result"]["winner"] == "self" + assert final_state["attempt"]["questions"][0]["correct_answer"] == "42" diff --git a/backend/contest/test_services.py b/backend/contest/test_services.py index e3faea9..7cd5495 100644 --- a/backend/contest/test_services.py +++ b/backend/contest/test_services.py @@ -15,9 +15,14 @@ from contest.models import ( RealtimeMatch, ) from contest.services import ( + cancel_waiting_match, + create_challenge, finalize_match, find_match, + join_challenge, + match_payload, normalize_answer, + refresh_match_state, start_attempt, submit_attempt, ) @@ -62,6 +67,16 @@ def daily_contest(db): return contest +@pytest.fixture +def realtime_contest(daily_contest): + daily_contest.kind = Contest.Kind.REALTIME + daily_contest.slug = "realtime-with-question" + daily_contest.title = "联机测试赛" + daily_contest.duration_seconds = 60 + daily_contest.save(update_fields=["kind", "slug", "title", "duration_seconds"]) + return daily_contest + + @pytest.mark.parametrize( "raw, expected", [ @@ -201,3 +216,147 @@ def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating( assert first.rating == 1016 assert second.rating == 984 assert RatingHistory.objects.filter(match=active).count() == 2 + + +@pytest.mark.django_db +def test_challenge_code_创建者和加入者通过联机码进入同一场(realtime_contest): + first = User.objects.create_user( + username="challenge_owner", + password="StrongPass_2026", + nickname="房主", + ) + second = User.objects.create_user( + username="challenge_guest", + password="StrongPass_2026", + nickname="访客", + ) + + waiting = create_challenge(first, realtime_contest) + active = join_challenge(second, waiting.challenge_code.lower()) + owner_payload = match_payload(active, first) + guest_payload = match_payload(active, second) + + assert len(waiting.challenge_code) == 6 + assert active.id == waiting.id + assert active.match_type == RealtimeMatch.MatchType.CHALLENGE + assert active.status == RealtimeMatch.Status.ACTIVE + assert active.attempts.count() == 2 + assert owner_payload["opponent"]["nickname"] == "访客" + assert guest_payload["opponent"]["nickname"] == "房主" + assert owner_payload["attempt"]["questions"] == guest_payload["attempt"]["questions"] + + +@pytest.mark.django_db +def test_random_match_不会加入联机码约战(realtime_contest): + owner = User.objects.create_user( + username="private_owner", + password="StrongPass_2026", + nickname="约战房主", + ) + random_player = User.objects.create_user( + username="random_player", + password="StrongPass_2026", + nickname="随机玩家", + ) + + challenge = create_challenge(owner, realtime_contest) + random_match = find_match(random_player, realtime_contest) + + assert challenge.status == RealtimeMatch.Status.WAITING + assert random_match.id != challenge.id + assert random_match.match_type == RealtimeMatch.MatchType.RANDOM + + +@pytest.mark.django_db +def test_realtime_submit_双方结束前不泄露答案且结束后结算(realtime_contest): + first = User.objects.create_user( + username="fair_player_one", + password="StrongPass_2026", + nickname="公平玩家一", + ) + second = User.objects.create_user( + username="fair_player_two", + password="StrongPass_2026", + nickname="公平玩家二", + ) + match = join_challenge( + second, + create_challenge(first, realtime_contest).challenge_code, + ) + first_attempt = match.attempts.get(user=first) + second_attempt = match.attempts.get(user=second) + + first_result = submit_attempt( + first, + first_attempt.id, + [{"order": 1, "answer": "42"}], + "fair-submit-one", + ) + active_payload = match_payload(match, first) + + assert "correct_answer" not in first_result["questions"][0] + assert active_payload["status"] == RealtimeMatch.Status.ACTIVE + assert active_payload["attempt"]["status"] == ContestAttempt.Status.SUBMITTED + + submit_attempt( + second, + second_attempt.id, + [{"order": 1, "answer": "0"}], + "fair-submit-two", + ) + match.refresh_from_db() + completed_payload = match_payload(match, first) + + assert match.status == RealtimeMatch.Status.COMPLETED + assert completed_payload["result"]["winner"] == "self" + assert completed_payload["attempt"]["questions"][0]["correct_answer"] == "42" + assert completed_payload["opponent"]["score"] == 0 + + +@pytest.mark.django_db +def test_realtime_timeout_未提交玩家自动过期并完成比赛(realtime_contest): + first = User.objects.create_user( + username="timeout_one", + password="StrongPass_2026", + nickname="超时玩家一", + ) + second = User.objects.create_user( + username="timeout_two", + password="StrongPass_2026", + nickname="超时玩家二", + ) + match = join_challenge( + second, + create_challenge(first, realtime_contest).challenge_code, + ) + RealtimeMatch.objects.filter(id=match.id).update( + started_at=timezone.now() - timedelta(seconds=61) + ) + + refreshed = refresh_match_state(match.id) + + assert refreshed.status == RealtimeMatch.Status.COMPLETED + assert not refreshed.attempts.filter(status=ContestAttempt.Status.ACTIVE).exists() + + +@pytest.mark.django_db +def test_challenge_owner_可取消等待中的联机码(realtime_contest): + owner = User.objects.create_user( + username="cancel_owner", + password="StrongPass_2026", + nickname="取消房主", + ) + waiting = create_challenge(owner, realtime_contest) + + cancelled = cancel_waiting_match(owner, waiting.id) + + assert cancelled.status == RealtimeMatch.Status.CANCELLED + with pytest.raises(ValidationError, match="失效"): + join_challenge( + User.objects.create_user( + username="late_guest", + password="StrongPass_2026", + nickname="迟到访客", + ), + waiting.challenge_code, + ) diff --git a/backend/contest/urls.py b/backend/contest/urls.py index a9f664d..d03a63f 100644 --- a/backend/contest/urls.py +++ b/backend/contest/urls.py @@ -3,8 +3,11 @@ from django.urls import path from .views import ( AttemptStartView, AttemptSubmitView, + ChallengeCreateView, + ChallengeJoinView, ContestListView, LeaderboardView, + MatchCancelView, MatchmakingView, MatchStateView, MathGameCatalogView, @@ -16,11 +19,18 @@ from .views import ( urlpatterns = [ path("", ContestListView.as_view(), name="contest-list"), + path("challenges/join/", ChallengeJoinView.as_view(), name="challenge-join"), + path("matches//", MatchStateView.as_view(), name="match-state"), + path("matches//cancel/", MatchCancelView.as_view(), name="match-cancel"), path("/start/", AttemptStartView.as_view(), name="attempt-start"), path("/matchmaking/", MatchmakingView.as_view(), name="matchmaking"), + path( + "/challenges/", + ChallengeCreateView.as_view(), + name="challenge-create", + ), path("/leaderboard/", LeaderboardView.as_view(), name="leaderboard"), path("attempts//submit/", AttemptSubmitView.as_view(), name="attempt-submit"), - path("matches//", MatchStateView.as_view(), name="match-state"), path("games/", MathGameCatalogView.as_view(), name="math-game-catalog"), path("games/history/", MathGameHistoryView.as_view(), name="math-game-history"), path("games//start/", MathGameStartView.as_view(), name="math-game-start"), diff --git a/backend/contest/views.py b/backend/contest/views.py index f9ab9cb..ffcded6 100644 --- a/backend/contest/views.py +++ b/backend/contest/views.py @@ -6,8 +6,12 @@ from rest_framework.views import APIView from .game_services import game_payload, request_sudoku_hint, start_game, submit_game from .models import Contest, ContestAttempt, MathGameAttempt, RealtimeMatch from .services import ( + cancel_waiting_match, + create_challenge, find_match, + join_challenge, match_payload, + refresh_match_state, start_attempt, submit_attempt, ) @@ -44,13 +48,16 @@ class AttemptStartView(APIView): class AttemptSubmitView(APIView): def post(self, request, attempt_id): - get_object_or_404(ContestAttempt, id=attempt_id, user=request.user) + attempt = get_object_or_404(ContestAttempt, id=attempt_id, user=request.user) payload = submit_attempt( user=request.user, attempt_id=attempt_id, raw_answers=request.data.get("answers", []), submission_key=request.headers.get("Idempotency-Key"), ) + if attempt.match_id: + match = refresh_match_state(attempt.match_id) + return Response(match_payload(match, request.user)) return Response(payload) @@ -63,12 +70,33 @@ class MatchmakingView(APIView): class MatchStateView(APIView): def get(self, request, match_id): - match = get_object_or_404( + existing = get_object_or_404( RealtimeMatch.objects.select_related("player_one", "player_two"), id=match_id, ) - if request.user.id not in (match.player_one_id, match.player_two_id): + if request.user.id not in (existing.player_one_id, existing.player_two_id): return Response(status=status.HTTP_403_FORBIDDEN) + match = refresh_match_state(match_id) + return Response(match_payload(match, request.user)) + + +class ChallengeCreateView(APIView): + def post(self, request, slug): + contest = get_object_or_404(Contest, slug=slug) + match = create_challenge(request.user, contest) + return Response(match_payload(match, request.user), status=status.HTTP_201_CREATED) + + +class ChallengeJoinView(APIView): + def post(self, request): + match = join_challenge(request.user, request.data.get("challenge_code")) + return Response(match_payload(match, request.user)) + + +class MatchCancelView(APIView): + def post(self, request, match_id): + get_object_or_404(RealtimeMatch, id=match_id) + match = cancel_waiting_match(request.user, match_id) return Response(match_payload(match, request.user)) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2e33cf1..45de113 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ pytest==8.3.5 pytest-django==4.11.1 pytest-cov==6.2.1 ruff==0.11.13 +daphne==4.1.2 From 1dd828609ef29c1815e6e94ba94df71767b83e3e Mon Sep 17 00:00:00 2001 From: Jacky Date: Sun, 9 Aug 2026 03:08:01 +0800 Subject: [PATCH 2/2] feat: add realtime challenge client and local smoke test --- Makefile | 5 +- README.md | 1 + backend/common/test_frontend_assets.py | 15 + backend/static/css/app.css | 9 + backend/static/js/app.js | 10 +- backend/static/js/realtime.js | 451 +++++++++++++++++++++++++ backend/templates/index.html | 15 + docs/LOCAL_REALTIME_TEST.md | 132 ++++++++ scripts/test_realtime_local.py | 241 +++++++++++++ 9 files changed, 872 insertions(+), 7 deletions(-) create mode 100644 backend/static/js/realtime.js create mode 100644 docs/LOCAL_REALTIME_TEST.md create mode 100644 scripts/test_realtime_local.py diff --git a/Makefile b/Makefile index d8ed05c..19541b9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install migrate seed run test check +.PHONY: install migrate seed run run-asgi test check install: python3 -m venv .venv @@ -14,6 +14,9 @@ seed: run: cd backend && ../.venv/bin/python manage.py runserver +run-asgi: + cd backend && ../.venv/bin/uvicorn config.asgi:application --host 127.0.0.1 --port 8000 --reload + test: .venv/bin/python -m pytest -q diff --git a/README.md b/README.md index 997acec..56bc89c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Gitea 会在 PR 合并到 `main` 后自动测试和部署: - [Ubuntu + 宝塔面板从零部署](docs/BAOTA_UBUNTU_FROM_ZERO.md) - [自动发布机制与运维说明](docs/DEPLOYMENT.md) - [MySQL 8 数据迁移说明](docs/MYSQL8_MIGRATION.md) +- [本地实时 1v1 与联机码约战测试](docs/LOCAL_REALTIME_TEST.md) ## 目录 diff --git a/backend/common/test_frontend_assets.py b/backend/common/test_frontend_assets.py index b5719ea..03e1df0 100644 --- a/backend/common/test_frontend_assets.py +++ b/backend/common/test_frontend_assets.py @@ -55,3 +55,18 @@ def test_toolbox_and_games_资源入口与移动端触控样式存在(): assert ".calculator-controls [hidden]" in styles assert ".sudoku-board" in styles assert "@media (max-width: 700px)" in styles + + +def test_realtime_match_联机码与_websocket_前端资源存在(): + template = (settings.BASE_DIR / "templates" / "index.html").read_text(encoding="utf-8") + realtime = (STATIC_ROOT / "js" / "realtime.js").read_text(encoding="utf-8") + styles = (STATIC_ROOT / "css" / "app.css").read_text(encoding="utf-8") + + assert 'id="challenge-create"' in template + assert 'id="challenge-join-form"' in template + assert template.count("js/realtime.js") == 1 + assert "new WebSocket" in realtime + assert "setInterval(refreshMatch, 2000)" in realtime + assert "Idempotency-Key" in realtime + assert ".challenge-panel" in styles + assert ".realtime-progress-panel" in styles diff --git a/backend/static/css/app.css b/backend/static/css/app.css index c729308..02793c9 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -132,6 +132,14 @@ button { color: inherit; } .track-switch { display: flex; gap: 5px; margin-bottom: 22px; } .track-switch button { border: 1px solid var(--line); padding: 9px 18px; border-radius: 99px; background: transparent; cursor: pointer; } .track-switch button.active { background: var(--ink); color: white; } +.challenge-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(380px, .9fr); gap: 24px; align-items: center; margin-bottom: 22px; padding: 24px 26px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(120deg, rgba(25,101,72,.07), rgba(204,232,91,.12)); }.challenge-panel h2 { margin: 7px 0 6px; font: 27px Georgia, serif; }.challenge-panel p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.challenge-actions { display: grid; gap: 9px; }.challenge-actions > .primary-button { width: 100%; }.challenge-actions form { display: grid; grid-template-columns: 1fr auto; gap: 8px; }.challenge-actions input { min-width: 0; border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; background: white; text-transform: uppercase; letter-spacing: .18em; font-weight: 700; }.challenge-actions .dark-button { width: auto; margin: 0; white-space: nowrap; } +.realtime-status-line { display: flex; justify-content: space-between; gap: 15px; margin: 10px 0 18px; color: var(--muted); font-size: 11px; }.realtime-status-line strong { color: var(--green); } +.realtime-waiting { display: grid; justify-items: center; gap: 16px; padding: 35px 20px; border-radius: 18px; background: #eef1eb; text-align: center; }.realtime-waiting p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.7; }.realtime-pulse { width: 18px; height: 18px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(25,101,72,.35); animation: realtime-pulse 1.5s infinite; }.challenge-code { border: 1px dashed var(--green); border-radius: 14px; padding: 14px 24px; background: white; color: var(--green); font: 700 31px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .22em; cursor: pointer; } +@keyframes realtime-pulse { 70% { box-shadow: 0 0 0 15px rgba(25,101,72,0); } 100% { box-shadow: 0 0 0 0 rgba(25,101,72,0); } } +.realtime-progress-panel { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }.realtime-progress-panel > div { padding: 13px 15px; border-radius: 12px; background: #eef1eb; }.realtime-progress-panel span, .realtime-progress-panel b { display: block; }.realtime-progress-panel span { color: var(--muted); font-size: 10px; }.realtime-progress-panel b { margin-top: 5px; color: var(--green); } +.realtime-answer-form input { margin-top: 7px; width: 100%; padding: 12px; border: 1px solid #d6d8d1; border-radius: 9px; }.realtime-submitted { margin-top: 20px; padding: 30px; border-radius: 16px; background: var(--ink); color: white; text-align: center; }.realtime-submitted strong { color: var(--lime); font: 27px Georgia, serif; }.realtime-submitted p { margin-bottom: 0; color: #b9c2bc; } +.realtime-result { margin: 20px 0; padding: 28px; border-radius: 17px; background: var(--ink); color: white; text-align: center; }.realtime-result > strong { color: var(--lime); font: 38px Georgia, serif; }.realtime-result p { color: #c5cec8; }.realtime-result > b { display: inline-block; padding: 6px 10px; border-radius: 99px; background: rgba(204,232,91,.13); color: var(--lime); }.result-opponent > strong { color: #ef947c; } +.realtime-review { display: grid; gap: 9px; }.realtime-review article { padding: 15px; border: 1px solid var(--line); border-left: 4px solid var(--green); border-radius: 11px; background: white; }.realtime-review article.incorrect { border-left-color: #c05245; }.realtime-review p { margin: 7px 0; color: #3e4942; }.realtime-review small { color: var(--muted); } .math-games-section { margin-top: 60px; } .game-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .game-card { min-height: 285px; padding: 26px; border: 1px solid var(--line); border-radius: 20px; background: var(--panel); display: flex; flex-direction: column; overflow: hidden; position: relative; } @@ -262,6 +270,7 @@ dialog::backdrop { background: rgba(17,25,20,.55); backdrop-filter: blur(5px); } .content-grid { gap: 11px; }.editor-shell { min-height: 700px; }.page-title { padding-top: 42px; }.page-title h1 { font-size: 45px; } .tool-card { min-height: 125px; }.workspace-heading, .map-heading { display: block; }.workspace-heading p { margin-top: 12px; }.tool-workspace { padding: 16px; }.calculator-controls { grid-template-columns: 1fr 1fr; }.calculator-display { min-height: 120px; padding: 18px; }.calculator-display output { font-size: 34px; }.calc-examples .primary-button { width: 100%; margin-left: 0; }.drawing-toolbar { align-items: stretch; }.drawing-toolbar .primary-button { width: 100%; margin-left: 0; }.canvas-stage canvas { width: 100%; min-width: 0; } .math-games-section { margin-top: 45px; }.game-card { min-height: 260px; padding: 21px; }.game-card-controls { align-items: stretch; }.game-card-controls .primary-button { flex: 1; }.twenty-four-numbers { gap: 7px; }.twenty-four-numbers button { border-radius: 13px; font-size: 28px; }.sudoku-board input { font-size: clamp(13px, 4.5vw, 20px); }.sudoku-actions { display: grid; grid-template-columns: 1fr 1fr; }.game-keypad { gap: 5px; } + .challenge-panel { grid-template-columns: 1fr; padding: 20px; }.challenge-actions form { grid-template-columns: 1fr; }.challenge-actions .dark-button { width: 100%; }.challenge-code { width: 100%; padding: 14px 10px; font-size: 27px; }.realtime-status-line { display: grid; }.realtime-progress-panel { grid-template-columns: 1fr 1fr; } .map-stage { height: 480px; transform: scale(.92); }.ability-node { width: 108px; height: 94px; }.node-vision { left: calc(50% - 54px); }.node-humanities { left: 0; top: 100px; }.node-connection { right: 0; top: 100px; }.node-detection { left: 2%; bottom: 30px; }.node-modeling { right: 2%; bottom: 30px; }.pet-node { top: 190px; } .map-lines { display: none; }.ability-legend { margin-top: 14px; justify-content: start; }.video-cover { height: 150px; } } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 09cfbe3..f667b21 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -308,12 +308,8 @@ async function beginContest(contest) { if (!requireAuth()) return; try { if (contest.kind === "realtime") { - const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {} }); - if (match.status === "waiting") { - showToast("已进入匹配队列,等待同赛道对手"); - return; - } - renderAttempt(match.attempt); + await window.HuluRealtime.startRandom(contest); + return; } else { renderAttempt(await api(`contests/${contest.slug}/start/`, { method: "POST", body: {} })); } @@ -951,6 +947,7 @@ function bindUI() { $("#auth-button").addEventListener("click", async () => { if (!state.user) return openAuth(); await api("accounts/logout/", { method: "POST", body: {} }); + window.HuluRealtime?.reset(); state.user = null; updateUserUI(); showToast("已退出登录"); @@ -996,6 +993,7 @@ function bindUI() { async function boot() { bindUI(); window.HuluToolbox?.init(); + window.HuluRealtime?.init(); renderSymbols(); await Promise.all([ loadUser(), diff --git a/backend/static/js/realtime.js b/backend/static/js/realtime.js new file mode 100644 index 0000000..aeee0a4 --- /dev/null +++ b/backend/static/js/realtime.js @@ -0,0 +1,451 @@ +(function () { + const realtime = { + match: null, + socket: null, + pollTimer: null, + clockTimer: null, + opponentProgress: 0, + reconnectTimer: null, + }; + + function stopTimers() { + if (realtime.pollTimer) window.clearInterval(realtime.pollTimer); + if (realtime.clockTimer) window.clearInterval(realtime.clockTimer); + realtime.pollTimer = null; + realtime.clockTimer = null; + } + + function closeSocket() { + if (realtime.reconnectTimer) window.clearTimeout(realtime.reconnectTimer); + realtime.reconnectTimer = null; + if (realtime.socket) { + realtime.socket.onclose = null; + realtime.socket.close(); + } + realtime.socket = null; + } + + function websocketUrl(path) { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}${path}`; + } + + function connectSocket() { + closeSocket(); + if (!realtime.match?.websocket_path) return; + const socket = new WebSocket(websocketUrl(realtime.match.websocket_path)); + realtime.socket = socket; + socket.addEventListener("open", () => { + updateConnectionStatus("实时连接已建立"); + socket.send(JSON.stringify({ type: "ping" })); + }); + socket.addEventListener("message", async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if (message.type === "state") { + await refreshMatch(); + } else if ( + message.type === "progress" && + message.user_id !== String(state.user?.id) + ) { + realtime.opponentProgress = message.answered_count; + updateProgressUI(); + } + }); + socket.addEventListener("close", () => { + updateConnectionStatus("实时连接中断,正在使用轮询"); + if (["waiting", "active"].includes(realtime.match?.status)) { + realtime.reconnectTimer = window.setTimeout(connectSocket, 2000); + } + }); + socket.addEventListener("error", () => { + updateConnectionStatus("WebSocket 不可用,轮询仍在工作"); + }); + } + + function updateConnectionStatus(message) { + const node = document.querySelector("#realtime-connection"); + if (node) node.textContent = message; + } + + async function refreshMatch() { + if (!realtime.match?.match_id) return; + try { + const match = await api(`contests/matches/${realtime.match.match_id}/`); + const previous = realtime.match; + const previousStatus = previous.status; + realtime.match = match; + const shouldRender = + previous.status !== match.status || + previous.attempt?.status !== match.attempt?.status; + if (shouldRender) renderMatch(); + else { + updateClock(); + if ( + previous.opponent?.status !== match.opponent?.status && + match.opponent?.status && + match.opponent.status !== "active" + ) { + realtime.opponentProgress = match.attempt?.questions.length || 0; + updateProgressUI(); + updateConnectionStatus("对手已提交,完成后将立即结算"); + } + } + if (previousStatus === "waiting" && match.status === "active") { + showToast(`已匹配到 ${match.opponent.nickname}`); + } + if (!["waiting", "active"].includes(match.status)) { + stopTimers(); + closeSocket(); + } + } catch (error) { + if (error.status === 404) { + stopTimers(); + closeSocket(); + } + } + } + + function startPolling() { + stopTimers(); + realtime.pollTimer = window.setInterval(refreshMatch, 2000); + realtime.clockTimer = window.setInterval(updateClock, 250); + } + + function openMatch(match) { + realtime.match = match; + realtime.opponentProgress = 0; + renderMatch(); + const dialog = document.querySelector("#experience-dialog"); + if (!dialog.open) dialog.showModal(); + connectSocket(); + if (["waiting", "active"].includes(match.status)) startPolling(); + } + + function header(root, match) { + const label = document.createElement("span"); + label.className = "kicker"; + label.textContent = + `${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`; + const title = document.createElement("h2"); + title.textContent = match.contest; + const status = document.createElement("div"); + status.className = "realtime-status-line"; + const connection = document.createElement("span"); + connection.id = "realtime-connection"; + connection.textContent = "正在建立实时连接…"; + const clock = document.createElement("strong"); + clock.id = "realtime-clock"; + status.append(connection, clock); + root.append(label, title, status); + } + + function updateClock() { + const clock = document.querySelector("#realtime-clock"); + if (!clock || !realtime.match) return; + if (realtime.match.status === "waiting") { + const expiresAt = new Date(realtime.match.expires_at).getTime(); + const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); + clock.textContent = `联机码 ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} 后失效`; + return; + } + if (realtime.match.status === "active") { + const startedAt = new Date(realtime.match.started_at).getTime(); + const elapsed = (Date.now() - startedAt) / 1000; + const remaining = Math.max(0, Math.ceil(realtime.match.duration_seconds - elapsed)); + clock.textContent = `剩余 ${remaining} 秒`; + if (remaining === 0) refreshMatch(); + return; + } + clock.textContent = ""; + } + + async function copyCode(code) { + try { + await navigator.clipboard.writeText(code); + showToast(`联机码 ${code} 已复制`); + } catch { + showToast(`联机码:${code}`); + } + } + + function renderWaiting(root, match) { + const panel = document.createElement("div"); + panel.className = "realtime-waiting"; + const pulse = document.createElement("span"); + pulse.className = "realtime-pulse"; + const message = document.createElement("p"); + message.textContent = + match.match_type === "challenge" + ? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。" + : "正在寻找同赛道、相近 Rating 的玩家。"; + panel.append(pulse, message); + if (match.challenge_code) { + const code = document.createElement("button"); + code.className = "challenge-code"; + code.textContent = match.challenge_code; + code.title = "点击复制联机码"; + code.addEventListener("click", () => copyCode(match.challenge_code)); + panel.append(code); + } + if (match.is_owner) { + const cancel = document.createElement("button"); + cancel.className = "ghost-button"; + cancel.textContent = "取消等待"; + cancel.addEventListener("click", async () => { + cancel.disabled = true; + try { + realtime.match = await api(`contests/matches/${match.match_id}/cancel/`, { + method: "POST", + body: {}, + }); + renderMatch(); + stopTimers(); + closeSocket(); + } catch (error) { + showToast(error.message); + cancel.disabled = false; + } + }); + panel.append(cancel); + } + root.append(panel); + updateClock(); + } + + function progressPanel(root, match) { + const panel = document.createElement("div"); + panel.className = "realtime-progress-panel"; + const self = document.createElement("div"); + self.innerHTML = `0 / ${match.attempt.questions.length}`; + const opponent = document.createElement("div"); + opponent.innerHTML = + `${match.opponent?.nickname || "对手"}` + + `${realtime.opponentProgress} / ${match.attempt.questions.length}`; + panel.append(self, opponent); + root.append(panel); + } + + function updateProgressUI(selfCount) { + if (Number.isInteger(selfCount)) { + const self = document.querySelector("#self-progress"); + if (self && realtime.match?.attempt) { + self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`; + } + } + const opponent = document.querySelector("#opponent-progress"); + if (opponent && realtime.match?.attempt) { + opponent.textContent = + `${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`; + } + } + + function sendProgress(answeredCount) { + if (realtime.socket?.readyState === WebSocket.OPEN) { + realtime.socket.send( + JSON.stringify({ type: "progress", answered_count: answeredCount }) + ); + } + } + + function renderActive(root, match) { + progressPanel(root, match); + const attempt = match.attempt; + if (attempt.status !== "active") { + const waiting = document.createElement("div"); + waiting.className = "realtime-submitted"; + waiting.innerHTML = + "答案已锁定

等待对手提交。双方完成后才会公开答案和 Rating 变化。

"; + root.append(waiting); + updateClock(); + return; + } + const form = document.createElement("form"); + form.className = "choice-list realtime-answer-form"; + attempt.questions.forEach((question) => { + const field = document.createElement("label"); + field.textContent = `${question.order}. ${question.prompt}`; + const input = document.createElement("input"); + input.name = String(question.order); + input.inputMode = "decimal"; + input.autocomplete = "off"; + input.addEventListener("input", () => { + const answered = [...form.querySelectorAll("input")].filter( + (item) => item.value.trim() + ).length; + updateProgressUI(answered); + sendProgress(answered); + }); + field.append(input); + form.append(field); + }); + const submit = document.createElement("button"); + submit.className = "primary-button"; + submit.type = "submit"; + submit.textContent = "提交并等待对手"; + form.append(submit); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + const data = new FormData(form); + try { + realtime.match = await api( + `contests/attempts/${attempt.attempt_id}/submit/`, + { + method: "POST", + headers: { "Idempotency-Key": createIdempotencyKey() }, + body: { + answers: attempt.questions.map((question) => ({ + order: question.order, + answer: data.get(String(question.order)) || "", + })), + }, + }, + ); + renderMatch(); + } catch (error) { + showToast(error.message); + submit.disabled = false; + } + }); + root.append(form); + updateClock(); + } + + function renderCompleted(root, match) { + const result = document.createElement("section"); + result.className = `realtime-result result-${match.result.winner}`; + const outcome = document.createElement("strong"); + outcome.textContent = + match.result.winner === "self" + ? "获胜" + : match.result.winner === "opponent" + ? "本局惜败" + : "平局"; + const score = document.createElement("p"); + score.textContent = + `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`; + const rating = document.createElement("b"); + const sign = match.result.rating_delta > 0 ? "+" : ""; + rating.textContent = + `Rating ${sign}${match.result.rating_delta} → ${match.result.rating_after}`; + result.append(outcome, score, rating); + root.append(result); + + const review = document.createElement("div"); + review.className = "realtime-review"; + match.attempt.questions.forEach((question) => { + const item = document.createElement("article"); + const title = document.createElement("b"); + title.textContent = `${question.order}. ${question.prompt}`; + const answer = document.createElement("p"); + answer.textContent = + `你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`; + const explanation = document.createElement("small"); + explanation.textContent = question.explanation || ""; + item.className = question.is_correct ? "correct" : "incorrect"; + item.append(title, answer, explanation); + review.append(item); + }); + root.append(review); + } + + function renderMatch() { + const root = document.querySelector("#experience-content"); + const match = realtime.match; + root.replaceChildren(); + header(root, match); + if (match.status === "waiting") renderWaiting(root, match); + else if (match.status === "active") renderActive(root, match); + else if (match.status === "completed") renderCompleted(root, match); + else { + const message = document.createElement("p"); + message.className = "scene"; + message.textContent = "这场匹配已取消或联机码已过期。"; + root.append(message); + } + } + + function currentRealtimeContest() { + return state.contests.find( + (contest) => contest.kind === "realtime" && contest.track === state.track + ); + } + + async function startRandom(contest) { + const match = await api(`contests/${contest.slug}/matchmaking/`, { + method: "POST", + body: {}, + }); + openMatch(match); + } + + async function createChallenge() { + if (!requireAuth()) return; + const contest = currentRealtimeContest(); + if (!contest) { + showToast("当前赛道没有可用的实时比赛"); + return; + } + try { + const match = await api(`contests/${contest.slug}/challenges/`, { + method: "POST", + body: {}, + }); + openMatch(match); + } catch (error) { + showToast(error.message); + } + } + + async function joinChallenge(event) { + event.preventDefault(); + if (!requireAuth()) return; + const input = document.querySelector("#challenge-code-input"); + const challengeCode = input.value.trim().toUpperCase(); + if (challengeCode.length !== 6) { + showToast("请输入 6 位联机码"); + return; + } + try { + const match = await api("contests/challenges/join/", { + method: "POST", + body: { challenge_code: challengeCode }, + }); + input.value = ""; + openMatch(match); + } catch (error) { + showToast(error.message); + } + } + + function init() { + document + .querySelector("#challenge-create") + .addEventListener("click", createChallenge); + document + .querySelector("#challenge-join-form") + .addEventListener("submit", joinChallenge); + document + .querySelector("#challenge-code-input") + .addEventListener("input", (event) => { + event.target.value = event.target.value + .toUpperCase() + .replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "") + .slice(0, 6); + }); + } + + function reset() { + stopTimers(); + closeSocket(); + realtime.match = null; + realtime.opponentProgress = 0; + } + + window.HuluRealtime = { init, startRandom, refreshMatch, reset }; +})(); diff --git a/backend/templates/index.html b/backend/templates/index.html index d1629bf..67d38d7 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -89,6 +89,20 @@
+
+
+ FRIEND CHALLENGE +

联机码约战

+

创建 6 位联机码发给朋友,或输入朋友的联机码加入同一场对局。

+
+
+ +
+ + +
+
+
正在读取比赛…
@@ -305,6 +319,7 @@
{% csrf_token %}
+ diff --git a/docs/LOCAL_REALTIME_TEST.md b/docs/LOCAL_REALTIME_TEST.md new file mode 100644 index 0000000..f690f97 --- /dev/null +++ b/docs/LOCAL_REALTIME_TEST.md @@ -0,0 +1,132 @@ +# 本地实时 1v1 与联机码约战测试 + +本指南用于在一台电脑上使用两个浏览器会话验证完整联机流程。 + +## 1. 准备数据 + +```bash +make install +make migrate +make seed +``` + +种子数据会创建三个赛道的实时 1v1 比赛。本地邀请码为: + +```text +HULU2026 +``` + +## 2. 使用 ASGI 启动 + +实时比赛依赖 WebSocket。不要使用普通 WSGI 服务测试联机。 + +```bash +make run-asgi +``` + +访问: + +```text +http://127.0.0.1:8000/ +``` + +本地没有配置 `REDIS_URL` 时会使用进程内 Channel Layer,适合单进程开发测试。生产环境必须使用 Redis。 + +### 一键端到端验证 + +保持 `make run-asgi` 运行,另开终端执行: + +```bash +.venv/bin/python scripts/test_realtime_local.py +``` + +脚本会临时创建两个本地账号,通过真实 HTTP Session 和两个真实 WebSocket 完成: + +```text +创建联机码 +→ 第二位玩家加入 +→ matched 状态推送 +→ 答题进度同步 +→ 第一位提交且不泄露答案 +→ 第二位提交 +→ completed 推送 +→ 胜负、Rating 和解析检查 +``` + +结束后脚本自动清理临时用户和比赛记录。 + +## 3. 准备两个独立登录会话 + +使用下列任一组合: + +- Chrome 普通窗口 + 无痕窗口 +- Chrome + Safari +- 两个不同浏览器 Profile + +两个窗口分别使用邀请码 `HULU2026` 注册不同账号。不要在同一浏览器 Profile 的两个普通标签页登录不同账号,因为它们会共享 Session Cookie。 + +## 4. 联机码约战 + +玩家 A: + +1. 打开“比赛”。 +2. 选择双方约定的赛道。 +3. 点击“创建当前赛道约战”。 +4. 复制 6 位联机码。 + +玩家 B: + +1. 打开“比赛”。 +2. 输入联机码。 +3. 点击“加入约战”。 + +预期结果: + +- 玩家 A 无需再次点击,自动进入答题。 +- 双方显示相同题目和同一个倒计时。 +- 任一方填写答案时,另一方看到答题数量变化。 +- 第一位提交者只看到“答案已锁定”,看不到正确答案。 +- 双方提交或倒计时结束后,同时展示胜负、双方分数、Rating 变化和题目解析。 + +## 5. 随机匹配 + +双方选择同一赛道并点击“开始匹配”。 + +预期结果: + +- 第一位玩家进入等待状态。 +- 第二位玩家加入后,第一位玩家自动进入答题。 +- 私人联机码房间不会被随机匹配玩家加入。 + +## 6. 断线与超时 + +验证以下场景: + +1. 答题时短暂关闭网络,再恢复。 +2. WebSocket 断开后页面仍每 2 秒轮询比赛状态。 +3. 关闭其中一个窗口,另一方等待倒计时结束。 +4. 服务端到时后将未提交 Attempt 标记为过期并完成结算。 +5. 等待中的联机码 10 分钟后失效。 + +## 7. 排查 + +浏览器开发者工具应看到: + +```text +WS /ws/v1/contest/matches// +GET /api/v1/contests/matches// +``` + +检查 Redis: + +```bash +redis-cli ping +``` + +检查 ASGI: + +```bash +curl http://127.0.0.1:8000/health/ +``` + +生产 Nginx 必须为 `/ws/` 设置 `Upgrade` 和 `Connection` 请求头。详见 `docs/DEPLOYMENT.md`。 diff --git a/scripts/test_realtime_local.py b/scripts/test_realtime_local.py new file mode 100644 index 0000000..23e24f0 --- /dev/null +++ b/scripts/test_realtime_local.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import http.cookiejar +import json +import os +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +from django.db.models import Q + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "backend")) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +import django # noqa: E402 + +django.setup() + +from django.conf import settings # noqa: E402 +from websockets.asyncio.client import connect # noqa: E402 + +from accounts.models import User # noqa: E402 +from contest.models import ( # noqa: E402 + Contest, + ContestAttempt, + RatingHistory, + RealtimeMatch, +) + + +class ApiClient: + def __init__(self, base_url): + self.base_url = base_url.rstrip("/") + self.cookies = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.cookies) + ) + + def request(self, method, path, payload=None, extra_headers=None): + body = json.dumps(payload).encode() if payload is not None else None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + csrf_token = self.cookie("csrftoken") + if csrf_token: + headers["X-CSRFToken"] = csrf_token + headers.update(extra_headers or {}) + request = urllib.request.Request( + f"{self.base_url}{path}", + data=body, + headers=headers, + method=method, + ) + try: + with self.opener.open(request, timeout=10) as response: + content = response.read() + if not content: + return None + if "application/json" in response.headers.get("Content-Type", ""): + return json.loads(content) + return content.decode() + except urllib.error.HTTPError as exc: + content = exc.read().decode() + raise RuntimeError(f"{method} {path} -> HTTP {exc.code}: {content}") from exc + + def get(self, path): + return self.request("GET", path) + + def post(self, path, payload, extra_headers=None): + return self.request("POST", path, payload, extra_headers) + + def cookie(self, name): + return next((item.value for item in self.cookies if item.name == name), "") + + @property + def cookie_header(self): + return "; ".join(f"{item.name}={item.value}" for item in self.cookies) + + +async def receive_until(websocket, event_type, reason=None): + for _ in range(10): + payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5)) + if payload.get("type") == event_type and ( + reason is None or payload.get("reason") == reason + ): + return payload + raise RuntimeError(f"未收到 WebSocket 事件: type={event_type}, reason={reason}") + + +def websocket_url(base_url, path): + parsed = urlparse(base_url) + scheme = "wss" if parsed.scheme == "https" else "ws" + return f"{scheme}://{parsed.netloc}{path}" + + +async def run_flow(base_url, first_client, second_client, contest): + created = first_client.post( + f"/api/v1/contests/{contest.slug}/challenges/", + {}, + ) + code = created["challenge_code"] + print(f"[1/7] 玩家 A 创建联机码: {code}") + + async with connect( + websocket_url(base_url, created["websocket_path"]), + additional_headers={"Cookie": first_client.cookie_header}, + open_timeout=10, + ) as first_socket: + await receive_until(first_socket, "connected") + joined = second_client.post( + "/api/v1/contests/challenges/join/", + {"challenge_code": code}, + ) + await receive_until(first_socket, "state", "matched") + print("[2/7] 玩家 B 加入,玩家 A 收到 matched 事件") + + first_state = first_client.get( + f"/api/v1/contests/matches/{created['match_id']}/" + ) + if first_state["status"] != "active": + raise RuntimeError("匹配后状态不是 active") + + async with connect( + websocket_url(base_url, joined["websocket_path"]), + additional_headers={"Cookie": second_client.cookie_header}, + open_timeout=10, + ) as second_socket: + await receive_until(second_socket, "connected") + await first_socket.send( + json.dumps({"type": "progress", "answered_count": 1}) + ) + progress = await receive_until(second_socket, "progress") + if progress["answered_count"] != 1: + raise RuntimeError("答题进度同步失败") + print("[3/7] 两个 WebSocket 已连接,答题进度同步成功") + + first_attempt = first_state["attempt"] + second_attempt = joined["attempt"] + first_answers = [ + {"order": item["order"], "answer": "0"} + for item in first_attempt["questions"] + ] + second_answers = [ + {"order": item["order"], "answer": "0"} + for item in second_attempt["questions"] + ] + first_result = first_client.post( + f"/api/v1/contests/attempts/{first_attempt['attempt_id']}/submit/", + {"answers": first_answers}, + {"Idempotency-Key": f"local-first-{created['match_id']}"}, + ) + if first_result["status"] != "active": + raise RuntimeError("首位玩家提交后比赛不应立即完成") + if "correct_answer" in first_result["attempt"]["questions"][0]: + raise RuntimeError("首位玩家提前看到了正确答案") + await receive_until(second_socket, "state", "submitted") + print("[4/7] 玩家 A 提交后答案锁定,未提前泄露正确答案") + + second_result = second_client.post( + f"/api/v1/contests/attempts/{second_attempt['attempt_id']}/submit/", + {"answers": second_answers}, + {"Idempotency-Key": f"local-second-{created['match_id']}"}, + ) + if second_result["status"] != "completed": + raise RuntimeError("双方提交后比赛没有完成") + await receive_until(first_socket, "state", "completed") + print("[5/7] 玩家 B 提交后双方收到 completed 事件") + + final_state = first_client.get( + f"/api/v1/contests/matches/{created['match_id']}/" + ) + if "correct_answer" not in final_state["attempt"]["questions"][0]: + raise RuntimeError("完成后没有公开题目解析") + if final_state["result"] is None: + raise RuntimeError("完成后没有胜负与 Rating 结果") + print("[6/7] 最终比分、Rating 和题目解析均可读取") + print("[7/7] 本地联机码约战端到端测试通过") + + +def cleanup(users): + user_ids = [user.id for user in users] + matches = RealtimeMatch.objects.filter( + Q(player_one_id__in=user_ids) | Q(player_two_id__in=user_ids) + ) + match_ids = list(matches.values_list("id", flat=True)) + ContestAttempt.objects.filter( + Q(user_id__in=user_ids) | Q(match_id__in=match_ids) + ).delete() + RatingHistory.objects.filter(match_id__in=match_ids).delete() + matches.delete() + User.objects.filter(id__in=user_ids).delete() + + +def main(): + parser = argparse.ArgumentParser(description="本地实时联机码约战端到端测试") + parser.add_argument("--base-url", default="http://127.0.0.1:8000") + args = parser.parse_args() + hostname = urlparse(args.base_url).hostname + if not settings.DEBUG or hostname not in {"127.0.0.1", "localhost"}: + raise RuntimeError("该脚本只允许在 DEBUG=true 的本机地址运行") + stamp = str(int(time.time() * 1000)) + password = "LocalRealtime2026!" + users = [ + User.objects.create_user( + username=f"local_ws_a_{stamp}", + password=password, + nickname="本地联机 A", + ), + User.objects.create_user( + username=f"local_ws_b_{stamp}", + password=password, + nickname="本地联机 B", + ), + ] + try: + contest = Contest.objects.filter( + kind=Contest.Kind.REALTIME, + status=Contest.Status.PUBLISHED, + track="standard", + ).first() + if contest is None: + raise RuntimeError("缺少标准赛道实时比赛,请先执行 make seed") + clients = [ApiClient(args.base_url), ApiClient(args.base_url)] + for client, user in zip(clients, users): + client.get("/") + client.post( + "/api/v1/accounts/login/", + {"username": user.username, "password": password}, + ) + asyncio.run(run_flow(args.base_url, clients[0], clients[1], contest)) + finally: + cleanup(users) + + +if __name__ == "__main__": + main()