v0.2 Preview: Construction Plan.
CI / test (push) Failing after 1m31s

This commit is contained in:
2026-08-08 21:25:27 +08:00
parent b88d15698a
commit 1f98f7152d
822 changed files with 96959 additions and 8 deletions
View File
+47
View File
@@ -0,0 +1,47 @@
from django.contrib import admin
from .models import (
CheatFlag,
Contest,
ContestAnswer,
ContestAttempt,
ContestQuestion,
LeaderboardSnapshot,
Question,
QuestionVersion,
RatingHistory,
RealtimeMatch,
)
class ContestQuestionInline(admin.TabularInline):
model = ContestQuestion
extra = 1
@admin.register(Contest)
class ContestAdmin(admin.ModelAdmin):
list_display = ("title", "kind", "track", "status", "starts_at", "ends_at")
list_filter = ("kind", "track", "status")
inlines = [ContestQuestionInline]
@admin.register(ContestAttempt)
class ContestAttemptAdmin(admin.ModelAdmin):
list_display = ("id", "user", "contest", "status", "score", "duration_ms", "started_at")
list_filter = ("status", "contest__kind", "contest__track")
readonly_fields = ("started_at", "submitted_at")
@admin.register(CheatFlag)
class CheatFlagAdmin(admin.ModelAdmin):
list_display = ("attempt", "reason", "status", "created_at")
list_filter = ("status", "reason")
admin.site.register(Question)
admin.site.register(QuestionVersion)
admin.site.register(ContestAnswer)
admin.site.register(RealtimeMatch)
admin.site.register(RatingHistory)
admin.site.register(LeaderboardSnapshot)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'contest'
+54
View File
@@ -0,0 +1,54 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from .models import RealtimeMatch
class MatchConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.match_id = self.scope["url_route"]["kwargs"]["match_id"]
self.group_name = f"match_{self.match_id}"
user = self.scope["user"]
if not user.is_authenticated or not await self._is_participant(user.id):
await self.close(code=4403)
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
await self.send_json({"type": "connected", "match_id": str(self.match_id)})
async def disconnect(self, close_code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
event_type = content.get("type")
if event_type == "ping":
await self.send_json({"type": "pong"})
return
if event_type == "progress":
answered_count = max(0, min(int(content.get("answered_count", 0)), 100))
await self.channel_layer.group_send(
self.group_name,
{
"type": "match.progress",
"user_id": str(self.scope["user"].id),
"answered_count": answered_count,
},
)
async def match_progress(self, event):
await self.send_json(
{
"type": "progress",
"user_id": event["user_id"],
"answered_count": event["answered_count"],
}
)
@database_sync_to_async
def _is_participant(self, user_id):
return RealtimeMatch.objects.filter(id=self.match_id).filter(
player_one_id=user_id
).exists() or RealtimeMatch.objects.filter(
id=self.match_id, player_two_id=user_id
).exists()
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,112 @@
from django.core.management.base import BaseCommand
from contest.models import Contest, ContestQuestion, Question, QuestionVersion
QUESTIONS = {
Question.Track.BEGINNER: [
("b-12-plus-19", "12 + 19", "31"),
("b-8-times-7", "8 × 7", "56"),
("b-90-minus-37", "90 - 37", "53"),
("b-144-div-12", "144 ÷ 12", "12"),
("b-25-times-4", "25 × 4", "100"),
],
Question.Track.STANDARD: [
("s-17-times-23", "17 × 23", "391"),
("s-625-div-25", "625 ÷ 25", "25"),
("s-48-times-15", "48 × 15", "720"),
("s-1000-minus-387", "1000 - 387", "613"),
("s-35-squared", "35²", "1225"),
],
Question.Track.ADVANCED: [
("a-mod-2pow10", "2¹⁰ 除以 7 的余数", "2"),
("a-sum-1-50", "1 到 50 的整数和", "1275"),
("a-15-choose-2", "C(15,2)", "105"),
("a-sqrt-2025", "√2025", "45"),
("a-3pow6", "3⁶", "729"),
],
}
class Command(BaseCommand):
help = "创建首批分层题目、实时 1v1、每日赛和单人练习"
def handle(self, *args, **options):
versions = {}
for track, questions in QUESTIONS.items():
versions[track] = []
for slug, prompt, answer in questions:
question, _ = Question.objects.update_or_create(
slug=slug,
defaults={"track": track, "tags": ["口算"], "is_active": True},
)
version, _ = QuestionVersion.objects.update_or_create(
question=question,
version=1,
defaults={
"prompt": prompt,
"answer": answer,
"explanation": f"答案为 {answer}",
},
)
versions[track].append(version)
contest_specs = []
for track, label in (
(Question.Track.BEGINNER, "入门"),
(Question.Track.STANDARD, "标准"),
(Question.Track.ADVANCED, "进阶"),
):
contest_specs.extend(
[
(
f"realtime-{track}",
f"{label}实时 1v1",
Contest.Kind.REALTIME,
track,
60,
),
(
f"daily-{track}",
f"{label}今日挑战",
Contest.Kind.DAILY,
track,
180,
),
(
f"practice-{track}",
f"{label}单人闯关",
Contest.Kind.PRACTICE,
track,
300,
),
]
)
for slug, title, kind, track, duration in contest_specs:
contest, _ = Contest.objects.update_or_create(
slug=slug,
defaults={
"title": title,
"kind": kind,
"track": track,
"duration_seconds": duration,
"status": Contest.Status.PUBLISHED,
},
)
ContestQuestion.objects.filter(contest=contest).delete()
ContestQuestion.objects.bulk_create(
[
ContestQuestion(
contest=contest,
question_version=version,
order=index,
points=100,
)
for index, version in enumerate(versions[track], start=1)
]
)
self.stdout.write(
self.style.SUCCESS(
f"已创建 {sum(map(len, QUESTIONS.values()))} 道题和 {len(contest_specs)} 场比赛"
)
)
+166
View File
@@ -0,0 +1,166 @@
# Generated by Django 4.2.23 on 2026-08-08 11:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Contest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(unique=True)),
('title', models.CharField(max_length=120)),
('kind', models.CharField(choices=[('realtime', '实时 1v1'), ('daily', '今日挑战'), ('practice', '单人闯关'), ('weekly', '主题周赛')], max_length=16)),
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('closed', '已结束')], default='draft', max_length=16)),
('duration_seconds', models.PositiveIntegerField(default=60)),
('starts_at', models.DateTimeField(blank=True, null=True)),
('ends_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(unique=True)),
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
('tags', models.JSONField(blank=True, default=list)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='RealtimeMatch',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('waiting', '等待对手'), ('active', '进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='waiting', max_length=16)),
('player_one_rating', models.PositiveIntegerField()),
('player_two_rating', models.PositiveIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contest')),
('player_one', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_one', to=settings.AUTH_USER_MODEL)),
('player_two', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='matches_as_player_two', to=settings.AUTH_USER_MODEL)),
('winner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='won_matches', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='RatingHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating_before', models.PositiveIntegerField()),
('rating_after', models.PositiveIntegerField()),
('delta', models.SmallIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('match', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rating_changes', to='contest.realtimematch')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rating_history', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='QuestionVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('prompt', models.TextField()),
('answer', models.CharField(max_length=200)),
('explanation', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='contest.question')),
],
),
migrations.CreateModel(
name='LeaderboardSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], max_length=16)),
('period', models.CharField(max_length=40)),
('rankings', models.JSONField(default=list)),
('generated_at', models.DateTimeField(auto_now_add=True)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contest.contest')),
],
),
migrations.CreateModel(
name='ContestQuestion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField()),
('points', models.PositiveIntegerField(default=100)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_questions', to='contest.contest')),
('question_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.questionversion')),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='ContestAttempt',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('active', '进行中'), ('submitted', '已提交'), ('expired', '已超时')], default='active', max_length=16)),
('score', models.PositiveIntegerField(default=0)),
('correct_count', models.PositiveIntegerField(default=0)),
('answer_count', models.PositiveIntegerField(default=0)),
('duration_ms', models.PositiveIntegerField(default=0)),
('submission_key', models.CharField(blank=True, max_length=80, null=True)),
('started_at', models.DateTimeField(auto_now_add=True)),
('submitted_at', models.DateTimeField(blank=True, null=True)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.contest')),
('match', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='attempts', to='contest.realtimematch')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_attempts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='ContestAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submitted_answer', models.CharField(max_length=200)),
('is_correct', models.BooleanField()),
('elapsed_ms', models.PositiveIntegerField()),
('answered_at', models.DateTimeField(auto_now_add=True)),
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='contest.contestattempt')),
('contest_question', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contest.contestquestion')),
],
),
migrations.CreateModel(
name='CheatFlag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.CharField(max_length=120)),
('evidence', models.JSONField(default=dict)),
('status', models.CharField(choices=[('open', '待处理'), ('dismissed', '已忽略'), ('confirmed', '已确认')], default='open', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cheat_flags', to='contest.contestattempt')),
],
),
migrations.AddConstraint(
model_name='questionversion',
constraint=models.UniqueConstraint(fields=('question', 'version'), name='unique_question_version'),
),
migrations.AddConstraint(
model_name='contestquestion',
constraint=models.UniqueConstraint(fields=('contest', 'order'), name='unique_contest_question_order'),
),
migrations.AddConstraint(
model_name='contestattempt',
constraint=models.UniqueConstraint(fields=('user', 'submission_key'), name='unique_user_contest_submission'),
),
migrations.AddConstraint(
model_name='contestanswer',
constraint=models.UniqueConstraint(fields=('attempt', 'contest_question'), name='unique_attempt_question_answer'),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.23 on 2026-08-08 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contest', '0001_initial'),
]
operations = [
migrations.AddIndex(
model_name='realtimematch',
index=models.Index(fields=['contest', 'status', 'player_one_rating', 'created_at'], name='matchmaking_lookup_idx'),
),
]
+194
View File
@@ -0,0 +1,194 @@
import uuid
from django.conf import settings
from django.db import models
class Question(models.Model):
class Track(models.TextChoices):
BEGINNER = "beginner", "入门"
STANDARD = "standard", "标准"
ADVANCED = "advanced", "进阶"
OPEN = "open", "Open"
slug = models.SlugField(unique=True)
track = models.CharField(max_length=16, choices=Track.choices)
tags = models.JSONField(default=list, blank=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return self.slug
class QuestionVersion(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="versions")
version = models.PositiveIntegerField()
prompt = models.TextField()
answer = models.CharField(max_length=200)
explanation = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=("question", "version"), name="unique_question_version")
]
def __str__(self):
return f"{self.question.slug} v{self.version}"
class Contest(models.Model):
class Kind(models.TextChoices):
REALTIME = "realtime", "实时 1v1"
DAILY = "daily", "今日挑战"
PRACTICE = "practice", "单人闯关"
WEEKLY = "weekly", "主题周赛"
class Status(models.TextChoices):
DRAFT = "draft", "草稿"
PUBLISHED = "published", "已发布"
CLOSED = "closed", "已结束"
slug = models.SlugField(unique=True)
title = models.CharField(max_length=120)
kind = models.CharField(max_length=16, choices=Kind.choices)
track = models.CharField(max_length=16, choices=Question.Track.choices)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
duration_seconds = models.PositiveIntegerField(default=60)
starts_at = models.DateTimeField(null=True, blank=True)
ends_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.title
class ContestQuestion(models.Model):
contest = models.ForeignKey(Contest, on_delete=models.CASCADE, related_name="contest_questions")
question_version = models.ForeignKey(QuestionVersion, on_delete=models.PROTECT)
order = models.PositiveIntegerField()
points = models.PositiveIntegerField(default=100)
class Meta:
constraints = [
models.UniqueConstraint(fields=("contest", "order"), name="unique_contest_question_order")
]
ordering = ["order"]
class RealtimeMatch(models.Model):
class Status(models.TextChoices):
WAITING = "waiting", "等待对手"
ACTIVE = "active", "进行中"
COMPLETED = "completed", "已完成"
CANCELLED = "cancelled", "已取消"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
contest = models.ForeignKey(Contest, on_delete=models.PROTECT)
player_one = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="matches_as_player_one"
)
player_two = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="matches_as_player_two",
)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.WAITING)
player_one_rating = models.PositiveIntegerField()
player_two_rating = models.PositiveIntegerField(null=True, blank=True)
winner = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="won_matches",
)
created_at = models.DateTimeField(auto_now_add=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"),
name="matchmaking_lookup_idx",
)
]
class ContestAttempt(models.Model):
class Status(models.TextChoices):
ACTIVE = "active", "进行中"
SUBMITTED = "submitted", "已提交"
EXPIRED = "expired", "已超时"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
contest = models.ForeignKey(Contest, on_delete=models.PROTECT, related_name="attempts")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="contest_attempts")
match = models.ForeignKey(
RealtimeMatch, null=True, blank=True, on_delete=models.PROTECT, related_name="attempts"
)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
score = models.PositiveIntegerField(default=0)
correct_count = models.PositiveIntegerField(default=0)
answer_count = models.PositiveIntegerField(default=0)
duration_ms = models.PositiveIntegerField(default=0)
submission_key = models.CharField(max_length=80, null=True, blank=True)
started_at = models.DateTimeField(auto_now_add=True)
submitted_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("user", "submission_key"), name="unique_user_contest_submission"
)
]
ordering = ["-started_at"]
class ContestAnswer(models.Model):
attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="answers")
contest_question = models.ForeignKey(ContestQuestion, on_delete=models.PROTECT)
submitted_answer = models.CharField(max_length=200)
is_correct = models.BooleanField()
elapsed_ms = models.PositiveIntegerField()
answered_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("attempt", "contest_question"), name="unique_attempt_question_answer"
)
]
class RatingHistory(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="rating_history")
match = models.ForeignKey(RealtimeMatch, on_delete=models.PROTECT, related_name="rating_changes")
rating_before = models.PositiveIntegerField()
rating_after = models.PositiveIntegerField()
delta = models.SmallIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
class LeaderboardSnapshot(models.Model):
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
track = models.CharField(max_length=16, choices=Question.Track.choices)
period = models.CharField(max_length=40)
rankings = models.JSONField(default=list)
generated_at = models.DateTimeField(auto_now_add=True)
class CheatFlag(models.Model):
class Status(models.TextChoices):
OPEN = "open", "待处理"
DISMISSED = "dismissed", "已忽略"
CONFIRMED = "confirmed", "已确认"
attempt = models.ForeignKey(ContestAttempt, on_delete=models.CASCADE, related_name="cheat_flags")
reason = models.CharField(max_length=120)
evidence = models.JSONField(default=dict)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.OPEN)
created_at = models.DateTimeField(auto_now_add=True)
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .consumers import MatchConsumer
websocket_urlpatterns = [
path("ws/v1/contest/matches/<uuid:match_id>/", MatchConsumer.as_asgi()),
]
+279
View File
@@ -0,0 +1,279 @@
from decimal import Decimal
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from accounts.models import User
from .models import (
CheatFlag,
Contest,
ContestAnswer,
ContestAttempt,
RatingHistory,
RealtimeMatch,
)
def normalize_answer(value):
text = str(value).strip().lower().replace(" ", "")
try:
return str(Decimal(text).normalize())
except Exception:
return text
def attempt_payload(attempt, include_results=False):
questions = []
answers = {answer.contest_question_id: answer for answer in attempt.answers.all()}
for item in attempt.contest.contest_questions.select_related("question_version").all():
question = {
"order": item.order,
"prompt": item.question_version.prompt,
"metadata": item.question_version.metadata,
"points": item.points,
}
if include_results and item.id in answers:
answer = answers[item.id]
question.update(
{
"submitted_answer": answer.submitted_answer,
"is_correct": answer.is_correct,
"correct_answer": item.question_version.answer,
"explanation": item.question_version.explanation,
}
)
questions.append(question)
return {
"attempt_id": attempt.id,
"contest": attempt.contest.title,
"kind": attempt.contest.kind,
"status": attempt.status,
"duration_seconds": attempt.contest.duration_seconds,
"server_started_at": attempt.started_at,
"score": attempt.score,
"correct_count": attempt.correct_count,
"answer_count": attempt.answer_count,
"duration_ms": attempt.duration_ms,
"questions": questions,
}
@transaction.atomic
def start_attempt(user, contest):
now = timezone.now()
if contest.status != Contest.Status.PUBLISHED:
raise ValidationError("比赛尚未发布")
if contest.starts_at and contest.starts_at > now:
raise ValidationError("比赛尚未开始")
if contest.ends_at and contest.ends_at <= now:
raise ValidationError("比赛已经结束")
if contest.kind == Contest.Kind.DAILY:
existing = ContestAttempt.objects.filter(user=user, contest=contest).first()
if existing:
return attempt_payload(
existing,
include_results=existing.status != ContestAttempt.Status.ACTIVE,
)
attempt = ContestAttempt.objects.create(contest=contest, user=user)
return attempt_payload(attempt)
@transaction.atomic
def submit_attempt(user, attempt_id, raw_answers, submission_key):
attempt = (
ContestAttempt.objects.select_for_update()
.select_related("contest")
.get(id=attempt_id, user=user)
)
if attempt.status != ContestAttempt.Status.ACTIVE:
if submission_key and attempt.submission_key == submission_key:
return attempt_payload(attempt, include_results=True)
raise ValidationError("该答题记录已经结算")
if not submission_key:
raise ValidationError({"Idempotency-Key": "正式提交必须提供幂等键"})
now = timezone.now()
duration_ms = max(0, int((now - attempt.started_at).total_seconds() * 1000))
limit_ms = attempt.contest.duration_seconds * 1000
items = list(
attempt.contest.contest_questions.select_related("question_version").all()
)
if not isinstance(raw_answers, list):
raise ValidationError({"answers": "答案必须是数组"})
by_order = {}
try:
for item in raw_answers:
order = int(item["order"])
if order <= 0 or order in by_order:
raise ValueError
by_order[order] = item.get("answer", "")
except (KeyError, TypeError, ValueError) as exc:
raise ValidationError({"answers": "答案题号无效或重复"}) from exc
score = 0
correct_count = 0
for contest_question in items:
submitted = str(by_order.get(contest_question.order, ""))[:200]
correct = normalize_answer(submitted) == normalize_answer(
contest_question.question_version.answer
)
if correct and duration_ms <= limit_ms:
score += contest_question.points
correct_count += 1
ContestAnswer.objects.create(
attempt=attempt,
contest_question=contest_question,
submitted_answer=submitted,
is_correct=correct and duration_ms <= limit_ms,
elapsed_ms=min(duration_ms, limit_ms + 60_000),
)
attempt.status = (
ContestAttempt.Status.SUBMITTED
if duration_ms <= limit_ms
else ContestAttempt.Status.EXPIRED
)
attempt.score = score
attempt.correct_count = correct_count
attempt.answer_count = len(raw_answers)
attempt.duration_ms = duration_ms
attempt.submission_key = submission_key
attempt.submitted_at = now
attempt.save()
if raw_answers and duration_ms / len(raw_answers) < 150:
CheatFlag.objects.create(
attempt=attempt,
reason="extreme_answer_speed",
evidence={
"duration_ms": duration_ms,
"answer_count": len(raw_answers),
},
)
if attempt.match_id:
finalize_match(attempt.match_id)
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("实时比赛不可用")
existing = (
RealtimeMatch.objects.filter(
Q(player_one=user) | Q(player_two=user),
contest=contest,
status__in=[RealtimeMatch.Status.WAITING, RealtimeMatch.Status.ACTIVE],
)
.order_by("-created_at")
.first()
)
if existing:
return existing
waiting = (
RealtimeMatch.objects.select_for_update(skip_locked=True)
.filter(
contest=contest,
status=RealtimeMatch.Status.WAITING,
player_one_rating__gte=max(0, user.rating - 300),
player_one_rating__lte=user.rating + 300,
)
.exclude(player_one=user)
.order_by("created_at")
.first()
)
if waiting is None:
return RealtimeMatch.objects.create(
contest=contest,
player_one=user,
player_one_rating=user.rating,
)
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"]
)
ContestAttempt.objects.bulk_create(
[
ContestAttempt(contest=contest, user=waiting.player_one, match=waiting),
ContestAttempt(contest=contest, user=user, match=waiting),
]
)
return waiting
def match_payload(match, user):
attempt = match.attempts.filter(user=user).first()
opponent = match.player_two if match.player_one_id == user.id else match.player_one
return {
"match_id": match.id,
"status": match.status,
"opponent": (
{"nickname": opponent.nickname, "rating": opponent.rating}
if opponent
else None
),
"attempt": attempt_payload(attempt) if attempt else None,
"websocket_path": f"/ws/v1/contest/matches/{match.id}/",
}
def _elo_delta(rating, opponent_rating, score, k=32):
expected = 1 / (1 + 10 ** ((opponent_rating - rating) / 400))
return round(k * (score - expected))
@transaction.atomic
def finalize_match(match_id):
match = RealtimeMatch.objects.select_for_update().get(id=match_id)
if match.status != RealtimeMatch.Status.ACTIVE:
return match
attempts = list(match.attempts.select_related("user").order_by("user_id"))
if len(attempts) != 2 or any(
attempt.status == ContestAttempt.Status.ACTIVE for attempt in attempts
):
return match
first = next(item for item in attempts if item.user_id == match.player_one_id)
second = next(item for item in attempts if item.user_id == match.player_two_id)
if first.score > second.score:
first_result, second_result = 1.0, 0.0
match.winner_id = first.user_id
elif second.score > first.score:
first_result, second_result = 0.0, 1.0
match.winner_id = second.user_id
else:
first_result = second_result = 0.5
users = {
user.id: user
for user in User.objects.select_for_update().filter(
id__in=[match.player_one_id, match.player_two_id]
)
}
player_one = users[match.player_one_id]
player_two = users[match.player_two_id]
deltas = (
_elo_delta(player_one.rating, player_two.rating, first_result),
_elo_delta(player_two.rating, player_one.rating, second_result),
)
for user, delta in zip((player_one, player_two), deltas):
before = user.rating
user.rating = max(0, before + delta)
user.save(update_fields=["rating"])
RatingHistory.objects.create(
user=user,
match=match,
rating_before=before,
rating_after=user.rating,
delta=delta,
)
match.status = RealtimeMatch.Status.COMPLETED
match.completed_at = timezone.now()
match.save(update_fields=["winner", "status", "completed_at"])
return match
+203
View File
@@ -0,0 +1,203 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from accounts.models import User
from contest.models import (
Contest,
ContestAttempt,
ContestQuestion,
Question,
QuestionVersion,
RatingHistory,
RealtimeMatch,
)
from contest.services import (
finalize_match,
find_match,
normalize_answer,
start_attempt,
submit_attempt,
)
@pytest.fixture
def user(db):
return User.objects.create_user(
username="contest_user",
password="StrongPass_2026",
nickname="比赛用户",
)
@pytest.fixture
def daily_contest(db):
question = Question.objects.create(
slug="sum-question",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="17 + 25",
answer="42",
explanation="相加得 42",
)
contest = Contest.objects.create(
slug="daily-test",
title="测试今日赛",
kind=Contest.Kind.DAILY,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=60,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
return contest
@pytest.mark.parametrize(
"raw, expected",
[
pytest.param(" 1.0 ", "1", id="小数标准化"),
pytest.param("ABC ", "abc", id="文本去空格并转小写"),
pytest.param("-0", "-0", id="保留十进制负零表示"),
],
)
def test_normalize_answer_标准化输入(raw, expected):
assert normalize_answer(raw) == expected
@pytest.mark.django_db
def test_start_attempt_每日赛重复进入复用同一记录(user, daily_contest):
first = start_attempt(user, daily_contest)
second = start_attempt(user, daily_contest)
assert first["attempt_id"] == second["attempt_id"]
assert ContestAttempt.objects.count() == 1
@pytest.mark.django_db
def test_submit_attempt_服务端判分且幂等重放(user, daily_contest):
started = start_attempt(user, daily_contest)
result = submit_attempt(
user,
started["attempt_id"],
[{"order": 1, "answer": "42.0"}],
"submission-1",
)
replay = submit_attempt(
user,
started["attempt_id"],
[{"order": 1, "answer": "0"}],
"submission-1",
)
assert result["status"] == ContestAttempt.Status.SUBMITTED
assert result["score"] == 100
assert result["correct_count"] == 1
assert result["questions"][0]["correct_answer"] == "42"
assert replay["score"] == 100
@pytest.mark.django_db
def test_submit_attempt_缺少幂等键时拒绝(user, daily_contest):
started = start_attempt(user, daily_contest)
with pytest.raises(ValidationError, match="幂等键"):
submit_attempt(
user,
started["attempt_id"],
[{"order": 1, "answer": "42"}],
None,
)
@pytest.mark.django_db
def test_submit_attempt_畸形题号返回校验错误且记录保持进行中(user, daily_contest):
started = start_attempt(user, daily_contest)
with pytest.raises(ValidationError):
submit_attempt(
user,
started["attempt_id"],
[{"order": "first", "answer": "42"}],
"malformed-order",
)
attempt = ContestAttempt.objects.get(id=started["attempt_id"])
assert attempt.status == ContestAttempt.Status.ACTIVE
assert attempt.answers.count() == 0
@pytest.mark.django_db
def test_submit_attempt_超过服务端时限不计分(user, daily_contest):
started = start_attempt(user, daily_contest)
ContestAttempt.objects.filter(id=started["attempt_id"]).update(
started_at=timezone.now() - timedelta(seconds=61)
)
result = submit_attempt(
user,
started["attempt_id"],
[{"order": 1, "answer": "42"}],
"late-submit",
)
assert result["status"] == ContestAttempt.Status.EXPIRED
assert result["score"] == 0
assert result["correct_count"] == 0
@pytest.mark.django_db
def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating():
first = User.objects.create_user(
username="player_one",
password="StrongPass_2026",
nickname="玩家一",
)
second = User.objects.create_user(
username="player_two",
password="StrongPass_2026",
nickname="玩家二",
)
contest = Contest.objects.create(
slug="realtime-test",
title="测试实时赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
)
waiting = find_match(first, contest)
active = find_match(second, contest)
active.refresh_from_db()
assert waiting.id == active.id
assert active.status == RealtimeMatch.Status.ACTIVE
assert active.attempts.count() == 2
active.attempts.filter(user=first).update(
status=ContestAttempt.Status.SUBMITTED,
score=200,
)
active.attempts.filter(user=second).update(
status=ContestAttempt.Status.SUBMITTED,
score=100,
)
finalized = finalize_match(active.id)
first.refresh_from_db()
second.refresh_from_db()
assert finalized.status == RealtimeMatch.Status.COMPLETED
assert finalized.winner == first
assert first.rating == 1016
assert second.rating == 984
assert RatingHistory.objects.filter(match=active).count() == 2
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from .views import (
AttemptStartView,
AttemptSubmitView,
ContestListView,
LeaderboardView,
MatchmakingView,
MatchStateView,
)
urlpatterns = [
path("", ContestListView.as_view(), name="contest-list"),
path("<slug:slug>/start/", AttemptStartView.as_view(), name="attempt-start"),
path("<slug:slug>/matchmaking/", MatchmakingView.as_view(), name="matchmaking"),
path("<slug:slug>/leaderboard/", LeaderboardView.as_view(), name="leaderboard"),
path("attempts/<uuid:attempt_id>/submit/", AttemptSubmitView.as_view(), name="attempt-submit"),
path("matches/<uuid:match_id>/", MatchStateView.as_view(), name="match-state"),
]
+100
View File
@@ -0,0 +1,100 @@
from django.shortcuts import get_object_or_404
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Contest, ContestAttempt, RealtimeMatch
from .services import (
find_match,
match_payload,
start_attempt,
submit_attempt,
)
class ContestListView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request):
contests = Contest.objects.filter(status=Contest.Status.PUBLISHED).order_by(
"kind", "title"
)
return Response(
[
{
"slug": contest.slug,
"title": contest.title,
"kind": contest.kind,
"track": contest.track,
"duration_seconds": contest.duration_seconds,
"starts_at": contest.starts_at,
"ends_at": contest.ends_at,
}
for contest in contests
]
)
class AttemptStartView(APIView):
def post(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
return Response(start_attempt(request.user, contest), status=status.HTTP_201_CREATED)
class AttemptSubmitView(APIView):
def post(self, request, attempt_id):
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"),
)
return Response(payload)
class MatchmakingView(APIView):
def post(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
match = find_match(request.user, contest)
return Response(match_payload(match, request.user), status=status.HTTP_202_ACCEPTED)
class MatchStateView(APIView):
def get(self, request, match_id):
match = 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):
return Response(status=status.HTTP_403_FORBIDDEN)
return Response(match_payload(match, request.user))
class LeaderboardView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request, slug):
contest = get_object_or_404(Contest, slug=slug)
attempts = (
ContestAttempt.objects.filter(
contest=contest,
status=ContestAttempt.Status.SUBMITTED,
cheat_flags__isnull=True,
)
.select_related("user")
.order_by("-score", "duration_ms", "submitted_at")[:100]
)
return Response(
[
{
"rank": index,
"nickname": attempt.user.nickname,
"track": attempt.user.track,
"score": attempt.score,
"correct_count": attempt.correct_count,
"duration_ms": attempt.duration_ms,
}
for index, attempt in enumerate(attempts, start=1)
]
)